Skip to content
Merged
32 changes: 32 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/AWPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,51 @@ 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(),
)
}

fun setLastSyncStatus(status: SyncStatus) {
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)
Expand Down
150 changes: 134 additions & 16 deletions mobile/src/main/java/net/activitywatch/android/SyncInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,90 @@ 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<String> = emptyList(),
) {
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",
)
private val WHITESPACE = Regex("\\s+")

fun normalizeError(raw: String?): String? =
raw?.trim()
?.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 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(
completedAt = completedAt,
success = success,
error = if (success) {
null
} else {
normalizeError(json.optString("error", "")) ?: "sync failed"
},
summary = normalizeError(json.optString("message", "")),
// 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),
peersSkipped = json.optInt("peers_skipped", 0).coerceAtLeast(0),
peersFailed = json.optInt("peers_failed", 0).coerceAtLeast(0),
warnings = warnings,
)
}
}
}

Expand Down Expand Up @@ -144,13 +218,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
Expand Down Expand Up @@ -192,36 +261,85 @@ 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 json = JSONObject(response)
val success = json.getBoolean("success")
val status = SyncStatus.fromJniResponse(response, System.currentTimeMillis())
nativeStatus = status
val success = status.success
val message = if (success) {
json.getString("message")
status.summary ?: "sync completed"
} else {
json.getString("error")
status.error ?: "sync failed"
}

// 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.
// 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.
//
// 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()
}
persistSyncStatus(status)
handler.post { callback(success, message) }
Comment thread
TimeToBuildBob marked this conversation as resolved.
} catch (e: Exception) {
val errorMsg = "Exception: ${e.message}"
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 delivering the callback), 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}"),
)
}
persistSyncStatus(status)
handler.post {
Log.e(TAG, "$operation failed", e)
callback(false, errorMsg)
callback(false, status.error ?: "sync failed")
}
} finally {
executor.shutdown()
}
}
}

/**
* 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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Comment thread
TimeToBuildBob marked this conversation as resolved.
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() {
Expand Down
Loading
Loading