Skip to content

feat(sync): record and show what each sync pass actually did - #285

Merged
ErikBjare merged 8 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/274-sync-report-status
Sep 17, 2026
Merged

ErikBjare merged 8 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/274-sync-report-status

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

SyncStatus is a timestamp and a boolean, so a pass that transferred a million
events and one that transferred nothing both render as Last sync succeeded at ….
That is the same silence that hid ActivityWatch/aw-server-rust#682 on desktop for
months (#274).

aw-sync now returns and persists a SyncReport from every pass
(ActivityWatch/aw-server-rust#699, merged), and that report already crosses the
JNI boundary
to_jni_json() is what all four sync entry points return. The
Android side was reading only success/message/error from it and throwing the
rest away.

What changed

  • SyncStatus carries the report aggregates: events pulled/pushed, peers
    imported/skipped/failed, and warnings, plus a hasReport marker. The counts
    default to 0, which is indistinguishable from a real no-op pass, so the
    renderer keys off the marker rather than the values.

  • SyncStatus.fromJniResponse parses the JNI payload in one place. It never
    throws: an unreadable response becomes a failure carrying the raw text, and a
    missing error field no longer propagates an exception out of the sync path.

  • Persistence moved into performSyncAsync — the single choke point every
    sync operation passes through, and the only place holding the parsed report.
    Previously only the full-sync path recorded a status, so manual pull and push
    runs left the settings line stale.

  • formatSyncStatus appends a facts line:

    Last sync succeeded at 2026-09-01 01:30
    pulled 1200, pushed 3 · peers 2/4 imported, 1 skipped, 1 failed
    

    Warnings render below it. When the payload carried no report the output is
    byte-for-byte what it was before, so an older bundled native lib renders
    unchanged.

  • Submodule bump: aw-server-rust 97f51e337aa0b0 for #699 (the
    SyncReport). The bump also picks up #705 (startServer port parameter — the
    Kotlin declaration on master already passes an Int port, verified against
    Java_net_activitywatch_android_RustInterface_startServer) and #704 (docs only).

Verification

JAVA_HOME=/opt/android-studio/jbr ./gradlew :mobile:testStandardDebugUnitTest \
  --tests "net.activitywatch.android.SyncSettingsActivityTest"
# BUILD SUCCESSFUL — tests="18" skipped="0" failures="0" errors="0"
  • The new tests cover: report parsing, an old payload without report fields, a
    no-op success that must not read as a plain success, peer outcomes, warnings
    (capped and whitespace-normalized), negative counts, a failure payload, and an
    unreadable response.

Not in this PR

  • "When will it sync next?" — a SyncScheduler question; no report data is
    involved, so it does not belong in the status model.
  • Per-peer names ("from whom" as hostnames rather than counts). The report
    carries peers[].hostname and an outcome tag; surfacing them is a natural
    follow-up once the aggregate line proves useful.

Part of #274 — the model now records what synced. Still open there: the next-attempt
and per-peer detail.

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#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
@ErikBjare

Copy link
Copy Markdown
Member

Model matches what #274 asked for — events pulled/pushed, peers imported/skipped/failed, warnings, parsed from the to_jni_json() payload ActivityWatch/aw-server-rust#699 now returns. That turns the status line from a boolean into something that can distinguish "moved a million events" from "did nothing", which is the class that hid ActivityWatch/aw-server-rust#682 for months.

One consequence worth stating explicitly: this PR bumps the aw-server-rust submodule to 37aa0b0, which carries datastore v6 (ActivityWatch/aw-server-rust#676). Until now the app embedded a v5 datastore, so a desktop on current master running ActivityWatch/aw-server-rust#700's read-only peer opens would have listed the phone as skipped, peer on older datastore version until Android caught up. With this pin, phone and desktop agree — that gap closes here, not in a separate change.

Merge on green (Android cadence, independent of the desktop cut).

@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previously reported premature-success persistence is fully fixed.

Findings

  1. P1 Success Recorded Before Mirroring

Summary

This PR records, persists, and displays detailed results from each native synchronization pass.

  • Parses sync-report counts, peer outcomes, warnings, and failure details from JNI responses.
  • Persists reports for full, pull-only, and push-only synchronization operations.
  • Delays full-sync success persistence until SAF mirroring completes and retains native report data if mirroring fails.
  • Preserves the legacy display when the native response has no sync report.
  • Adds unit coverage for parsing, formatting, malformed responses, warnings, and count normalization.

Diagram

sequenceDiagram
    participant Caller
    participant Sync as SyncInterface
    participant Native as Native sync
    participant SAF as SAF mirror
    participant Prefs as AWPreferences
    participant UI as Settings UI

    Caller->>Sync: Start sync operation
    Sync->>Native: Invoke JNI entry point
    Native-->>Sync: JSON SyncReport
    Sync->>Sync: Parse SyncStatus
    alt Successful full sync requiring mirroring
        Sync->>SAF: Mirror sync files
        alt Mirroring succeeds
            Sync->>Prefs: Persist successful report
        else Mirroring fails
            Sync->>Prefs: Persist failure with native report retained
        end
    else Pull, push, or native failure
        Sync->>Prefs: Persist parsed status
    end
    Prefs-->>UI: Broadcast status change
    Sync-->>Caller: Post callback
Loading

Reviews (4) · Last reviewed commit: "fix(sync): persist full-sync status only..."

// 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)

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 Success Recorded Before Mirroring

For a full sync with a configured SAF directory, this persists and broadcasts the native success before mirroring finishes. The settings UI can therefore report a successful completed sync while required mirroring is still running. If mirroring then fails, the catch path replaces the result with a report-less failure status, losing the counts and warnings from the completed native sync. Persist the final status after mirroring and retain the parsed report when recording a mirror failure.

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 a226ec7 — the first half of this finding was indeed still live: the success status (with its SyncReport) was persisted before mirroring ran, so the settings UI could show a completed full sync while the SAF mirror was still in flight (the earlier c6cced7 fix only addressed the report-retention half).

performSyncAsync now persists after mirrorSyncFilesToSafDir() returns for mirroring operations (success && mirrorBeforeCallback); non-mirroring operations keep the immediate persist, so pull/push runs stay recorded at the single choke point. The catch path is unchanged: a mirror failure persists the native report carried into a failure status.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

One pin detail before this merges: the submodule bump is 97f51e337aa0b0, which is #699's merge commit (08:41:16Z). ActivityWatch/aw-server-rust#678 (3d3b9dd, 08:47:23Z) landed six minutes later and is exactly one commit ahead of that pin — the only commit in 37aa0b0..3d3b9dd is feat(sync): reconcile owner-originated event edits (#678).

That matters here because #678 is the v0.14.x stopgap for #253 (owner-originated event edits not reaching the phone). Pinning 37aa0b0 merges the Android app without it and #253 stays unfixed in the app until a later bump; 3d3b9dd is the same landed, CI-green set plus that one commit (all checks green on the merge commit: ubuntu/macOS/windows/clippy/format/coverage/Android).

Not blocking — if you'd rather keep this PR's bump minimal and fold #678 into the next pin, that's fine, but it's worth deciding deliberately rather than by pin arithmetic. No code change made from my side.

@TimeToBuildBob

TimeToBuildBob commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI code review

This PR adds SyncReport fields (events pulled/pushed, peer counts, warnings, hasReport) to SyncStatus, parses them from the JNI response via a new fromJniResponse() function, persists them in AWPreferences, and renders them in the sync settings UI. It also moves status persistence into performSyncAsync so all sync operations record a status, and bumps the aw-server-rust submodule.

Not safe to merge — 1 P1 open

Confidence 3/5

1 finding · ❌ 1 P1

❌ P1 highmobile/src/main/java/net/activitywatch/android/SyncInterface.kt:289

In the catch path of performSyncAsync, when the native sync succeeded but a post-sync step (SAF mirroring or callback) throws, the code builds a failure status via native.copy(...) and then calls AWPreferences.setLastSyncStatus(status). However, the original native status had success=true and hasReport=true, and the copy keeps hasReport=true while setting success=false. The settings UI then renders the failure headline followed by the detail line (counts/warnings). That is intended. But there is a subtler issue: the catch block also runs when the exception is thrown by AWPreferences.setLastSyncStatus(status) itself on line 289 (e.g., a SharedPreferences failure or a broadcast exception). In that case nativeStatus is non-null and native.success is true, so the catch builds a status with error 'post-sync step failed: ...' and calls setLastSyncStatus again, which may throw again, propagating out of the executor thread and never invoking the callback. The callback is only invoked inside the try after setLastSyncStatus, so a failure in persistence leaves the caller hanging without a callback. This is a real error-handling gap: the callback is not guaranteed to be called if setLastSyncStatus throws. The consequence is that the sync operation appears to hang from the caller's perspective (e.g., the UI never gets the success/failure callback), and the status may be partially persisted. The fix is to wrap the persistence call in its own try/catch or move it after the callback, or ensure the callback is always invoked in a finally-like manner.

Wrap the persistence call in its own try/catch, or move it after handler.post, or use a finally to ensure callback is invoked.

How this was verified: Traced the try/catch in performSyncAsync: the callback is posted only after setLastSyncStatus on line 290. If setLastSyncStatus throws, control jumps to catch, which calls setLastSyncStatus again (line 309) and then posts the callback with the failure status. If the second setLastSyncStatus also throws, the callback is never posted. Even if the second succeeds, the callback is posted with a failure status, but the original success is lost. The caller (e.g., syncBothAsync) relies on the callback to clear syncInFlight; if the callback never runs, syncInFlight stays true and all future syncs are skipped.

1 advisory finding (summary-only, not scored)

These P2 guard, heuristic, trade-off, or documentation claims are retained for judgment without opening review threads.

⚠️ P2 mediummobile/src/main/java/net/activitywatch/android/SyncInterface.kt:99

The new fromJniResponse function uses json.optInt for counts and coerces to at least 0. However, if the JSON contains a very large number (e.g., 2^31-1), optInt returns that value, and the UI displays it. If the JSON contains a number larger than Int.MAX_VALUE, optInt returns the default 0? Actually, org.json.JSONObject.optInt returns the default if the value is not an integer or if it overflows? Let me check: JSONObject.optInt uses Number.intValue() which truncates. For a long value like 3000000000, it would return a negative number due to overflow, and then coerceAtLeast(0) would turn it into 0. That could under-report. But the native lib is unlikely to return counts that large. This is a guard-level concern.

Consider using optLong and clamping to Int range, or use optLong for the fields.

How this was verified: org.json's optInt uses Number.intValue() which can overflow for large longs. For a value > 2^31-1, it becomes negative and is coerced to 0, under-reporting. This is a theoretical edge case.

Files changed (5) — the diff as I read it
  • aw-server-rust — Submodule pointer bump from 97f51e30 to 3d3b9dd1.
  • mobile/src/main/java/net/activitywatch/android/AWPreferences.kt — Adds get/set persistence for the new SyncStatus fields: summary, hasReport, event/peer counts, and warnings joined by newline.
  • mobile/src/main/java/net/activitywatch/android/SyncInterface.kt — Adds SyncStatus fields and fromJniResponse() parser; moves status persistence into performSyncAsync and keeps the native report on post-sync failures.
  • mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt — Extends formatSyncStatus to append a detail line with counts, peer outcomes, and warnings when hasReport is true.
  • mobile/src/test/java/net/activitywatch/android/SyncSettingsActivityTest.kt — Adds tests for formatSyncStatus rendering and fromJniResponse parsing, including warnings, negative counts, and unreadable responses.
Previous review passes
commit score findings engine when
5ef072c01a1b 4/5 2 llm 2026-09-17 09:22 UTC
a97cf3330350 4/5 2 llm 2026-09-17 10:26 UTC
a226ec76b477 3/5 2 llm 2026-09-17 11:24 UTC

Reviewed f726b05b97cb · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 491s · about this reviewer

Maintainer commands

@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.

Comment thread mobile/src/main/java/net/activitywatch/android/SyncInterface.kt Outdated
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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Fixed the P1 from Greptile (c6cced7): a full sync persisted the success status (with its SyncReport counts/warnings) before SAF mirroring, and a mirroring failure overwrote it with a report-less failure status — erasing what the native pass actually did.

performSyncAsync now keeps the parsed native status, and when the post-sync step fails after a successful native sync, the failure status is written on top of the native one (success=false, error names the failed step) while retaining the report counts and warnings. Deliberately keeping persist-before-mirror: persisting after would risk losing the report entirely if mirroring crashes before the write.

CI should pick this up; re-review triggered.

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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Bumped the aw-server-rust pin from 37aa0b0 to 3d3b9dd (a97cf33), so this PR now includes #678 — reconcile owner-originated event edits — which landed six minutes after the previous pin and is directly relevant to the sync reporting here. CI will re-run on the new head; Greptile re-review triggered.

Comment thread mobile/src/main/java/net/activitywatch/android/SyncInterface.kt Outdated
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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the remaining Greptile P1 on the new head a226ec7: for full syncs, the success status is now persisted only after SAF mirroring completes (the earlier c6cced7 fix covered only the report-retention half), so the settings UI can no longer show a completed sync while the mirror is still running. Non-mirroring operations keep the immediate persist.

  • CI green on a226ec7 (Build, Test, Test E2E)
  • Greptile re-review: 5/5, no findings; both bob-ai-review P2 threads resolved (one fixed by the same change, one duplicate)
  • Self-merge not eligible here (cross-repo, pull-only access) — ready for maintainer merge

Comment thread mobile/src/main/java/net/activitywatch/android/SyncInterface.kt
Comment thread mobile/src/main/java/net/activitywatch/android/SyncInterface.kt Outdated
…rvive

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
…er counts

Git-Session-Id: 3794dab5-f699-5115-ade7-bcdd99be743d
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the two findings from the latest AI-review round on head f726b05:

  • P1 (catch path misreporting): rejected as a false positive — the reviewer's trace assumed handler.post { callback(...) } executes synchronously inside the try; it enqueues onto the main Looper, so a throwing callback is never caught in performSyncAsync and cannot flip a successful sync to failed. Replied and resolved on the thread.
  • P2 (warnings cap): fixed in a4ca0c7 — the cap is applied after blank-filtering, so trailing meaningful warnings survive.
  • Additional fix from the in-band pre-push review: f726b05 keys hasReport on any report field, so a push-only pass carrying events_pushed/peer counts but no events_pulled still renders its facts line.

if (success && mirrorBeforeCallback) {
mirrorSyncFilesToSafDir()
}
AWPreferences(appContext).setLastSyncStatus(status)

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.

P1 — In the catch path of performSyncAsync, when the native sync succeeded but a post-sync step (SAF mirroring or callback) throws, the code builds a failure status via native.copy(...) and then calls AWPreferences.setLastSyncStatus(status). However, the original native status had success=true and hasReport=true, and the copy keeps hasReport=true while setting success=false. The settings UI then renders the failure headline followed by the detail line (counts/warnings). That is intended. But there is a subtler issue: the catch block also runs when the exception is thrown by AWPreferences.setLastSyncStatus(status) itself on line 289 (e.g., a SharedPreferences failure or a broadcast exception). In that case nativeStatus is non-null and native.success is true, so the catch builds a status with error 'post-sync step failed: ...' and calls setLastSyncStatus again, which may throw again, propagating out of the executor thread and never invoking the callback. The callback is only invoked inside the try after setLastSyncStatus, so a failure in persistence leaves the caller hanging without a callback. This is a real error-handling gap: the callback is not guaranteed to be called if setLastSyncStatus throws. The consequence is that the sync operation appears to hang from the caller's perspective (e.g., the UI never gets the success/failure callback), and the status may be partially persisted. The fix is to wrap the persistence call in its own try/catch or move it after the callback, or ensure the callback is always invoked in a finally-like manner.

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 ff3472f.

Both persist calls in performSyncAsync now go through a persistSyncStatus() helper that logs and swallows a storage/broadcast failure, so the completion callback is posted even if setLastSyncStatus throws. That closes the exact path you traced: previously an exception from line 289 fell into the catch, the catch persisted again (line 309, which could throw a second time), and only then posted the callback — so a persistence failure could either suppress the callback entirely or relabel a successful sync as failed.

Two things this preserves that are worth stating:

  • A status that could not be stored is no longer treated as a sync that failed — the failure is logged, and the callback reports the real native outcome.
  • The callback is what clears syncInFlight in syncBothAsync, so the change also removes the "one persistence failure and every later sync is rejected as already in flight" lockup.

Verified: ANDROID_HOME=... ./gradlew :mobile:clean :mobile:testStandardDebugUnitTest --tests "net.activitywatch.android.SyncSettingsActivityTest" → BUILD SUCCESSFUL.

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
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
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed the P1 from the latest AI-review round, plus the two items a local re-review raised on ff3472f:

  • P1 — persistence could suppress the completion callback. Fixed in ff3472f. Both persist calls in performSyncAsync now go through a persistSyncStatus() helper that logs and swallows a storage/broadcast failure, so the callback is posted regardless. That closes the path where an exception from setLastSyncStatus fell into the catch, persisted again (which could throw a second time), and only then posted the callback — leaving syncInFlight set so every later sync was rejected as "already in flight". It also stops a storage failure being recorded as a sync failure. Replied on the thread.

  • P1 — hasReport keyed on warnings. Fixed in 63ad76e. warnings is not a count; a payload carrying warnings but no count keys marked itself report-bearing, so formatSyncStatus would render an invented pulled 0, pushed 0 line for a pass that never reported numbers. REPORT_KEYS is now count keys only. Push-only responses (events_pushed + peer counts, no events_pulled) are unaffected and still render counts.

  • P1 — "catch discards a native partial report". Not applicable as described, no code change. A native failure (success=false) does not throw: fromJniResponse returns normally, the try path persists that full status and posts the callback. The catch is only reached on an exception, and in the branch where nativeStatus == null there is no report to retain. Preserving a partial native report in the failure path is already what the native.copy(...) branch does for the mirroring case.

Verified locally: ./gradlew :mobile:testStandardDebugUnitTest --tests "net.activitywatch.android.SyncSettingsActivityTest" → BUILD SUCCESSFUL.

Greptile was 5/5 on the prior head; neither change touches anything Greptile flagged, so no re-trigger. CI is running on the new head.

@ErikBjare
ErikBjare merged commit 41913a1 into ActivityWatch:master Sep 17, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants