From 5ef072c01a1bfae184e88a258610de2205ea9276 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 08:54:38 +0000 Subject: [PATCH 1/8] feat(sync): record and show what each sync pass actually did SyncStatus was a timestamp and a boolean, so a pass that transferred a million events and one that transferred nothing both rendered as "succeeded" (ActivityWatch/aw-android#274). aw-sync returns a SyncReport across the JNI boundary now (ActivityWatch/aw-server-rust#699); the app was parsing only success/message/error out of it and discarding the aggregates. - SyncStatus carries events pulled/pushed, peers imported/skipped/failed, and warnings, with a hasReport marker so a run recorded by an older native lib still renders as before rather than as "pulled 0, pushed 0". - SyncStatus.fromJniResponse parses the payload in one place and never throws; a missing error field no longer propagates an exception. - Status persistence moved into performSyncAsync, the single choke point all four sync entry points pass through. Only the full-sync path used to record a status, so manual pull/push runs left the settings line stale. - formatSyncStatus appends the facts line and any warnings. Bumps the aw-server-rust submodule to pick up #699 (plus #705, whose port parameter the Kotlin declaration already matches, and #704 docs only). Git-Session-Id: 4b0fca5c-13ca-53f7-9910-98381b800dd3 --- aw-server-rust | 2 +- .../activitywatch/android/AWPreferences.kt | 32 +++ .../activitywatch/android/SyncInterface.kt | 90 +++++++-- .../android/SyncSettingsActivity.kt | 37 +++- .../android/SyncSettingsActivityTest.kt | 185 ++++++++++++++++++ 5 files changed, 326 insertions(+), 20 deletions(-) diff --git a/aw-server-rust b/aw-server-rust index 97f51e30..37aa0b02 160000 --- a/aw-server-rust +++ b/aw-server-rust @@ -1 +1 @@ -Subproject commit 97f51e3078e685c9d188bc80d7b37d55cb00848f +Subproject commit 37aa0b02551e8c048783b1a53405a5cbd116bd19 diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt index 140cf720..f68d8f4b 100644 --- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt +++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt @@ -93,6 +93,21 @@ class AWPreferences(context: Context) { success = sharedPreferences.getBoolean("lastSyncSucceeded", false), error = sharedPreferences.getString("lastSyncError", null) ?.takeIf { it.isNotEmpty() }, + summary = sharedPreferences.getString("lastSyncSummary", null) + ?.takeIf { it.isNotEmpty() }, + hasReport = sharedPreferences.getBoolean("lastSyncHasReport", false), + eventsPulled = sharedPreferences.getInt("lastSyncEventsPulled", 0), + eventsPushed = sharedPreferences.getInt("lastSyncEventsPushed", 0), + peersImported = sharedPreferences.getInt("lastSyncPeersImported", 0), + peersSkipped = sharedPreferences.getInt("lastSyncPeersSkipped", 0), + peersFailed = sharedPreferences.getInt("lastSyncPeersFailed", 0), + // Warnings are normalized to single lines before they get here, so a + // newline join round-trips exactly and unlike a JSON array it cannot + // fail to parse. + warnings = sharedPreferences.getString("lastSyncWarnings", null) + ?.split("\n") + ?.filter { it.isNotEmpty() } + ?: emptyList(), ) } @@ -100,12 +115,29 @@ class AWPreferences(context: Context) { val editor = sharedPreferences.edit() .putLong("lastSyncCompletedAt", status.completedAt) .putBoolean("lastSyncSucceeded", status.success) + .putBoolean("lastSyncHasReport", status.hasReport) + .putInt("lastSyncEventsPulled", status.eventsPulled) + .putInt("lastSyncEventsPushed", status.eventsPushed) + .putInt("lastSyncPeersImported", status.peersImported) + .putInt("lastSyncPeersSkipped", status.peersSkipped) + .putInt("lastSyncPeersFailed", status.peersFailed) val error = status.error?.takeIf { it.isNotEmpty() } if (error == null) { editor.remove("lastSyncError") } else { editor.putString("lastSyncError", error) } + val summary = status.summary?.takeIf { it.isNotEmpty() } + if (summary == null) { + editor.remove("lastSyncSummary") + } else { + editor.putString("lastSyncSummary", summary) + } + if (status.warnings.isEmpty()) { + editor.remove("lastSyncWarnings") + } else { + editor.putString("lastSyncWarnings", status.warnings.joinToString("\n")) + } editor.apply() appContext.sendBroadcast( android.content.Intent(LAST_SYNC_STATUS_CHANGED_ACTION).setPackage(appContext.packageName) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 9a28cc07..033f0274 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -23,9 +23,25 @@ data class SyncStatus( // JNI already returns {"success": false, "error": "..."}; keep a bounded // copy so the settings line can say why, not just that it failed. val error: String? = null, + // Per-run facts from aw-sync's SyncReport. A boolean cannot tell "moved a + // million events" from "did nothing"; these can. Every field defaults to + // the pre-SyncReport payload (activitywatch/aw-server-rust#699), so an + // older bundled native lib still parses. + val summary: String? = null, + // True only when the payload actually carried a SyncReport. The counts default + // to 0, which is indistinguishable from a real no-op pass, so the renderer + // keys off this instead of the values. + val hasReport: Boolean = false, + val eventsPulled: Int = 0, + val eventsPushed: Int = 0, + val peersImported: Int = 0, + val peersSkipped: Int = 0, + val peersFailed: Int = 0, + val warnings: List = emptyList(), ) { companion object { const val MAX_ERROR_CHARS = 500 + const val MAX_WARNINGS = 5 private val WHITESPACE = Regex("\\s+") fun normalizeError(raw: String?): String? = @@ -33,6 +49,48 @@ data class SyncStatus( ?.replace(WHITESPACE, " ") ?.take(MAX_ERROR_CHARS) ?.ifBlank { null } + + /** + * Parse a JNI sync response into a status. + * + * Never throws: an unreadable response becomes a failure carrying the + * raw text, rather than propagating an exception out of the sync path. + */ + fun fromJniResponse(response: String, completedAt: Long): SyncStatus { + val json = try { + JSONObject(response) + } catch (e: Exception) { + return SyncStatus( + completedAt = completedAt, + success = false, + error = normalizeError("Unreadable sync response: ${e.message}"), + ) + } + + val success = json.optBoolean("success", false) + val warnings = json.optJSONArray("warnings")?.let { arr -> + (0 until minOf(arr.length(), MAX_WARNINGS)) + .mapNotNull { i -> normalizeError(arr.optString(i, "")) } + } ?: emptyList() + + return SyncStatus( + completedAt = completedAt, + success = success, + error = if (success) { + null + } else { + normalizeError(json.optString("error", "")) ?: "sync failed" + }, + summary = normalizeError(json.optString("message", "")), + hasReport = json.has("events_pulled"), + eventsPulled = json.optInt("events_pulled", 0).coerceAtLeast(0), + eventsPushed = json.optInt("events_pushed", 0).coerceAtLeast(0), + peersImported = json.optInt("peers_imported", 0).coerceAtLeast(0), + peersSkipped = json.optInt("peers_skipped", 0).coerceAtLeast(0), + peersFailed = json.optInt("peers_failed", 0).coerceAtLeast(0), + warnings = warnings, + ) + } } } @@ -144,13 +202,8 @@ class SyncInterface(context: Context) { "Full Sync", { success, message -> syncInFlight.set(false) - AWPreferences(appContext).setLastSyncStatus( - SyncStatus( - completedAt = System.currentTimeMillis(), - success = success, - error = if (success) null else SyncStatus.normalizeError(message), - ) - ) + // Status persistence now happens in performSyncAsync, which has the + // parsed SyncReport; building it here again would discard the counts. callback(success, message) }, mirrorBeforeCallback @@ -194,14 +247,20 @@ class SyncInterface(context: Context) { Log.i(TAG, "Starting sync operation: $operation") try { val response = syncFn() - val json = JSONObject(response) - val success = json.getBoolean("success") + val status = SyncStatus.fromJniResponse(response, System.currentTimeMillis()) + val success = status.success val message = if (success) { - json.getString("message") + status.summary ?: "sync completed" } else { - json.getString("error") + status.error ?: "sync failed" } + // Single choke point for status persistence: every sync operation runs + // through here, and this is the only place that holds the SyncReport + // returned across the JNI boundary. Persisting per-caller (as the full-sync + // path used to) is what left pull/push runs unrecorded. + AWPreferences(appContext).setLastSyncStatus(status) + // Keep completion feedback honest: a configured SAF directory is part of a // successful Android sync, so mirror failures must reach the user instead of // being logged as a non-fatal success. Full-sync callers wait for mirroring. @@ -211,10 +270,15 @@ class SyncInterface(context: Context) { } handler.post { callback(success, message) } } catch (e: Exception) { - val errorMsg = "Exception: ${e.message}" + val status = SyncStatus( + completedAt = System.currentTimeMillis(), + success = false, + error = SyncStatus.normalizeError("Exception: ${e.message}"), + ) + AWPreferences(appContext).setLastSyncStatus(status) handler.post { Log.e(TAG, "$operation failed", e) - callback(false, errorMsg) + callback(false, status.error ?: "sync failed") } } finally { executor.shutdown() diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt index 19027085..dace8df5 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt @@ -27,14 +27,39 @@ internal fun formatSyncStatus(status: SyncStatus?, dateFormat: DateFormat): Stri if (status == null) return "Last sync: never" val whenText = dateFormat.format(Date(status.completedAt)) - if (status.success) return "Last sync succeeded at $whenText" - - val error = SyncStatus.normalizeError(status.error) - return if (error != null) { - "Last sync failed at $whenText: $error" + val headline = if (status.success) { + "Last sync succeeded at $whenText" } else { - "Last sync failed at $whenText" + val error = SyncStatus.normalizeError(status.error) + if (error != null) { + "Last sync failed at $whenText: $error" + } else { + "Last sync failed at $whenText" + } + } + // Runs recorded before the SyncReport crossed the JNI boundary carry no + // counts; showing "pulled 0, pushed 0" for them would invent an answer. + if (!status.hasReport) return headline + return "$headline\n${formatSyncDetail(status)}" +} + +/** + * The per-run facts line: what moved and which peers it came from. Without + * this, a pass that transferred nothing is indistinguishable from one that + * transferred everything — the failure mode of a boolean-only status. + */ +internal fun formatSyncDetail(status: SyncStatus): String { + val peers = status.peersImported + status.peersSkipped + status.peersFailed + val parts = mutableListOf("pulled ${status.eventsPulled}, pushed ${status.eventsPushed}") + if (peers > 0) { + var peerText = "peers ${status.peersImported}/$peers imported" + if (status.peersSkipped > 0) peerText += ", ${status.peersSkipped} skipped" + if (status.peersFailed > 0) peerText += ", ${status.peersFailed} failed" + parts += peerText } + val line = parts.joinToString(" · ") + if (status.warnings.isEmpty()) return line + return (listOf(line) + status.warnings).joinToString("\n") } class SyncSettingsActivity : AppCompatActivity() { diff --git a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt index 10a7e9f7..3fe62f6c 100644 --- a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt @@ -80,4 +80,189 @@ class SyncSettingsActivityTest { val normalized = SyncStatus.normalizeError(raw) assertEquals(SyncStatus.MAX_ERROR_CHARS, normalized!!.length) } + + @Test + fun formatSyncStatus_omitsCountsWhenPayloadCarriedNoReport() { + // Pre-SyncReport payloads must render exactly as they did before. + assertEquals( + "Last sync succeeded at 2026-09-01 01:30", + formatSyncStatus( + SyncStatus(completedAt = 1_788_226_200_000L, success = true, hasReport = false), + dateFormat, + ), + ) + } + + @Test + fun formatSyncStatus_showsThatASuccessfulPassMovedNothing() { + // The whole point of #274: a no-op success must not read as "succeeded". + assertEquals( + "Last sync succeeded at 2026-09-01 01:30\npulled 0, pushed 0", + formatSyncStatus( + SyncStatus(completedAt = 1_788_226_200_000L, success = true, hasReport = true), + dateFormat, + ), + ) + } + + @Test + fun formatSyncStatus_reportsCountsAndPeerOutcomes() { + assertEquals( + "Last sync succeeded at 2026-09-01 01:30\n" + + "pulled 1200, pushed 3 · peers 2/4 imported, 1 skipped, 1 failed", + formatSyncStatus( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + eventsPulled = 1200, + eventsPushed = 3, + peersImported = 2, + peersSkipped = 1, + peersFailed = 1, + ), + dateFormat, + ), + ) + } + + @Test + fun formatSyncStatus_listsWarningsBelowTheCounts() { + assertEquals( + "Last sync succeeded at 2026-09-01 01:30\n" + + "pulled 0, pushed 0\n" + + "no readable peers in sync folder\n" + + "push aborted after pull failure", + formatSyncStatus( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + warnings = listOf( + "no readable peers in sync folder", + "push aborted after pull failure", + ), + ), + dateFormat, + ), + ) + } + + @Test + fun formatSyncStatus_showsCountsBesideAFailure() { + assertEquals( + "Last sync failed at 2026-09-01 01:30: push failed: no such host\n" + + "pulled 12, pushed 0 · peers 1/2 imported, 1 failed", + formatSyncStatus( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = false, + error = "push failed: no such host", + hasReport = true, + eventsPulled = 12, + peersImported = 1, + peersFailed = 1, + ), + dateFormat, + ), + ) + } + + @Test + fun fromJniResponse_parsesSyncReport() { + val status = SyncStatus.fromJniResponse( + """ + { + "success": true, + "message": "Synced 1200 events in from 2/4 peers (1 skipped, 1 failed)", + "events_pulled": 1200, + "events_pushed": 3, + "peers_imported": 2, + "peers_skipped": 1, + "peers_failed": 1, + "warnings": ["push aborted after pull failure"] + } + """.trimIndent(), + completedAt = 1_788_226_200_000L, + ) + + assertEquals(true, status.success) + assertEquals(true, status.hasReport) + assertEquals(1200, status.eventsPulled) + assertEquals(3, status.eventsPushed) + assertEquals(2, status.peersImported) + assertEquals(1, status.peersSkipped) + assertEquals(1, status.peersFailed) + assertEquals(listOf("push aborted after pull failure"), status.warnings) + assertEquals(null, status.error) + } + + @Test + fun fromJniResponse_acceptsPayloadWithoutReportFields() { + val status = SyncStatus.fromJniResponse( + """{"success": true, "message": "Successfully pulled from all hosts"}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(true, status.success) + assertEquals(false, status.hasReport) + assertEquals(0, status.eventsPulled) + } + + @Test + fun fromJniResponse_treatsErrorPayloadAsFailure() { + val status = SyncStatus.fromJniResponse( + """{"success": false, "error": "Sync pull failed: connection refused"}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(false, status.success) + assertEquals("Sync pull failed: connection refused", status.error) + assertEquals(false, status.hasReport) + } + + @Test + fun fromJniResponse_neverThrowsOnUnreadableResponse() { + val status = SyncStatus.fromJniResponse( + "not json at all", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(false, status.success) + assertEquals(true, status.error!!.startsWith("Unreadable sync response:")) + } + + @Test + fun fromJniResponse_fallsBackToGenericErrorWhenReasonMissing() { + val status = SyncStatus.fromJniResponse( + """{"success": false}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals("sync failed", status.error) + } + + @Test + fun fromJniResponse_capsAndNormalizesWarnings() { + val warnings = "\"first\\n warning\"," + + (2..6).joinToString(",") { "\"warning $it\"" } + val status = SyncStatus.fromJniResponse( + """{"success": true, "events_pulled": 0, "warnings": [$warnings]}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(SyncStatus.MAX_WARNINGS, status.warnings.size) + assertEquals("first warning", status.warnings[0]) + } + + @Test + fun fromJniResponse_ignoresNegativeCounts() { + val status = SyncStatus.fromJniResponse( + """{"success": true, "events_pulled": -5, "events_pushed": 2}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(0, status.eventsPulled) + assertEquals(2, status.eventsPushed) + } } From c6cced7c821515da2c586cd5894b0b6e7be98749 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 09:46:42 +0000 Subject: [PATCH 2/8] fix(sync): keep the native sync report when SAF mirroring fails The success status (with SyncReport counts/warnings) is persisted before SAF mirroring runs for full syncs. If mirroring then threw, the catch path replaced the persisted status with a report-less failure, erasing what the pass actually did. Keep the native status and record the mirror failure on top of it, preserving counts and warnings. Git-Session-Id: 029e7f5e-0490-56c7-ad5f-5d0a05d90ea0 --- .../activitywatch/android/SyncInterface.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 033f0274..16dd2378 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -245,9 +245,14 @@ class SyncInterface(context: Context) { executor.execute { Log.i(TAG, "Starting sync operation: $operation") + // Native-sync report kept for the catch path: when mirroring fails after + // a successful sync, the failure status must still carry the report + // (counts/warnings) instead of erasing what the pass actually did. + var nativeStatus: SyncStatus? = null try { val response = syncFn() val status = SyncStatus.fromJniResponse(response, System.currentTimeMillis()) + nativeStatus = status val success = status.success val message = if (success) { status.summary ?: "sync completed" @@ -270,11 +275,23 @@ class SyncInterface(context: Context) { } handler.post { callback(success, message) } } catch (e: Exception) { - val status = SyncStatus( - completedAt = System.currentTimeMillis(), - success = false, - error = SyncStatus.normalizeError("Exception: ${e.message}"), - ) + val native = nativeStatus + val status = if (native != null && native.success) { + // The native sync already completed (and was persisted); this + // failure came from the post-sync step, so keep its report. + val step = if (mirrorBeforeCallback) "SAF mirroring failed" else "post-sync step failed" + native.copy( + completedAt = System.currentTimeMillis(), + success = false, + error = SyncStatus.normalizeError("$step: ${e.message}"), + ) + } else { + SyncStatus( + completedAt = System.currentTimeMillis(), + success = false, + error = SyncStatus.normalizeError("Exception: ${e.message}"), + ) + } AWPreferences(appContext).setLastSyncStatus(status) handler.post { Log.e(TAG, "$operation failed", e) From a97cf33303506746513b46f511fdd9adfd9bc1be Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 10:09:11 +0000 Subject: [PATCH 3/8] chore(deps): bump aw-server-rust to 3d3b9dd (#678) Pins the submodule at #678 (reconcile owner-originated event edits), which landed six minutes after the previous pin 37aa0b0 and is directly relevant to the sync reporting this PR consumes. Git-Session-Id: 38fdae10-a8ad-5df2-bf53-99533d65f74c --- aw-server-rust | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aw-server-rust b/aw-server-rust index 37aa0b02..3d3b9dd1 160000 --- a/aw-server-rust +++ b/aw-server-rust @@ -1 +1 @@ -Subproject commit 37aa0b02551e8c048783b1a53405a5cbd116bd19 +Subproject commit 3d3b9dd106f185e5d1f7d90bf336e548261f9047 From a226ec76b47702d94e2c7b3f8b802f7433e2d5d9 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 10:40:00 +0000 Subject: [PATCH 4/8] fix(sync): persist full-sync status only after SAF mirroring completes Greptile P1 (thread 4034960583): for a full sync with a configured SAF directory, the success status was persisted before mirroring finished, so the settings UI could show a completed sync while the mirror was still running. The persist now happens after the mirror for mirroring operations; non-mirroring operations keep the immediate persist. The catch-path report retention is unchanged. Git-Session-Id: 93c0a634-35df-50c7-a1d8-36d5fa94d03c --- .../net/activitywatch/android/SyncInterface.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 16dd2378..b3dc3825 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -264,21 +264,22 @@ class SyncInterface(context: Context) { // through here, and this is the only place that holds the SyncReport // returned across the JNI boundary. Persisting per-caller (as the full-sync // path used to) is what left pull/push runs unrecorded. - AWPreferences(appContext).setLastSyncStatus(status) - - // Keep completion feedback honest: a configured SAF directory is part of a - // successful Android sync, so mirror failures must reach the user instead of - // being logged as a non-fatal success. Full-sync callers wait for mirroring. + // + // A configured SAF directory is part of a successful Android sync, so for + // full syncs the persist happens only AFTER mirroring completes: the + // settings UI must never show a completed sync while the mirror is still + // running. Non-mirroring operations persist immediately as before. Log.i(TAG, "$operation completed: success=$success, message=$message") if (success && mirrorBeforeCallback) { mirrorSyncFilesToSafDir() } + AWPreferences(appContext).setLastSyncStatus(status) handler.post { callback(success, message) } } catch (e: Exception) { val native = nativeStatus val status = if (native != null && native.success) { - // The native sync already completed (and was persisted); this - // failure came from the post-sync step, so keep its report. + // The native sync itself completed; this failure came from the + // post-sync step (mirroring or callback), so keep its report. val step = if (mirrorBeforeCallback) "SAF mirroring failed" else "post-sync step failed" native.copy( completedAt = System.currentTimeMillis(), From a4ca0c73d6eebf106019d8cc6308dd5613a96b45 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 11:59:06 +0000 Subject: [PATCH 5/8] fix(sync): cap warnings after blank-filtering so trailing warnings survive A payload whose first five warning entries normalize to blank would drop any later meaningful warning. Filter blanks first, then cap. Git-Session-Id: 3794dab5-f699-5115-ade7-bcdd99be743d --- .../src/main/java/net/activitywatch/android/SyncInterface.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index b3dc3825..8a8a5046 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -69,8 +69,11 @@ data class SyncStatus( val success = json.optBoolean("success", false) val warnings = json.optJSONArray("warnings")?.let { arr -> - (0 until minOf(arr.length(), MAX_WARNINGS)) + (0 until arr.length()) .mapNotNull { i -> normalizeError(arr.optString(i, "")) } + // Cap after blank-filtering: capping first would drop a + // meaningful warning trailing five blank entries. + .take(MAX_WARNINGS) } ?: emptyList() return SyncStatus( From f726b05b97cb9d7ed22db4e360b303f4249c2a04 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 12:05:12 +0000 Subject: [PATCH 6/8] fix(sync): key hasReport on any report field so push-only passes render counts Git-Session-Id: 3794dab5-f699-5115-ade7-bcdd99be743d --- .../java/net/activitywatch/android/SyncInterface.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 8a8a5046..0b37cd91 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -42,6 +42,14 @@ data class SyncStatus( companion object { const val MAX_ERROR_CHARS = 500 const val MAX_WARNINGS = 5 + private val REPORT_KEYS = listOf( + "events_pulled", + "events_pushed", + "peers_imported", + "peers_skipped", + "peers_failed", + "warnings", + ) private val WHITESPACE = Regex("\\s+") fun normalizeError(raw: String?): String? = @@ -85,7 +93,9 @@ data class SyncStatus( normalizeError(json.optString("error", "")) ?: "sync failed" }, summary = normalizeError(json.optString("message", "")), - hasReport = json.has("events_pulled"), + // Any report field counts: a push-only pass carries events_pushed + // and peer counts but may omit events_pulled. + hasReport = REPORT_KEYS.any { json.has(it) }, eventsPulled = json.optInt("events_pulled", 0).coerceAtLeast(0), eventsPushed = json.optInt("events_pushed", 0).coerceAtLeast(0), peersImported = json.optInt("peers_imported", 0).coerceAtLeast(0), From ff3472f06380a0a81c24a2f4f33efee7d0e4bdf2 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 13:21:08 +0000 Subject: [PATCH 7/8] fix(sync): never let status persistence suppress the sync callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performSyncAsync posted its completion callback only after AWPreferences.setLastSyncStatus() returned. If persistence threw — in the try path or, worse, in the catch path's own persist — the callback was never posted. Callers use it to clear syncInFlight, so a single persistence failure would leave every later sync rejected as "already in flight" and the UI waiting. Route both persist calls through persistSyncStatus(), which logs and swallows the failure. A sync whose status could not be stored is not a failed sync, and the callback contract must not depend on storage health. Git-Session-Id: 6b9bee26-4b2b-533a-bb4e-97220cf3780c --- .../activitywatch/android/SyncInterface.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 0b37cd91..6c0b679e 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -286,13 +286,14 @@ class SyncInterface(context: Context) { if (success && mirrorBeforeCallback) { mirrorSyncFilesToSafDir() } - AWPreferences(appContext).setLastSyncStatus(status) + persistSyncStatus(status) handler.post { callback(success, message) } } catch (e: Exception) { val native = nativeStatus val status = if (native != null && native.success) { // The native sync itself completed; this failure came from the - // post-sync step (mirroring or callback), so keep its report. + // post-sync step (mirroring, or delivering the callback), so keep + // its report. val step = if (mirrorBeforeCallback) "SAF mirroring failed" else "post-sync step failed" native.copy( completedAt = System.currentTimeMillis(), @@ -306,7 +307,7 @@ class SyncInterface(context: Context) { error = SyncStatus.normalizeError("Exception: ${e.message}"), ) } - AWPreferences(appContext).setLastSyncStatus(status) + persistSyncStatus(status) handler.post { Log.e(TAG, "$operation failed", e) callback(false, status.error ?: "sync failed") @@ -317,6 +318,25 @@ class SyncInterface(context: Context) { } } + /** + * Persist the terminal status without letting a storage/broadcast failure + * escape. + * + * The completion callback is the caller's only signal that a pass ended — it + * is what clears [syncInFlight] in [syncBothAsync] and stops the UI waiting. + * If persistence threw inside [performSyncAsync]'s try (or, worse, inside its + * catch), the callback would never be posted and every later sync would be + * rejected as "already in flight". Persistence failing is not the sync + * failing, so it is logged and the callback still fires. + */ + private fun persistSyncStatus(status: SyncStatus) { + try { + AWPreferences(appContext).setLastSyncStatus(status) + } catch (e: Exception) { + Log.e(TAG, "Failed to persist sync status", e) + } + } + private fun mirrorSyncFilesToSafDir() { copySyncFilesToSafDir() } From 63ad76ea9a9f7bd9e1e7280065d7f0ce6ef4b9c2 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 13:34:19 +0000 Subject: [PATCH 8/8] fix(sync): key hasReport on count fields only, not warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker decides whether the renderer appends the counts line. Including `warnings` in the key list let a payload that carried warnings but no count fields mark itself as report-bearing, so formatSyncStatus would render "pulled 0, pushed 0" — inventing a no-op pass out of a report that never supplied numbers. A pass that reports counts still marks itself through those keys, so push-only responses (events_pushed + peer counts, no events_pulled) are unaffected. Git-Session-Id: 6b9bee26-4b2b-533a-bb4e-97220cf3780c --- .../src/main/java/net/activitywatch/android/SyncInterface.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt index 6c0b679e..7bd048ed 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -42,13 +42,16 @@ data class SyncStatus( companion object { const val MAX_ERROR_CHARS = 500 const val MAX_WARNINGS = 5 + // Count keys only. `warnings` is deliberately excluded: it is not a + // count, so a payload carrying warnings but no counts would mark + // hasReport and render an invented "pulled 0, pushed 0" line for a + // pass that never reported its numbers. private val REPORT_KEYS = listOf( "events_pulled", "events_pushed", "peers_imported", "peers_skipped", "peers_failed", - "warnings", ) private val WHITESPACE = Regex("\\s+")