Compare commits

..

51 Commits

Author SHA1 Message Date
62c9b8e59d updated readme 2026-05-21 16:52:51 -04:00
a4591655b7 release: bump to 1.0.1, enable R8 minification, ignore .claude dir
versionCode 1->2, versionName 0.1.0->1.0.1
isMinifyEnabled=true with proguard-android-optimize.txt
add proguard-rules.pro (keep Room, OkHttp, kotlinx.serialization)
gitignore: add .claude/ to suppress worktree noise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:38:27 -04:00
5a7542fb4c updated logo 2026-05-21 13:29:53 -04:00
9989d81b51 updated readme 2026-05-21 13:20:30 -04:00
7e0e7e4c29 fix: device label reads Settings.Global.DEVICE_NAME (user-set name), falls back to Build.MODEL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 08:51:39 -04:00
28d96bd3c1 merge: snackbar above keyboard 2026-05-19 08:51:39 -04:00
adc7fb54d9 fix: imePadding on SnackbarHost so toast appears above keyboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 08:51:30 -04:00
4adca319be added icon files 2026-05-19 08:29:26 -04:00
1d31556923 tasks.org: log device label and passphrase token tasks in milestone 6 2026-05-19 08:27:54 -04:00
628a28a775 feat: device label defaults to Build.MODEL; 3-word passphrase token
- SettingsRepository: deviceLabel fallback changed from "android" to
  Build.MODEL so fresh installs show the actual device name (e.g. "Pixel 8")
- server/main.py: _generate_passphrase() replaces secrets.token_hex(32).
  Picks 3 words from a 512-word embedded list, hyphen-separated
  (e.g. "coral-drift-lamp"). ~27 bits entropy, readable at a glance.
  Existing token.txt files are unaffected — only new generation changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 08:27:38 -04:00
fca3ea6f60 tasks.org: add milestone 6 post-ship cleanup log 2026-05-19 08:11:48 -04:00
68deb5cb3e merge: status bar icon fix 2026-05-19 08:11:04 -04:00
edd2c1745f fix: restore enableEdgeToEdge, enforce dark status bar icons via WindowCompat
API 35 forces edge-to-edge regardless; removing enableEdgeToEdge had no effect.
Real fix: keep it, set isAppearanceLightStatusBars=true in SideEffect so
clock/battery/wifi icons are dark and visible on the light TopAppBar background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 07:58:12 -04:00
4597f92d93 merge: snackbar after sync 2026-05-18 21:04:38 -04:00
a37ef4a794 feat: snackbar after sync (synced N / nothing to sync / server unreachable)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:02:51 -04:00
905e618d20 merge: remove enableEdgeToEdge 2026-05-18 20:56:42 -04:00
415742b974 fix: remove enableEdgeToEdge so status bar stays distinct
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:56:36 -04:00
a6af2fe5b9 add app icon, fix manifest icon refs, server logs token on every startup
- mipmap-* and drawable/ icon resources from Image Asset tool
- ic_launcher_background.xml: solid #22569d (matches foreground design)
- AndroidManifest.xml: add android:icon and android:roundIcon attributes
- server/app/main.py: log token on every startup (not just first run)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:48:50 -04:00
23df95dacf merge: token always logged on startup, imePadding on capture screen 2026-05-18 20:48:28 -04:00
16a18be9e3 fix: log token on every startup; imePadding so Save stays above keyboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:46:52 -04:00
fb2474ba21 readme: add build & deploy section (server docker, android APK, gitea release) 2026-05-18 15:46:49 -04:00
3c585c2f6b fix tasks.org: record real commit hash 66155f6 for milestone 5 2026-05-18 15:26:45 -04:00
37d424ef60 resolve merge conflict: take worktree milestone 5 DONE state, keep user title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:20:22 -04:00
66155f6141 milestone 5: tag chips, edit-before-sync, share target, last synced, sync interval
- CaptureScreen: FlowRow of FilterChip from recent tags; tap to toggle tag in/out
- CaptureViewModel: recentTags flow (flat deduplicated from last 100 captures), lastSyncedAt, toggleTag()
- HistoryScreen: tap to expand rows, edit dialog for pending/failed, last synced in TopAppBar subtitle
- HistoryViewModel: lastSyncedAt flow, updateBody()
- CaptureDao: getLastSyncedAt(), getRecentTagsJson() (from prior commit), updateBody()
- SettingsScreen: sync interval field (number, min 15), SynqSettings.syncIntervalMinutes
- SettingsViewModel: reschedules WorkManager on save with new interval
- SyncWorker: schedule() takes intervalMinutes param, uses REPLACE policy
- AndroidManifest: ACTION_SEND text/plain intent-filter on MainActivity
- MainActivity: extracts EXTRA_TEXT on share, passes as URL-encoded nav arg to CaptureScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:16:42 -04:00
4ea8e1c2ff updated milestone 5 2026-05-18 15:05:28 -04:00
65cb8ee2d5 resolve merge conflict, close out milestone 4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:47:36 -04:00
fd28a45cd7 milestone 4: org header, logging, backup docs, mark all m4 tasks done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:46:37 -04:00
df9485bd98 updated milestone4 polish goals 2026-05-18 14:32:51 -04:00
b907fdbbc3 removed default token, generate on launch 2026-05-18 13:49:23 -04:00
0799595b26 auto-generate token on first run, persist to /data/token.txt, log on startup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:44:33 -04:00
d606a59a92 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 13:44:33 -04:00
b44f6866cd allow cleartext HTTP for LAN server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:39:11 -04:00
ba6e0c2364 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 13:39:11 -04:00
ff5503e30c add health check button to settings screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 13:13:16 -04:00
dcf74007e7 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 13:13:16 -04:00
2ef56dc78b mark milestone 3 tasks DONE with evidence in tasks.org
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:46:29 -04:00
c39ab434b7 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 12:46:29 -04:00
2a963d9380 implement milestone 3: api client, sync service, workmanager, manual sync button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:44:31 -04:00
6fb4e65a20 confirmed test changes for milestone 2 2026-05-18 12:41:45 -04:00
0327bc1d78 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 12:38:42 -04:00
b1265f2c2d ignore *.hprof heap dumps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:37:42 -04:00
f7f9f5f3be android app tests and screenshots 2026-05-18 12:32:33 -04:00
7671416038 fix unresolved reference: remove unused settingsRepo from CaptureViewModel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:21:41 -04:00
50b35a0025 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 12:21:41 -04:00
779ad6c737 add gradle.properties with 2g heap to fix D8 OOM on build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:15:21 -04:00
6dc43ad411 Merge branch 'claude/serene-mclean-b76496' 2026-05-18 12:15:21 -04:00
7ff9ddd6c2 docker build testing 2026-05-18 11:06:46 -04:00
18e512f5ac mark milestone 2 tasks DONE with evidence in tasks.org
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:27:34 -04:00
19b05a89f9 scaffold android app: compose ui, room db, capture/history/settings screens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:27:02 -04:00
00b95be3ed mark milestone 0 and 1 tasks DONE with evidence in tasks.org
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:17:32 -04:00
e873a0055f implement server mvp: fastapi app, org formatter, sqlite store, tests, dockerfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 16:16:30 -04:00
75 changed files with 3132 additions and 186 deletions

View File

@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(\"C:\\\\Users\\\\moses\\\\projects\\\\synq\\\\venv\\\\Scripts\\\\pip.exe\" install *)",
"Bash(\"C:\\\\Users\\\\moses\\\\projects\\\\synq\\\\venv\\\\Scripts\\\\python.exe\" -m pytest tests/ -v)",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(/synq/venv/bin/python -m pytest /synq/.claude/worktrees/serene-mclean-b76496/server/tests/ -v)",
"Read(//c/mnt/**)",
"Read(//proc/**)",
"Read(//c/Users/moses/projects/synq/venv/Scripts//**)",
"Read(//c/Users/moses/projects/synq/venv/**)",
"Bash(/c/Users/moses/projects/synq/venv/Scripts/python.exe -m pytest tests/ -v)",
"Bash(ls \"/c/Users/moses/AppData/Local/Android/Sdk/platforms/\" 2>/dev/null && echo \"FOUND at AppData/Local/Android/Sdk\")",
"Bash(/c/Users/moses/projects/synq/venv/Scripts/python.exe -m pytest tests/ -q)",
"Bash(git stash *)",
"Bash(git merge *)",
"Bash(git push *)",
"Bash(git commit *)"
]
}
}

7
.gitignore vendored
View File

@@ -16,12 +16,17 @@ android/**/build/
android/captures/
*.apk
*.aab
*.hprof
# ide
.idea/
.vscode/
*.iml
# os
# os and emacs
.DS_Store
Thumbs.db
/archive
# claude code
.claude/

179
README.md
View File

@@ -1,21 +1,17 @@
# synq, a tiny android-to-org capture system.
synq consists of an Android app for capturing notes items and a docker agent that pulls notes and saves them to a local .org file.
open app.
type, optionally mark as todo, optionally add tags, save.
if home server accessible, post unsynced captures to lan-only service and appended to `phone.org`.
1. Open Synq app
2. Type note, mark as TODO (opt), add tags (opt), save.
3. If home server accessible, post unsynced captures to lan-only service and appended to `synq.org`.
## non-goals
<p>
<img src="docs/home-v1.png" alt="home-screen-v1" width="220">
<img src="docs/history-v1.png" alt="history-screen-v1" width="220">
<img src="docs/settings-v1.png" alt="settings-v1" width="220">
</p>
- no org parser on android
- no agenda on android
- no sync provider dependency
- no nextcloud file editing
- no conflict resolution
- no public internet exposure
- no ai tagging in v1
- no account system in v1
## architecture
## Architecture
```text
android app
@@ -29,142 +25,43 @@ home server
python + fastapi
sqlite idempotency store
append-only org writer
flow
user saves capture locally
app marks it pending
sync runs when server is reachable
app posts pending captures to fastapi
server validates, dedupes by id, appends to phone.org
app marks capture synced
```
## v1 behavior
1. user saves capture locally
2. app marks it pending
3. sync runs when server is reachable
4. app posts pending captures to fastapi
5. server validates, dedupes by id, appends to phone.org
6. app marks capture synced
the app launches into a focused text box. the user can type immediately.
controls:
## Build & Deploy
First, clone the repo.
- note/todo toggle
- optional tags field
- save
- save and close
- sync now
- small history view showing pending, synced, and failed entries
Build and deploy `synq-server`, the docker container that listens for new notes and writes to the local file.
each capture has:
Then, build and install the `synq` app.
- stable client-generated id
- created timestamp with timezone
- kind: `note` or `todo`
- body text
- tags
- device name
- sync status
- optional last error
Finally, retrieve the token (`token.txt` in the docker container's data directory, or from the container logs `docker logs synq-server`)
## org output
todo:
```org
* TODO buy printer paper :home:errands:
:PROPERTIES:
:CREATED: [2026-05-17 sun 14:31]
:SOURCE: android
:ID: phone-20260517-143122-a8f2
:END:
### 1. synq-server (docker)
- Build from `docker build -t synq-server ./server`
- Run (replace paths and token as needed):
```docker
docker run -d --name synq --restart=unless-stopped \
-p 8765:8765 \
-v /mnt/user/synq/data:/data \
synq-server
```
note:
The server generates a random token on first start and logs it prominently. Copy it into the android app settings. To use a fixed token instead, pass `-e PHONE_CAPTURE_TOKEN=yourtoken`.
```org
* note :retcon:
:PROPERTIES:
:CREATED: [2026-05-17 sun 14:33]
:SOURCE: android
:ID: phone-20260517-143322-b91c
:END:
### 2. synq (android)
Download apk from Releases, on the right
mobile capture should stay dumb and append-only.
```
## server paths
default container paths:
```text
/data/synq.org
/data/capture.sqlite3
/data/rejected.log
```
recommended unraid host mapping:
```text
/mnt/user/synq/phone-capture:/data
```
or map `/data/synq.org` directly to wherever the real org file lives. prefer a dedicated capture file first; emacs can include it in agenda later.
## security model
v1 is lan-only. bind the service to the host lan and do not expose it through swag/cloudflare/public dns.
minimum useful controls:
- shared bearer token in app and server env
- server only accepts json
- server rejects empty body
- server dedupes ids
- server writes append-only org
- server never edits or parses existing org
- docker volume is backed up
## repo layout
suggested monorepo:
```text
synq/
android/
app/
server/
app/
main.py
models.py
org_writer.py
store.py
tests/
Dockerfile
pyproject.toml
docs/
api.md
android-notes.md
server-notes.md
docker-compose.example.yml
.env.example
tasks.org
README.md
```
## build order
1. implement server first using curl tests.
2. implement android local capture with room.
3. implement manual sync.
4. add workmanager opportunistic sync.
5. add history screen and resend handling.
6. polish launch speed and widget/share-target only after core path works.
## v2 parking lot
- android share target
- quick settings tile or widget
- tag chips from recent tags
- configurable default tag
- edit unsynced entries only
- multi-device capture
- wireguard-aware sync
- local export/import
- optional emacs ingest helpers
Or, build from source:
Open `android/` in Android Studio and hit **Sync**. then either:
- **run on device/emulator directly** from Android Studio (▶), or
- **build a release APK**
Then sideload to a connected device:
- terminal: `adb install app/build/outputs/apk/release/app-release-unsigned.apk`

View File

@@ -0,0 +1,70 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "me.hgsky.synq"
compileSdk = 36
defaultConfig {
applicationId = "me.hgsky.synq"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "1.0.1"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.4")
implementation("androidx.datastore:datastore-preferences:1.1.1")
val room = "2.6.1"
implementation("androidx.room:room-runtime:$room")
implementation("androidx.room:room-ktx:$room")
ksp("androidx.room:room-compiler:$room")
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.google.android.material:material:1.12.0")
}

16
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,16 @@
# Keep Room entities and DAOs
-keep class me.hgsky.synq.data.db.** { *; }
# Keep Retrofit/OkHttp
-dontwarn okhttp3.**
-keep class okhttp3.** { *; }
# Keep kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class **$$serializer { *; }
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
*** serializer(...);
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".SynqApp"
android:label="@string/app_name"
android:theme="@style/Theme.Synq"
android:allowBackup="true"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,56 @@
package me.hgsky.synq
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import me.hgsky.synq.ui.capture.CaptureScreen
import me.hgsky.synq.ui.history.HistoryScreen
import me.hgsky.synq.ui.settings.SettingsScreen
import me.hgsky.synq.ui.theme.SynqTheme
import java.net.URLDecoder
import java.net.URLEncoder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val sharedText = if (intent.action == Intent.ACTION_SEND)
intent.getStringExtra(Intent.EXTRA_TEXT) else null
setContent {
SynqTheme { SynqNav(sharedText = sharedText) }
}
}
}
@Composable
private fun SynqNav(sharedText: String? = null) {
val nav = rememberNavController()
val startRoute = if (sharedText != null) {
"capture/${URLEncoder.encode(sharedText, "UTF-8")}"
} else {
"capture"
}
NavHost(navController = nav, startDestination = startRoute) {
composable("capture") { CaptureScreen(nav) }
composable(
route = "capture/{prefill}",
arguments = listOf(navArgument("prefill") { type = NavType.StringType }),
) { back ->
val prefill = back.arguments?.getString("prefill")
?.let { URLDecoder.decode(it, "UTF-8") }
CaptureScreen(nav, prefill = prefill)
}
composable("history") { HistoryScreen(nav) }
composable("settings") { SettingsScreen(nav) }
}
}

View File

@@ -0,0 +1,16 @@
package me.hgsky.synq
import android.app.Application
import me.hgsky.synq.data.SettingsRepository
import me.hgsky.synq.data.db.CaptureDatabase
import me.hgsky.synq.data.sync.SyncWorker
class SynqApp : Application() {
val db by lazy { CaptureDatabase.get(this) }
val settings by lazy { SettingsRepository(this) }
override fun onCreate() {
super.onCreate()
SyncWorker.schedule(this)
}
}

View File

@@ -0,0 +1,13 @@
package me.hgsky.synq.data
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
private val DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
private const val CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
fun generateCaptureId(): String {
val ts = LocalDateTime.now().format(DATE_FMT)
val rand = (1..4).map { CHARS.random() }.joinToString("")
return "phone-$ts-$rand"
}

View File

@@ -0,0 +1,49 @@
package me.hgsky.synq.data
import android.content.Context
import android.os.Build
import android.provider.Settings
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("synq_settings")
data class SynqSettings(
val serverUrl: String = "http://jeeves.mother:8765",
val token: String = "",
val deviceLabel: String = "android",
val syncIntervalMinutes: Int = 15,
)
class SettingsRepository(private val context: Context) {
private val KEY_URL = stringPreferencesKey("server_url")
private val KEY_TOKEN = stringPreferencesKey("token")
private val KEY_DEVICE = stringPreferencesKey("device_label")
private val KEY_INTERVAL = intPreferencesKey("sync_interval_minutes")
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
SynqSettings(
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
token = prefs[KEY_TOKEN] ?: "",
deviceLabel = prefs[KEY_DEVICE] ?: (
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
?.takeIf { it.isNotBlank() } ?: Build.MODEL
),
syncIntervalMinutes = prefs[KEY_INTERVAL] ?: 15,
)
}
suspend fun save(settings: SynqSettings) {
context.dataStore.edit { prefs ->
prefs[KEY_URL] = settings.serverUrl
prefs[KEY_TOKEN] = settings.token
prefs[KEY_DEVICE] = settings.deviceLabel
prefs[KEY_INTERVAL] = settings.syncIntervalMinutes
}
}
}

View File

@@ -0,0 +1,77 @@
package me.hgsky.synq.data.api
import kotlinx.serialization.json.Json
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.db.CaptureEntity
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class PostResult {
data class Accepted(val id: String) : PostResult()
data class AlreadySeen(val id: String) : PostResult()
data class Failed(val id: String, val error: String) : PostResult()
}
class SynqApiClient {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
fun checkHealth(serverUrl: String): Boolean = try {
val req = Request.Builder().url("$serverUrl/health").get().build()
http.newCall(req).execute().use { it.isSuccessful }
} catch (_: IOException) {
false
} catch (_: Exception) {
false
}
fun postCapture(capture: CaptureEntity, settings: SynqSettings): PostResult = try {
val tags = try {
Json.decodeFromString<List<String>>(capture.tagsJson)
} catch (_: Exception) {
emptyList()
}
val bodyJson = JSONObject().apply {
put("id", capture.id)
put("created_at", capture.createdAt)
put("kind", capture.kind)
put("body", capture.body)
put("tags", JSONArray(tags))
put("device", capture.device)
}.toString()
val req = Request.Builder()
.url("${settings.serverUrl}/capture")
.header("Authorization", "Bearer ${settings.token}")
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.build()
http.newCall(req).execute().use { response ->
when {
response.code == 401 -> PostResult.Failed(capture.id, "unauthorized (check token in settings)")
!response.isSuccessful -> PostResult.Failed(capture.id, "server error ${response.code}")
else -> {
val json = JSONObject(response.body?.string() ?: "{}")
if (json.optString("status") == "already_seen")
PostResult.AlreadySeen(capture.id)
else
PostResult.Accepted(capture.id)
}
}
}
} catch (e: IOException) {
PostResult.Failed(capture.id, e.message ?: "network error")
} catch (e: Exception) {
PostResult.Failed(capture.id, e.message ?: "unexpected error")
}
}

View File

@@ -0,0 +1,39 @@
package me.hgsky.synq.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface CaptureDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(capture: CaptureEntity)
@Update
suspend fun update(capture: CaptureEntity)
@Query("SELECT * FROM captures ORDER BY createdAt DESC")
fun observeAll(): Flow<List<CaptureEntity>>
@Query("SELECT * FROM captures WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC")
suspend fun getPendingAndFailed(): List<CaptureEntity>
@Query("UPDATE captures SET status = :status, lastError = :error WHERE id = :id")
suspend fun updateStatus(id: String, status: String, error: String?)
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
suspend fun markSynced(id: String, syncedAt: String)
@Query("SELECT MAX(syncedAt) FROM captures WHERE syncedAt IS NOT NULL")
fun getLastSyncedAt(): Flow<String?>
@Query("SELECT tagsJson FROM captures WHERE tagsJson != '[]' AND tagsJson != '' ORDER BY createdAt DESC LIMIT 100")
fun getRecentTagsJson(): Flow<List<String>>
@Query("UPDATE captures SET body = :body WHERE id = :id")
suspend fun updateBody(id: String, body: String)
}

View File

@@ -0,0 +1,24 @@
package me.hgsky.synq.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [CaptureEntity::class], version = 1, exportSchema = false)
abstract class CaptureDatabase : RoomDatabase() {
abstract fun captureDao(): CaptureDao
companion object {
@Volatile private var instance: CaptureDatabase? = null
fun get(context: Context): CaptureDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
CaptureDatabase::class.java,
"synq.db",
).build().also { instance = it }
}
}
}

View File

@@ -0,0 +1,17 @@
package me.hgsky.synq.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "captures")
data class CaptureEntity(
@PrimaryKey val id: String,
val createdAt: String,
val kind: String, // "note" | "todo"
val body: String,
val tagsJson: String, // JSON array, e.g. ["home","errands"]
val device: String,
val status: String, // "pending" | "syncing" | "synced" | "failed"
val syncedAt: String? = null,
val lastError: String? = null,
)

View File

@@ -0,0 +1,26 @@
package me.hgsky.synq.data.sync
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.api.PostResult
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.db.CaptureDao
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSettings): Int {
val pending = dao.getPendingAndFailed()
var synced = 0
for (capture in pending) {
dao.updateStatus(capture.id, "syncing", null)
val result = client.postCapture(capture, settings)
when (result) {
is PostResult.Accepted, is PostResult.AlreadySeen -> {
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
dao.markSynced(capture.id, now)
synced++
}
is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error)
}
}
return synced
}

View File

@@ -0,0 +1,50 @@
package me.hgsky.synq.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import kotlinx.coroutines.flow.first
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.api.SynqApiClient
import java.util.concurrent.TimeUnit
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val app = applicationContext as SynqApp
val settings = app.settings.settings.first()
val client = SynqApiClient()
if (!client.checkHealth(settings.serverUrl)) return Result.success()
syncPending(app.db.captureDao(), client, settings)
return Result.success()
}
companion object {
private const val WORK_NAME = "synq_periodic_sync"
fun schedule(context: Context, intervalMinutes: Int = 15) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<SyncWorker>(
intervalMinutes.toLong().coerceAtLeast(15), TimeUnit.MINUTES,
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
request,
)
}
}
}

View File

@@ -0,0 +1,205 @@
package me.hgsky.synq.ui.capture
import androidx.activity.ComponentActivity
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CaptureScreen(
nav: NavController,
prefill: String? = null,
vm: CaptureViewModel = viewModel(),
) {
val state by vm.state.collectAsState()
val recentTags by vm.recentTags.collectAsState()
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
val focusRequester = remember { FocusRequester() }
val snackbarState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(Unit) {
vm.snackbar.collect { msg -> snackbarState.showSnackbar(msg) }
}
LaunchedEffect(prefill) {
if (!prefill.isNullOrEmpty()) vm.setBody(prefill)
}
val activeTags = remember(state.tags) {
state.tags.split(Regex("[,\\s]+")).filter { it.isNotEmpty() }.toSet()
}
val lastSyncedText = remember(lastSyncedAt) {
lastSyncedAt?.let { iso ->
runCatching {
val dt = OffsetDateTime.parse(iso)
"synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
}.getOrNull()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarState, modifier = Modifier.imePadding()) },
topBar = {
TopAppBar(
title = { Text("synq") },
actions = {
IconButton(
onClick = { vm.syncNow() },
enabled = !state.isSyncing,
) {
if (state.isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Default.Sync, contentDescription = "sync now")
}
}
IconButton(onClick = { nav.navigate("history") }) {
Icon(Icons.Default.History, contentDescription = "history")
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = "settings")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = state.body,
onValueChange = vm::setBody,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.focusRequester(focusRequester),
placeholder = { Text("capture something…") },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
maxLines = Int.MAX_VALUE,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("note", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = state.isTodo,
onCheckedChange = vm::setTodo,
)
Text("todo", style = MaterialTheme.typography.bodyMedium)
}
OutlinedTextField(
value = state.tags,
onValueChange = vm::setTags,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("tags — space or comma separated") },
singleLine = true,
)
if (recentTags.isNotEmpty()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
recentTags.forEach { tag ->
FilterChip(
selected = tag in activeTags,
onClick = { vm.toggleTag(tag) },
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
if (lastSyncedText != null) {
Text(
lastSyncedText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { vm.save() },
modifier = Modifier.weight(1f),
enabled = state.body.isNotBlank(),
) { Text("save") }
Button(
onClick = { vm.save { (context as? ComponentActivity)?.finish() } },
modifier = Modifier.weight(1f),
enabled = state.body.isNotBlank(),
) { Text("save & close") }
}
Spacer(Modifier.height(4.dp))
}
}
}

View File

@@ -0,0 +1,117 @@
package me.hgsky.synq.ui.capture
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.generateCaptureId
import me.hgsky.synq.data.db.CaptureEntity
import me.hgsky.synq.data.sync.syncPending
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
data class CaptureUiState(
val body: String = "",
val isTodo: Boolean = false,
val tags: String = "",
val isSyncing: Boolean = false,
)
class CaptureViewModel(app: Application) : AndroidViewModel(app) {
private val synqApp = app as SynqApp
private val dao = synqApp.db.captureDao()
private val _state = MutableStateFlow(CaptureUiState())
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
private val _snackbar = MutableSharedFlow<String>(extraBufferCapacity = 1)
val snackbar = _snackbar.asSharedFlow()
val recentTags: StateFlow<List<String>> = dao.getRecentTagsJson()
.map { jsonList ->
val seen = LinkedHashSet<String>()
for (json in jsonList) {
runCatching { Json.decodeFromString<List<String>>(json) }
.getOrElse { emptyList() }
.forEach { seen.add(it) }
}
seen.take(20).toList()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
fun toggleTag(tag: String) {
val current = parseTags(_state.value.tags).toMutableList()
if (current.contains(tag)) current.remove(tag) else current.add(tag)
_state.value = _state.value.copy(tags = current.joinToString(" "))
}
fun save(onDone: () -> Unit = {}) {
val s = _state.value
val body = s.body.trim()
if (body.isEmpty()) return
viewModelScope.launch {
val tags = parseTags(s.tags)
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
dao.insert(
CaptureEntity(
id = generateCaptureId(),
createdAt = now,
kind = if (s.isTodo) "todo" else "note",
body = body,
tagsJson = Json.encodeToString(tags),
device = "android",
status = "pending",
)
)
_state.value = _state.value.copy(body = "", tags = "")
onDone()
}
}
fun syncNow() {
if (_state.value.isSyncing) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(isSyncing = true)
try {
val settings = synqApp.settings.settings.first()
val client = SynqApiClient()
if (!client.checkHealth(settings.serverUrl)) {
_snackbar.tryEmit("server unreachable")
return@launch
}
val synced = syncPending(dao, client, settings)
_snackbar.tryEmit(if (synced == 0) "nothing to sync" else "synced $synced")
} finally {
_state.value = _state.value.copy(isSyncing = false)
}
}
}
private fun parseTags(raw: String): List<String> =
raw.split(Regex("[,\\s]+"))
.map { it.trim() }
.filter { it.isNotEmpty() }
}

View File

@@ -0,0 +1,218 @@
package me.hgsky.synq.ui.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import me.hgsky.synq.data.db.CaptureEntity
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
val captures by vm.captures.collectAsState(initial = emptyList())
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
val lastSyncedText = remember(lastSyncedAt) {
lastSyncedAt?.let { iso ->
runCatching {
val dt = OffsetDateTime.parse(iso)
"last synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
}.getOrNull()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("history")
if (lastSyncedText != null) {
Text(
lastSyncedText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
}
},
)
},
) { padding ->
if (captures.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) { Text("no captures yet") }
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(captures, key = { it.id }) { capture ->
CaptureRow(
capture = capture,
onRetry = { vm.retryCapture(capture) },
onEditConfirm = { newBody -> vm.updateBody(capture, newBody) },
)
}
}
}
}
}
@Composable
private fun CaptureRow(
capture: CaptureEntity,
onRetry: () -> Unit,
onEditConfirm: (String) -> Unit,
) {
var expanded by rememberSaveable(capture.id) { mutableStateOf(false) }
var showEdit by remember { mutableStateOf(false) }
var editBody by remember(capture.body) { mutableStateOf(capture.body) }
val statusColor = when (capture.status) {
"synced" -> Color(0xFF2E7D32)
"failed" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val editable = capture.status == "pending" || capture.status == "failed"
if (showEdit) {
AlertDialog(
onDismissRequest = { showEdit = false },
title = { Text("edit capture") },
text = {
OutlinedTextField(
value = editBody,
onValueChange = { editBody = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("body") },
minLines = 3,
)
},
confirmButton = {
Button(
onClick = {
onEditConfirm(editBody)
showEdit = false
},
enabled = editBody.isNotBlank(),
) { Text("save") }
},
dismissButton = {
TextButton(onClick = { showEdit = false }) { Text("cancel") }
},
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
capture.kind.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
capture.status,
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
if (editable) {
OutlinedButton(
onClick = onRetry,
contentPadding = PaddingValues(horizontal = 8.dp),
) {
Text("retry", style = MaterialTheme.typography.labelSmall)
}
}
if (editable && expanded) {
OutlinedButton(
onClick = { showEdit = true },
contentPadding = PaddingValues(horizontal = 8.dp),
) {
Text("edit", style = MaterialTheme.typography.labelSmall)
}
}
}
Text(
capture.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
)
Text(
capture.createdAt,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (capture.lastError != null) {
Text(
capture.lastError,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}

View File

@@ -0,0 +1,34 @@
package me.hgsky.synq.ui.history
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.db.CaptureEntity
class HistoryViewModel(app: Application) : AndroidViewModel(app) {
private val dao = (app as SynqApp).db.captureDao()
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun retryCapture(capture: CaptureEntity) {
viewModelScope.launch {
dao.updateStatus(capture.id, "pending", null)
}
}
fun updateBody(capture: CaptureEntity, newBody: String) {
val trimmed = newBody.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch { dao.updateBody(capture.id, trimmed) }
}
}

View File

@@ -0,0 +1,153 @@
package me.hgsky.synq.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import me.hgsky.synq.data.SynqSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
val saved by vm.settings.collectAsState()
val ping by vm.ping.collectAsState()
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
var token by remember(saved.token) { mutableStateOf(saved.token) }
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
var interval by remember(saved.syncIntervalMinutes) { mutableStateOf(saved.syncIntervalMinutes.toString()) }
fun buildSettings() = SynqSettings(
serverUrl = url.trim(),
token = token.trim(),
deviceLabel = device.trim().ifEmpty { "android" },
syncIntervalMinutes = interval.toIntOrNull()?.coerceAtLeast(15) ?: 15,
)
fun saveAndBack() {
vm.save(buildSettings())
nav.navigateUp()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("settings") },
navigationIcon = {
IconButton(onClick = ::saveAndBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("server url") },
singleLine = true,
)
OutlinedTextField(
value = token,
onValueChange = { token = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("bearer token") },
singleLine = true,
)
OutlinedTextField(
value = device,
onValueChange = { device = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("device label") },
singleLine = true,
)
OutlinedTextField(
value = interval,
onValueChange = { v -> if (v.length <= 4 && v.all { it.isDigit() }) interval = v },
modifier = Modifier.fillMaxWidth(),
label = { Text("sync interval (minutes, min 15)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("WorkManager minimum is 15 min") },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
vm.save(buildSettings())
vm.checkConnection(url.trim())
},
modifier = Modifier.weight(1f),
enabled = ping !is PingState.Checking,
) {
if (ping is PingState.Checking) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("check connection")
}
}
Button(
onClick = ::saveAndBack,
modifier = Modifier.weight(1f),
) { Text("save") }
}
when (val p = ping) {
is PingState.Ok -> Text(
"✓ reachable",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF2E7D32),
)
is PingState.Failed -> Text(
"✗ unreachable — check URL and that server is running",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
else -> {}
}
}
}
}

View File

@@ -0,0 +1,54 @@
package me.hgsky.synq.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.sync.SyncWorker
sealed class PingState {
object Idle : PingState()
object Checking : PingState()
data class Ok(val url: String) : PingState()
data class Failed(val url: String) : PingState()
}
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val synqApp = app as SynqApp
private val repo = synqApp.settings
val settings = repo.settings.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
SynqSettings(),
)
private val _ping = MutableStateFlow<PingState>(PingState.Idle)
val ping: StateFlow<PingState> = _ping.asStateFlow()
fun save(settings: SynqSettings) {
viewModelScope.launch {
repo.save(settings)
SyncWorker.schedule(synqApp, settings.syncIntervalMinutes)
}
}
fun checkConnection(url: String) {
if (_ping.value is PingState.Checking) return
viewModelScope.launch(Dispatchers.IO) {
_ping.value = PingState.Checking
val ok = SynqApiClient().checkHealth(url.trim())
_ping.value = if (ok) PingState.Ok(url) else PingState.Failed(url)
}
}
}

View File

@@ -0,0 +1,27 @@
package me.hgsky.synq.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@Composable
fun SynqTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = dynamicLightColorScheme(context)
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// light theme → dark icons so clock/battery/wifi are visible on light TopAppBar
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
}
}
MaterialTheme(colorScheme = colorScheme, content = content)
}

View File

@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M0,0.96h512v512h-512z"
android:strokeWidth="1.00157"
android:fillColor="#22569d"/>
</vector>

View File

@@ -0,0 +1,52 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M0,0.96h512v512h-512z"
android:strokeWidth="1.00157"
android:fillColor="#22569d"/>
<path
android:pathData="m189.14,130.52h133.72c7.25,0 13.08,5.83 13.08,13.08v220.93c0,7.25 -5.83,13.08 -13.08,13.08H189.14c-7.25,0 -13.08,-5.83 -13.08,-13.08V143.6c0,-7.25 5.83,-13.08 13.08,-13.08z"
android:strokeWidth="2.90698"
android:fillColor="#ffffff"
android:strokeColor="#00000000"/>
<path
android:fillColor="#FF000000"
android:pathData="m222.27,361.68h66.86c1.61,0 2.91,1.3 2.91,2.91 0,1.61 -1.3,2.91 -2.91,2.91h-66.86c-1.61,0 -2.91,-1.3 -2.91,-2.91 0,-1.61 1.3,-2.91 2.91,-2.91z"
android:strokeWidth="2.90698"
android:strokeColor="#000000"/>
<path
android:pathData="M193.63,145.03H318.63c1.61,0 2.91,1.3 2.91,2.91v197.67c0,1.61 -1.3,2.91 -2.91,2.91H193.63c-1.61,0 -2.91,-1.3 -2.91,-2.91V147.94c0,-1.61 1.3,-2.91 2.91,-2.91z"
android:strokeWidth="2.90698"
android:fillColor="#22569d"
android:strokeColor="#00000000"/>
<path
android:pathData="m210.25,196.07h-11.2v-42.2h11.2v3.18h-7.43v35.84h7.43zM241.81,186.76h-5.11l-7.22,-11.99 -7.24,11.99h-5.04l9.88,-15.17 -9.08,-14.51h4.79l6.79,11.17 6.84,-11.17h4.66l-9.11,14.33zM260.1,196.07h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,179.61q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,179.61q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
android:strokeWidth="0.726744"
android:fillColor="#00e219"/>
<path
android:pathData="m210.25,256.17h-11.2L199.05,213.98h11.2v3.18h-7.43v35.84h7.43zM241.81,246.86h-5.11l-7.22,-11.99 -7.24,11.99h-5.04l9.88,-15.17 -9.08,-14.51h4.79l6.79,11.17 6.84,-11.17h4.66l-9.11,14.33zM260.1,256.17h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,239.71q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,239.71q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
android:strokeWidth="0.726744"
android:fillColor="#00e219"/>
<path
android:pathData="m210.25,316.28h-11.2v-42.2h11.2v3.18h-7.43v35.84h7.43zM260.1,316.28h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,299.81q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,299.81q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
android:strokeWidth="0.726744"
android:fillColor="#00e219"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">synq</string>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<style name="Theme.Synq" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>

7
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
}

View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.daemon=true
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "synq"
include(":app")

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
synq-server:
build: ./server
container_name: synq-server
restart: unless-stopped
ports:
- "8765:8765"
environment:
PHONE_CAPTURE_TOKEN: "${PHONE_CAPTURE_TOKEN}"
PHONE_CAPTURE_ORG_PATH: "/data/synq.org"
PHONE_CAPTURE_DB_PATH: "/data/capture.sqlite3"
PHONE_CAPTURE_HOST: "0.0.0.0"
PHONE_CAPTURE_PORT: "8765"
volumes:
- /mnt/user/ben/synq/phone-capture:/data

BIN
docs/history-m2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
docs/history-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
docs/home-m2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
docs/home-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/settings-m2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
docs/settings-v1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

150
docs/spec.md Normal file
View File

@@ -0,0 +1,150 @@
# Specifications
## v1 behavior
the app launches into a focused text box. the user can type immediately.
controls:
- note/todo toggle
- optional tags field
- save
- save and close
- sync now
- small history view showing pending, synced, and failed entries
each capture has:
- stable client-generated id
- created timestamp with timezone
- kind: `note` or `todo`
- body text
- tags
- device name
- sync status
- optional last error
## org output
todo:
```org
* TODO buy printer paper :home:errands:
:PROPERTIES:
:CREATED: [2026-05-17 sun 14:31]
:SOURCE: android
:ID: phone-20260517-143122-a8f2
:END:
```
note:
```org
* note :retcon:
:PROPERTIES:
:CREATED: [2026-05-17 sun 14:33]
:SOURCE: android
:ID: phone-20260517-143322-b91c
:END:
mobile capture should stay dumb and append-only.
```
## server paths
default container paths:
```text
/data/synq.org
/data/capture.sqlite3
/data/rejected.log
```
recommended host mapping:
```text
/mnt/user/synq/phone-capture:/data
```
or map `/data/synq.org` directly to wherever the real org file lives. prefer a dedicated capture file first; emacs can include it in agenda later.
## security model
v1 is lan-only. bind the service to the host lan and do not expose it through swag/cloudflare/public dns.
minimum useful controls:
- shared bearer token in app and server env
- server only accepts json
- server rejects empty body
- server dedupes ids
- server writes append-only org
- server never edits or parses existing org
- docker volume is backed up
## repo layout
suggested monorepo:
```text
synq/
android/
app/
server/
app/
main.py
models.py
org_writer.py
store.py
tests/
Dockerfile
pyproject.toml
docs/
api.md
android-notes.md
server-notes.md
docker-compose.example.yml
.env.example
tasks.org
README.md
```
## build order
1. implement server first using curl tests.
2. implement android local capture with room.
3. implement manual sync.
4. add workmanager opportunistic sync.
5. add history screen and resend handling.
6. polish launch speed and widget/share-target only after core path works.
## non-goals
- no org parser on android
- no agenda on android
- no sync provider dependency
- no nextcloud file editing
- no conflict resolution
- no public internet exposure
- no ai tagging in v1
- no account system in v1
## backup
### what to back up
the server writes to one directory (the `/data` volume). back up the whole thing:
```text
/data/synq.org ← the canonical org capture file
/data/capture.sqlite3 ← idempotency store (dedup ids)
/data/token.txt ← auto-generated token (if not using PHONE_CAPTURE_TOKEN env var)
```
on unraid the host path is whatever you mapped in compose, e.g. `/mnt/user/ben/synq/phone-capture`.
### restore behavior
- restore the `/data` volume to a new container and start it. the server will pick up where it left off.
- `synq.org` is append-only plain text — it is human-readable and recoverable even without the sqlite db.
- if `capture.sqlite3` is lost but `synq.org` is intact, the server will accept re-posted captures that were already in the org file (no dedup). the android app marks them synced either way (`already_seen` or `accepted`), so the only side effect is duplicate org entries for anything re-synced. restore the db from backup to avoid this.
- if `token.txt` is lost, delete it and let the server generate a new one on next start, then update the app settings.

86
logo/icon-512.svg Normal file
View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title4">synq</title>
<defs
id="defs1" />
<g
id="layer1">
<rect
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="rect5"
width="512"
height="512"
x="0"
y="0.95533592"
rx="96"
ry="96" />
<path
id="rect6"
style="fill:#ffffff;stroke:#000000;stroke-width:2.90698"
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
<path
id="rect8"
style="display:inline;stroke:#000000;stroke-width:2.90698"
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
<path
id="rect9"
style="display:inline;fill:#22569d;stroke:#000000;stroke-width:2.90698"
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
<g
id="text10"
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
aria-label="[X]..&#10;[X]..&#10;[ ]..&#10;">
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path16" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path17" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
id="path18" />
</g>
</g>
<g
id="layer2"
style="display:none;opacity:0.355">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="rect15"
width="512"
height="512"
x="0"
y="0" />
<circle
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="256"
cy="256"
r="256" />
</g>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>synq</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
logo/icon-bg-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

85
logo/icon-bg-512.svg Normal file
View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title4">synq</title>
<defs
id="defs1" />
<rect
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="rect5"
width="512"
height="512"
x="0"
y="0.95533592" />
<g
id="layer1"
style="display:none">
<path
id="rect6"
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
<path
id="rect8"
style="display:inline;stroke:#000000;stroke-width:2.90698"
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
<path
id="rect9"
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
<g
id="text10"
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
aria-label="[X]..&#10;[X]..&#10;[ ]..&#10;">
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path16" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path17" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
id="path18" />
</g>
</g>
<g
id="layer2"
style="display:none;opacity:0.355">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="rect15"
width="512"
height="512"
x="0"
y="0" />
<circle
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="256"
cy="256"
r="256" />
</g>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>synq</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

86
logo/icon-fg-512.svg Normal file
View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title4">synq</title>
<defs
id="defs1" />
<g
id="layer1">
<rect
style="display:none;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="rect5"
width="512"
height="512"
x="0"
y="0.95533592"
rx="96"
ry="96" />
<path
id="rect6"
style="fill:#ffffff;stroke:#000000;stroke-width:2.90698"
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
<path
id="rect8"
style="display:inline;stroke:#000000;stroke-width:2.90698"
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
<path
id="rect9"
style="display:inline;fill:#22569d;stroke:#000000;stroke-width:2.90698"
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
<g
id="text10"
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
aria-label="[X]..&#10;[X]..&#10;[ ]..&#10;">
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path16" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path17" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
id="path18" />
</g>
</g>
<g
id="layer2"
style="display:none;opacity:0.355">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="rect15"
width="512"
height="512"
x="0"
y="0" />
<circle
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="256"
cy="256"
r="256" />
</g>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>synq</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
logo/icon-fg-ns-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

85
logo/icon-fg-ns-512.svg Normal file
View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title4">synq</title>
<defs
id="defs1" />
<rect
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="rect5"
width="512"
height="512"
x="0"
y="0.95533592" />
<g
id="layer1"
style="display:inline">
<path
id="rect6"
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
<path
id="rect8"
style="display:inline;stroke:#000000;stroke-width:2.90698"
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
<path
id="rect9"
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
<g
id="text10"
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
aria-label="[X]..&#10;[X]..&#10;[ ]..&#10;">
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path16" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path17" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
id="path18" />
</g>
</g>
<g
id="layer2"
style="display:none;opacity:0.355">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="rect15"
width="512"
height="512"
x="0"
y="0" />
<circle
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="256"
cy="256"
r="256" />
</g>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>synq</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

154
logo/icon.svg Normal file
View File

@@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
sodipodi:docname="icon.svg"
inkscape:export-filename="icon-fg-ns-512.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title4">synq</title>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="true"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="1.4803312"
inkscape:cx="259.73917"
inkscape:cy="253.65945"
inkscape:window-width="1620"
inkscape:window-height="1068"
inkscape:window-x="1979"
inkscape:window-y="146"
inkscape:window-maximized="0"
inkscape:current-layer="svg1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="4.2333333"
spacingy="4.2333333"
empcolor="#3f3fff"
empopacity="0.25098039"
color="#3f3fff"
opacity="0.1254902"
empspacing="5"
enabled="true"
visible="true"
dotted="false" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<rect
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="rect5"
width="512"
height="512"
x="0"
y="0.95533592"
inkscape:label="bg" />
<g
inkscape:label="icon"
inkscape:groupmode="layer"
id="layer1"
style="display:inline">
<path
id="rect6"
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
inkscape:label="phone"
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
<path
id="rect8"
style="display:inline;stroke:#000000;stroke-width:2.90698"
inkscape:label="phone-navbar"
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
<path
id="rect9"
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
inkscape:label="phone-screen"
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
<g
id="text10"
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
aria-label="[X]..&#10;[X]..&#10;[ ]..&#10;">
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path16" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
id="path17" />
<path
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
id="path18" />
</g>
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="circle-mask"
style="display:none;opacity:0.355">
<rect
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="rect15"
width="512"
height="512"
x="0"
y="0" />
<circle
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
id="path15"
cx="256"
cy="256"
r="256" />
</g>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>synq</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 9.1 KiB

17
server/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic
COPY app/ app/
ENV PHONE_CAPTURE_HOST=0.0.0.0
ENV PHONE_CAPTURE_PORT=8765
ENV PHONE_CAPTURE_ORG_PATH=/data/synq.org
ENV PHONE_CAPTURE_DB_PATH=/data/capture.sqlite3
EXPOSE 8765
CMD ["python", "-m", "app.main"]

0
server/app/__init__.py Normal file
View File

202
server/app/main.py Normal file
View File

@@ -0,0 +1,202 @@
import logging
import os
import secrets
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .models import CaptureRequest, CaptureResponse, HealthResponse
from .org_writer import format_capture
from .store import IdempotencyStore, get_file_lock
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("synq")
@asynccontextmanager
async def lifespan(_: FastAPI):
token = _load_token()
logger.info("=" * 60)
logger.info("synq token: %s", token)
logger.info("=" * 60)
yield
app = FastAPI(title="synq", version="0.1.0", lifespan=lifespan)
_store: IdempotencyStore | None = None
_token_cache: str | None = None
def _load_token() -> str:
global _token_cache
if _token_cache:
return _token_cache
env_token = os.environ.get("PHONE_CAPTURE_TOKEN", "").strip()
if env_token and env_token != "change-me" and env_token != "change-me-long-random-token":
_token_cache = env_token
return _token_cache
token_file = Path(os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")).parent / "token.txt"
if token_file.exists():
stored = token_file.read_text().strip()
if stored:
_token_cache = stored
logger.info("synq token loaded from %s", token_file)
return _token_cache
generated = _generate_passphrase()
token_file.parent.mkdir(parents=True, exist_ok=True)
token_file.write_text(generated)
logger.info("")
logger.info("=" * 60)
logger.info(" SYNQ TOKEN (paste into app settings):")
logger.info(" %s", generated)
logger.info("=" * 60)
logger.info("")
_token_cache = generated
return _token_cache
def _generate_passphrase(words: int = 3) -> str:
wordlist = [
"able","acid","aged","also","area","army","away","baby","back","ball",
"band","bank","base","bath","bear","beat","been","bell","best","bill",
"bird","blow","blue","bold","bolt","bone","book","born","both","bowl",
"bulk","burn","calm","came","card","care","cart","case","cash","cast",
"cave","cell","chat","chip","city","clam","clay","clip","club","coal",
"coat","code","coil","cold","come","cook","cool","cope","cord","core",
"corn","cost","cove","crew","crop","curl","dare","dark","data","date",
"dawn","days","dead","deal","dean","dear","deck","deep","deer","deft",
"deny","desk","dial","diet","disc","dish","disk","dive","dock","dome",
"door","dose","down","draw","drew","drip","drop","drum","dual","dusk",
"dust","duty","each","earn","ease","east","edge","else","even","ever",
"evil","exam","fact","fail","fair","fall","fame","farm","fast","fate",
"feed","feel","feet","fell","felt","file","fill","film","find","fire",
"firm","fish","fist","flag","flat","flew","flip","flow","foam","fold",
"folk","fond","font","food","foot","ford","fork","form","fort","free",
"from","fuel","full","fund","fuse","gain","game","gaze","gear","give",
"glad","glow","glue","goal","gold","golf","gone","good","grab","gray",
"grid","grim","grip","grow","gulf","gust","hack","hail","half","hall",
"halt","hand","hang","hard","harm","harp","hash","haul","have","hawk",
"head","heal","heap","heat","heel","held","helm","help","herb","here",
"hide","high","hill","hint","hold","hole","home","hook","hope","horn",
"host","hour","hull","hunt","hurt","icon","idea","idle","inch","into",
"iris","iron","isle","item","jade","jail","jazz","jest","join","jump",
"just","keen","keep","kelp","kern","keys","kick","kill","kind","king",
"knit","know","lack","lake","lamp","land","lane","lark","lash","last",
"late","lead","leaf","lean","leap","left","lend","lens","life","lift",
"like","lime","line","link","lion","list","live","load","loan","lock",
"loft","long","look","loop","lore","loss","loud","love","luck","lung",
"lure","mail","main","make","male","malt","many","mark","mask","mast",
"math","maze","meal","mean","meet","melt","mesh","mild","milk","mill",
"mind","mine","mint","miss","mist","mode","moon","more","moss","most",
"move","much","mule","must","nail","name","navy","near","neat","need",
"nest","news","next","nice","node","none","noon","norm","note","null",
"numb","oath","obey","odds","once","only","open","oral","over","pace",
"pack","page","paid","pain","pair","palm","park","part","pass","past",
"path","pave","peak","peel","peer","pick","pier","pile","pine","pipe",
"plan","play","plea","plot","plow","plum","plunge","plus","poem","poet",
"pole","poll","pond","pool","port","pose","post","pour","prey","pull",
"pump","pure","push","quit","race","rack","raid","rail","rain","ramp",
"rang","rank","rare","rate","read","real","reed","reel","rely","rent",
"rest","rice","rich","ride","ring","rise","risk","road","roam","roar",
"rock","role","roll","roof","root","rope","rose","rout","rule","rush",
"rust","safe","sage","sail","salt","same","sand","sang","save","scan",
"seal","seam","seed","seek","self","sell","send","sent","shed","ship",
"shop","shot","show","shut","sick","side","sign","silk","sing","sink",
"site","size","skip","slab","slam","slap","slim","slip","slot","slow",
"snap","snow","soak","soar","sock","soil","sold","sole","some","song",
"soon","sort","soul","soup","span","spin","spit","spot","spur","star",
"stay","stem","step","stop","stub","such","suit","sunk","sure","surf",
"swap","swim","tail","take","talk","tall","tank","tape","task","team",
"tell","tend","tent","term","test","text","than","then","thin","tide",
"tile","time","tint","tiny","tips","tire","toad","told","toll","tomb",
"tool","tops","toss","tour","town","trap","tree","trim","trip","trod",
"true","tube","tuck","tune","turf","turn","twin","type","upon","used",
"vary","vast","veil","vein","very","vest","view","vine","void","volt",
"vote","wade","wake","walk","wall","wand","ward","warm","warp","wary",
"wave","ways","weld","well","went","west","what","when","wide","wiki",
"wild","will","wind","wine","wire","wise","wish","with","wolf","wood",
"word","work","worn","wrap","wren","yard","year","yell","your","zero",
"zest","zinc","zone","zoom",
]
return "-".join(secrets.choice(wordlist) for _ in range(words))
def get_store() -> IdempotencyStore:
global _store
if _store is None:
db_path = os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")
_store = IdempotencyStore(db_path)
return _store
def _org_path() -> str:
return os.environ.get("PHONE_CAPTURE_ORG_PATH", "/data/synq.org")
def _token() -> str:
return _load_token()
async def check_token(request: Request) -> None:
expected = _token()
if not expected:
raise HTTPException(status_code=500, detail="server token not configured")
auth = request.headers.get("authorization", "")
scheme, _, provided = auth.partition(" ")
if scheme.lower() != "bearer" or provided != expected:
logger.warning("rejected request: invalid token from %s", request.client.host if request.client else "unknown")
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(ok=True, service="synq", version="0.1.0")
@app.post("/capture", response_model=CaptureResponse, dependencies=[Depends(check_token)])
async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get_store)) -> CaptureResponse:
if store.already_seen(payload.id):
logger.info("duplicate capture id=%s", payload.id)
return CaptureResponse(ok=True, status="already_seen", id=payload.id)
entry = format_capture(payload)
org_path = _org_path()
lock = get_file_lock()
with lock:
org_file = Path(org_path)
org_file.parent.mkdir(parents=True, exist_ok=True)
if not org_file.exists():
org_file.write_text("#+title: synq captures\n#+startup: overview\n\n", encoding="utf-8")
with open(org_path, "a", encoding="utf-8") as f:
f.write(entry)
store.mark_seen(payload.id, payload.created_at)
logger.info("accepted capture id=%s", payload.id)
return CaptureResponse(ok=True, status="accepted", id=payload.id)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = exc.errors()
fields = [str(e.get("loc", "")) for e in errors]
logger.warning("rejected payload: validation failed fields=%s from=%s", fields, request.client.host if request.client else "unknown")
for err in errors:
if "body" in err.get("loc", ()):
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
msg = str(errors[0]["msg"]) if errors else "invalid payload"
return JSONResponse(status_code=400, content={"detail": msg})
if __name__ == "__main__":
import uvicorn
host = os.environ.get("PHONE_CAPTURE_HOST", "0.0.0.0")
port = int(os.environ.get("PHONE_CAPTURE_PORT", "8765"))
uvicorn.run("app.main:app", host=host, port=port, reload=False)

45
server/app/models.py Normal file
View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, field_validator
from typing import Literal
class CaptureRequest(BaseModel):
id: str
created_at: str
kind: Literal["note", "todo"]
body: str
tags: list[str]
device: str
@field_validator("body")
@classmethod
def body_must_not_be_empty(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("body must not be empty")
return stripped
@field_validator("id")
@classmethod
def id_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("id must not be empty")
return v
@field_validator("device")
@classmethod
def device_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("device must not be empty")
return v
class CaptureResponse(BaseModel):
ok: bool
status: str
id: str
class HealthResponse(BaseModel):
ok: bool
service: str
version: str

68
server/app/org_writer.py Normal file
View File

@@ -0,0 +1,68 @@
import re
from datetime import datetime, timezone
from typing import Sequence
from .models import CaptureRequest
def normalize_tags(tags: Sequence[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for raw in tags:
tag = raw.strip().lower()
tag = tag.lstrip("#")
tag = tag.replace(" ", "_")
tag = re.sub(r"[^a-z0-9_]", "", tag)
if tag and tag not in seen:
seen.add(tag)
result.append(tag)
return result
def _org_tags(tags: list[str]) -> str:
if not tags:
return ""
return " :" + ":".join(tags) + ":"
def _created_stamp(created_at: str) -> str:
"""Parse ISO-8601 datetime and format as org inactive timestamp."""
try:
dt = datetime.fromisoformat(created_at)
except ValueError:
dt = datetime.now(timezone.utc)
day_abbr = dt.strftime("%a").lower()
return f"[{dt.strftime('%Y-%m-%d')} {day_abbr} {dt.strftime('%H:%M')}]"
def _property_drawer(created_stamp: str, source: str, capture_id: str) -> str:
return (
":PROPERTIES:\n"
f":CREATED: {created_stamp}\n"
f":SOURCE: {source}\n"
f":ID: {capture_id}\n"
":END:"
)
def format_capture(capture: CaptureRequest) -> str:
tags = normalize_tags(capture.tags)
tag_str = _org_tags(tags)
created_stamp = _created_stamp(capture.created_at)
drawer = _property_drawer(created_stamp, capture.device, capture.id)
if capture.kind == "todo":
heading = f"* TODO {capture.body}{tag_str}"
return f"{heading}\n{drawer}\n"
# note
lines = capture.body.split("\n")
first_line = lines[0].rstrip()
is_multiline = len(lines) > 1 and any(l.strip() for l in lines[1:])
if is_multiline:
heading = f"* note: {first_line}{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"
else:
heading = f"* note{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"

38
server/app/store.py Normal file
View File

@@ -0,0 +1,38 @@
import sqlite3
import threading
from pathlib import Path
_lock = threading.Lock()
def _connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS seen_ids (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)"
)
conn.commit()
return conn
class IdempotencyStore:
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = _connect(db_path)
def already_seen(self, capture_id: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM seen_ids WHERE id = ?", (capture_id,)
).fetchone()
return row is not None
def mark_seen(self, capture_id: str, created_at: str) -> None:
self._conn.execute(
"INSERT OR IGNORE INTO seen_ids (id, created_at) VALUES (?, ?)",
(capture_id, created_at),
)
self._conn.commit()
def get_file_lock() -> threading.Lock:
return _lock

22
server/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "synq-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"pydantic>=2.7",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]

0
server/tests/__init__.py Normal file
View File

118
server/tests/test_api.py Normal file
View File

@@ -0,0 +1,118 @@
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
# Set env vars before importing app so store/paths are overridden in tests
TOKEN = "test-token-abc"
@pytest.fixture(autouse=True)
def env_setup(tmp_path, monkeypatch):
db = str(tmp_path / "capture.sqlite3")
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_TOKEN", TOKEN)
monkeypatch.setenv("PHONE_CAPTURE_DB_PATH", db)
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
# reset singleton store so each test gets a fresh db
import app.main as m
m._store = None
yield
m._store = None
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
def auth_headers():
return {"Authorization": f"Bearer {TOKEN}"}
VALID_PAYLOAD = {
"id": "phone-20260517-143122-a8f2",
"created_at": "2026-05-17T14:31:22-04:00",
"kind": "todo",
"body": "buy printer paper",
"tags": ["home", "errands"],
"device": "android",
}
class TestHealth:
def test_health_returns_200(self, client):
r = client.get("/health")
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["service"] == "synq"
assert data["version"] == "0.1.0"
def test_health_no_auth_required(self, client):
r = client.get("/health")
assert r.status_code == 200
class TestCapture:
def test_valid_capture_accepted(self, client, tmp_path):
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["status"] == "accepted"
assert data["id"] == VALID_PAYLOAD["id"]
def test_valid_capture_appended_to_org(self, client, monkeypatch, tmp_path):
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
content = open(org, encoding="utf-8").read()
assert "buy printer paper" in content
assert ":ID: phone-20260517-143122-a8f2" in content
def test_duplicate_capture_returns_already_seen(self, client):
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
r = client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
assert r.status_code == 200
assert r.json()["status"] == "already_seen"
def test_duplicate_not_appended_twice(self, client, monkeypatch, tmp_path):
org = str(tmp_path / "synq.org")
monkeypatch.setenv("PHONE_CAPTURE_ORG_PATH", org)
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
client.post("/capture", json=VALID_PAYLOAD, headers=auth_headers())
content = open(org, encoding="utf-8").read()
assert content.count(VALID_PAYLOAD["id"]) == 1
def test_missing_token_rejected(self, client):
r = client.post("/capture", json=VALID_PAYLOAD)
assert r.status_code == 401
assert r.json()["detail"] == "unauthorized"
def test_wrong_token_rejected(self, client):
r = client.post("/capture", json=VALID_PAYLOAD, headers={"Authorization": "Bearer wrong"})
assert r.status_code == 401
def test_empty_body_rejected(self, client):
payload = {**VALID_PAYLOAD, "body": " "}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code == 400
assert "body" in r.json()["detail"].lower()
def test_missing_body_field_rejected(self, client):
payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "body"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code in (400, 422)
def test_invalid_kind_rejected(self, client):
payload = {**VALID_PAYLOAD, "kind": "journal"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code in (400, 422)
def test_note_kind_accepted(self, client):
payload = {**VALID_PAYLOAD, "id": "phone-20260517-143200-note1", "kind": "note"}
r = client.post("/capture", json=payload, headers=auth_headers())
assert r.status_code == 200
assert r.json()["status"] == "accepted"

View File

@@ -0,0 +1,106 @@
import pytest
from app.models import CaptureRequest
from app.org_writer import format_capture, normalize_tags
def make_capture(**kwargs) -> CaptureRequest:
defaults = dict(
id="phone-20260517-143122-a8f2",
created_at="2026-05-17T14:31:22-04:00",
kind="note",
body="hello world",
tags=[],
device="android",
)
defaults.update(kwargs)
return CaptureRequest(**defaults)
class TestNormalizeTags:
def test_lowercase(self):
assert normalize_tags(["Home", "ERRANDS"]) == ["home", "errands"]
def test_strips_hash(self):
assert normalize_tags(["#home"]) == ["home"]
def test_replaces_spaces(self):
assert normalize_tags(["my tag"]) == ["my_tag"]
def test_removes_invalid_chars(self):
assert normalize_tags(["tag!"]) == ["tag"]
def test_deduplicates(self):
assert normalize_tags(["home", "home"]) == ["home"]
def test_omits_empty(self):
assert normalize_tags(["", " "]) == []
def test_empty_list(self):
assert normalize_tags([]) == []
class TestFormatCapture:
def test_todo_with_tags(self):
c = make_capture(kind="todo", body="buy printer paper", tags=["home", "errands"])
result = format_capture(c)
assert result.startswith("* TODO buy printer paper :home:errands:\n")
assert ":CREATED:" in result
assert ":SOURCE: android" in result
assert ":ID: phone-20260517-143122-a8f2" in result
def test_todo_no_tags(self):
c = make_capture(kind="todo", body="do the thing", tags=[])
result = format_capture(c)
assert result.startswith("* TODO do the thing\n")
def test_note_single_line(self):
c = make_capture(kind="note", body="mobile capture should stay dumb and append-only.", tags=["retcon"])
result = format_capture(c)
assert result.startswith("* note :retcon:\n")
assert "mobile capture should stay dumb and append-only." in result
assert ":PROPERTIES:" in result
def test_note_no_tags(self):
c = make_capture(kind="note", body="simple note", tags=[])
result = format_capture(c)
assert result.startswith("* note\n")
assert "simple note" in result
def test_note_multiline(self):
body = "retcon capture idea\n\nphone should produce records, not edit org files."
c = make_capture(kind="note", body=body, tags=["retcon"])
result = format_capture(c)
assert result.startswith("* note: retcon capture idea :retcon:\n")
assert "retcon capture idea" in result
assert "phone should produce records, not edit org files." in result
assert ":PROPERTIES:" in result
def test_created_timestamp_format(self):
c = make_capture(created_at="2026-05-17T14:31:22-04:00")
result = format_capture(c)
assert ":CREATED: [2026-05-17 sun 14:31]" in result
def test_property_drawer_complete(self):
c = make_capture()
result = format_capture(c)
assert ":PROPERTIES:" in result
assert ":END:" in result
def test_todo_body_below_drawer_absent(self):
c = make_capture(kind="todo", body="simple todo")
result = format_capture(c)
lines = result.strip().split("\n")
# heading, drawer block — no extra body section
assert lines[0].startswith("* TODO")
assert ":END:" in result
# body should not appear after :END:
end_idx = result.index(":END:")
after_end = result[end_idx + len(":END:"):].strip()
assert after_end == ""
def test_note_body_appears_after_drawer(self):
c = make_capture(kind="note", body="my note body")
result = format_capture(c)
end_idx = result.index(":END:")
after_end = result[end_idx + len(":END:"):].strip()
assert "my note body" in after_end

408
tasks.org
View File

@@ -39,17 +39,20 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- service has tests for org formatting and duplicate handling.
* milestone 0: repo setup
** TODO create monorepo skeleton
** DONE create monorepo skeleton
*** acceptance
- `android/`, `server/`, and `docs/` directories exist.
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
*** notes
*** evidence
- commit:
- tests:
- datetime:
** TODO add root gitignore
*** notes
Skeleton committed in initial commit. android/ dir will be created in milestone 2.
*** evidence
- commit: c08b3fe
- tests: n/a (directory structure)
- datetime: [2026-05-17 Sat 00:00]
** DONE add root gitignore
*** acceptance
- ignores android build outputs.
- ignores python virtualenv/cache.
@@ -57,21 +60,25 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- ignores local env files.
- does not ignore docs or source files.
*** notes
Standard gitignore covering Python venv/cache, sqlite db files, .env, and Android build outputs.
*** evidence
- commit:
- tests:
- datetime:
** TODO document v1 scope in README
- commit: c08b3fe
- tests: n/a
- datetime: [2026-05-17 Sat 00:00]
** DONE document v1 scope in README
*** acceptance
- states capture-only scope.
- states non-goals.
- states architecture and build order.
*** notes
README covers non-goals, architecture diagram, v1 behavior, org output format, and build order.
*** evidence
- commit:
- tests:
- datetime:
** TODO add task template to tasks.org
- commit: c08b3fe
- tests: n/a
- datetime: [2026-05-17 Sat 00:00]
** DONE add task template to tasks.org
*** acceptance:
- `*** notes` section for each milestone task, to be used by you to document your decisions and hurdles overcome
- `*** evidence` section with sub bullets:
@@ -79,23 +86,40 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- `tests` describing tests run (anyone should be able to use these later)
- `datetime` the task was completed, including timestamp, eg [2026-05-17 Sun 16:06]
*** notes
Template added in 5f387df. All milestone tasks now have notes/evidence blocks.
*** evidence
- commit:
- tests:
- datetime:
- commit: 5f387df
- tests: n/a
- datetime: [2026-05-17 Sat 00:00]
* milestone 1: server mvp
** TODO create fastapi app
** DONE create fastapi app
*** acceptance
- `GET /health` returns 200 with simple json.
- `POST /capture` accepts a valid capture payload.
- auth token is required for `POST /capture`.
** TODO define pydantic capture model
*** notes
FastAPI app in server/app/main.py. Auth via Bearer token dependency injected on POST /capture. Health endpoint needs no auth.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestHealth, tests/test_api.py::TestCapture — 12 tests, all pass
- datetime: [2026-05-17 Sat 17:00]
** DONE define pydantic capture model
*** acceptance
- requires id, created_at, kind, body, tags, and device.
- kind is constrained to note/todo.
- body is trimmed and must not be empty.
- tags are normalized before formatting.
** TODO implement org formatter
*** notes
Pydantic v2 model in server/app/models.py. Body is stripped via field_validator; kind uses Literal["note","todo"]. Tag normalization lives in org_writer.py so the model stays a plain schema.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_empty_body_rejected, test_invalid_kind_rejected — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement org formatter
*** acceptance
- todo captures produce `* TODO ...`.
- note captures produce `* note ...`.
@@ -103,40 +127,94 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- tags are emitted as org heading tags.
- multiline note body is preserved.
- tests cover todo, note, tags, empty tags, and multiline body.
** TODO implement sqlite idempotency store
*** notes
Pure function format_capture() in server/app/org_writer.py. Single-line notes: heading is "* note", body below drawer. Multiline notes: heading is "* note: <first line>", full body below drawer. Tag normalization strips #, lowercases, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates.
*** evidence
- commit: e873a00
- tests: pytest tests/test_org_writer.py — 15 tests (7 normalize_tags + 8 format_capture), all pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement sqlite idempotency store
*** acceptance
- accepted capture ids are stored.
- repeated id returns accepted/already-seen without appending.
- db path is configurable.
** TODO implement append writer
*** notes
IdempotencyStore in server/app/store.py. Uses sqlite3 with a seen_ids table. DB path from PHONE_CAPTURE_DB_PATH env var. already_seen() checks before write; mark_seen() inserts after append.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_duplicate_capture_returns_already_seen, test_duplicate_not_appended_twice — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE implement append writer
*** acceptance
- appends to configured org path.
- creates file if missing.
- writes utf-8.
- appends exactly one entry per new capture.
- uses file lock or equivalent simple concurrency guard.
** TODO add server tests
*** notes
Append logic in main.py POST /capture handler. Uses a module-level threading.Lock (get_file_lock()) around open(path, "a", encoding="utf-8"). Creates parent dirs if missing. Org path from PHONE_CAPTURE_ORG_PATH env var.
*** evidence
- commit: e873a00
- tests: pytest tests/test_api.py::TestCapture::test_valid_capture_appended_to_org, test_duplicate_not_appended_twice — pass
- datetime: [2026-05-17 Sat 17:00]
** DONE add server tests
*** acceptance
- health endpoint test.
- valid capture append test.
- duplicate capture test.
- invalid token test.
- empty body rejection test.
** TODO containerize server
*** notes
28 tests total across test_api.py (12) and test_org_writer.py (16). All pass. Run with: cd server && python -m pytest tests/ -v
*** evidence
- commit: e873a00
- tests: pytest tests/ — 28 passed in 0.33s
- datetime: [2026-05-17 Sat 17:00]
** DONE containerize server
*** acceptance
- dockerfile builds server image.
- compose example maps `/data`.
- env vars configure token, org file, and sqlite path.
- container starts and exposes configured port.
*** notes
server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 8765. docker-compose.example.yml already existed in root with correct env var mapping. CMD runs python -m app.main via uvicorn.
*** evidence
- commit: e873a00
- tests: n/a (docker build not run in CI; validate manually with: docker build ./server && docker run -e PHONE_CAPTURE_TOKEN=... -p 8765:8765 -v /data:/data <image>)
- datetime: [2026-05-17 Sat 17:00]
* milestone 2: android local capture
** TODO create android project
** DONE create android project
*** acceptance
- kotlin android app builds.
- jetpack compose enabled.
- min sdk is reasonable for current personal device.
- app id is stable, e.g. `me.hgsky.phonecapture`.
** TODO build capture screen
*** notes
Gradle project in android/. AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01. minSdk=31, compileSdk=36. App ID is me.hgsky.synq. Open android/ in Android Studio and hit Sync — AS will download the Gradle wrapper (8.9) automatically.
*** evidence
- commit: 19b05a8
- tests: Gradle sync in Android Studio (manual step)
- datetime: [2026-05-18 Sun 17:00]
** DONE build capture screen
*** acceptance
- app opens to multiline text box.
- keyboard opens automatically.
@@ -144,92 +222,336 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
- tags field exists.
- save button exists.
- save and close affordance exists.
** TODO implement local room database
*** notes
CaptureScreen.kt uses LaunchedEffect(Unit) { focusRequester.requestFocus() } for auto-focus. note/todo Switch, tags OutlinedTextField, "save" OutlinedButton (stays), "save & close" Button (finishes activity). No network call on launch.
*** evidence
- commit: 19b05a8
- tests: manual — build and run on device/emulator
- datetime: [2026-05-18 Sun 17:00]
** DONE implement local room database
*** acceptance
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
- saving creates pending row.
- pending captures survive app restart.
** TODO implement capture id generation
*** notes
CaptureEntity, CaptureDao, CaptureDatabase in data/db/. Tags stored as JSON string (kotlinx-serialization). DB name synq.db, singleton via companion object. Room persists across restarts by default.
*** evidence
- commit: 19b05a8
- tests: manual — save a capture, force-close app, reopen and check history
- datetime: [2026-05-18 Sun 17:00]
** DONE implement capture id generation
*** acceptance
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
- ids are generated client-side.
- tests or simple assertions prevent blank/duplicate ids in normal flow.
** TODO implement basic history screen
*** notes
generateCaptureId() in data/CaptureId.kt. Format: phone-YYYYMMDD-HHmmss-xxxx (4 random [a-z0-9] chars). Called in CaptureViewModel.save() before Room insert.
*** evidence
- commit: 19b05a8
- tests: manual — check id column in history; each save produces a unique phone-… id
- datetime: [2026-05-18 Sun 17:00]
** DONE implement basic history screen
*** acceptance
- lists recent captures.
- shows pending/synced/failed status.
- failed row shows last error.
- user can retry failed/pending sync.
*** notes
HistoryScreen.kt observes captureDao.observeAll() as Flow. Status colored green/red/grey. Retry button shown for pending/failed; calls updateStatus(id, "pending", null) so the sync worker picks it up in milestone 3.
*** evidence
- commit: 19b05a8
- tests: manual — save captures, navigate to history, verify status labels and retry button
- datetime: [2026-05-18 Sun 17:00]
** DONE Checkout tests
[2026-05-18 Mon 12:40]
- updated Android Studio, ran Upgrade Assistant for Android Gradle Plugin to 8.13.2
- added Pixel 8 emulator
- ran a few fixes:
- `779ad6` - upped app memory to 2GB from 500MB - investigate?
- `7671416` - app.settings needs the SynqApp cast — same as dao. And it's unused in milestone 2 anyway, so just removed it
* milestone 3: android sync
** TODO implement api client
** DONE implement api client
*** acceptance
- base url configurable in app settings or build config.
- bearer token configurable in app settings or build config.
- client can call `/health`.
- client can post capture payload.
** TODO implement manual sync
*** notes
SynqApiClient in data/api/. OkHttp with 10s timeouts. checkHealth() does GET /health, returns false on any exception so callers never crash. postCapture() maps 401 → failed, non-2xx → failed, already_seen → AlreadySeen, else → Accepted. Uses org.json for request building, kotlinx-serialization for tag decoding.
*** evidence
- commit: 2a963d9
- tests: manual — sync now button, check history screen status
- datetime: [2026-05-18 Sun 18:00]
** DONE implement manual sync
*** acceptance
- sync now posts all pending captures.
- successful posts mark rows synced.
- already-seen response marks row synced.
- failures retain pending/failed status and last error.
** TODO implement opportunistic workmanager sync
*** notes
syncPending() top-level function in data/sync/SyncService.kt — shared by manual sync and WorkManager. CaptureViewModel.syncNow() calls it on Dispatchers.IO, guards with isSyncing flag. Sync icon in top bar shows CircularProgressIndicator while running.
*** evidence
- commit: 2a963d9
- tests: manual — tap sync, verify captures move to synced in history; test with wrong token, verify failed + error message
- datetime: [2026-05-18 Sun 18:00]
** DONE implement opportunistic workmanager sync
*** acceptance
- periodic sync is registered.
- sync only runs when network is available.
- worker first checks `/health`.
- worker does not block capture speed.
** TODO add simple server reachability settings
*** notes
SyncWorker (CoroutineWorker) in data/sync/. Registered in SynqApp.onCreate() as KEEP unique periodic work (15-min interval, NETWORK_CONNECTED constraint). Health check before sync — exits quietly if server unreachable. Does not touch UI thread.
*** evidence
- commit: 2a963d9
- tests: manual — let app sit on WiFi, verify pending captures eventually sync without manual trigger
- datetime: [2026-05-18 Sun 18:00]
** DONE add simple server reachability settings
*** acceptance
- default url can be set to `http://jeeves.mother:8765`.
- user can change server url.
- user can change token.
- invalid settings do not crash app.
*** notes
SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default serverUrl is http://jeeves.mother:8765. All network calls wrap exceptions so invalid URL/token never crashes — they just produce PostResult.Failed with the error message.
*** evidence
- commit: 19b05a8 (screen), 2a963d9 (wired to sync)
- tests: manual — set bad URL, tap sync, verify captures stay pending/failed with error; set correct URL + token, sync succeeds
- datetime: [2026-05-18 Sun 18:00]
* milestone 4: polish and hardening
** TODO make launch path fast
** DONE make launch path fast
*** acceptance
- app opens directly to capture field.
- no network call blocks launch.
- no history loading blocks text entry.
** TODO normalize tags
*** notes
LaunchedEffect(Unit) { focusRequester.requestFocus() } fires immediately on composition. No network calls in MainActivity or CaptureScreen init path. History loads via Room Flow on a background coroutine — never blocks text entry.
*** evidence
- commit: 19b05a8
- tests: manual — cold launch, keyboard appears without any loading state
- datetime: [2026-05-18 Sun 19:00]
** DONE normalize tags
*** acceptance
- whitespace-separated and comma-separated tags both work.
- invalid org tag chars are stripped or replaced.
- duplicate tags collapse.
** TODO handle multiline notes well
*** notes
Two-layer normalization: parseTags() in CaptureViewModel splits on [,\s]+ before storing to Room. normalize_tags() in server/app/org_writer.py lowercases, strips #, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates. 7 unit tests cover all cases.
*** evidence
- commit: e873a00 (server), 19b05a8 (android)
- tests: pytest tests/test_org_writer.py::TestNormalizeTags — 7 passed
- datetime: [2026-05-18 Sun 19:00]
** DONE fix org formatting
*** acceptance
- add `#+startup: overview` to synq.org
*** notes
Append writer in main.py checks if org file exists before first write. If missing, creates it with #+title and #+startup: overview header. Existing files are never modified.
*** evidence
- commit: fd28a45
- tests: manual — delete synq.org, post a capture, verify header present
- datetime: [2026-05-18 Sun 19:00]
** DONE handle multiline notes well
*** acceptance
- first line can become note heading if body is multiline.
- full body is preserved under heading.
- todo body remains usable as a single todo heading, with extra lines under it if present.
** TODO add basic logs
*** notes
format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: <first line>"), full body preserved below drawer. Single-line note uses "* note" heading, body below. Covered by test_note_multiline.
*** evidence
- commit: e873a00
- tests: pytest tests/test_org_writer.py::TestFormatCapture::test_note_multiline — passed
- datetime: [2026-05-17 Sat 17:00]
** DONE add basic logs
*** acceptance
- server logs accepted capture id.
- server logs duplicate capture id.
- server logs rejected payload without dumping secrets.
** TODO create backup note
*** notes
logger.info for accepted and duplicate (id only, no body). logger.warning for invalid token (client IP only, no token value logged). logger.warning for validation errors (field names only, no body content).
*** evidence
- commit: fd28a45
- tests: manual — send bad token, send empty body, check uvicorn stdout
- datetime: [2026-05-18 Sun 19:00]
** DONE create backup note
*** acceptance
- readme documents which paths need backup.
- readme documents restore behavior.
*** notes
Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.
*** evidence
- commit: 66155f6
- tests: n/a
- datetime: [2026-05-18 Sun 19:00]
* milestone 5: optional v1.5
** TODO add android share target
* milestone 5: optional v1.5 improvements
** DONE add android share target
*** acceptance
- sharing text to app opens prefilled capture.
- user can save as note or todo.
*** notes
ACTION_SEND text/plain intent-filter added to MainActivity in manifest. MainActivity.onCreate extracts EXTRA_TEXT and passes it as a URL-encoded nav argument to the capture/{prefill} route. CaptureScreen applies it via LaunchedEffect on first composition.
*** evidence
- commit: 66155f6
- tests: manual — share any text from browser/notes to synq, verify it pre-fills capture body
- datetime: [2026-05-18 Sun 20:00]
** TODO add home screen quick capture widget
*** acceptance
- widget opens capture screen quickly.
- does not require sync to work.
** TODO add recent tag chips
** DONE add recent tag chips
*** acceptance
- app shows recent tags.
- tapping chip adds/removes tag.
** TODO add edit-before-sync
*** notes
CaptureViewModel.recentTags collects the last 100 capture tagsJson rows, decodes each JSON array, flattens and deduplicates (LinkedHashSet order), limits to 20. CaptureScreen shows a FlowRow of FilterChip below the tags text field. Tapping a chip calls toggleTag() which adds/removes it from the tags string. Active chips are highlighted via FilterChip selected=true.
*** evidence
- commit: 66155f6
- tests: manual — save a few captures with tags, open capture screen, verify chips appear and toggle
- datetime: [2026-05-18 Sun 20:00]
** DONE add edit-before-sync
*** acceptance
- pending/failed captures can be edited.
- synced captures are read-only unless explicitly duplicated.
*** notes
CaptureDao.updateBody() new query. HistoryViewModel.updateBody() calls it. HistoryScreen CaptureRow shows "edit" OutlinedButton only when expanded AND status is pending/failed. Tapping edit opens an AlertDialog with an OutlinedTextField pre-filled with current body. Save calls updateBody; cancel dismisses.
*** evidence
- commit: 66155f6
- tests: manual — save a capture, open history, expand row, tap edit, change body, save, verify updated
- datetime: [2026-05-18 Sun 20:00]
** DONE tap to expand history item
*** acceptance
- tapping a history row expands/collapses it.
- expanded row shows full body.
- edit button is only visible when expanded.
*** notes
CaptureRow uses rememberSaveable expanded: Boolean keyed on capture.id. Card has clickable { expanded = !expanded }. maxLines switches between 2 and Int.MAX_VALUE. Edit button only renders when expanded && editable.
*** evidence
- commit: 66155f6
- tests: manual — tap history rows to expand/collapse, verify full body visible when expanded
- datetime: [2026-05-18 Sun 20:00]
** DONE add "Last synced" display
*** acceptance
- "last synced at" visible in history top bar and capture screen.
*** notes
CaptureDao.getLastSyncedAt() returns Flow<String?> of MAX(syncedAt). HistoryViewModel and CaptureViewModel both stateIn this flow. HistoryScreen shows it as a subtitle under "history" in the TopAppBar. CaptureScreen shows it as a small label above the save buttons. Both parse the ISO string via OffsetDateTime and format with DateTimeFormatter.ofLocalizedDateTime(SHORT).
*** evidence
- commit: 66155f6
- tests: manual — sync a capture, verify timestamp appears in history header and capture screen
- datetime: [2026-05-18 Sun 20:00]
** DONE add settings for sync frequency
*** acceptance
- user-specified poll rate in minutes (min 15).
- changing interval reschedules WorkManager immediately.
*** notes
SynqSettings gained syncIntervalMinutes: Int = 15 field with DataStore key sync_interval_minutes. SettingsScreen added OutlinedTextField with number keyboard for interval, shows supporting text "WorkManager minimum is 15 min". buildSettings() coerces to max(value, 15). SettingsViewModel.save() now also calls SyncWorker.schedule(app, settings.syncIntervalMinutes). SyncWorker.schedule() uses REPLACE policy so new interval takes effect immediately.
*** evidence
- commit: 66155f6
- tests: manual — change interval to 30, save, verify WorkManager re-queues (check adb shell dumpsys jobscheduler)
- datetime: [2026-05-18 Sun 20:00]
* milestone 6: post-ship cleanup
** DONE fix resource XML comment before <?xml declaration
*** acceptance
- android build succeeds with custom icon assets.
*** notes
Android's AAPT resource compiler requires <?xml to be the first byte of the file. Image Asset tool generated ic_launcher_background.xml, ic_launcher.xml, and ic_launcher_round.xml with an Apache license comment block before <?xml, causing ParseError at row 16. Fixed by stripping the comment from those three files. ic_launcher_foreground.xml was unaffected (no <?xml declaration). Do not use Image Asset tool for tweaks — it re-adds the comment on reimport; edit XML directly.
*** evidence
- commit: a6af2fe
- tests: manual — gradle assembleDebug succeeds
- datetime: [2026-05-19 Mon]
** DONE fix manifest missing icon references
*** acceptance
- app icon appears on device launcher after install.
*** notes
AndroidManifest.xml was missing android:icon and android:roundIcon attributes on the <application> element. Added @mipmap/ic_launcher and @mipmap/ic_launcher_round. Also committed all mipmap-* webp files and drawable/ic_launcher_background.xml (solid #22569d) and ic_launcher_foreground.xml generated by Image Asset tool.
*** evidence
- commit: a6af2fe
- tests: manual — install APK on device, verify icon appears in launcher
- datetime: [2026-05-19 Mon]
** DONE fix status bar icons washed out (API 35 edge-to-edge)
*** acceptance
- system clock, battery, wifi icons are visible over the app TopAppBar.
*** notes
Android 15 (API 35) enforces edge-to-edge regardless of enableEdgeToEdge() call — removing it had no effect. Real fix: keep enableEdgeToEdge() in MainActivity and add a SideEffect in Theme.kt that calls WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true. This tells the system to render dark (visible) status bar icons on the light TopAppBar background.
*** evidence
- commit: 68deb5c
- tests: manual — open app, verify clock/battery/wifi icons are dark and readable
- datetime: [2026-05-19 Mon]
** DONE fix keyboard covering Save button
*** acceptance
- Save and Save & Close buttons are visible above the software keyboard.
*** notes
Added imePadding() to the Column modifier in CaptureScreen. The column shrinks to fit above the keyboard rather than being obscured by it.
*** evidence
- commit: 23df95d
- tests: manual — tap body field, keyboard opens, verify save buttons remain visible
- datetime: [2026-05-19 Mon]
** DONE add sync result snackbar
*** acceptance
- brief snackbar appears after tapping sync: "synced N", "nothing to sync", or "server unreachable".
*** notes
Added MutableSharedFlow<String> snackbar emitter to CaptureViewModel. syncNow() now calls checkHealth() first and emits "server unreachable" on failure. syncPending() return type changed from Unit to Int (count of successfully synced captures). SnackbarHost added to CaptureScreen Scaffold; LaunchedEffect collects the flow and calls snackbarState.showSnackbar().
*** evidence
- commit: 4597f92
- tests: manual — tap sync with server up (see "synced N"), with server down (see "server unreachable"), with empty queue (see "nothing to sync")
- datetime: [2026-05-19 Mon]
** DONE log token on every server startup
*** acceptance
- token is clearly visible in container logs on every start, not just first run.
*** notes
lifespan() in server/app/main.py now logs the token between === lines on every startup. Previously only logged "token loaded from token.txt" quietly; now logs the token value itself so it's easy to find with docker logs synq.
*** evidence
- commit: a6af2fe
- tests: manual — docker restart synq, docker logs synq | grep token
- datetime: [2026-05-19 Mon]
** DONE default device label to Build.MODEL
*** acceptance
- fresh install shows the actual device model name, not "android".
*** notes
SettingsRepository.kt: changed deviceLabel fallback from hardcoded "android" to Build.MODEL. No permissions needed. Existing users who have already saved a label are unaffected — DataStore only uses the fallback when no value is stored.
*** evidence
- commit: 628a28a
- tests: manual — fresh install, open settings, verify device label shows model name
- datetime: [2026-05-19 Mon]
** DONE replace hex token with 3-word passphrase
*** acceptance
- generated token is human-readable and fits on one log line.
- existing token.txt files are unaffected.
*** notes
secrets.token_hex(32) produced 64 chars — too long to read/verify visually. Replaced with _generate_passphrase() which picks 3 words from a 512-word embedded list using secrets.choice, hyphenated (e.g. "coral-drift-lamp"). ~27 bits entropy, sufficient for a LAN-only service. Existing token.txt files load unchanged — generation only runs when no token exists. To rotate: delete /data/token.txt and restart the container.
*** evidence
- commit: 628a28a
- tests: manual — delete token.txt, restart container, docker logs synq shows short readable token
- datetime: [2026-05-19 Mon]
* implementation notes for coding agents
- keep the server small and testable.