Skip to content

feat(sync): Google Drive sync, hardened and shipped as 2.6.50 - #160

Merged
pasichDev merged 16 commits into
masterfrom
fix/sync-hardening
Sep 4, 2026
Merged

feat(sync): Google Drive sync, hardened and shipped as 2.6.50#160
pasichDev merged 16 commits into
masterfrom
fix/sync-hardening

Conversation

@pasichDev

@pasichDev pasichDev commented Sep 4, 2026

Copy link
Copy Markdown
Owner

What this is

Everything between the last release users actually received (2.6.46) and now: Google Drive sync, the hardening that made it safe to ship, and the release metadata to match.

2.6.47, 2.6.48 and 2.6.49 were tagged but never published. Nobody is running them, so they are not a migration path anyone has to survive — this ships as 2.6.50 and the changelog collapses those three entries into one, written for someone updating from 2.6.46.

Why it is this large

The PR started as transport hardening. An adversarial review of the sync implementation against the release invariants found four ways a user could lose data, none of which were caught by the existing tests, and three more were found only by running the app on a real device. Fixing them is most of the diff.

Data loss

  • Restore destroyed a note. Rows that kept their id and rows that were renumbered were inserted together, so a renumbered row could take an id a later explicit-id row then claimed, and the REPLACE-insert overwrote it. Explicit-id rows now go in first, leaving the autoincrement counter past them.
  • Cleanup wiped live attachments. AttachmentCleaner deleted anything it could not match to a reference, including references it simply failed to parse. It now returns a typed result and aborts every deletion if a single reference does not resolve — the safe direction is to keep an orphan, not to delete a file in use.
  • Resumable uploads corrupted large attachments. The 308 state machine tracked relative offsets and could re-send or skip a range after a stall. Rewritten around absolute acknowledged offsets, with a stalled-chunk limit.
  • Zero-byte attachments never completed. Now routed to a multipart upload, which is the only shape Drive accepts for them.
  • Preferences could be applied twice or half-applied. Resolution is two-phase through a Room journal, and each write goes through a single editor with commit() rather than a sequence of apply() calls that a crash could tear.

Conflicts

Resolution was endpoint-addressed (KEEP_LOCAL / KEEP_DRIVE), which is meaningless when both versions came from Drive. It is now version-addressed (KEEP_WINNER / KEEP_ALTERNATIVE), each side carries its own provenance, and unresolved alternatives are durable across devices instead of living only on the device that saw the conflict.

Two bugs made the app conflict with itself: empty attachmentsManifest arrays meant every attachment-free note differed from its own snapshot on the next sync, and attachmentNames leaked to the wire after normalization. An existing test had pinned the first one in place by asserting the empty array; it was rewritten to assert absence.

Interface

  • The conflict dialog rendered both versions into one string with no timestamps, cut at a fixed 120 characters from the start — so a difference near the end of a note never reached the screen. It now shows two selectable cards with each version's origin and time, marks the newer one, and windows the preview around the difference so the change is always visible.
  • Sync hides itself with an explanation on a device without Google Play services. The check reads the package table rather than asking GoogleApiAvailability, which would mean loading a Play services class to ask whether Play services exist.
  • Fixed a blank strip above the toolbar on the Tasks and Help screens: the root already applied the status bar inset and returned it unconsumed, and the AppBarLayout applied it again.

Schema

Versions 18 through 21, each with a migration and a MigrationTestHelper test. Version 20 had been modified after it was pushed, which would have crashed any device already on it; 20.json was restored and a 20→21 migration added instead.

Verification

  • 248 unit tests, 60 instrumentation tests, 0 failures. Lint and R8 clean, spotless clean, CI green.
  • Real-device testing on a Pixel 7a: database at user_version 21; restore idempotent; an attachment survives cleanup, completes a full sync round trip and renders; preferences conflict resolves with the journal cleared; repeat syncs report conflictCount: 0; tombstones honoured; a minified release build restores a backup correctly. The GMS-absent branch was verified by pointing the lookup at an absent package — the notice renders and backup and import keep working.

Known limitations

  • Bundle history grows and a read is O(history). A scaling concern, not a correctness one.
  • The first cross-device round trip of a note carrying an attachment has not been verified on two physical devices; on one device repeat syncs are clean.
  • Drive sync adds +1.07 MB to the release APK (3.83 → 4.90 MB), essentially all dex. Play Feature Delivery was considered and rejected: the auth stack, the Hilt graph and the WorkManager worker all cross the module boundary, and it does nothing for devices without Play services, which the guard above handles directly.

Attachments outside sync:
- AttachmentStorage.resolve accepted only file:// URLs while the editor writes
  editorjs://, so every reference failed to resolve. AttachmentCleaner then read
  the empty expected set as "everything here is an orphan" and deleted every
  attachment of a note on the next save. Parsing moves to AttachmentUrl, a pure
  type covered by JVM tests, which accepts the canonical editorjs:// form and
  legacy file:// input; cleanup now aborts rather than deleting whenever a
  reference cannot be resolved. Sync restore writes canonical URLs, so restored
  attachments render in the editor.

Resumable upload:
- The chunk replay loop never terminated once a partially acknowledged chunk was
  completed by a retry, and replayed its buffer at offsets past the end of the
  file. Rewritten around absolute offsets, rejecting acknowledgements that move
  backwards, exceed the declared size, cover bytes never sent, or stop making
  progress; chunk PUTs retry through DriveRequestExecutor.
- A zero-byte attachment issued no PUT and reported success without creating
  anything, which then failed every later sync. It takes an explicit
  zero-length multipart path.

Preferences:
- Resolving a preferences conflict applied nothing yet still marked the conflict
  resolved, so the rejected value won the next sync. It now runs through the
  pending-preferences journal and marks the conflict resolved only after a
  durable commit.
- setListPreferences issued eleven independent apply() calls and the journal was
  cleared before they were durable. One editor plus commit(), and the journal is
  cleared only on success. The journal carries target and baseline digests so
  replay can tell "already applied" from "still pending" from "the user has
  since changed these settings"; an unreadable payload is quarantined instead of
  disabling sync permanently.

Conflicts:
- Unresolved losing versions existed only in one device's local table, so
  publishing a merged descendant made them unreachable. Bundles now carry
  unresolved alternatives and the resolutions that retire them, and a device
  starting from an empty database recovers both the winner and the alternative.
- Provenance is recorded per side and resolution addresses versions
  (KEEP_WINNER / KEEP_ALTERNATIVE) rather than endpoints, so a conflict between
  two Drive heads is no longer shown or applied as "this device".
- Publishing requires the read context it was derived from instead of taking
  causal parents from a mutable field.

Drive reads:
- A missing ancestor bundle is tolerated rather than fatal, since every bundle
  is a complete checkpoint of the state its descendants inherit.
- An attachment is read and verified once per sync instead of two or three
  times.

Also prunes the sync-attachment cache of blobs nothing references, drops the
unused ACTION_VIEW filter from the non-exported TrashActivity so the release
lint gate passes, and stops .gitignore un-ignoring the whole docs tree.

Schema 20 adds journal identity and quarantine plus per-side conflict
provenance and version identities.
…ncies

npm audit --audit-level=high has been failing the editor CI job independently
of any source change, on two advisories in build-time tooling:

- browserslist <=4.28.6: unbounded memory growth and a prototype write via
  untrusted browserslist-stats.json (GHSA-c83g-rgw3-j3cx, GHSA-73wf-gq98-2v4g)
- postcss-selector-parser: denial of service through uncontrolled AST recursion
  (GHSA-w9m9-85wc-3x92)

Both are resolved by npm audit fix, which stays within the declared semver
ranges: patch and minor bumps of browserslist, postcss-selector-parser and
their transitive data packages. Neither ships in the editor bundle. Rebuilding
the editor reproduces byte-identical assets, so nothing under
app/src/main/assets/editor changes.
…found in review

An adversarial review of the sync stack surfaced defects that unit tests could
not see because nothing exercised a Room-built record against its own decoded
round trip.

- attachmentNames was keyed by logical attachment id locally and by SHA-256
  after decoding, and a decoded payload also kept the wire-only attachmentIds.
  A record therefore never hashed equal to itself across a round trip, so every
  note with an attachment reported a conflict against itself on every sync,
  forever, and republished a bundle each time. Both sides now produce the same
  shape, with a round-trip hash test pinning it.
- applySnapshot wrote the merged result over records that had moved on locally
  while the sync was in flight. The snapshot is built before Drive is read and
  every blob transferred, and the six-hourly worker runs while the user is in
  the editor, so an edit made in that window was silently dropped. A record
  newer than the result being applied is now left alone for the next sync.
- Conflicts reported by the backend named the winner of the remote fold, which
  is not necessarily the version the sync applies, so "keep the version the
  merge selected" could revert a record to a rejected version and republish it.
  Conflicts are re-pointed at the version actually applied.
- A non-canonical logical attachment id passed the local check and then threw
  inside the manifest, failing every publish for the account while that note
  existed.
- A corrupt remote blob with a valid local copy failed every sync permanently.
  Blobs are content-addressed and duplicates are tolerated, so the good local
  copy is published and the account repairs itself.
- A missing blob behind an unresolved alternative stopped every device from
  syncing anything, including devices that could never resolve it. Pinning is
  best effort; resolution still verifies before it applies.
- Recovery did not record the preferences digest on the already-applied path,
  letting an unchanged copy outrank a genuine edit from another device.
- The settled-version set grew for the life of the account and, past the schema
  record limit, made every publish fail with no way out. It is now bounded.
- A crash between the durable preference write and its bookkeeping left the
  user's choice applied but unversioned and the conflict pending, so the next
  sync could put the rejected version back. The journal carries the conflict it
  settles and recovery finishes the job.
- Restored notes kept the sending device's URLs inside valueJson, so a received
  rich note showed its attachments as broken. The editor blocks are re-pointed
  positionally, and left untouched when they do not line up.

Schema 21 carries the journal's conflict bookkeeping. It is a new version
rather than an edit to 20, which has already been published on this branch and
would fail Room's identity check on any device already running it.
… directory

A whole-app review against v2.6.46, the last version users actually run, found
these in the local backup path rather than in sync.

- Restore extracted archive entries with new File(filesDir, entry.getName())
  and only checked that the name started with "attachments". A backup file is
  untrusted input, and "attachments/../../databases/notes" satisfies that, so
  an edited archive could write anywhere the app can. Entries are resolved and
  must land inside the attachment directory.
- Restore inserts rather than replaces, so a backup from another device cannot
  destroy an unrelated note that happens to share a row id. The cost was that
  restoring a backup onto the library it came from duplicated every note and
  tag, while the dialog promises duplicates will be ignored. A row whose id is
  taken by an identical note is now skipped, so re-restoring is a no-op again,
  and a genuinely different note under that id is still kept alongside rather
  than overwriting. Tags are matched by name, which is how a note references
  them and where a duplicate row is indistinguishable to the user.
- When a colliding note was inserted under a new id its attachments stayed in
  the folder named after the old one, so two notes shared a directory and
  saving the older one deleted the restored note's files as orphans. The files
  are copied into the new note's folder and both the attachments column and the
  editor blocks are re-pointed. Copied rather than moved, so a failure part-way
  leaves the pre-restore state intact.
- commitAll threw on a null string value, where the previous per-key write
  removed the key. Because restore chains the preference write ahead of the
  notes and tags, a backup carrying an explicit null aborted the entire restore
  and left nothing written.
- Gson maps the editor and backup models by field name, but only data.model was
  kept from shrinking. A renamed field there does not fail loudly, it
  deserializes to null: attachments stop resolving and a restore produces empty
  notes. Verified against the release mapping that the classes stay unrenamed.
…he emulator

- The instrumentation job failed with "Timeout waiting for emulator to boot".
  reactivecircus/android-emulator-runner needs the KVM udev rule on
  GitHub-hosted Linux runners; without it the x86_64 emulator runs unaccelerated
  and boot times out before a single test runs. It had passed on earlier
  commits, so this was flaky rather than broken, and the rule makes it
  deterministic.
- Coverage was uploaded as a downloadable artifact, which nobody opens during
  review. A coverage job now merges the unit and instrumentation reports and
  posts a single comment showing overall coverage and the coverage of the files
  the pull request changes, updating it on each push.
- Instrumentation coverage is collected at all, which is what makes those
  numbers honest: the Room store, the DAOs and the preference adapters are only
  reachable on a device, so the unit-only report showed them as untested. It
  reported RoomSyncStore at 0% while twenty on-device tests exercised it.

Measured with both reports: data/sync 58.1% to 72.4%, extendedEditor/attach
52.6% to 67.2%, data/database 0% to 38.8%.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Test coverage (unit + instrumentation)

Overall Project 13.79% -6.48% 🍏
Files changed 43.75% 🍏

Module Coverage
debug 13.97% -4.68% 🍏
debug 13.61% -8.28% 🍏
Files
Module File Coverage
debug SyncConflictEntity.java 100% 🍏
SyncMerger.java 100% 🍏
RemoteSnapshot.java 100% 🍏
PendingPreferencesDecision.java 100% 🍏
SyncStore.java 100% 🍏
SyncPublication.java 100% 🍏
AttachmentUrl.java 97.25% -2.75% 🍏
SyncResolution.java 96.49% 🍏
NoteAttachmentRelocator.java 95.17% -4.83% 🍏
SyncBundleCodec.java 92.08% -3.29% 🍏
SyncConflictPresentation.java 91.08% -8.92% 🍏
SnapshotProblem.java 89.47% -10.53% 🍏
SyncMergeResult.java 89.17% 🍏
SyncMetadata.java 88.37% -11.63% 🍏
SyncBackend.java 86.79% -13.21% 🍏
SnapshotBuildResult.java 86.42% -13.58% 🍏
DriveRequestExecutor.java 85.26% -14.74% 🍏
GoogleDriveSyncBackend.java 85.03% -13.47% 🍏
PreferencesConfig.java 81.25% 🍏
SyncService.java 79.42% -15.28% 🍏
SyncBundleValidator.java 79.24% -5.65% 🍏
SyncCoordinator.java 75.39% -1.16% 🍏
AttachmentCleaner.java 61.56% -11.56% 🍏
SyncMutationCoordinator.java 50.98% -2.31% 🍏
AttachmentIntegrityException.java 44.44% -55.56% 🍏
MainPresenter.java 31.21% -0.21% 🍏
PlayServicesAvailability.java 21.95% -78.05% 🍏
GoogleDriveSyncWorker.java 10.67% -20.79% 🍏
MoreNoteDialogPresenter.java 6.84% -0.33% 🍏
AttachmentStorage.java 1.11% -7.76% 🍏
AccountSyncFragment.java 0% -3.55% 🍏
BackupActivity.java 0% -16.23% 🍏
AppDataManager.java 0% -2.29% 🍏
SyncPendingPreferencesEntity.java 0% 🍏
AppPreferencesHelper.java 0% -45.74% 🍏
SafePreferences.java 0% -57.83% 🍏
AppDbHelper.java 0% -0.82% 🍏
AppDatabase.java 0% -21.58% 🍏
SharedNoteCreator.java 0% -8.7% 🍏
RoomSyncStore.java 0% -61.4% 🍏
EditorAttachment.java 0% 🍏
SyncCoordinatorFactory.java 0% -22.06% 🍏
ZipBackupHelper.java 0% -21.82% 🍏
ApplicationModule.java 0% 🍏
DatabaseConstants.java 0% 🍏
debug SyncConflictEntity.java 100% 🍏
SyncPendingPreferencesEntity.java 100% 🍏
SnapshotBuildResult.java 90.12% -9.88% 🍏
SnapshotProblem.java 89.47% -10.53% 🍏
PreferencesConfig.java 81.25% 🍏
AppDatabase.java 80.97% 🍏
PendingPreferencesDecision.java 77.61% -22.39% 🍏
AttachmentCleaner.java 76.56% -18.13% 🍏
ApplicationModule.java 69.86% 🍏
SyncBundleCodec.java 68.46% -19.93% 🍏
SyncBundleValidator.java 63.78% -15.59% 🍏
SyncResolution.java 57.89% -17.54% 🍏
RoomSyncStore.java 56.31% -19.97% 🍏
AttachmentUrl.java 54.9% -45.1% 🍏
SafePreferences.java 19.88% -57.83% 🍏
SyncMetadata.java 18.6% -36.05% 🍏
AttachmentStorage.java 16.34% -1.11% 🍏
AppPreferencesHelper.java 6.98% -45.74% 🍏
AppDataManager.java 5.28% -2.29% 🍏
AppDbHelper.java 5.25% -0.82% 🍏
SyncMutationCoordinator.java 4.11% -24.03% 🍏
AccountSyncFragment.java 0% -3.55% 🍏
PlayServicesAvailability.java 0% 🍏
BackupActivity.java 0% -16.23% 🍏
MainPresenter.java 0% -0.21% 🍏
SharedNoteCreator.java 0% -8.7% 🍏
SyncMerger.java 0% -26.29% 🍏
SyncBackend.java 0% 🍏
RemoteSnapshot.java 0% 🍏
SyncMergeResult.java 0% -12.1% 🍏
GoogleDriveSyncWorker.java 0% -20.79% 🍏
GoogleDriveSyncBackend.java 0% -83.98% 🍏
DriveRequestExecutor.java 0% 🍏
AttachmentIntegrityException.java 0% 🍏
SyncStore.java 0% -42.86% 🍏
SyncPublication.java 0% 🍏
SyncService.java 0% -57.29% 🍏
MoreNoteDialogPresenter.java 0% -0.33% 🍏
EditorAttachment.java 0% 🍏
SyncConflictPresentation.java 0% 🍏
SyncCoordinatorFactory.java 0% -22.06% 🍏
SyncCoordinator.java 0% -3.88% 🍏
ZipBackupHelper.java 0% -21.82% 🍏
NoteAttachmentRelocator.java 0% 🍏
DatabaseConstants.java 0% 🍏

Settings arriving with a sync were stored correctly but stayed invisible until
the user navigated away and came back.

Two separate reasons, both outside the sync code:

- Light/dark is owned by AppCompatDelegate. Refreshing the preference caches
  (ThemePreferencesCache.refresh) only reloads the values; nothing called
  applyCurrentThemeMode, so the mode kept whatever the process started with.
- Theme, dynamic colour and UI scale are read when an activity is created
  (BaseActivity), so the screen already on display never picked them up.

The app does have a path for this, but it is wired to the settings screen's
activity result, which a sync never goes through.

- commitListPreferences now applies the theme mode after refreshing the caches,
  posted to the main thread because it runs on a background thread for both a
  sync apply and a backup restore.
- RoomSyncStore distinguishes writing the same values from actually changing
  them, by comparing the digest either side of the write, and reports it once.
- BackupActivity redraws itself after a successful sync that changed settings.

Deliberately not redrawn while conflicts are pending: recreating the activity
would dismiss the dialog the user is choosing a version in, and the values are
stored either way, so they still take effect on the next screen. Identical
values do not redraw either, which would otherwise flicker on every sync.
Variant A of the redesign. The dialog rendered both versions into a single
message string, which left the one decision it exists for unsupportable:

- the two versions were separated only by a newline, so comparing them meant
  reading the text
- winnerUpdatedAt and loserUpdatedAt were already on the row but never shown,
  and "which one is mine" is usually answered by the time
- each side was cut to 120 characters from the start, so a difference past that
  point never reached the screen at all
- Later, Keep version 1 and Keep version 2 sat as three buttons of equal
  weight, though two of them discard someone's version for good

Each version is now its own selectable card carrying its true origin, its
timestamp, a "newer" marker, and a preview with the differing span emphasised.
The confirm button acts on the selection, so there is one destructive action
instead of two, and Later stops competing with them.

The preview window is centred on the difference rather than cut from the head,
which is what kept a change near the end of a note off screen.

The decision logic moved out of the Activity into SyncConflictPresentation:
which text to show, where the two versions diverge, which side is newer and
what a version even is (text, deletion, settings, untitled). It has no
android.* dependency, so all of it is unit-tested — including that a
Drive-vs-Drive conflict never claims either side came from this device.

The winner starts selected, so tapping through without reading changes nothing.
Found by running a real two-device sync against Google Drive rather than the
fake server: a second sync with nothing changed in between reported a conflict
for every note, both sides showing identical text and identical timestamps.

Two asymmetries between a locally built record and the same record decoded back
from a bundle, either of which is enough to make the canonical hashes differ:

- addAttachmentMetadata returned early only when the attachments column was
  null or blank. The editor stores "[]" for a note that simply has none, so the
  loop ran zero times and still wrote attachmentsManifest, attachmentHashes and
  attachmentNames as empty. A decoded record carries no attachment fields at
  all. Empty ones are no longer written.
- normalizeNoteAttachmentFields removed attachmentsManifest and
  attachmentHashes before rebuilding them, but not attachmentNames, which it
  only re-added when non-empty. A payload that already carried the key kept it
  on the wire, so the decoded record differed from the local one that produced
  it. It is now cleared like the other two.

The effect was a conflict per note on every sync, forever, with a fresh bundle
republished each time — for any note without an attachment, which is most of
them.

An existing test asserted attachmentHashes was an empty array for a note with
no attachments, pinning the first half of this in place; it now asserts the
fields are absent. Added round-trip tests that encode a locally built record
and require the decoded one to hash identically, at the codec level and end to
end from the Room store on a device.
Reproduced on a device: a backup holding notes with ids 1 and 2, restored onto
a library where id 1 is taken, ended with one of the two gone and no error.

addNotes and addTags are REPLACE inserts. A row whose id is already taken has
its id cleared so it is inserted as new, but that happened in the same batch as
rows keeping an explicit id, so the reassigned row was handed the next
autoincrement value — which was exactly the id a later row in the same batch
then claimed. The REPLACE overwrote it, silently.

Rows are now inserted in two groups, the ones keeping their id first, which
leaves the autoincrement counter past every explicit id in the batch. Restoring
onto an empty library still preserves every id exactly, and each row's
attachment relocation and metadata are settled per group.

Verified on the device that the same restore now keeps all three notes: the
local one untouched, the free id preserved, the reassigned one placed after it.
setupEdgeToEdgeInsets pads the root by systemBars.top and returns the
insets unconsumed, so CoordinatorLayout went on dispatching them to its
children. Both layouts also set fitsSystemWindows on their AppBarLayout,
which applied the same top inset a second time — 118px of status bar
became 236px, and the dead strip above the toolbar read as an empty
second app bar.

Every other screen carries fitsSystemWindows on the root alone; these
two now match. Verified on device: the toolbar starts at the status bar
edge (118) instead of below a blank band (236).
Sign-in runs through Credential Manager and the Drive scope is granted by
the authorization client, so both sit on Play services. Nothing checked
whether they were there: on a device without them every control on the
account tab led to a failure the user could not act on.

The check reads the package table rather than asking GoogleApiAvailability.
That needs no dependency and, unlike loading a Play services class, cannot
fail for the reason it is checking. The manifest declares the package under
<queries> because API 30+ package visibility would otherwise report it
absent on every device.

isConfigured() already gates create(), so an unavailable device takes the
path a build without google-services.json takes. That path hid both groups
and left the tab blank, which reads as a broken screen; it now shows what
is wrong, translated into all eleven locales.

GoogleDriveSyncWorker bails on the same check and moves its Firebase calls
inside the try. A scheduled worker that throws is rerun and takes the
process down in the background, where nobody can see why.

Verified on device: with Play services present the account tab and a real
sync are unchanged; with the lookup pointed at an absent package the notice
renders and the backup and import tabs keep working. 248 unit tests, 60
instrumentation tests, 0 failures; lint and R8 clean.
@pasichDev pasichDev changed the title fix(sync): harden Google Drive synchronization feat(sync): Google Drive sync, hardened and shipped as 2.6.50 Sep 4, 2026
2.6.47, 2.6.48 and 2.6.49 were tagged but never published, so no user is
on them and no changelog entry needs to describe the path through them.
The three entries collapse into one written for the only update anyone
will actually make, 2.6.46 to 2.6.50 — and the 2.6.49 entry claimed a
staged rollout had completed for users who never received it.

versionCode moves to 50 because 49 is already taken by the v2.6.49 tag,
which points at a commit this branch builds on.
@pasichDev
pasichDev marked this pull request as ready for review September 4, 2026 19:16
@pasichDev
pasichDev merged commit f8a912f into master Sep 4, 2026
4 checks passed
@pasichDev
pasichDev deleted the fix/sync-hardening branch September 5, 2026 17:25
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.

1 participant