diff --git a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt index 6c4ce7ff..cb4114de 100644 --- a/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt +++ b/mobile/src/main/java/net/activitywatch/android/AWPreferences.kt @@ -119,6 +119,9 @@ class AWPreferences(context: Context) { ?.split("\n") ?.filter { it.isNotEmpty() } ?: emptyList(), + peers = SyncStatus.decodePeers( + sharedPreferences.getString("lastSyncPeers", null), + ), ) } @@ -149,6 +152,12 @@ class AWPreferences(context: Context) { } else { editor.putString("lastSyncWarnings", status.warnings.joinToString("\n")) } + val peersEncoded = SyncStatus.encodePeers(status.peers) + if (peersEncoded == null) { + editor.remove("lastSyncPeers") + } else { + editor.putString("lastSyncPeers", peersEncoded) + } 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 6d46a56a..001d619d 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncInterface.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.system.Os import android.util.Log import androidx.documentfile.provider.DocumentFile +import org.json.JSONArray import org.json.JSONObject import java.io.File import java.io.FileInputStream @@ -17,6 +18,17 @@ import java.util.concurrent.atomic.AtomicBoolean private const val TAG = "SyncInterface" +/** + * Per-peer summary from a single sync pass. Carries only the fields needed for + * the UI: hostname (human-readable device name) and outcome kind. Full detail + * (buckets, path) stays in the Rust layer. + */ +data class SyncPeer( + val hostname: String, + // "imported", "skipped", or "failed" — the "kind" field from PeerOutcome + val outcome: String, +) + data class SyncStatus( val completedAt: Long, val success: Boolean, @@ -38,6 +50,9 @@ data class SyncStatus( val peersSkipped: Int = 0, val peersFailed: Int = 0, val warnings: List = emptyList(), + // Per-peer breakdown from the "peers" array in the JNI response. Empty for + // older native libs that pre-date the SyncReport JNI output. + val peers: List = emptyList(), ) { companion object { const val MAX_ERROR_CHARS = 500 @@ -87,6 +102,15 @@ data class SyncStatus( .take(MAX_WARNINGS) } ?: emptyList() + val peers = json.optJSONArray("peers")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + val peer = arr.optJSONObject(i) ?: return@mapNotNull null + val hostname = peer.optString("hostname", "").ifBlank { return@mapNotNull null } + val outcome = peer.optJSONObject("outcome")?.optString("kind", "") ?: "" + SyncPeer(hostname = hostname, outcome = outcome) + } + } ?: emptyList() + return SyncStatus( completedAt = completedAt, success = success, @@ -105,8 +129,42 @@ data class SyncStatus( peersSkipped = json.optInt("peers_skipped", 0).coerceAtLeast(0), peersFailed = json.optInt("peers_failed", 0).coerceAtLeast(0), warnings = warnings, + peers = peers, ) } + + /** + * SharedPreferences encoding for [peers]. Null means "store nothing" + * (empty list). Decode never throws: a corrupt prefs value becomes + * empty rather than crashing the settings screen. + */ + fun encodePeers(peers: List): String? { + if (peers.isEmpty()) return null + val arr = JSONArray() + for (peer in peers) { + arr.put( + JSONObject() + .put("hostname", peer.hostname) + .put("outcome", peer.outcome), + ) + } + return arr.toString() + } + + fun decodePeers(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return try { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { i -> + val obj = arr.optJSONObject(i) ?: return@mapNotNull null + val hostname = obj.optString("hostname", "").ifBlank { return@mapNotNull null } + val outcome = obj.optString("outcome", "") + SyncPeer(hostname = hostname, outcome = outcome) + } + } catch (_: Exception) { + emptyList() + } + } } } diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt index e63ae002..4c2411a1 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt @@ -88,12 +88,20 @@ internal fun formatNextSyncStatus( * transferred everything — the failure mode of a boolean-only status. */ internal fun formatSyncDetail(status: SyncStatus, isPushOnlyDevice: Boolean = false): String { - val peers = status.peersImported + status.peersSkipped + status.peersFailed + val peerCount = 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 (peerCount > 0) { + var peerText = "peers ${status.peersImported}/$peerCount imported" if (status.peersSkipped > 0) peerText += ", ${status.peersSkipped} skipped" if (status.peersFailed > 0) peerText += ", ${status.peersFailed} failed" + // Append per-peer hostnames when available (requires aw-server-rust >= SyncReport JNI). + // Show all imported peers; for failed peers prefix "!" to distinguish without extra prose. + val importedHosts = status.peers.filter { it.outcome == "imported" }.map { it.hostname } + val failedHosts = status.peers.filter { it.outcome == "failed" }.map { "!${it.hostname}" } + val namedHosts = importedHosts + failedHosts + if (namedHosts.isNotEmpty()) { + peerText += " (${namedHosts.joinToString(", ")})" + } parts += peerText } val line = parts.joinToString(" · ") diff --git a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt index 71cc207e..b896c50b 100644 --- a/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt @@ -385,4 +385,146 @@ class SyncSettingsActivityTest { assertEquals(0, status.eventsPulled) assertEquals(2, status.eventsPushed) } + + @Test + fun fromJniResponse_parsesPeersArray() { + val status = SyncStatus.fromJniResponse( + """ + { + "success": true, + "events_pulled": 10, + "events_pushed": 0, + "peers_imported": 2, + "peers_skipped": 1, + "peers_failed": 1, + "peers": [ + {"device_id": "abc", "hostname": "desktop", "path": "/sync/desktop", "outcome": {"kind": "imported"}, "buckets": []}, + {"device_id": "def", "hostname": "laptop", "path": "/sync/laptop", "outcome": {"kind": "imported"}, "buckets": []}, + {"device_id": "ghi", "hostname": "workpc", "path": "/sync/workpc", "outcome": {"kind": "skipped", "reason": "up to date"}, "buckets": []}, + {"device_id": "jkl", "hostname": "server", "path": "/sync/server", "outcome": {"kind": "failed", "error": "read error"}, "buckets": []} + ] + } + """.trimIndent(), + completedAt = 1_788_226_200_000L, + ) + + assertEquals(4, status.peers.size) + assertEquals(SyncPeer("desktop", "imported"), status.peers[0]) + assertEquals(SyncPeer("laptop", "imported"), status.peers[1]) + assertEquals(SyncPeer("workpc", "skipped"), status.peers[2]) + assertEquals(SyncPeer("server", "failed"), status.peers[3]) + } + + @Test + fun fromJniResponse_emptyPeersWhenNoPeersKey() { + val status = SyncStatus.fromJniResponse( + """{"success": true, "events_pulled": 0, "peers_imported": 1}""", + completedAt = 1_788_226_200_000L, + ) + + assertEquals(emptyList(), status.peers) + } + + @Test + fun formatSyncDetail_appendsImportedAndFailedPeerNames() { + // Per-peer hostnames appear in parens after the aggregate text. + // Skipped peers are intentionally omitted (not notable in normal operation). + // Failed peers are prefixed with "!" to distinguish them without extra prose. + assertEquals( + "pulled 10, pushed 0 · peers 2/4 imported, 1 skipped, 1 failed (desktop, laptop, !server)", + formatSyncDetail( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + eventsPulled = 10, + peersImported = 2, + peersSkipped = 1, + peersFailed = 1, + peers = listOf( + SyncPeer("desktop", "imported"), + SyncPeer("laptop", "imported"), + SyncPeer("workpc", "skipped"), + SyncPeer("server", "failed"), + ), + ), + ), + ) + } + + @Test + fun formatSyncDetail_omitsParensWhenNoPeers() { + // Older native libs return no peers array; aggregate text stays unchanged. + assertEquals( + "pulled 10, pushed 0 · peers 2/3 imported, 1 skipped", + formatSyncDetail( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + eventsPulled = 10, + peersImported = 2, + peersSkipped = 1, + ), + ), + ) + } + + @Test + fun encodeDecodePeers_roundTripsHostnamesAndOutcomes() { + val peers = listOf( + SyncPeer("desktop", "imported"), + SyncPeer("laptop", "imported"), + SyncPeer("workpc", "skipped"), + SyncPeer("server", "failed"), + ) + assertEquals(peers, SyncStatus.decodePeers(SyncStatus.encodePeers(peers))) + } + + @Test + fun encodePeers_returnsNullForEmpty() { + assertEquals(null, SyncStatus.encodePeers(emptyList())) + } + + @Test + fun decodePeers_emptyOnNullOrBlank() { + assertEquals(emptyList(), SyncStatus.decodePeers(null)) + assertEquals(emptyList(), SyncStatus.decodePeers("")) + assertEquals(emptyList(), SyncStatus.decodePeers(" ")) + } + + @Test + fun decodePeers_neverThrowsOnMalformed() { + assertEquals(emptyList(), SyncStatus.decodePeers("not json")) + assertEquals(emptyList(), SyncStatus.decodePeers("{]")) + } + + @Test + fun formatSyncDetail_usesDecodedPeersAfterPrefsRoundTrip() { + // The settings UI reloads through SharedPreferences, so names must + // survive encode → decode or the new hostname line never appears. + val encoded = SyncStatus.encodePeers( + listOf( + SyncPeer("desktop", "imported"), + SyncPeer("laptop", "imported"), + SyncPeer("workpc", "skipped"), + SyncPeer("server", "failed"), + ), + ) + assertEquals( + "pulled 10, pushed 0 · peers 2/4 imported, 1 skipped, 1 failed (desktop, laptop, !server)", + formatSyncDetail( + SyncStatus( + completedAt = 1_788_226_200_000L, + success = true, + hasReport = true, + eventsPulled = 10, + peersImported = 2, + peersSkipped = 1, + peersFailed = 1, + peers = SyncStatus.decodePeers(encoded), + ), + ), + ) + } }