Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ class AWPreferences(context: Context) {
?.split("\n")
?.filter { it.isNotEmpty() }
?: emptyList(),
peers = SyncStatus.decodePeers(
sharedPreferences.getString("lastSyncPeers", null),
),
)
}

Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/SyncInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -38,6 +50,9 @@ data class SyncStatus(
val peersSkipped: Int = 0,
val peersFailed: Int = 0,
val warnings: List<String> = 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<SyncPeer> = emptyList(),
Comment on lines +53 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Peer names are discarded

peers is added to SyncStatus, but the SharedPreferences adapter neither writes nor restores it. The settings UI always reloads the status through prefs.getLastSyncStatus(), so this field defaults to an empty list even after the completion broadcast. As a result, the new hostname rendering never appears in the actual UI; persist and restore the peer summaries alongside the aggregate fields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8752040. AWPreferences now persists peers as JSON (lastSyncPeers) next to the aggregate counts, and getLastSyncStatus() restores them. Decode never throws. Added encode/decode round-trip tests so formatSyncDetail still shows hostnames after the prefs reload the UI actually uses.

) {
companion object {
const val MAX_ERROR_CHARS = 500
Expand Down Expand Up @@ -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,
Expand All @@ -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<SyncPeer>): 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<SyncPeer> {
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()
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(" · ")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SyncPeer>(), 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<SyncPeer>(), SyncStatus.decodePeers(null))
assertEquals(emptyList<SyncPeer>(), SyncStatus.decodePeers(""))
assertEquals(emptyList<SyncPeer>(), SyncStatus.decodePeers(" "))
}

@Test
fun decodePeers_neverThrowsOnMalformed() {
assertEquals(emptyList<SyncPeer>(), SyncStatus.decodePeers("not json"))
assertEquals(emptyList<SyncPeer>(), 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),
),
),
)
}
}
Loading