feat: add Google Drive sync and authentication - #156
Merged
Merged
Conversation
pasichDev
force-pushed
the
chore/upgrade-dependencies-edge-to-edge
branch
from
August 31, 2026 18:16
e7eb34f to
d743377
Compare
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
force-pushed
the
chore/upgrade-dependencies-edge-to-edge
branch
from
August 31, 2026 18:21
d743377 to
ddbdc25
Compare
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
marked this pull request as ready for review
September 1, 2026 12:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
SyncServiceorchestrates one attempt,SyncMergermerges two snapshots,SyncStore/SyncBackendkeep Room and Drive behind interfaces.updatedAtwins; 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.MyNotes.sync.v1.zip(sync-manifest.json,records.json,attachments/) published into an app-ownedMyNotes Syncfolder on Drive, with the narrowdrive.filescope.SyncBundleValidatorrejects checksum mismatches, unknown attachment references, oversized metadata, and path/zip traversal.versioncounter is read before publishing and compared after, so a bundle changed since it was read is refused instead of overwritten.SyncMutationCoordinatorstamps every local insert/update/delete transactionally and keeps timestamps monotonic when the device clock moves backwards.SyncConflictEntity: winner, loser, resolution) and resolvable as keep-local or keep-Drive."Your data" screen
SyncCoordinatorFactory, so the screen stays presentation-only.Robustness
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 theGOOGLE_SERVICES_JSONsecret.Context#getMainExecutor(API 28) replaced withContextCompat— Google sign-in threwNoSuchMethodErroron Android 8.0/8.1, below the project'sminSdk 26.Localization and lint
Delivered from the original roadmap
ui/sync/SyncCoordinator+SyncCoordinatorFactory, unit-tested.SyncMutationCoordinator, nine tests including batch deletes, tag renames and backwards clock movement.SyncBundleCodec/SyncBundleValidator, app-owned Drive folder, atomic publication.GoogleDriveSyncBackendTestdrives the backend against a fake Drive server; rollout bucket gates enablement.Verification
./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:spotlessCheck— all green.app/google-services.jsonremoved: no crash, sync UI hidden.Known limitations
SyncConvergenceTest(seven cases over two independent stores sharing one backend), but has not been exercised on real hardware.