diff --git a/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt b/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt index 3b07337..21189f7 100644 --- a/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt +++ b/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt @@ -3,8 +3,14 @@ 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) + } } diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/api/SynqApiClient.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/api/SynqApiClient.kt new file mode 100644 index 0000000..cb236dd --- /dev/null +++ b/android/app/src/main/kotlin/me/hgsky/synq/data/api/SynqApiClient.kt @@ -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>(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") + } +} diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncService.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncService.kt new file mode 100644 index 0000000..4f24d20 --- /dev/null +++ b/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncService.kt @@ -0,0 +1,23 @@ +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) { + val pending = dao.getPendingAndFailed() + 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) + } + is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error) + } + } +} diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncWorker.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncWorker.kt new file mode 100644 index 0000000..6bbbccb --- /dev/null +++ b/android/app/src/main/kotlin/me/hgsky/synq/data/sync/SyncWorker.kt @@ -0,0 +1,48 @@ +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) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt index 0716bda..2bd9bd5 100644 --- a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt +++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt @@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.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.Icon import androidx.compose.material3.IconButton @@ -53,6 +56,19 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) { 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") } @@ -105,9 +121,7 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) { ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedButton( @@ -122,6 +136,8 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) { enabled = state.body.isNotBlank(), ) { Text("save & close") } } + + Spacer(Modifier.height(4.dp)) } } } diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt index 2216b37..63624b5 100644 --- a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt +++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt @@ -3,15 +3,19 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first 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 @@ -19,11 +23,13 @@ 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 dao = (app as SynqApp).db.captureDao() + private val synqApp = app as SynqApp + private val dao = synqApp.db.captureDao() private val _state = MutableStateFlow(CaptureUiState()) val state: StateFlow = _state.asStateFlow() @@ -51,11 +57,27 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) { status = "pending", ) ) - _state.value = CaptureUiState() + _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)) { + syncPending(dao, client, settings) + } + } finally { + _state.value = _state.value.copy(isSyncing = false) + } + } + } + private fun parseTags(raw: String): List = raw.split(Regex("[,\\s]+")) .map { it.trim() } diff --git a/tasks.org b/tasks.org index 14130f8..5bc3783 100644 --- a/tasks.org +++ b/tasks.org @@ -275,30 +275,57 @@ HistoryScreen.kt observes captureDao.observeAll() as Flow. Status colored green/ - `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