Skip to content

feat: add Google Drive sync and authentication - #156

Merged
pasichDev merged 36 commits into
masterfrom
chore/upgrade-dependencies-edge-to-edge
Sep 1, 2026
Merged

pasichDev merged 36 commits into
masterfrom
chore/upgrade-dependencies-edge-to-edge

Conversation

@pasichDev

@pasichDev pasichDev commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Why

Deliver opt-in Google Drive synchronization while keeping MyNotes local-first: the app must stay fully usable offline, and the existing backup/export flow must keep working unchanged. The branch also completes the Credential Manager sign-in path, Android 15 edge-to-edge handling, and the dependency refresh.

The five follow-up items this PR originally deferred are now part of it — see Delivered from the original roadmap below.

What changed

Sync engine

  • Provider-independent core: SyncService orchestrates one attempt, SyncMerger merges two snapshots, SyncStore/SyncBackend keep Room and Drive behind interfaces.
  • Deterministic merge: newest updatedAt wins; equal timestamps fall back to the lexicographically smaller canonical SHA-256, so every device reaches the same result regardless of merge order. A missing version never means deletion — removals travel as tombstones.
  • Versioned bundle MyNotes.sync.v1.zip (sync-manifest.json, records.json, attachments/) published into an app-owned MyNotes Sync folder on Drive, with the narrow drive.file scope.
  • SyncBundleValidator rejects checksum mismatches, unknown attachment references, oversized metadata, and path/zip traversal.
  • Optimistic concurrency: the bundle's Drive version counter is read before publishing and compared after, so a bundle changed since it was read is refused instead of overwritten.
  • Attachments are content-addressed by SHA-256, verified in both directions, and never uploaded twice.
  • Room-backed sync state and metadata (schema 17, migrations 15→16→17), SyncMutationCoordinator stamps every local insert/update/delete transactionally and keeps timestamps monotonic when the device clock moves backwards.
  • Conflicts are persisted with both versions (SyncConflictEntity: winner, loser, resolution) and resolvable as keep-local or keep-Drive.
  • Background sync via WorkManager, network-constrained, with exponential backoff and a staged rollout bucket.

"Your data" screen

  • Three tabs — Account, Backup & export, Import — replacing a single scrolling column. The account tab holds identity, sync status, the sync action and the background-sync switch, and fits without scrolling.
  • First-sync confirmation states how many records will be merged and roughly how much will be uploaded before anything leaves the device.
  • Sync coordinator wiring moved into SyncCoordinatorFactory, so the screen stays presentation-only.

Robustness

  • The project builds and runs without app/google-services.json: the google-services plugin is applied conditionally and Firebase is provided as absent, so a fresh clone, a fork, and PRs from forks all build. Sign-in and Drive sync are simply disabled in such a build. CI restores the file from the GOOGLE_SERVICES_JSON secret.
  • Room is never touched on the main thread.
  • Sync callbacks that outlive their screen can no longer crash the app.
  • Context#getMainExecutor (API 28) replaced with ContextCompat — Google sign-in threw NoSuchMethodError on Android 8.0/8.1, below the project's minSdk 26.
  • Starting a sync no longer erases the timestamp of the last successful one.

Localization and lint

  • 175 translated strings across ten locales, plus the new account-tab strings; lint reports zero errors (was 31).

Delivered from the original roadmap

  1. Coordinator extracted — ui/sync/SyncCoordinator + SyncCoordinatorFactory, unit-tested.
  2. Transactional mutations and tombstones — SyncMutationCoordinator, nine tests including batch deletes, tag renames and backwards clock movement.
  3. Versioned bundle and validation — SyncBundleCodec/SyncBundleValidator, app-owned Drive folder, atomic publication.
  4. Persisted conflicts and settings UX — conflict entities, keep-local/keep-Drive resolution, last-sync and error surfaces, first-sync confirmation.
  5. Backend tests and staged rollout — GoogleDriveSyncBackendTest drives the backend against a fake Drive server; rollout bucket gates enablement.

Verification

  • ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:spotlessCheck — all green.
  • 102 unit tests, 0 failures across 24 classes.
  • Lint: 0 errors, 155 warnings.
  • Built and launched with app/google-services.json removed: no crash, sync UI hidden.
  • On device: sign-in, first-sync confirmation, sync to Drive, background sync, and leaving the screen mid-sync.

Known limitations

  • Verified on a single device. Two-device convergence is covered by SyncConvergenceTest (seven cases over two independent stores sharing one backend), but has not been exercised on real hardware.
  • "Newest wins" trusts the editing device's clock. Metadata timestamps are monotonic per device, but two devices with skewed clocks are not reconciled.
  • Kazakh and Belarusian strings are machine translations and want a native speaker's review.
  • The sync error state and the signed-out state on the account tab are implemented but have not been observed on a device.

@pasichDev pasichDev changed the title chore: upgrade dependencies and fix edge-to-edge feat: add Google Credential Manager foundation and sync preparation Aug 31, 2026
@pasichDev pasichDev changed the title feat: add Google Credential Manager foundation and sync preparation feat: add Google Drive sync and authentication Aug 31, 2026
@pasichDev
pasichDev force-pushed the chore/upgrade-dependencies-edge-to-edge branch from e7eb34f to d743377 Compare August 31, 2026 18:16
The plan and design notes under docs/ are working material rather than
part of the project, and were removed from this branch's history.
Ignoring the directory stops the files on disk from being re-added.
@pasichDev
pasichDev force-pushed the chore/upgrade-dependencies-edge-to-edge branch from d743377 to ddbdc25 Compare August 31, 2026 18:21
app/google-services.json is gitignored, so a fresh clone and every CI
job lacked it while the google-services plugin was applied
unconditionally — the plugin aborts the build when the file is missing.

Applying it conditionally was not enough on its own: providesFirebaseAuth
called FirebaseAuth.getInstance() unconditionally and the default
FirebaseApp only exists once the plugin has generated its resources, so
such a build crashed as soon as a screen injected it. FirebaseAuth is now
provided as null, FirebaseGoogleAuth reports itself unavailable, and the
sync worker treats a missing FirebaseApp as nothing to sync.

An empty default_web_client_id fallback keeps the resources compiling.
Every sync failed with "Drive file metadata response is missing an ETag".
fetchFileRef read the ETag response header as its concurrency token, but
Drive API v3 dropped the ETags that v2 sent, so the header is never
present and the backend threw before writing anything.

The Files resource exposes "version" instead — a counter the server bumps
on every change — which is exactly what the read-compare-write check in
verifyConcurrentState needs. The bundle upload no longer sends If-Match:
a bare version is not an entity-tag, and RFC 7232 requires a quoted one.

The test fake hid this: it answered with an ETag header the real API
never sends, and enforced If-Match, which Drive does not honour on files.
It now behaves like the real API, so the three backend tests exercise the
production path — including the concurrent-update one, whose guarantee
comes from the client comparing the version it read.
RoomSyncStore seeded the preferences metadata row from its constructor,
so every caller hit Room on whatever thread happened to construct the
store. On the main thread Room throws, which crashed the backup screen on
open. Seeding is deferred to the operations that already run in the
background.
SyncService persists the SYNCING state, and SyncState.syncing() carried
no lastSuccessfulSyncAt, so starting a sync destroyed the only record of
the previous one. The screen read "never synced" for the whole attempt —
and permanently when the process died before the attempt finished, which
also left the status stuck on "syncing" forever.

The syncing state now carries the value through, and RoomSyncStore reads
it back from both the Room row and the legacy preferences payload.
The coordinator submits work to the executor owned by the activity that
built it, so a callback arriving after that activity finished hit a
terminated pool and threw RejectedExecutionException from whichever
thread delivered it — a real crash when leaving the backup screen mid
sync. Checking isShutdown() first cannot close the race between the check
and the submission; catching the rejection can.

Both the worker submission and the delivery back to the main thread are
now guarded in one place, so no call site can reintroduce this.
"Your data" mixed identity, sync and backup into one scrolling column.
The account, sync status, sync action and background-sync switch now live
in an Account tab beside Backup and Import, and fit without scrolling.

The coordinator wiring moved out of BackupActivity into
SyncCoordinatorFactory, which also reports when the build has no Firebase
configuration — GoogleCredentialAuth rejects a blank client ID in its
constructor, so a coordinator must not be built in that case. The
activity keeps ownership because Drive authorization returns through
onActivityResult, and pushes finished state into the tab.

The status card uses colorSurfaceContainer: bg_item_full paints
colorSurface, the same value as this screen's background, so the card was
invisible.
Lint reported 31 errors; abortOnError hid them from the build.

The one with teeth: GoogleCredentialAuth called Context#getMainExecutor,
added in API 28, while minSdk is 26 — Google sign-in threw
NoSuchMethodError on Android 8.0 and 8.1. It uses ContextCompat now.

The rest: the four-inset AlertDialog.setView overload is restricted to
the AndroidX group, so CopyTextDialog pads the scrolling view instead;
the quick-settings tile's pre-API-34 branch is annotated rather than
rewritten, since that overload is the only one below 34; android:tint
became app:tint in four layouts, where it would not have applied on older
releases; and the Kazakh wordCount plural gained the "one" form it needs.

The remaining 22 were untranslated help and task strings: 175 values
across ten locales, plus the tab and signed-out strings the account tab
needs. Kazakh and Belarusian are machine translations and want a native
speaker's pass.

Two source strings also escape their quotes now: unescaped ones are
consumed by the resource compiler rather than shown.
The merge engine was tested on its own, but nothing exercised the
property a user with a phone and a tablet actually depends on: that after
everyone has synced, both devices and the backend hold the same records,
whatever order they synced in.

Seven cases drive the real SyncService over two independent stores
sharing one backend — separate notes, competing edits, equal timestamps,
a deletion, an edit that outlives a deletion, a fresh device joining, and
the same set synced in the opposite order.
The file is gitignored and never committed, so CI built without Firebase
by accident. Both workflows now decode it from GOOGLE_SERVICES_JSON
before building.

Pull requests from forks get no secrets, so the step is a no-op there and
the build proceeds without Firebase, which it now supports.
@pasichDev
pasichDev marked this pull request as ready for review September 1, 2026 12:40
@pasichDev
pasichDev merged commit f78a706 into master Sep 1, 2026
2 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.

1 participant