Merge branch 'claude/serene-mclean-b76496'
This commit is contained in:
@@ -3,8 +3,14 @@ package me.hgsky.synq
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import me.hgsky.synq.data.SettingsRepository
|
import me.hgsky.synq.data.SettingsRepository
|
||||||
import me.hgsky.synq.data.db.CaptureDatabase
|
import me.hgsky.synq.data.db.CaptureDatabase
|
||||||
|
import me.hgsky.synq.data.sync.SyncWorker
|
||||||
|
|
||||||
class SynqApp : Application() {
|
class SynqApp : Application() {
|
||||||
val db by lazy { CaptureDatabase.get(this) }
|
val db by lazy { CaptureDatabase.get(this) }
|
||||||
val settings by lazy { SettingsRepository(this) }
|
val settings by lazy { SettingsRepository(this) }
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
SyncWorker.schedule(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -53,6 +56,19 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("synq") },
|
title = { Text("synq") },
|
||||||
actions = {
|
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") }) {
|
IconButton(onClick = { nav.navigate("history") }) {
|
||||||
Icon(Icons.Default.History, contentDescription = "history")
|
Icon(Icons.Default.History, contentDescription = "history")
|
||||||
}
|
}
|
||||||
@@ -105,9 +121,7 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -122,6 +136,8 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
enabled = state.body.isNotBlank(),
|
enabled = state.body.isNotBlank(),
|
||||||
) { Text("save & close") }
|
) { Text("save & close") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ package me.hgsky.synq.ui.capture
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import me.hgsky.synq.SynqApp
|
import me.hgsky.synq.SynqApp
|
||||||
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
import me.hgsky.synq.data.generateCaptureId
|
import me.hgsky.synq.data.generateCaptureId
|
||||||
import me.hgsky.synq.data.db.CaptureEntity
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import me.hgsky.synq.data.sync.syncPending
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@@ -19,11 +23,13 @@ data class CaptureUiState(
|
|||||||
val body: String = "",
|
val body: String = "",
|
||||||
val isTodo: Boolean = false,
|
val isTodo: Boolean = false,
|
||||||
val tags: String = "",
|
val tags: String = "",
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
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())
|
private val _state = MutableStateFlow(CaptureUiState())
|
||||||
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
||||||
@@ -51,11 +57,27 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
status = "pending",
|
status = "pending",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_state.value = CaptureUiState()
|
_state.value = _state.value.copy(body = "", tags = "")
|
||||||
onDone()
|
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<String> =
|
private fun parseTags(raw: String): List<String> =
|
||||||
raw.split(Regex("[,\\s]+"))
|
raw.split(Regex("[,\\s]+"))
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
|
|||||||
35
tasks.org
35
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
|
- `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
|
* milestone 3: android sync
|
||||||
** TODO implement api client
|
** DONE implement api client
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- base url configurable in app settings or build config.
|
- base url configurable in app settings or build config.
|
||||||
- bearer token configurable in app settings or build config.
|
- bearer token configurable in app settings or build config.
|
||||||
- client can call `/health`.
|
- client can call `/health`.
|
||||||
- client can post capture payload.
|
- 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
|
*** acceptance
|
||||||
- sync now posts all pending captures.
|
- sync now posts all pending captures.
|
||||||
- successful posts mark rows synced.
|
- successful posts mark rows synced.
|
||||||
- already-seen response marks row synced.
|
- already-seen response marks row synced.
|
||||||
- failures retain pending/failed status and last error.
|
- 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
|
*** acceptance
|
||||||
- periodic sync is registered.
|
- periodic sync is registered.
|
||||||
- sync only runs when network is available.
|
- sync only runs when network is available.
|
||||||
- worker first checks `/health`.
|
- worker first checks `/health`.
|
||||||
- worker does not block capture speed.
|
- 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
|
*** acceptance
|
||||||
- default url can be set to `http://jeeves.mother:8765`.
|
- default url can be set to `http://jeeves.mother:8765`.
|
||||||
- user can change server url.
|
- user can change server url.
|
||||||
- user can change token.
|
- user can change token.
|
||||||
- invalid settings do not crash app.
|
- 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
|
* milestone 4: polish and hardening
|
||||||
** TODO make launch path fast
|
** TODO make launch path fast
|
||||||
|
|||||||
Reference in New Issue
Block a user