diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt index 6614e440..e63ae002 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt @@ -31,8 +31,15 @@ private const val TAG = "SyncSettingsActivity" // of flipping to "due now". private const val NEXT_SYNC_REFRESH_INTERVAL_MS = 30 * 1000L -internal fun formatSyncStatus(status: SyncStatus?, dateFormat: DateFormat): String { - if (status == null) return "Last sync: never" +internal fun formatSyncStatus( + status: SyncStatus?, + dateFormat: DateFormat, + isPushOnlyDevice: Boolean = false, +): String { + // Android is push-only until sync v2 ships; append a fixed note so users know + // pull is expected to be empty and won't arrive until v2. + val pushOnlyNote = if (isPushOnlyDevice) "\nPush-only on this device (pull arrives with sync v2)" else "" + if (status == null) return "Last sync: never$pushOnlyNote" val whenText = dateFormat.format(Date(status.completedAt)) val headline = if (status.success) { @@ -47,8 +54,8 @@ internal fun formatSyncStatus(status: SyncStatus?, dateFormat: DateFormat): Stri } // 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)}" + if (!status.hasReport) return "$headline$pushOnlyNote" + return "$headline\n${formatSyncDetail(status, isPushOnlyDevice)}$pushOnlyNote" } /** @@ -80,7 +87,7 @@ internal fun formatNextSyncStatus( * 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 { +internal fun formatSyncDetail(status: SyncStatus, isPushOnlyDevice: Boolean = false): String { val peers = status.peersImported + status.peersSkipped + status.peersFailed val parts = mutableListOf("pulled ${status.eventsPulled}, pushed ${status.eventsPushed}") if (peers > 0) { @@ -90,8 +97,12 @@ internal fun formatSyncDetail(status: SyncStatus): String { parts += peerText } val line = parts.joinToString(" · ") - if (status.warnings.isEmpty()) return line - return (listOf(line) + status.warnings).joinToString("\n") + // The "zero peers in a configured sync dir" warning is a desktop diagnostic + // (aw-server-rust#687) — not meaningful on Android where pull is a no-op by + // construction (SAF mirror is outbound-only until sync v2). + val warnings = if (isPushOnlyDevice) status.warnings.filter { "zero peers" !in it } else status.warnings + if (warnings.isEmpty()) return line + return (listOf(line) + warnings).joinToString("\n") } class SyncSettingsActivity : AppCompatActivity() { @@ -251,6 +262,7 @@ class SyncSettingsActivity : AppCompatActivity() { tvLastSyncStatus.text = formatSyncStatus( prefs.getLastSyncStatus(), combinedDateTimeFormat(), + isPushOnlyDevice = true, ) } diff --git a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt index 7651a151..71cc207e 100644 --- a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt @@ -320,6 +320,61 @@ class SyncSettingsActivityTest { assertEquals("first warning", status.warnings[0]) } + @Test + fun formatSyncStatus_pushOnlyDevice_suppressesZeroPeersWarning() { + // aw-server-rust#687 "zero peers" warning is desktop-only; Android is push-only + // until sync v2, so the warning must be filtered and replaced by a push-only note. + assertEquals( + "Last sync succeeded at 2026-09-01 01:30\n" + + "pulled 0, pushed 0\n" + + "Push-only on this device (pull arrives with sync v2)", + formatSyncStatus( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + warnings = listOf( + "zero peers in a configured sync dir is usually a layout or setup problem", + ), + ), + dateFormat, + isPushOnlyDevice = true, + ), + ) + } + + @Test + fun formatSyncStatus_pushOnlyDevice_preservesOtherWarnings() { + // Non-zero-peers warnings (e.g. "push aborted after pull failure") still surface. + assertEquals( + "Last sync succeeded at 2026-09-01 01:30\n" + + "pulled 0, pushed 0\n" + + "push aborted after pull failure\n" + + "Push-only on this device (pull arrives with sync v2)", + formatSyncStatus( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + warnings = listOf( + "zero peers in a configured sync dir is usually a layout or setup problem", + "push aborted after pull failure", + ), + ), + dateFormat, + isPushOnlyDevice = true, + ), + ) + } + + @Test + fun formatSyncStatus_pushOnlyDevice_appendsNoteEvenWhenNeverSynced() { + assertEquals( + "Last sync: never\nPush-only on this device (pull arrives with sync v2)", + formatSyncStatus(null, dateFormat, isPushOnlyDevice = true), + ) + } + @Test fun fromJniResponse_ignoresNegativeCounts() { val status = SyncStatus.fromJniResponse(