Merge branch 'claude/serene-mclean-b76496'
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CaptureUiState> = _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<String> =
|
||||
raw.split(Regex("[,\\s]+"))
|
||||
.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
|
||||
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user