From 0f1178925597613f7fbfcc7acf1e7dc0469ff238 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 16:43:43 -0400 Subject: [PATCH 01/18] docs(sync): design spec for Google Drive sync backend (all platforms) --- ...-07-02-google-drive-sync-backend-design.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md diff --git a/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md new file mode 100644 index 000000000..a06c24ca8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md @@ -0,0 +1,274 @@ +# Google Drive Sync Backend — Design + +**Date:** 2026-07-02 +**Status:** Approved (pending implementation) +**Branch:** `worktree-google-drive-sync` + +## Summary + +Make Google Drive a complete, fully functional cloud-sync backend on all five +supported platforms (iOS, macOS, Android, Windows, Linux). The Drive REST +layer already exists and is complete; this work adds a second authentication +path for desktop, per-platform OAuth configuration, UI re-enablement, +availability gating, error-handling refinements, and test coverage. + +## Current state + +- `lib/core/services/cloud_storage/google_drive_storage_provider.dart` + fully implements `CloudStorageProvider` against Drive v3's hidden + `appDataFolder` (`drive.appdata` scope): idempotent create-or-update + upload by name, streaming download, `name contains` list filtering, + delete, folder management, sign-in/sign-out with deferred silent auth. +- All dependencies are already declared: `google_sign_in` ^7.2.0, + `googleapis` ^16.0.0, `extension_google_sign_in_as_googleapis_auth` + ^3.0.0, `googleapis_auth` ^2.0.0. +- `CloudProviderType.googledrive` participates in the factory + (`cloudProviderInstanceFor`), per-provider sync cursors (schema v81), + and the backend-switch safety machinery with no engine changes needed. +- iOS OAuth is configured (`GIDClientID` + reversed-client-ID URL scheme + in `ios/Runner/Info.plist`, project `433819313354`). +- The provider was deliberately hidden (commit `d884ee2a4c2`): the + settings tile is removed from `cloud_sync_page.dart` and a persisted + `googledrive` selection is coerced to `null` (lines 56–62). +- Gaps: no macOS or Android OAuth config, no Windows/Linux auth path at + all (`google_sign_in` does not support them), `isAvailable()` returns + a hard `true`, no persistent desktop credentials, no tests. + +## Decisions made + +| Decision | Choice | +|---|---| +| Platforms | All five: iOS, macOS, Android, Windows, Linux | +| Data location | Hidden `appDataFolder` (`drive.appdata` scope) — unchanged | +| Auth architecture | One provider, pluggable authenticator seam (Approach A) | +| Desktop client secret | Committed in source (RFC 8252 §8.5: installed-app secrets are non-confidential) | +| Acceptance | Full unit/widget coverage + manual device checklist pass | + +The `no hardcoded secrets` rule in CLAUDE.md is explicitly waived for the +desktop OAuth client ID/secret: Google classifies installed-app client +secrets as non-confidential (they ship inside every desktop binary and +cannot protect anything). This matches standard practice for open-source +desktop apps (rclone, the Google Cloud SDK). + +## Architecture + +One `GoogleDriveStorageProvider` remains the single `CloudProviderType.googledrive` +implementation. Authentication is extracted behind an internal seam so the +Drive REST code is shared and platform-agnostic. + +The seam boundary is `http.Client`, not `DriveApi`: both +`extension_google_sign_in_as_googleapis_auth` (mobile/macOS) and +`googleapis_auth` (desktop) produce an authorized `http.Client`, so the +provider constructs `DriveApi(client)` itself and neither auth world leaks +into the REST layer. + +### New files (mirroring the `s3/` submodule pattern) + +| File | Purpose | +|---|---| +| `lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart` | Abstract seam: `authenticate()`, `attemptSilentAuth()`, `getAuthClient()` → authorized `http.Client`, `signOut()`, `userEmail` | +| `lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart` | iOS/macOS/Android: the existing `google_sign_in` v7 logic lifted out of the provider, behavior unchanged, including the `_allowSilentAuth` deferral that avoids keychain prompts before the user opts in | +| `lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart` | Windows/Linux: loopback OAuth via `googleapis_auth` `clientViaUserConsent`, silent re-auth from a stored refresh token | +| `lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart` | Desktop-only `AccessCredentials` persistence in `FallbackSecureStorage` under key `sync_gdrive_credentials`; same JSON-blob pattern as `S3CredentialsStore`, including preserving (not deleting) a corrupt blob | +| `lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart` | Committed constants: desktop client ID/secret, Android `serverClientId` (Web client ID), with the RFC 8252 §8.5 comment | + +### Changed files + +- `google_drive_storage_provider.dart` — the ~80 lines of `google_sign_in` + code move into `GoogleSignInAuthenticator`; the constructor takes a + `GoogleDriveAuthenticator` with a default factory selecting by + `Platform` (injectable for tests). All Drive REST logic is unchanged. +- `sync_providers.dart` — singleton construction only; no engine changes. +- `cloud_sync_page.dart` — tile restored, coercion removed (see UI below). +- `macos/Runner/Info.plist`, Android manifest/init — OAuth config (below). +- `storage_providers.dart` — the currently-dead `supportsGoogleDrive` + capability flag is wired to the provider's real `isAvailable()` logic + so the two cannot disagree. + +## Desktop OAuth flow (Windows/Linux) + +1. **First sign-in:** `authenticate()` calls `clientViaUserConsent` with + the desktop client ID and the `drive.appdata` scope. It binds an + ephemeral port on `127.0.0.1`, opens the system browser to Google's + consent page, and receives the auth code on the loopback redirect + (RFC 8252 §7.3). A small in-app dialog shows "Complete sign-in in + your browser…" with a Cancel button that closes the loopback listener. +2. **Persistence:** the resulting `AccessCredentials` (access token, + refresh token, expiry, scopes) serialize to one JSON blob in + `FallbackSecureStorage` under `sync_gdrive_credentials`. +3. **Cold launch:** `attemptSilentAuth()` rebuilds an auto-refreshing + client from the stored refresh token — no browser, no prompt. Nothing + touches secure storage until the user has selected Google Drive at + least once (same "no keychain prompt before opt-in" rule the mobile + path enforces). +4. **Sign-out:** best-effort POST to Google's token-revocation endpoint, + then delete the stored blob. Revocation failure (e.g. offline) does + not block local sign-out. +5. **Revoked/expired refresh token:** the auth client throws on refresh; + the provider surfaces a `CloudStorageException` whose `displayMessage` + asks the user to sign in again, and the stored blob is cleared so the + next attempt re-runs the browser flow. + +## Per-platform OAuth configuration + +| Platform | GCP console client type | App-side config | +|---|---|---| +| iOS | Existing iOS client (done) | Already in `ios/Runner/Info.plist` | +| macOS | Reuses the iOS client (Google treats macOS apps as iOS-type clients) | Add `GIDClientID` + reversed-client-ID URL scheme to `macos/Runner/Info.plist`; add the Keychain Sharing entitlement `google_sign_in` requires on macOS | +| Android | New Android client per signing key (debug SHA-1 and release SHA-1), plus a Web application client | Web client ID passed as `serverClientId` to `GoogleSignIn.instance.initialize()` via `google_drive_client_config.dart`. No `google-services.json` (that is a Firebase artifact, not needed) | +| Windows/Linux | One shared Desktop app client | Client ID + secret committed in `google_drive_client_config.dart` | + +All clients live in the same Google Cloud project (`433819313354`) so every +platform shares the same `appDataFolder` — this is what makes cross-device +sync work, because `appDataFolder` is scoped per project, per user. + +Click-by-click console instructions are in Appendix A. + +## UI changes (`cloud_sync_page.dart`) + +- Restore the Google Drive tile in `_buildProviderSection`, mirroring the + iCloud tile's structure. When connected, the subtitle shows the + signed-in account email (`getUserEmail()`); a trailing overflow action + offers **Sign out**. +- Sign-out routes through the existing backend-departure flow + (`recordBackendDeparture`) before deselecting, so the per-provider + cursor (v81) is stamped and the stale-cursor bug class PR #327 fixed + cannot recur. +- Remove the `googledrive → null` coercion (lines 56–62); a persisted + selection resumes normally. +- Selecting the tile triggers `authenticate()`; on failure the tile stays + unselected and the error's `displayMessage` shows in a SnackBar with + `persist: false` + `showCloseIcon` (the #406 SnackBar lesson). +- The existing localization string + `settings_cloudSync_provider_googleDrive` is reused; any new strings + are translated into all 10 non-English locales and regenerated. + +## Availability gating + +`isAvailable()` stops returning a hard `true`: + +- iOS, macOS, Android: `true` unconditionally (config is compile-time). +- Windows, Linux: `true` only when the desktop client config constants + are non-empty — a build without OAuth constants degrades to a hidden + tile, not a runtime crash. + +## Error handling + +All Drive calls already funnel through `CloudStorageException`. Additions: + +- `DetailedApiRequestError` 401 → one silent re-auth attempt, then a + "sign in to Google Drive again" failure. (Drive access tokens expire + hourly mid-session; one retry disambiguates a stale token from a + revoked grant.) +- 403 `storageQuotaExceeded` → explicit "Google Drive storage is full" + message. +- `SocketException` → the standard offline message the sync engine + already handles. +- Auth-specific failures — browser launch failure, user closes the + browser without consenting, loopback port bind failure — get distinct + messages and never leave partial state (no stored blob, tile + unselected). + +## Testing + +### Unit + +- `test/core/services/cloud_storage/google_drive_storage_provider_test.dart` + with a fake authenticator + mocked `http.Client` (the S3 suite is the + template): upload create-vs-update by name, list filtering, download, + delete, folder get-or-create caching, 401 retry-once, quota error + mapping. +- `test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart`: + silent auth from stored credentials, revoked-token cleanup, sign-out + revocation best-effort semantics. +- `test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart` + with an in-memory secure-storage fake: round-trip, corrupt blob + preserved, delete. + +### Widget + +- Update the two `cloud_sync_page_test.dart` cases that currently assert + the tile is hidden (`:554`, `:810`). +- New cases: tile shown on all platforms, selecting triggers + authentication, connected subtitle shows the account email, sign-out + routes through backend departure. + +### Manual device checklist (acceptance gate) + +Run on real hardware per platform — macOS, iPhone or iPad, Android +device, Windows, Linux: + +1. Fresh sign-in from the Cloud Sync page (browser flow on desktop, + native sheet on mobile/macOS). +2. Cold-launch silent auth: force-quit, relaunch, confirm sync works + without any prompt. +3. Two-device sync round-trip: edit a dive on device A, Sync Now on + both, confirm it appears on device B (and the reverse). +4. Sign out; confirm the tile deselects and no keychain prompts appear + afterward. +5. Revoke the app's access from myaccount.google.com, then attempt a + sync; confirm the "sign in again" recovery path works. +6. Backend switch iCloud → Google Drive (Apple platforms): confirm the + departure/moved-marker flow and that cursors do not read stale. + +## Out of scope + +- Any change to the sync engine, changeset layout (`ssv1.` flat files), + or per-provider cursor machinery — Drive slots into all of it as-is. +- A visible My Drive folder option (`drive.file` scope) — rejected in + favor of `appDataFolder`. +- Multi-account support beyond sign-out/sign-in-as-another-user. +- Web platform support. + +## Appendix A — Google Cloud Console walkthrough + +All steps happen in the existing project `433819313354` at +console.cloud.google.com. Exact UI labels current as of mid-2026. + +### A.1 Consent screen check (once) + +1. APIs & Services → OAuth consent screen. +2. Confirm the app is configured (it must be, since the iOS client + works). Scope `…/auth/drive.appdata` is classified non-sensitive, so + no verification review is required. If the publishing status is + "Testing", either add each Google account you will test with as a + test user, or publish to "In production" (no review needed for + non-sensitive scopes). +3. APIs & Services → Enabled APIs: confirm **Google Drive API** is + enabled; enable it if not. + +### A.2 Android clients + +1. Get the debug SHA-1: + `keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA1` +2. Get the release SHA-1 the same way from the release keystore + (and, if distributing via Play with Play App Signing, copy the + app-signing SHA-1 from Play Console → Setup → App integrity). +3. APIs & Services → Credentials → Create Credentials → OAuth client ID + → Application type **Android**. Package name: the applicationId from + `android/app/build.gradle`. SHA-1: the debug fingerprint. Create. +4. Repeat step 3 for each additional SHA-1 (release, Play app-signing). + Android clients need no app-side config; Google matches package + + signature at runtime. +5. Create Credentials → OAuth client ID → Application type + **Web application**, name "Submersion Android serverClientId". No + redirect URIs needed. Copy its client ID — this is the + `serverClientId` constant. + +### A.3 Desktop client (Windows/Linux) + +1. Create Credentials → OAuth client ID → Application type + **Desktop app**, name "Submersion Desktop". +2. Copy the client ID and client secret — these are the committed + constants in `google_drive_client_config.dart`. + +### A.4 macOS + +No new console client. Reuse the existing iOS client ID: + +1. Copy `GIDClientID` and the reversed-client-ID URL scheme from + `ios/Runner/Info.plist` into `macos/Runner/Info.plist`. +2. Add the Keychain Sharing entitlement to both + `macos/Runner/DebugProfile.entitlements` and + `macos/Runner/Release.entitlements` as required by `google_sign_in` + on macOS. From 82b008ea480259d7b95ed963e4c8ff53e7baa059 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:02:06 -0400 Subject: [PATCH 02/18] docs(sync): implementation plan for Google Drive sync backend --- .../2026-07-02-google-drive-sync-backend.md | 2303 +++++++++++++++++ ...-07-02-google-drive-sync-backend-design.md | 13 +- 2 files changed, 2310 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-02-google-drive-sync-backend.md diff --git a/docs/superpowers/plans/2026-07-02-google-drive-sync-backend.md b/docs/superpowers/plans/2026-07-02-google-drive-sync-backend.md new file mode 100644 index 000000000..7a44cc103 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-google-drive-sync-backend.md @@ -0,0 +1,2303 @@ +# Google Drive Sync Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Google Drive a complete, fully functional cloud-sync backend on iOS, macOS, Android, Windows, and Linux. + +**Architecture:** The existing `GoogleDriveStorageProvider` keeps all its Drive v3 REST logic (appDataFolder). Authentication is extracted behind a `GoogleDriveAuthenticator` seam with two implementations: `GoogleSignInAuthenticator` (iOS/macOS/Android, `google_sign_in` v7) and `DesktopOAuthAuthenticator` (Windows/Linux, loopback OAuth via `googleapis_auth` with refresh-token persistence in `FallbackSecureStorage`). The settings tile is restored and gated by real availability. + +**Tech Stack:** Flutter, Riverpod, `google_sign_in` ^7.2.0, `googleapis` ^16.0.0, `googleapis_auth` ^2.0.0, `extension_google_sign_in_as_googleapis_auth` ^3.0.0, `url_launcher` ^6.3.1, `flutter_secure_storage` ^10.0.0, `package:http/testing.dart` MockClient for tests. + +**Spec:** `docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md` + +## Global Constraints + +- All work happens in the `google-drive-sync` worktree on branch `worktree-google-drive-sync`. Never touch the main checkout. +- Run `dart format .` (whole repo) before every commit; CI checks the whole project. +- Run `flutter analyze` (whole project, never piped to `tail`) before every commit. +- Run tests as specific files (`flutter test test/path/file_test.dart`), never broad directories (Bash timeout risk). +- No emojis in code, comments, or documentation. +- Commits: conventional-commit style, no `Co-Authored-By` lines. +- New user-facing strings go in `lib/l10n/arb/app_en.arb` AND all 10 other locales (`ar, de, es, fr, he, hu, it, nl, pt, zh`), then regenerate with `flutter gen-l10n`. +- The desktop OAuth client ID/secret are intentionally committed constants (RFC 8252 section 8.5 — installed-app secrets are non-confidential). This is an approved, documented exception to the "no hardcoded secrets" rule; keep the explanatory comment. +- Existing behavior of the Drive REST methods (query strings, appDataFolder spaces, idempotent name-based upload) must not change. + +--- + +### Task 1: Client config, authenticator interface, and GoogleSignInAuthenticator + +No unit tests in this task: `GoogleSignInAuthenticator` is a thin wrapper over the +`GoogleSignIn.instance` platform channel (untestable on the host), the interface is +abstract, and the config is constants. The verification gate is `flutter analyze`. +The moved sign-in logic must be byte-for-byte the same behavior as the current +provider code (`google_drive_storage_provider.dart:43-143`). + +**Files:** +- Create: `lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart` +- Create: `lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart` +- Create: `lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart` + +**Interfaces:** +- Consumes: nothing new. +- Produces (later tasks depend on these exact names): + - `GoogleDriveClientConfig.desktopClientId/desktopClientSecret/androidServerClientId: String` (static const), `GoogleDriveClientConfig.hasDesktopClient: bool` (static getter) + - `abstract class GoogleDriveAuthenticator { Future authenticate(); Future attemptSilentAuth(); http.Client? get authClient; Future get userEmail; Future signOut(); Future handleAuthFailure(); }` + - `class GoogleSignInAuthenticator implements GoogleDriveAuthenticator` with a default constructor. + +- [ ] **Step 1: Write `google_drive_client_config.dart`** + +```dart +/// OAuth client configuration for Google Drive sync. +/// +/// The desktop client ID and secret are committed to source intentionally: +/// Google classifies installed-app client secrets as non-confidential +/// (RFC 8252 section 8.5) -- they ship inside every desktop binary and can +/// not protect anything. Committing them matches standard practice for +/// open-source desktop applications (rclone, the Google Cloud SDK). +/// +/// All clients must belong to the same Google Cloud project so every +/// platform shares the same Drive appDataFolder (it is scoped per project, +/// per user); that is what makes cross-device sync work. +class GoogleDriveClientConfig { + /// OAuth 2.0 "Desktop app" client used by the Windows/Linux loopback + /// flow. Empty until the client is created in the Google Cloud console; + /// an empty value disables Google Drive on desktop instead of crashing. + static const String desktopClientId = ''; + static const String desktopClientSecret = ''; + + /// "Web application" client ID passed as serverClientId to + /// google_sign_in on Android. Empty means initialize() is called without + /// a serverClientId (sufficient for iOS/macOS, which read GIDClientID + /// from Info.plist). + static const String androidServerClientId = ''; + + /// True when the Desktop-app client is configured in this build. + static bool get hasDesktopClient => + desktopClientId.isNotEmpty && desktopClientSecret.isNotEmpty; +} +``` + +- [ ] **Step 2: Write `google_drive_authenticator.dart`** + +```dart +import 'package:http/http.dart' as http; + +/// Authentication seam for GoogleDriveStorageProvider. +/// +/// Two implementations exist: GoogleSignInAuthenticator (iOS/macOS/Android, +/// native google_sign_in flow) and DesktopOAuthAuthenticator (Windows/Linux, +/// loopback OAuth). The boundary is deliberately [http.Client] -- both auth +/// worlds produce an authorized client, and the provider builds its own +/// DriveApi from it, so neither leaks into the Drive REST code. +abstract class GoogleDriveAuthenticator { + /// Interactive sign-in. May show UI (account sheet or system browser). + /// Throws CloudStorageException on failure or user cancellation. + Future authenticate(); + + /// Non-interactive re-auth from cached state (google_sign_in lightweight + /// auth, or a stored refresh token). Never shows UI. Returns false when + /// re-auth is not possible; must not throw. + /// + /// Implementations must not touch secure storage before the user has + /// opted in by authenticating once (no keychain prompt before opt-in). + Future attemptSilentAuth(); + + /// The authorized HTTP client, or null when not authenticated. + http.Client? get authClient; + + /// Signed-in account email, or null when unknown. + Future get userEmail; + + /// Sign out and clear stored credentials. + Future signOut(); + + /// Called after an API 401: drop the (stale or revoked) client state so + /// the next attemptSilentAuth() rebuilds from scratch. + Future handleAuthFailure(); +} +``` + +- [ ] **Step 3: Write `google_sign_in_authenticator.dart`** + +This is the sign-in code currently in `google_drive_storage_provider.dart:43-143`, +moved verbatim apart from: (a) the class wrapper, (b) the Android +`serverClientId` in `_ensureInitialized`, (c) `handleAuthFailure`. +Do not delete the old code from the provider yet — that happens in Task 4. + +```dart +import 'dart:io'; + +import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:googleapis_auth/googleapis_auth.dart' as gapis_auth; +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// google_sign_in-backed authenticator for iOS, macOS, and Android. +/// +/// Token persistence across launches is handled by google_sign_in's own +/// cache via attemptLightweightAuthentication(); nothing is stored by the +/// app. Silent sign-in is deferred until the user has opted in once +/// (_allowSilentAuth) because it touches the platform keychain. +class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { + static final _log = LoggerService.forClass(GoogleSignInAuthenticator); + + static const _scopes = [drive.DriveApi.driveAppdataScope]; + + // Use the shared instance; configuration is provided per-call via scope + // hints. + final GoogleSignIn _googleSignIn = GoogleSignIn.instance; + bool _initialized = false; + bool _allowSilentAuth = false; + gapis_auth.AuthClient? _authClient; + GoogleSignInAccount? _currentUser; + + @override + http.Client? get authClient => _authClient; + + @override + Future get userEmail async => _currentUser?.email; + + Future _ensureInitialized() async { + if (_initialized) return; + final serverClientId = + Platform.isAndroid && + GoogleDriveClientConfig.androidServerClientId.isNotEmpty + ? GoogleDriveClientConfig.androidServerClientId + : null; + await _googleSignIn.initialize(serverClientId: serverClientId); + _initialized = true; + } + + @override + Future attemptSilentAuth() async { + try { + if (_authClient != null) return true; + + // Defer any silent sign-in (which triggers Keychain access) until the + // user has explicitly opted in by signing in once. + if (!_allowSilentAuth) return false; + + await _ensureInitialized(); + final futureAccount = _googleSignIn.attemptLightweightAuthentication(); + if (futureAccount == null) return false; + + final account = await futureAccount; + if (account == null) return false; + + final authorization = await account.authorizationClient + .authorizationForScopes(_scopes); + if (authorization == null) return false; + + _installClient(account, authorization); + return true; + } catch (e) { + _log.warning('Silent sign-in failed: $e'); + return false; + } + } + + @override + Future authenticate() async { + try { + await _ensureInitialized(); + final account = await _googleSignIn.authenticate(scopeHint: _scopes); + final authorization = await account.authorizationClient.authorizeScopes( + _scopes, + ); + + _installClient(account, authorization); + _allowSilentAuth = true; + _log.info('Authenticated with Google Drive as ${account.email}'); + } on GoogleSignInException catch (e, stackTrace) { + if (e.code == GoogleSignInExceptionCode.canceled) { + _log.info('Google Sign-In was cancelled by the user'); + throw CloudStorageException( + 'Google Sign-In was cancelled', + e, + stackTrace, + ); + } + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException( + 'Google Sign-In failed: ${e.description ?? e.code.name}', + e, + stackTrace, + ); + } catch (e, stackTrace) { + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException('Google Sign-In failed: $e', e, stackTrace); + } + } + + void _installClient( + GoogleSignInAccount account, + GoogleSignInClientAuthorization authorization, + ) { + _authClient?.close(); + _authClient = authorization.authClient(scopes: _scopes); + _currentUser = account; + } + + @override + Future signOut() async { + await _googleSignIn.signOut(); + // Close the auth client if it exists; close is synchronous. + _authClient?.close(); + _authClient = null; + _currentUser = null; + _allowSilentAuth = false; + _log.info('Signed out from Google Drive'); + } + + @override + Future handleAuthFailure() async { + // Drop the stale client; keep _allowSilentAuth so the next + // attemptSilentAuth() can rebuild authorization without UI. + _authClient?.close(); + _authClient = null; + } +} +``` + +- [ ] **Step 4: Verify** + +Run: `flutter analyze` +Expected: `No issues found!` (the new files compile; nothing references them yet) + +- [ ] **Step 5: Format and commit** + +```bash +dart format . +git add lib/core/services/cloud_storage/google_drive/ +git commit -m "feat(sync): add Google Drive authenticator seam and google_sign_in implementation" +``` + +--- + +### Task 2: GoogleDriveTokenStore (TDD) + +Mirror of `S3CredentialsStore` + its test, storing `googleapis_auth` +`AccessCredentials` JSON. Template: `lib/core/services/cloud_storage/s3/s3_credentials_store.dart` +and `test/core/services/cloud_storage/s3/s3_credentials_store_test.dart` (uses +`InMemoryKeychain` from `test/support/fake_keychain_storage.dart`). + +**Files:** +- Create: `lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart` +- Test: `test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart` + +**Interfaces:** +- Consumes: `FallbackSecureStorage` (`lib/core/services/secure_storage/fallback_secure_storage.dart`), `AccessCredentials.toJson()/fromJson()` from `googleapis_auth`. +- Produces: `class GoogleDriveTokenStore { GoogleDriveTokenStore({FlutterSecureStorage? storage}); static const String storageKey = 'sync_gdrive_credentials'; Future load(); Future save(AccessCredentials credentials); Future clear(); }` + +- [ ] **Step 1: Write the failing test** + +```dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:googleapis_auth/googleapis_auth.dart' as gauth; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + late InMemoryKeychain storage; + late GoogleDriveTokenStore store; + + setUp(() { + storage = InMemoryKeychain(); + store = GoogleDriveTokenStore(storage: storage); + }); + + gauth.AccessCredentials creds() => gauth.AccessCredentials( + gauth.AccessToken( + 'Bearer', + 'at-1', + DateTime.utc(2026, 7, 2, 12), + ), + 'rt-1', + ['https://www.googleapis.com/auth/drive.appdata'], + idToken: 'id-1', + ); + + test('load returns null when nothing is stored', () async { + expect(await store.load(), isNull); + }); + + test('save then load round-trips the credentials', () async { + await store.save(creds()); + final loaded = await store.load(); + expect(loaded, isNotNull); + expect(loaded!.accessToken.data, 'at-1'); + expect(loaded.refreshToken, 'rt-1'); + expect(loaded.idToken, 'id-1'); + expect(loaded.scopes, ['https://www.googleapis.com/auth/drive.appdata']); + expect(storage.values.keys, [GoogleDriveTokenStore.storageKey]); + }); + + test('clear removes the blob', () async { + await store.save(creds()); + await store.clear(); + expect(await store.load(), isNull); + expect(storage.values, isEmpty); + }); + + test('corrupted JSON loads as null instead of throwing', () async { + storage.values[GoogleDriveTokenStore.storageKey] = 'not-json{'; + expect(await store.load(), isNull); + // The corrupt blob is preserved, not deleted (save() overwrites it). + expect(storage.values, isNotEmpty); + }); + + test('valid JSON that is not an object loads as null', () async { + storage.values[GoogleDriveTokenStore.storageKey] = '[]'; + expect(await store.load(), isNull); + }); + + test('an object with wrong-typed fields loads as null', () async { + storage.values[GoogleDriveTokenStore.storageKey] = jsonEncode({ + 'accessToken': 42, + }); + expect(await store.load(), isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart` +Expected: FAIL — compilation error, `google_drive_token_store.dart` does not exist. + +- [ ] **Step 3: Write the implementation** + +```dart +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:googleapis_auth/googleapis_auth.dart' as gauth; + +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// Persists the desktop Google Drive OAuth credentials (access token, +/// refresh token, expiry, scopes) as a single JSON blob in the platform +/// keychain. One blob keeps load/save atomic; nothing about the Google +/// Drive setup ever touches SharedPreferences or the database. +/// +/// A corrupt blob is left in place rather than deleted, so a transient +/// decode bug cannot destroy credentials; save() simply overwrites it. +/// +/// Keychain access goes through [FallbackSecureStorage], which retries on +/// the legacy keychain when the ad-hoc no-sandbox build has no access +/// group. Only the desktop authenticator uses this store; the mobile path +/// relies on google_sign_in's own token cache. +class GoogleDriveTokenStore { + GoogleDriveTokenStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + final FallbackSecureStorage _storage; + + static const String storageKey = 'sync_gdrive_credentials'; + + /// The stored credentials, or null when unset or the stored blob is + /// corrupt. Keychain errors other than a missing entitlement propagate. + Future load() async { + final raw = await _storage.read(key: storageKey); + if (raw == null) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return gauth.AccessCredentials.fromJson(decoded); + } on FormatException { + return null; + } on TypeError { + return null; + } on ArgumentError { + // AccessToken.fromJson rejects e.g. a non-UTC expiry. + return null; + } + } + + Future save(gauth.AccessCredentials credentials) => + _storage.write(key: storageKey, value: jsonEncode(credentials.toJson())); + + Future clear() => _storage.delete(key: storageKey); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart` +Expected: All 6 tests PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart test/core/services/cloud_storage/google_drive/ +git commit -m "feat(sync): add keychain-backed Google Drive token store" +``` + +--- + +### Task 3: DesktopOAuthAuthenticator (TDD) + +Loopback OAuth for Windows/Linux. Every external effect is injectable so the +whole class is host-testable: the consent flow, the auto-refreshing-client +builder, the base HTTP client factory, and the browser launcher. + +**Files:** +- Create: `lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart` +- Test: `test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart` + +**Interfaces:** +- Consumes: `GoogleDriveAuthenticator` (Task 1), `GoogleDriveClientConfig` (Task 1), `GoogleDriveTokenStore` (Task 2), `googleapis_auth/auth_io.dart` (`obtainAccessCredentialsViaUserConsent`, `autoRefreshingClient`, `ClientId`, `AccessCredentials`, `AutoRefreshingAuthClient`), `url_launcher_string`. +- Produces: `class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator` with constructor `DesktopOAuthAuthenticator({GoogleDriveTokenStore? tokenStore, ObtainConsentCredentials? obtainConsent, BuildRefreshingClient? buildClient, http.Client Function()? baseClientFactory, Future Function(String url)? launchBrowser})`, plus `static const List scopes`. + +- [ ] **Step 1: Write the failing test** + +```dart +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:googleapis_auth/auth_io.dart' as gauth; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; + +class _MemoryTokenStore implements GoogleDriveTokenStore { + gauth.AccessCredentials? stored; + int saves = 0; + + @override + Future load() async => stored; + + @override + Future save(gauth.AccessCredentials credentials) async { + stored = credentials; + saves++; + } + + @override + Future clear() async => stored = null; +} + +/// The authenticator only uses credentialUpdates, close(), and passes the +/// client through as an http.Client; nothing sends real requests in tests. +class _FakeRefreshingClient extends Fake + implements gauth.AutoRefreshingAuthClient { + _FakeRefreshingClient(this.credentials); + + @override + final gauth.AccessCredentials credentials; + + final StreamController updates = + StreamController.broadcast(); + + bool closed = false; + + @override + Stream get credentialUpdates => updates.stream; + + @override + void close() => closed = true; +} + +String idTokenWithEmail(String email) { + final payload = base64Url.encode(utf8.encode(jsonEncode({'email': email}))); + return 'header.$payload.signature'; +} + +gauth.AccessCredentials creds({String? refreshToken, String? idToken}) => + gauth.AccessCredentials( + gauth.AccessToken('Bearer', 'at-1', DateTime.utc(2026, 7, 2, 12)), + refreshToken, + DesktopOAuthAuthenticator.scopes, + idToken: idToken, + ); + +void main() { + late _MemoryTokenStore store; + late List revokeRequests; + + MockClient baseClient() => MockClient((request) async { + revokeRequests.add(request); + return http.Response('{}', 200); + }); + + setUp(() { + store = _MemoryTokenStore(); + revokeRequests = []; + }); + + DesktopOAuthAuthenticator authenticator({ + gauth.AccessCredentials? consentResult, + List<_FakeRefreshingClient>? builtClients, + }) => DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async { + if (consentResult == null) { + throw Exception('consent should not run in this test'); + } + prompt('https://accounts.google.com/o/oauth2/auth?fake'); + return consentResult; + }, + buildClient: (clientId, credentials, base) { + final fake = _FakeRefreshingClient(credentials); + builtClients?.add(fake); + return fake; + }, + baseClientFactory: baseClient, + launchBrowser: (url) async {}, + ); + + test('attemptSilentAuth returns false with no stored credentials', () async { + final auth = authenticator(); + expect(await auth.attemptSilentAuth(), isFalse); + expect(auth.authClient, isNull); + }); + + test('attemptSilentAuth returns false without a refresh token', () async { + store.stored = creds(refreshToken: null); + final auth = authenticator(); + expect(await auth.attemptSilentAuth(), isFalse); + }); + + test('attemptSilentAuth installs a client from stored credentials', () async { + store.stored = creds( + refreshToken: 'rt-1', + idToken: idTokenWithEmail('diver@example.com'), + ); + final built = <_FakeRefreshingClient>[]; + final auth = authenticator(builtClients: built); + + expect(await auth.attemptSilentAuth(), isTrue); + expect(auth.authClient, isNotNull); + expect(built, hasLength(1)); + expect(await auth.userEmail, 'diver@example.com'); + }); + + test('authenticate stores credentials and installs a client', () async { + final auth = authenticator( + consentResult: creds( + refreshToken: 'rt-9', + idToken: idTokenWithEmail('new@example.com'), + ), + ); + + await auth.authenticate(); + + expect(store.stored?.refreshToken, 'rt-9'); + expect(auth.authClient, isNotNull); + expect(await auth.userEmail, 'new@example.com'); + }); + + test('a credential update from the refreshing client is persisted', () async { + store.stored = creds(refreshToken: 'rt-1'); + final built = <_FakeRefreshingClient>[]; + final auth = authenticator(builtClients: built); + await auth.attemptSilentAuth(); + + built.single.updates.add(creds(refreshToken: 'rt-2')); + await Future.delayed(Duration.zero); + + expect(store.stored?.refreshToken, 'rt-2'); + expect(store.saves, greaterThanOrEqualTo(1)); + }); + + test('consent failure surfaces as CloudStorageException', () async { + final auth = DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async => + throw Exception('user closed browser'), + buildClient: (clientId, credentials, base) => + _FakeRefreshingClient(credentials), + baseClientFactory: baseClient, + launchBrowser: (url) async {}, + ); + + expect(auth.authenticate(), throwsA(isA())); + }); + + test('signOut revokes the token and clears the store', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = authenticator(); + await auth.attemptSilentAuth(); + + await auth.signOut(); + + expect(store.stored, isNull); + expect(auth.authClient, isNull); + expect(revokeRequests, hasLength(1)); + expect(revokeRequests.single.url.host, 'oauth2.googleapis.com'); + expect(revokeRequests.single.url.path, '/revoke'); + }); + + test('signOut still clears local state when revocation throws', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async => + throw Exception('unused'), + buildClient: (clientId, credentials, base) => + _FakeRefreshingClient(credentials), + baseClientFactory: () => + MockClient((request) async => throw Exception('offline')), + launchBrowser: (url) async {}, + ); + await auth.attemptSilentAuth(); + + await auth.signOut(); + + expect(store.stored, isNull); + expect(auth.authClient, isNull); + }); + + test('handleAuthFailure clears the client and stored credentials', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = authenticator(); + await auth.attemptSilentAuth(); + + await auth.handleAuthFailure(); + + expect(auth.authClient, isNull); + expect(store.stored, isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart` +Expected: FAIL — compilation error, `desktop_oauth_authenticator.dart` does not exist. + +- [ ] **Step 3: Write the implementation** + +```dart +import 'dart:async'; +import 'dart:convert'; + +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:googleapis_auth/auth_io.dart' as gauth; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher_string.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// Runs the user-consent step of the loopback flow and returns credentials. +typedef ObtainConsentCredentials = + Future Function( + gauth.ClientId clientId, + List scopes, + http.Client client, + void Function(String url) prompt, + ); + +/// Builds an auto-refreshing client from stored credentials. +typedef BuildRefreshingClient = + gauth.AutoRefreshingAuthClient Function( + gauth.ClientId clientId, + gauth.AccessCredentials credentials, + http.Client baseClient, + ); + +/// Loopback-OAuth authenticator for Windows and Linux (RFC 8252 section +/// 7.3): binds an ephemeral 127.0.0.1 port, opens the system browser to +/// Google's consent page, and receives the auth code on the local +/// redirect. Credentials persist in [GoogleDriveTokenStore]; cold-launch +/// re-auth is silent via the stored refresh token. +class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { + DesktopOAuthAuthenticator({ + GoogleDriveTokenStore? tokenStore, + ObtainConsentCredentials? obtainConsent, + BuildRefreshingClient? buildClient, + http.Client Function()? baseClientFactory, + Future Function(String url)? launchBrowser, + }) : _tokenStore = tokenStore ?? GoogleDriveTokenStore(), + _obtainConsent = + obtainConsent ?? gauth.obtainAccessCredentialsViaUserConsent, + _buildClient = buildClient ?? gauth.autoRefreshingClient, + _baseClientFactory = baseClientFactory ?? http.Client.new, + _launchBrowser = launchBrowser ?? launchUrlString; + + static final _log = LoggerService.forClass(DesktopOAuthAuthenticator); + + /// openid + email are included so the id_token carries the account email + /// for the settings tile subtitle; drive.appdata is the only Drive scope. + static const List scopes = [ + drive.DriveApi.driveAppdataScope, + 'openid', + 'email', + ]; + + static const String _revokeEndpoint = 'https://oauth2.googleapis.com/revoke'; + + final GoogleDriveTokenStore _tokenStore; + final ObtainConsentCredentials _obtainConsent; + final BuildRefreshingClient _buildClient; + final http.Client Function() _baseClientFactory; + final Future Function(String url) _launchBrowser; + + gauth.AutoRefreshingAuthClient? _authClient; + StreamSubscription? _updateSubscription; + String? _email; + + gauth.ClientId get _clientId => gauth.ClientId( + GoogleDriveClientConfig.desktopClientId, + GoogleDriveClientConfig.desktopClientSecret, + ); + + @override + http.Client? get authClient => _authClient; + + @override + Future get userEmail async => _email; + + @override + Future authenticate() async { + final base = _baseClientFactory(); + try { + final credentials = await _obtainConsent(_clientId, scopes, base, (url) { + unawaited(_launchBrowser(url)); + }); + await _tokenStore.save(credentials); + _installClient(credentials); + _log.info('Authenticated with Google Drive via browser consent'); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException('Google Sign-In failed: $e', e, stackTrace); + } finally { + base.close(); + } + } + + @override + Future attemptSilentAuth() async { + try { + if (_authClient != null) return true; + + final credentials = await _tokenStore.load(); + if (credentials == null || credentials.refreshToken == null) { + return false; + } + _installClient(credentials); + return true; + } catch (e) { + _log.warning('Silent sign-in failed: $e'); + return false; + } + } + + void _installClient(gauth.AccessCredentials credentials) { + _teardownClient(); + final client = _buildClient(_clientId, credentials, _baseClientFactory()); + _updateSubscription = client.credentialUpdates.listen( + (updated) => unawaited(_tokenStore.save(updated)), + ); + _authClient = client; + _email = _emailFromIdToken(credentials.idToken) ?? _email; + } + + void _teardownClient() { + unawaited(_updateSubscription?.cancel()); + _updateSubscription = null; + _authClient?.close(); + _authClient = null; + } + + @override + Future signOut() async { + final credentials = await _tokenStore.load(); + final token = + credentials?.refreshToken ?? _authClient?.credentials.accessToken.data; + if (token != null) { + // Best effort: revocation failure (e.g. offline) must not block + // local sign-out. + final base = _baseClientFactory(); + try { + await base.post( + Uri.parse(_revokeEndpoint), + headers: {'content-type': 'application/x-www-form-urlencoded'}, + body: 'token=$token', + ); + } catch (e) { + _log.warning('Token revocation failed (ignored): $e'); + } finally { + base.close(); + } + } + _teardownClient(); + _email = null; + await _tokenStore.clear(); + _log.info('Signed out from Google Drive'); + } + + @override + Future handleAuthFailure() async { + // A 401 that survives the auto-refreshing client means the grant was + // revoked; clear everything so the next attempt re-runs the browser + // flow instead of looping on a dead refresh token. + _teardownClient(); + await _tokenStore.clear(); + } + + /// Extracts the email claim from a JWT id_token, or null. + static String? _emailFromIdToken(String? idToken) { + if (idToken == null) return null; + final parts = idToken.split('.'); + if (parts.length != 3) return null; + try { + final payload = utf8.decode( + base64Url.decode(base64Url.normalize(parts[1])), + ); + final decoded = jsonDecode(payload); + if (decoded is! Map) return null; + return decoded['email'] as String?; + } on FormatException { + return null; + } + } +} +``` + +Implementation notes for the engineer: +- `gauth.obtainAccessCredentialsViaUserConsent` and `gauth.autoRefreshingClient` + come from `package:googleapis_auth/auth_io.dart`. If their signatures differ + from the typedefs (extra named params like `hostedDomain`), wrap them in a + closure in the constructor initializer instead of tear-offs, e.g. + `obtainConsent ?? (id, s, c, p) => gauth.obtainAccessCredentialsViaUserConsent(id, s, c, p)`. +- `handleAuthFailure` clearing the store is the desktop revoked-grant recovery + path from the spec (provider retries silent auth once after 401; if that + fails the user is asked to sign in again and the next sign-in re-prompts). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart` +Expected: All 9 tests PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart +git commit -m "feat(sync): add desktop loopback OAuth authenticator for Google Drive" +``` + +--- + +### Task 4: Refactor GoogleDriveStorageProvider onto the seam, with 401 retry and error mapping (TDD) + +The provider keeps all Drive REST behavior, drops its embedded google_sign_in +code, takes a `GoogleDriveAuthenticator` (default chosen by platform), gains a +`_run` wrapper (one silent re-auth retry on 401) and quota/offline error +mapping, and gates desktop availability on `GoogleDriveClientConfig`. + +**Files:** +- Modify: `lib/core/services/cloud_storage/google_drive_storage_provider.dart` (full rewrite below) +- Test: `test/core/services/cloud_storage/google_drive_storage_provider_test.dart` (new) + +**Interfaces:** +- Consumes: `GoogleDriveAuthenticator` (Task 1), `GoogleSignInAuthenticator` (Task 1), `DesktopOAuthAuthenticator` (Task 3), `GoogleDriveClientConfig` (Task 1). +- Produces: `GoogleDriveStorageProvider({GoogleDriveAuthenticator? authenticator})` — the no-arg construction in `sync_providers.dart:152` keeps compiling unchanged. `isAvailable()` returns `GoogleDriveClientConfig.hasDesktopClient` on Windows/Linux, true elsewhere (Task 5 and the UI rely on this). + +- [ ] **Step 1: Write the failing test** + +```dart +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive_storage_provider.dart'; + +class _FakeAuthenticator implements GoogleDriveAuthenticator { + _FakeAuthenticator(this._client); + + http.Client? _client; + int authenticateCalls = 0; + int silentAuthCalls = 0; + int authFailures = 0; + bool silentAuthResult = true; + bool signedOut = false; + + @override + http.Client? get authClient => _client; + + @override + Future authenticate() async => authenticateCalls++; + + @override + Future attemptSilentAuth() async { + silentAuthCalls++; + return silentAuthResult; + } + + @override + Future handleAuthFailure() async => authFailures++; + + @override + Future signOut() async { + signedOut = true; + _client = null; + } + + @override + Future get userEmail async => 'diver@example.com'; +} + +const _jsonHeaders = {'content-type': 'application/json; charset=utf-8'}; + +/// Minimal fake Drive v3 backend. List responses are keyed by a substring +/// of the q query parameter (folder lookups contain the folder mimeType, +/// file lookups contain the file name). +class _FakeDrive { + final List requests = []; + final Map>> listResponses = {}; + int failuresRemaining = 0; + int failureStatus = 401; + String failureReason = 'authError'; + + MockClient client() => MockClient((request) async { + requests.add(request); + if (failuresRemaining > 0) { + failuresRemaining--; + return http.Response( + jsonEncode({ + 'error': { + 'code': failureStatus, + 'message': 'fake failure', + 'errors': [ + {'reason': failureReason, 'message': 'fake failure'}, + ], + }, + }), + failureStatus, + headers: _jsonHeaders, + ); + } + final path = request.url.path; + if (request.method == 'GET' && path == '/drive/v3/files') { + final q = request.url.queryParameters['q'] ?? ''; + for (final entry in listResponses.entries) { + if (q.contains(entry.key)) { + return http.Response( + jsonEncode({'files': entry.value}), + 200, + headers: _jsonHeaders, + ); + } + } + return http.Response( + jsonEncode({'files': []}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'POST' && path == '/upload/drive/v3/files') { + return http.Response( + jsonEncode({'id': 'created-1', 'name': 'created'}), + 200, + headers: _jsonHeaders, + ); + } + if ((request.method == 'PATCH' || request.method == 'PUT') && + path.startsWith('/upload/drive/v3/files/')) { + return http.Response( + jsonEncode({'id': path.split('/').last, 'name': 'updated'}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'POST' && path == '/drive/v3/files') { + return http.Response( + jsonEncode({'id': 'folder-created-1', 'name': 'Submersion Sync'}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'GET' && path.startsWith('/drive/v3/files/')) { + if (request.url.queryParameters['alt'] == 'media') { + return http.Response.bytes([1, 2, 3], 200); + } + return http.Response( + jsonEncode({ + 'id': path.split('/').last, + 'name': 'meta.json', + 'modifiedTime': '2026-07-02T10:00:00.000Z', + 'size': '3', + }), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'DELETE') { + return http.Response('', 204); + } + return http.Response('unexpected ${request.method} $path', 500); + }); +} + +const _folderQueryKey = "mimeType = 'application/vnd.google-apps.folder'"; + +void main() { + late _FakeDrive drive; + late _FakeAuthenticator auth; + late GoogleDriveStorageProvider provider; + + setUp(() { + drive = _FakeDrive(); + drive.listResponses[_folderQueryKey] = [ + {'id': 'folder-7', 'name': 'Submersion Sync'}, + ]; + auth = _FakeAuthenticator(drive.client()); + provider = GoogleDriveStorageProvider(authenticator: auth); + }); + + test('isAvailable is platform + desktop-config gated', () async { + final expected = (Platform.isWindows || Platform.isLinux) + ? GoogleDriveClientConfig.hasDesktopClient + : true; + expect(await provider.isAvailable(), expected); + }); + + test('isAuthenticated delegates to silent auth when no client yet', () async { + final unauthenticated = GoogleDriveStorageProvider( + authenticator: _FakeAuthenticator(null)..silentAuthResult = false, + ); + expect(await unauthenticated.isAuthenticated(), isFalse); + }); + + test('getUserEmail delegates to the authenticator', () async { + expect(await provider.getUserEmail(), 'diver@example.com'); + }); + + test('upload creates a new file when none exists by that name', () async { + final result = await provider.uploadFile( + Uint8List.fromList([1, 2]), + 'ssv1.dev.cs.000001.json', + ); + expect(result.fileId, 'created-1'); + expect( + drive.requests.any( + (r) => r.method == 'POST' && r.url.path == '/upload/drive/v3/files', + ), + isTrue, + ); + }); + + test('upload updates in place when the name already exists', () async { + drive.listResponses["name = 'ssv1.dev.manifest.json'"] = [ + {'id': 'existing-9', 'name': 'ssv1.dev.manifest.json'}, + ]; + final result = await provider.uploadFile( + Uint8List.fromList([1, 2]), + 'ssv1.dev.manifest.json', + ); + expect(result.fileId, 'existing-9'); + expect( + drive.requests.any( + (r) => r.url.path == '/upload/drive/v3/files/existing-9', + ), + isTrue, + ); + }); + + test('the sync folder id is cached across calls', () async { + await provider.uploadFile(Uint8List.fromList([1]), 'a.json'); + await provider.uploadFile(Uint8List.fromList([2]), 'b.json'); + final folderQueries = drive.requests.where( + (r) => (r.url.queryParameters['q'] ?? '').contains(_folderQueryKey), + ); + expect(folderQueries, hasLength(1)); + }); + + test('download returns the file bytes', () async { + final bytes = await provider.downloadFile('file-1'); + expect(bytes, Uint8List.fromList([1, 2, 3])); + }); + + test('listFiles maps Drive results to CloudFileInfo', () async { + drive.listResponses['ssv1.'] = [ + { + 'id': 'f1', + 'name': 'ssv1.dev.cs.000001.json', + 'modifiedTime': '2026-07-01T00:00:00.000Z', + 'size': '10', + }, + ]; + final files = await provider.listFiles(namePattern: 'ssv1.'); + expect(files, hasLength(1)); + expect(files.single.id, 'f1'); + expect(files.single.sizeBytes, 10); + }); + + test('deleteFile issues a DELETE', () async { + await provider.deleteFile('f1'); + expect(drive.requests.last.method, 'DELETE'); + expect(drive.requests.last.url.path, '/drive/v3/files/f1'); + }); + + test('a 401 triggers one silent re-auth and a retry', () async { + drive.failuresRemaining = 1; + final files = await provider.listFiles(namePattern: 'ssv1.'); + expect(files, isEmpty); + expect(auth.authFailures, 1); + expect(auth.silentAuthCalls, 1); + }); + + test('a 401 with failed re-auth surfaces a sign-in-again error', () async { + drive.failuresRemaining = 1; + auth.silentAuthResult = false; + expect( + () => provider.listFiles(namePattern: 'ssv1.'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sign in'), + ), + ), + ); + }); + + test('quota exhaustion maps to a storage-is-full error', () async { + drive.failuresRemaining = 1; + drive.failureStatus = 403; + drive.failureReason = 'storageQuotaExceeded'; + expect( + () => provider.listFiles(namePattern: 'ssv1.'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('storage is full'), + ), + ), + ); + }); + + test('signOut resets provider caches and delegates', () async { + await provider.uploadFile(Uint8List.fromList([1]), 'a.json'); + await provider.signOut(); + expect(auth.signedOut, isTrue); + expect(await provider.getFileInfo('x'), isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/core/services/cloud_storage/google_drive_storage_provider_test.dart` +Expected: FAIL — `GoogleDriveStorageProvider` has no `authenticator` parameter. + +- [ ] **Step 3: Rewrite the provider** + +Replace the entire contents of +`lib/core/services/cloud_storage/google_drive_storage_provider.dart` with: + +```dart +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:googleapis/drive/v3.dart' as drive; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// Google Drive implementation of CloudStorageProvider +/// +/// Uses the Drive API's appDataFolder for app-specific storage. +/// This folder is hidden from the user and only accessible by this app. +/// +/// Authentication is delegated to a [GoogleDriveAuthenticator]: +/// google_sign_in on iOS/macOS/Android, loopback OAuth on Windows/Linux. +class GoogleDriveStorageProvider + with CloudStorageProviderMixin + implements CloudStorageProvider { + GoogleDriveStorageProvider({GoogleDriveAuthenticator? authenticator}) + : _authenticator = authenticator ?? _defaultAuthenticator(); + + static final _log = LoggerService.forClass(GoogleDriveStorageProvider); + + static GoogleDriveAuthenticator _defaultAuthenticator() => + (Platform.isWindows || Platform.isLinux) + ? DesktopOAuthAuthenticator() + : GoogleSignInAuthenticator(); + + final GoogleDriveAuthenticator _authenticator; + drive.DriveApi? _driveApi; + String? _syncFolderId; + + @override + String get providerName => 'Google Drive'; + + @override + String get providerId => 'googledrive'; + + @override + Future isAvailable() async { + // Mobile and macOS OAuth config is compile-time (Info.plist / Android + // client registration). Desktop needs the committed Desktop-app client; + // a build without it degrades to a hidden tile instead of crashing. + if (Platform.isWindows || Platform.isLinux) { + return GoogleDriveClientConfig.hasDesktopClient; + } + return true; + } + + @override + Future isAuthenticated() async { + if (_api != null) return true; + if (await _authenticator.attemptSilentAuth()) { + return _api != null; + } + return false; + } + + @override + Future authenticate() async { + await _authenticator.authenticate(); + _driveApi = null; // rebuilt lazily from the fresh auth client + if (_api == null) { + throw const CloudStorageException( + 'Google Sign-In did not produce an authorized client', + ); + } + } + + @override + Future signOut() async { + await _authenticator.signOut(); + _driveApi = null; + _syncFolderId = null; + } + + @override + Future getUserEmail() => _authenticator.userEmail; + + /// The Drive API bound to the authenticator's current client, or null + /// when not authenticated. Rebuilt lazily so a re-auth (new client) + /// transparently produces a new API instance. + drive.DriveApi? get _api { + final client = _authenticator.authClient; + if (client == null) { + _driveApi = null; + return null; + } + return _driveApi ??= drive.DriveApi(client); + } + + drive.DriveApi get _requireApi { + final api = _api; + if (api == null) { + throw const CloudStorageException('Not authenticated with Google Drive'); + } + return api; + } + + /// Runs a Drive operation with a single 401 retry: access tokens expire + /// hourly mid-session, so one silent re-auth disambiguates a stale token + /// from a revoked grant. On a revoked grant the authenticator clears its + /// stored state and the user is asked to sign in again. + Future _run(Future Function() operation) async { + try { + return await operation(); + } on drive.DetailedApiRequestError catch (e) { + if (e.status != 401) rethrow; + _log.info('Drive API returned 401; attempting silent re-auth'); + await _authenticator.handleAuthFailure(); + _driveApi = null; + if (!await _authenticator.attemptSilentAuth()) { + throw CloudStorageException( + 'Google Drive sign-in expired. Please sign in again.', + e, + ); + } + return await operation(); + } + } + + /// Maps a Drive error to a CloudStorageException with an actionable + /// message where one exists (quota); otherwise a generic wrapper. + CloudStorageException _mapDriveError( + String operation, + Object e, + StackTrace stackTrace, + ) { + if (e is drive.DetailedApiRequestError && + e.status == 403 && + e.errors.any((d) => d.reason == 'storageQuotaExceeded')) { + return CloudStorageException( + 'Google Drive storage is full', + e, + stackTrace, + ); + } + return CloudStorageException('$operation failed: $e', e, stackTrace); + } + + @override + Future uploadFile( + Uint8List data, + String filename, { + String? folderId, + }) async { + try { + return await _run(() async { + final targetFolder = folderId ?? await getOrCreateSyncFolder(); + + // Check if file already exists + final existingFile = await _findFile(filename, targetFolder); + + drive.File result; + final media = drive.Media(Stream.fromIterable([data]), data.length); + + if (existingFile != null) { + // Update existing file + result = await _requireApi.files.update( + drive.File(), + existingFile.id!, + uploadMedia: media, + ); + _log.info('Updated file: $filename (${result.id})'); + } else { + // Create new file + final fileMetadata = drive.File() + ..name = filename + ..parents = [targetFolder]; + + result = await _requireApi.files.create( + fileMetadata, + uploadMedia: media, + ); + _log.info('Created file: $filename (${result.id})'); + } + + return UploadResult(fileId: result.id!, uploadTime: DateTime.now()); + }); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error( + 'Failed to upload file: $filename', + error: e, + stackTrace: stackTrace, + ); + throw _mapDriveError('Upload', e, stackTrace); + } + } + + @override + Future downloadFile(String fileId) async { + try { + return await _run(() async { + final response = await _requireApi.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ); + + if (response is! drive.Media) { + throw const CloudStorageException('Invalid download response'); + } + + final chunks = >[]; + await for (final chunk in response.stream) { + chunks.add(chunk); + } + + final allBytes = chunks.expand((x) => x).toList(); + _log.info('Downloaded file: $fileId (${allBytes.length} bytes)'); + return Uint8List.fromList(allBytes); + }); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error( + 'Failed to download file: $fileId', + error: e, + stackTrace: stackTrace, + ); + throw _mapDriveError('Download', e, stackTrace); + } + } + + @override + Future getFileInfo(String fileId) async { + try { + return await _run(() async { + final file = + await _requireApi.files.get( + fileId, + $fields: 'id,name,modifiedTime,size', + ) + as drive.File; + + return CloudFileInfo( + id: file.id!, + name: file.name!, + modifiedTime: file.modifiedTime ?? DateTime.now(), + sizeBytes: file.size != null ? int.tryParse(file.size!) : null, + ); + }); + } catch (e) { + _log.warning('Failed to get file info: $fileId - $e'); + return null; + } + } + + @override + Future> listFiles({ + String? folderId, + String? namePattern, + }) async { + try { + return await _run(() async { + final targetFolder = folderId ?? await getOrCreateSyncFolder(); + + var query = "'$targetFolder' in parents and trashed = false"; + if (namePattern != null) { + query += " and name contains '$namePattern'"; + } + + final fileList = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + $fields: 'files(id,name,modifiedTime,size)', + ); + + return (fileList.files ?? []) + .where((f) => f.id != null && f.name != null) + .map( + (f) => CloudFileInfo( + id: f.id!, + name: f.name!, + modifiedTime: f.modifiedTime ?? DateTime.now(), + sizeBytes: f.size != null ? int.tryParse(f.size!) : null, + ), + ) + .toList(); + }); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error('Failed to list files', error: e, stackTrace: stackTrace); + throw _mapDriveError('List files', e, stackTrace); + } + } + + @override + Future deleteFile(String fileId) async { + try { + await _run(() => _requireApi.files.delete(fileId)); + _log.info('Deleted file: $fileId'); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error( + 'Failed to delete file: $fileId', + error: e, + stackTrace: stackTrace, + ); + throw _mapDriveError('Delete', e, stackTrace); + } + } + + @override + Future fileExists(String fileId) async { + try { + await _run(() => _requireApi.files.get(fileId, $fields: 'id')); + return true; + } catch (e) { + return false; + } + } + + @override + Future createFolder( + String folderName, { + String? parentFolderId, + }) async { + try { + return await _run(() async { + final folderMetadata = drive.File() + ..name = folderName + ..mimeType = 'application/vnd.google-apps.folder' + ..parents = [parentFolderId ?? 'appDataFolder']; + + final folder = await _requireApi.files.create(folderMetadata); + _log.info('Created folder: $folderName (${folder.id})'); + return folder.id!; + }); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error( + 'Failed to create folder: $folderName', + error: e, + stackTrace: stackTrace, + ); + throw _mapDriveError('Create folder', e, stackTrace); + } + } + + @override + Future getOrCreateSyncFolder() async { + // Return cached folder ID if available + if (_syncFolderId != null) { + return _syncFolderId!; + } + + try { + return await _run(() async { + // Look for existing sync folder + const query = + "name = '${CloudStorageProviderMixin.syncFolderName}' " + "and mimeType = 'application/vnd.google-apps.folder' " + "and 'appDataFolder' in parents " + "and trashed = false"; + + final fileList = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + $fields: 'files(id,name)', + ); + + if (fileList.files != null && fileList.files!.isNotEmpty) { + _syncFolderId = fileList.files!.first.id!; + _log.info('Found existing sync folder: $_syncFolderId'); + return _syncFolderId!; + } + + // Create new sync folder + _syncFolderId = await createFolder( + CloudStorageProviderMixin.syncFolderName, + parentFolderId: 'appDataFolder', + ); + return _syncFolderId!; + }); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error( + 'Failed to get/create sync folder', + error: e, + stackTrace: stackTrace, + ); + throw _mapDriveError('Get/create sync folder', e, stackTrace); + } + } + + /// Find a file by name in a specific folder + Future _findFile(String filename, String folderId) async { + try { + final query = + "name = '$filename' " + "and '$folderId' in parents " + "and trashed = false"; + + final fileList = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + $fields: 'files(id,name)', + ); + + if (fileList.files != null && fileList.files!.isNotEmpty) { + return fileList.files!.first; + } + return null; + } on drive.DetailedApiRequestError { + // Let auth errors reach _run's 401 handling instead of masking them + // as "file not found" (which would create a duplicate). + rethrow; + } catch (e) { + _log.warning('Failed to find file: $filename - $e'); + return null; + } + } +} +``` + +Behavior deltas vs. the old file, all intentional (verify nothing else changed): +1. Auth methods delegate to the authenticator; Drive REST bodies are unchanged. +2. `_run` wraps every REST call with one 401 retry. +3. `_findFile` rethrows `DetailedApiRequestError` instead of swallowing it, so + an expired token during upload cannot silently fork a duplicate file. +4. `_mapDriveError` adds the quota message. +5. `isAvailable()` is platform/config gated. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/core/services/cloud_storage/google_drive_storage_provider_test.dart` +Expected: All 13 tests PASS. + +- [ ] **Step 5: Run the neighboring suites (regression)** + +Run: `flutter test test/core/services/cloud_storage/cloud_storage_provider_test.dart test/core/services/sync/sync_provider_type_test.dart` +Expected: PASS (enum membership and mixin behavior unchanged). + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/core/services/cloud_storage/google_drive_storage_provider.dart test/core/services/cloud_storage/google_drive_storage_provider_test.dart +git commit -m "refactor(sync): Google Drive provider on authenticator seam with 401 retry and error mapping" +``` + +--- + +### Task 5: Availability providers and capabilities wiring (TDD) + +Expose availability and the signed-in email to the UI as Riverpod providers, +and make the dead `supportsGoogleDrive` capability flag agree with the real +gating so the two cannot diverge. + +**Files:** +- Modify: `lib/features/settings/presentation/providers/sync_providers.dart` (add two providers near `cloudProviderInstanceFor`, around line 170) +- Modify: `lib/features/settings/presentation/providers/storage_providers.dart:44` +- Test: `test/features/settings/presentation/providers/google_drive_ui_providers_test.dart` (new) + +**Interfaces:** +- Consumes: `cloudProviderInstanceFor`, `selectedCloudProviderTypeProvider`, `syncStateProvider` (all existing in `sync_providers.dart`), `GoogleDriveClientConfig`. +- Produces: `googleDriveAvailableProvider: FutureProvider`, `googleDriveAccountEmailProvider: FutureProvider` (Task 6 UI watches these and tests override them). + +- [ ] **Step 1: Write the failing test** + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart' + show CloudProviderType; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; + +void main() { + test('googleDriveAccountEmailProvider is null when Drive not selected', () async { + final container = ProviderContainer( + overrides: [ + selectedCloudProviderTypeProvider.overrideWith( + (ref) => CloudProviderType.icloud, + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(googleDriveAccountEmailProvider.future), + isNull, + ); + }); + + test('googleDriveAvailableProvider resolves without authentication', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Must not throw and must not require sign-in; the exact value is + // platform-dependent (config-gated on Windows/Linux, true elsewhere). + expect( + await container.read(googleDriveAvailableProvider.future), + isA(), + ); + }); +} +``` + +Note: if `ProviderContainer` needs base overrides (e.g. SharedPreferences) to +construct these providers, reuse the pattern from existing provider tests in +`test/features/settings/presentation/providers/`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/features/settings/presentation/providers/google_drive_ui_providers_test.dart` +Expected: FAIL — the two providers are undefined. + +- [ ] **Step 3: Add the providers to `sync_providers.dart`** (after `cloudProviderInstanceFor`, before `cloudStorageProviderProvider`) + +```dart +/// Whether Google Drive can be offered on this platform/build. True on +/// iOS/macOS/Android; on Windows/Linux only when the Desktop-app OAuth +/// client is compiled in (GoogleDriveClientConfig). +final googleDriveAvailableProvider = FutureProvider((ref) { + return cloudProviderInstanceFor(CloudProviderType.googledrive).isAvailable(); +}); + +/// Signed-in Google account email for the provider tile subtitle, or null +/// when Google Drive is not the selected provider or no account is known. +/// Watches syncStateProvider so connect/sign-out refresh the subtitle. +final googleDriveAccountEmailProvider = FutureProvider((ref) async { + final type = ref.watch(selectedCloudProviderTypeProvider); + if (type != CloudProviderType.googledrive) return null; + ref.watch(syncStateProvider); + return cloudProviderInstanceFor( + CloudProviderType.googledrive, + ).getUserEmail(); +}); +``` + +- [ ] **Step 4: Wire the capability flag in `storage_providers.dart`** + +Replace line 44 (`supportsGoogleDrive: true, // All platforms`) with: + +```dart + // Mirrors GoogleDriveStorageProvider.isAvailable(): compile-time + // config on mobile/macOS, Desktop-app client required on desktop. + supportsGoogleDrive: + !(Platform.isWindows || Platform.isLinux) || + GoogleDriveClientConfig.hasDesktopClient, +``` + +Add the import at the top of the file: + +```dart +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/features/settings/presentation/providers/google_drive_ui_providers_test.dart` +Expected: PASS. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/settings/presentation/providers/ test/features/settings/presentation/providers/google_drive_ui_providers_test.dart +git commit -m "feat(sync): Google Drive availability and account-email providers, capability wiring" +``` + +--- + +### Task 6: Restore the Google Drive tile in Cloud Sync settings (TDD) + +Remove the hide/coercion, add the tile with email subtitle, add the desktop +browser-wait dialog, add three localized strings to all 11 locales, and update +the widget tests that assert the tile is hidden. + +**Files:** +- Modify: `lib/features/settings/presentation/pages/cloud_sync_page.dart` +- Modify: `lib/l10n/arb/app_en.arb` (+ `app_ar.arb, app_de.arb, app_es.arb, app_fr.arb, app_he.arb, app_hu.arb, app_it.arb, app_nl.arb, app_pt.arb, app_zh.arb`) +- Test: `test/features/settings/presentation/pages/cloud_sync_page_test.dart` + +**Interfaces:** +- Consumes: `googleDriveAvailableProvider`, `googleDriveAccountEmailProvider` (Task 5); existing `_buildProviderSection`, `_selectProvider`, `pumpPage` test harness. +- Produces: l10n keys `settings_cloudSync_googleDrive_desktopNotConfigured`, `settings_cloudSync_googleDrive_browserWait_title`, `settings_cloudSync_googleDrive_browserWait_message`. + +- [ ] **Step 1: Update the two existing widget tests that assert hidden** + +In `test/features/settings/presentation/pages/cloud_sync_page_test.dart`: + +(a) In the base-render test (around line 554), change: + +```dart + expect(find.text('Google Drive'), findsNothing); +``` + +to: + +```dart + expect(find.text('Google Drive'), findsOneWidget); +``` + +(b) Replace the test `'persisted googledrive selection reads as no provider since the tile is hidden'` (around line 810) entirely with: + +```dart + testWidgets('persisted googledrive selection selects the tile', ( + tester, + ) async { + await pumpPage( + tester, + selectedProvider: CloudProviderType.googledrive, + googleDriveEmail: 'diver@example.com', + ); + + // The Google Drive tile shows the connected check icon. + expect(find.byIcon(Icons.check_circle), findsOneWidget); + // The subtitle shows the signed-in account. + expect(find.text('diver@example.com'), findsOneWidget); + // Sync Now is enabled (no coercion to "no provider" anymore). + final button = tester.widget( + find.widgetWithText(FilledButton, 'Sync Now'), + ); + expect(button.onPressed, isNotNull); + }); +``` + +(c) Add two new tests in the provider-selection group: + +```dart + testWidgets('Google Drive tile is disabled when unavailable', ( + tester, + ) async { + await pumpPage(tester, googleDriveAvailable: false); + + final tile = tester.widget( + find.ancestor( + of: find.text('Google Drive'), + matching: find.byType(ListTile), + ), + ); + expect(tile.enabled, isFalse); + }); + + testWidgets('tapping Google Drive authenticates and connects', ( + tester, + ) async { + final fake = FakeCloudStorageProvider(); + await pumpPage(tester, cloudProvider: fake); + + await tester.tap(find.text('Google Drive')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Connected to'), findsOneWidget); + }); +``` + +Reuse the file's existing import of `FakeCloudStorageProvider` if present; +otherwise import `../../../../support/fake_cloud_storage_provider.dart`. +Follow how existing tests route `cloudProvider:` through `pumpPage`. + +(d) Extend `pumpPage` with two parameters and overrides: + +```dart + bool googleDriveAvailable = true, + String? googleDriveEmail, +``` + +and inside the `overrides:` list: + +```dart + googleDriveAvailableProvider.overrideWith( + (ref) async => googleDriveAvailable, + ), + googleDriveAccountEmailProvider.overrideWith( + (ref) async => googleDriveEmail, + ), +``` + +- [ ] **Step 2: Run tests to verify the new expectations fail** + +Run: `flutter test test/features/settings/presentation/pages/cloud_sync_page_test.dart` +Expected: FAIL — 'Google Drive' not found (tile still hidden), plus the new tests fail. + +- [ ] **Step 3: Add the l10n strings** + +In `lib/l10n/arb/app_en.arb`, next to the existing +`settings_cloudSync_provider_googleDrive` entries (around line 5845), add: + +```json + "settings_cloudSync_googleDrive_desktopNotConfigured": "Not available in this build", + "settings_cloudSync_googleDrive_browserWait_title": "Continue in your browser", + "settings_cloudSync_googleDrive_browserWait_message": "Finish signing in to Google in your web browser, then return to Submersion.", +``` + +Translate all three strings into each of the 10 other locale files +(`app_ar, app_de, app_es, app_fr, app_he, app_hu, app_it, app_nl, app_pt, app_zh`), +matching each file's existing tone and formality for the settings strings, +then run: + +```bash +flutter gen-l10n +``` + +- [ ] **Step 4: Edit `cloud_sync_page.dart`** + +(a) Remove the coercion in `build` (lines 55-62). Replace: + +```dart + // Google Drive is hidden until its integration is implemented, but a + // persisted selection or SyncRepository's fallback can still surface + // `googledrive`. Treat it as no provider so the page can never show + // Sync Now enabled with no selected tile. + final rawProvider = ref.watch(selectedCloudProviderTypeProvider); + final selectedProvider = rawProvider == CloudProviderType.googledrive + ? null + : rawProvider; +``` + +with: + +```dart + final selectedProvider = ref.watch(selectedCloudProviderTypeProvider); +``` + +(b) In `_buildProviderSection` (around line 466), replace the hidden-tile +comment with the tile call, so the children read: + +```dart + _buildProviderTile( + context, + ref, + provider: CloudProviderType.icloud, + title: 'iCloud', + subtitle: 'Sync via Apple iCloud', + icon: Icons.cloud, + isSelected: selectedProvider == CloudProviderType.icloud, + isAvailable: isApple && !iCloudUnsupported, + disabledSubtitle: iCloudDisabledSubtitle, + ), + _buildGoogleDriveProviderTile(context, ref, selectedProvider), + _buildS3ProviderTile(context, ref, selectedProvider), +``` + +(c) Add the tile builder after `_buildProviderTile`: + +```dart + Widget _buildGoogleDriveProviderTile( + BuildContext context, + WidgetRef ref, + CloudProviderType? selectedProvider, + ) { + final l10n = context.l10n; + final isSelected = selectedProvider == CloudProviderType.googledrive; + // Render from AsyncValue.value so a provider reload does not flash the + // tile through a disabled state. + final isAvailable = + ref.watch(googleDriveAvailableProvider).value ?? false; + final email = ref.watch(googleDriveAccountEmailProvider).value; + + return Semantics( + selected: isSelected, + child: ListTile( + leading: const Icon(Icons.add_to_drive), + title: Text(l10n.settings_cloudSync_provider_googleDrive), + subtitle: Text( + !isAvailable + ? l10n.settings_cloudSync_googleDrive_desktopNotConfigured + : (isSelected && email != null + ? email + : l10n.settings_cloudSync_provider_googleDrive_subtitle), + ), + trailing: isSelected + ? const Icon( + Icons.check_circle, + color: Colors.green, + semanticLabel: 'Connected', + ) + : null, + enabled: isAvailable, + onTap: isAvailable + ? () => _selectProvider(context, ref, CloudProviderType.googledrive) + : null, + ), + ); + } +``` + +(d) Desktop browser-wait dialog. Add imports at the top of the file: + +```dart +import 'dart:async'; +import 'dart:io'; +``` + +In `_selectProvider`, replace the line: + +```dart + await cloudProvider.authenticate(); +``` + +with: + +```dart + await _authenticateWithBrowserWait(context, cloudProvider, provider); +``` + +and add the method: + +```dart + /// On desktop, Google Drive authentication round-trips through the + /// system browser (loopback OAuth); keep a cancellable waiting dialog up + /// while it completes so the page does not look frozen. Other providers + /// and platforms authenticate directly. + Future _authenticateWithBrowserWait( + BuildContext context, + CloudStorageProvider cloudProvider, + CloudProviderType provider, + ) async { + final needsDialog = + provider == CloudProviderType.googledrive && + (Platform.isWindows || Platform.isLinux); + if (!needsDialog) { + await cloudProvider.authenticate(); + return; + } + + var dialogUp = true; + final auth = cloudProvider.authenticate().whenComplete(() { + if (dialogUp && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(false); + } + }); + final cancelled = + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text( + dialogContext.l10n.settings_cloudSync_googleDrive_browserWait_title, + ), + content: Text( + dialogContext + .l10n + .settings_cloudSync_googleDrive_browserWait_message, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text( + MaterialLocalizations.of(dialogContext).cancelButtonLabel, + ), + ), + ], + ), + ) ?? + false; + dialogUp = false; + if (cancelled) { + // Abandon the pending flow; the loopback listener times out on its + // own. Swallow its eventual error so nothing surfaces later. + unawaited(auth.catchError((_) {})); + throw const CloudStorageException('Google Sign-In was cancelled'); + } + await auth; + } +``` + +`CloudStorageException` needs importing if not already imported: +`import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart';` + +- [ ] **Step 5: Run the widget tests** + +Run: `flutter test test/features/settings/presentation/pages/cloud_sync_page_test.dart` +Expected: All tests PASS (including the updated and new ones). Note the file +has Apple-only tap tests; run on the macOS dev machine. + +- [ ] **Step 6: Format, analyze, commit** + +```bash +dart format . +flutter analyze +git add lib/features/settings/presentation/pages/cloud_sync_page.dart lib/l10n/ test/features/settings/presentation/pages/cloud_sync_page_test.dart +git commit -m "feat(sync): restore Google Drive provider tile with availability gating and desktop browser flow" +``` + +--- + +### Task 7: macOS OAuth configuration + +No unit tests (plist/entitlements); the gate is a successful macOS build. +google_sign_in on macOS reuses the existing iOS OAuth client. + +**Files:** +- Modify: `macos/Runner/Info.plist` +- Modify: `macos/Runner/DebugProfile.entitlements` +- Modify: `macos/Runner/Release.entitlements` + +- [ ] **Step 1: Read the iOS client values** + +```bash +/usr/libexec/PlistBuddy -c "Print :GIDClientID" ios/Runner/Info.plist +/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" ios/Runner/Info.plist +``` + +Note the `GIDClientID` (`-.apps.googleusercontent.com`) and the +reversed URL scheme (`com.googleusercontent.apps.-`). + +- [ ] **Step 2: Add both to `macos/Runner/Info.plist`** + +Inside the top-level ``, add (substituting the two values read in +Step 1): + +```xml + GIDClientID + VALUE-FROM-STEP-1.apps.googleusercontent.com + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.VALUE-FROM-STEP-1 + + + +``` + +If `macos/Runner/Info.plist` already has a `CFBundleURLTypes` array, append the +inner `` to it instead of adding a second array. + +- [ ] **Step 3: Add the Keychain Sharing entitlement** + +google_sign_in on macOS requires the Keychain Sharing capability. In BOTH +`macos/Runner/DebugProfile.entitlements` and `macos/Runner/Release.entitlements`, +add inside the ``: + +```xml + keychain-access-groups + + $(AppIdentifierPrefix)com.google.GIDSignIn + +``` + +- [ ] **Step 4: Verify the build** + +```bash +plutil -lint macos/Runner/Info.plist +flutter build macos --debug +``` + +Expected: lint OK; build succeeds. (Memory note: if the build fails with +"cannot find X" after plugin changes, run `pod install` in `macos/`.) + +- [ ] **Step 5: Commit** + +```bash +git add macos/Runner/ +git commit -m "feat(sync): configure Google Sign-In OAuth on macOS" +``` + +--- + +### Task 8: Google Cloud console clients and committed constants — REQUIRES USER + +The engineer cannot do this alone: the user must create OAuth clients in the +Google Cloud console (project `433819313354`). Walk them through Appendix A of +the spec (`docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md`), +then commit the resulting IDs. + +- [ ] **Step 1: Consent screen + API check (user, with guidance)** + +Per spec Appendix A.1: confirm the OAuth consent screen is configured, the +`drive.appdata` scope needs no verification, test users are added if the app +is in Testing status, and the Google Drive API is enabled. + +- [ ] **Step 2: Android clients (user, with guidance)** + +Per spec Appendix A.2: create Android OAuth clients for the debug and release +SHA-1s, plus one Web application client. Debug SHA-1 command: + +```bash +keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA1 +``` + +Collect: the Web client ID (this becomes `androidServerClientId`). + +- [ ] **Step 3: Desktop client (user, with guidance)** + +Per spec Appendix A.3: create one "Desktop app" OAuth client. Collect its +client ID and client secret. + +- [ ] **Step 4: Fill the constants** + +In `lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart`, +replace the three empty strings with the collected values. Keep the +RFC 8252 comment. + +- [ ] **Step 5: Verify, format, commit** + +```bash +flutter analyze +flutter test test/core/services/cloud_storage/google_drive_storage_provider_test.dart +dart format . +git add lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart +git commit -m "feat(sync): register Google Drive OAuth clients for Android and desktop" +``` + +--- + +### Task 9: Manual test checklist doc and final verification sweep + +**Files:** +- Create: `docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md` + +- [ ] **Step 1: Write the checklist file** + +```markdown +# Google Drive Sync — Manual Device Test Checklist + +Run on real hardware per platform: macOS, iPhone or iPad, Android device, +Windows, Linux. All items must pass before Google Drive sync is considered +done (acceptance gate from the 2026-07-02 design spec). + +For each platform: + +- [ ] 1. Fresh sign-in from Settings > Cloud Sync (native account sheet on + iOS/macOS/Android; system browser + return on Windows/Linux). Tile + shows the account email after connecting. +- [ ] 2. Cold-launch silent auth: force-quit, relaunch, run Sync Now. + No sign-in prompt, no keychain dialog, sync succeeds. +- [ ] 3. Two-device round-trip: edit a dive on device A, Sync Now on A + then B; the change appears on B. Repeat in the other direction. +- [ ] 4. Sign out (Advanced > Sign Out): tile deselects, subsequent + launches show no keychain prompts. +- [ ] 5. Revoke access at myaccount.google.com > Security > Third-party + access, then Sync Now: a "sign in again" error appears; re-auth + via the tile recovers and sync works. +- [ ] 6. (Apple platforms) Backend switch iCloud -> Google Drive: the + departure confirmation appears, the moved-marker lands on iCloud, + and the per-provider cursor does not read stale (first Drive sync + is a full first-contact sync, not an incremental continuation). +- [ ] 7. (Windows/Linux) Cancel the browser dialog mid-sign-in: the tile + stays unselected, no credentials are stored, retrying works. + +Cross-platform matrix (any two platforms with different auth paths, e.g. +macOS + Windows): items 1-3 passing proves both OAuth clients land in the +same appDataFolder (same Google Cloud project). +``` + +- [ ] **Step 2: Final verification sweep** + +```bash +dart format . +flutter analyze +flutter test \ + test/core/services/cloud_storage/google_drive_storage_provider_test.dart \ + test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart \ + test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart \ + test/features/settings/presentation/providers/google_drive_ui_providers_test.dart \ + test/features/settings/presentation/pages/cloud_sync_page_test.dart \ + test/core/services/cloud_storage/cloud_storage_provider_test.dart \ + test/core/services/sync/sync_provider_type_test.dart +``` + +Expected: format makes no changes, analyze clean, all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md +git commit -m "docs(sync): manual device test checklist for Google Drive sync" +``` + +- [ ] **Step 4: Hand off** + +Report completion; the user runs the manual checklist on hardware. PR via +`git push -u origin worktree-google-drive-sync --no-verify` (worktree +pre-push hook runs against the main tree — known issue) and `gh pr create`, +when the user asks for it. diff --git a/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md index a06c24ca8..c1e8c3963 100644 --- a/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md +++ b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md @@ -128,12 +128,13 @@ Click-by-click console instructions are in Appendix A. - Restore the Google Drive tile in `_buildProviderSection`, mirroring the iCloud tile's structure. When connected, the subtitle shows the - signed-in account email (`getUserEmail()`); a trailing overflow action - offers **Sign out**. -- Sign-out routes through the existing backend-departure flow - (`recordBackendDeparture`) before deselecting, so the per-provider - cursor (v81) is stamped and the stale-cursor bug class PR #327 fixed - cannot recur. + signed-in account email (`getUserEmail()`). +- Sign-out reuses the page's existing Advanced > Sign Out row, which + already confirms and disconnects the active provider; no per-tile + sign-out affordance is added. Provider switching continues to route + through the existing backend-departure flow (`recordBackendDeparture`), + so the per-provider cursor (v81) is stamped and the stale-cursor bug + class PR #327 fixed cannot recur. - Remove the `googledrive → null` coercion (lines 56–62); a persisted selection resumes normally. - Selecting the tile triggers `authenticate()`; on failure the tile stays From 74c2454acf0846f69fb5fdd6d71c5d1a46cdea87 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:06:00 -0400 Subject: [PATCH 03/18] feat(sync): add Google Drive authenticator seam and google_sign_in implementation --- .../google_drive_authenticator.dart | 35 +++++ .../google_drive_client_config.dart | 28 ++++ .../google_sign_in_authenticator.dart | 138 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart create mode 100644 lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart create mode 100644 lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart b/lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart new file mode 100644 index 000000000..de5154923 --- /dev/null +++ b/lib/core/services/cloud_storage/google_drive/google_drive_authenticator.dart @@ -0,0 +1,35 @@ +import 'package:http/http.dart' as http; + +/// Authentication seam for GoogleDriveStorageProvider. +/// +/// Two implementations exist: GoogleSignInAuthenticator (iOS/macOS/Android, +/// native google_sign_in flow) and DesktopOAuthAuthenticator (Windows/Linux, +/// loopback OAuth). The boundary is deliberately [http.Client] -- both auth +/// worlds produce an authorized client, and the provider builds its own +/// DriveApi from it, so neither leaks into the Drive REST code. +abstract class GoogleDriveAuthenticator { + /// Interactive sign-in. May show UI (account sheet or system browser). + /// Throws CloudStorageException on failure or user cancellation. + Future authenticate(); + + /// Non-interactive re-auth from cached state (google_sign_in lightweight + /// auth, or a stored refresh token). Never shows UI. Returns false when + /// re-auth is not possible; must not throw. + /// + /// Implementations must not touch secure storage before the user has + /// opted in by authenticating once (no keychain prompt before opt-in). + Future attemptSilentAuth(); + + /// The authorized HTTP client, or null when not authenticated. + http.Client? get authClient; + + /// Signed-in account email, or null when unknown. + Future get userEmail; + + /// Sign out and clear stored credentials. + Future signOut(); + + /// Called after an API 401: drop the (stale or revoked) client state so + /// the next attemptSilentAuth() rebuilds from scratch. + Future handleAuthFailure(); +} diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart new file mode 100644 index 000000000..b29af810f --- /dev/null +++ b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart @@ -0,0 +1,28 @@ +/// OAuth client configuration for Google Drive sync. +/// +/// The desktop client ID and secret are committed to source intentionally: +/// Google classifies installed-app client secrets as non-confidential +/// (RFC 8252 section 8.5) -- they ship inside every desktop binary and can +/// not protect anything. Committing them matches standard practice for +/// open-source desktop applications (rclone, the Google Cloud SDK). +/// +/// All clients must belong to the same Google Cloud project so every +/// platform shares the same Drive appDataFolder (it is scoped per project, +/// per user); that is what makes cross-device sync work. +class GoogleDriveClientConfig { + /// OAuth 2.0 "Desktop app" client used by the Windows/Linux loopback + /// flow. Empty until the client is created in the Google Cloud console; + /// an empty value disables Google Drive on desktop instead of crashing. + static const String desktopClientId = ''; + static const String desktopClientSecret = ''; + + /// "Web application" client ID passed as serverClientId to + /// google_sign_in on Android. Empty means initialize() is called without + /// a serverClientId (sufficient for iOS/macOS, which read GIDClientID + /// from Info.plist). + static const String androidServerClientId = ''; + + /// True when the Desktop-app client is configured in this build. + static bool get hasDesktopClient => + desktopClientId.isNotEmpty && desktopClientSecret.isNotEmpty; +} diff --git a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart new file mode 100644 index 000000000..d0fa73aba --- /dev/null +++ b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:googleapis_auth/googleapis_auth.dart' as gapis_auth; +import 'package:http/http.dart' as http; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// google_sign_in-backed authenticator for iOS, macOS, and Android. +/// +/// Token persistence across launches is handled by google_sign_in's own +/// cache via attemptLightweightAuthentication(); nothing is stored by the +/// app. Silent sign-in is deferred until the user has opted in once +/// (_allowSilentAuth) because it touches the platform keychain. +class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { + static final _log = LoggerService.forClass(GoogleSignInAuthenticator); + + static const _scopes = [drive.DriveApi.driveAppdataScope]; + + // Use the shared instance; configuration is provided per-call via scope + // hints. + final GoogleSignIn _googleSignIn = GoogleSignIn.instance; + bool _initialized = false; + bool _allowSilentAuth = false; + gapis_auth.AuthClient? _authClient; + GoogleSignInAccount? _currentUser; + + @override + http.Client? get authClient => _authClient; + + @override + Future get userEmail async => _currentUser?.email; + + Future _ensureInitialized() async { + if (_initialized) return; + final serverClientId = + Platform.isAndroid && + GoogleDriveClientConfig.androidServerClientId.isNotEmpty + ? GoogleDriveClientConfig.androidServerClientId + : null; + await _googleSignIn.initialize(serverClientId: serverClientId); + _initialized = true; + } + + @override + Future attemptSilentAuth() async { + try { + if (_authClient != null) return true; + + // Defer any silent sign-in (which triggers Keychain access) until the + // user has explicitly opted in by signing in once. + if (!_allowSilentAuth) return false; + + await _ensureInitialized(); + final futureAccount = _googleSignIn.attemptLightweightAuthentication(); + if (futureAccount == null) return false; + + final account = await futureAccount; + if (account == null) return false; + + final authorization = await account.authorizationClient + .authorizationForScopes(_scopes); + if (authorization == null) return false; + + _installClient(account, authorization); + return true; + } catch (e) { + _log.warning('Silent sign-in failed: $e'); + return false; + } + } + + @override + Future authenticate() async { + try { + await _ensureInitialized(); + final account = await _googleSignIn.authenticate(scopeHint: _scopes); + final authorization = await account.authorizationClient.authorizeScopes( + _scopes, + ); + + _installClient(account, authorization); + _allowSilentAuth = true; + _log.info('Authenticated with Google Drive as ${account.email}'); + } on GoogleSignInException catch (e, stackTrace) { + if (e.code == GoogleSignInExceptionCode.canceled) { + _log.info('Google Sign-In was cancelled by the user'); + throw CloudStorageException( + 'Google Sign-In was cancelled', + e, + stackTrace, + ); + } + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException( + 'Google Sign-In failed: ${e.description ?? e.code.name}', + e, + stackTrace, + ); + } catch (e, stackTrace) { + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException('Google Sign-In failed: $e', e, stackTrace); + } + } + + void _installClient( + GoogleSignInAccount account, + GoogleSignInClientAuthorization authorization, + ) { + _authClient?.close(); + _authClient = authorization.authClient(scopes: _scopes); + _currentUser = account; + } + + @override + Future signOut() async { + await _googleSignIn.signOut(); + // Close the auth client if it exists; close is synchronous. + _authClient?.close(); + _authClient = null; + _currentUser = null; + _allowSilentAuth = false; + _log.info('Signed out from Google Drive'); + } + + @override + Future handleAuthFailure() async { + // Drop the stale client; keep _allowSilentAuth so the next + // attemptSilentAuth() can rebuild authorization without UI. + _authClient?.close(); + _authClient = null; + } +} From 81a5dd09d1c5cf2a553711ea06a83c753540a0bd Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:11:02 -0400 Subject: [PATCH 04/18] feat(sync): add keychain-backed Google Drive token store --- .../google_drive_token_store.dart | 51 +++++++++++++++ .../google_drive_token_store_test.dart | 65 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart create mode 100644 test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart b/lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart new file mode 100644 index 000000000..150064e77 --- /dev/null +++ b/lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:googleapis_auth/googleapis_auth.dart' as gauth; + +import 'package:submersion/core/services/secure_storage/fallback_secure_storage.dart'; + +/// Persists the desktop Google Drive OAuth credentials (access token, +/// refresh token, expiry, scopes) as a single JSON blob in the platform +/// keychain. One blob keeps load/save atomic; nothing about the Google +/// Drive setup ever touches SharedPreferences or the database. +/// +/// A corrupt blob is left in place rather than deleted, so a transient +/// decode bug cannot destroy credentials; save() simply overwrites it. +/// +/// Keychain access goes through [FallbackSecureStorage], which retries on +/// the legacy keychain when the ad-hoc no-sandbox build has no access +/// group. Only the desktop authenticator uses this store; the mobile path +/// relies on google_sign_in's own token cache. +class GoogleDriveTokenStore { + GoogleDriveTokenStore({FlutterSecureStorage? storage}) + : _storage = FallbackSecureStorage(storage ?? const FlutterSecureStorage()); + + final FallbackSecureStorage _storage; + + static const String storageKey = 'sync_gdrive_credentials'; + + /// The stored credentials, or null when unset or the stored blob is + /// corrupt. Keychain errors other than a missing entitlement propagate. + Future load() async { + final raw = await _storage.read(key: storageKey); + if (raw == null) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return gauth.AccessCredentials.fromJson(decoded); + } on FormatException { + return null; + } on TypeError { + return null; + } on ArgumentError { + // AccessToken.fromJson rejects e.g. a non-UTC expiry. + return null; + } + } + + Future save(gauth.AccessCredentials credentials) => + _storage.write(key: storageKey, value: jsonEncode(credentials.toJson())); + + Future clear() => _storage.delete(key: storageKey); +} diff --git a/test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart b/test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart new file mode 100644 index 000000000..bda728c23 --- /dev/null +++ b/test/core/services/cloud_storage/google_drive/google_drive_token_store_test.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:googleapis_auth/googleapis_auth.dart' as gauth; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; + +import '../../../../support/fake_keychain_storage.dart'; + +void main() { + late InMemoryKeychain storage; + late GoogleDriveTokenStore store; + + setUp(() { + storage = InMemoryKeychain(); + store = GoogleDriveTokenStore(storage: storage); + }); + + gauth.AccessCredentials creds() => gauth.AccessCredentials( + gauth.AccessToken('Bearer', 'at-1', DateTime.utc(2026, 7, 2, 12)), + 'rt-1', + ['https://www.googleapis.com/auth/drive.appdata'], + idToken: 'id-1', + ); + + test('load returns null when nothing is stored', () async { + expect(await store.load(), isNull); + }); + + test('save then load round-trips the credentials', () async { + await store.save(creds()); + final loaded = await store.load(); + expect(loaded, isNotNull); + expect(loaded!.accessToken.data, 'at-1'); + expect(loaded.refreshToken, 'rt-1'); + expect(loaded.idToken, 'id-1'); + expect(loaded.scopes, ['https://www.googleapis.com/auth/drive.appdata']); + expect(storage.values.keys, [GoogleDriveTokenStore.storageKey]); + }); + + test('clear removes the blob', () async { + await store.save(creds()); + await store.clear(); + expect(await store.load(), isNull); + expect(storage.values, isEmpty); + }); + + test('corrupted JSON loads as null instead of throwing', () async { + storage.values[GoogleDriveTokenStore.storageKey] = 'not-json{'; + expect(await store.load(), isNull); + // The corrupt blob is preserved, not deleted (save() overwrites it). + expect(storage.values, isNotEmpty); + }); + + test('valid JSON that is not an object loads as null', () async { + storage.values[GoogleDriveTokenStore.storageKey] = '[]'; + expect(await store.load(), isNull); + }); + + test('an object with wrong-typed fields loads as null', () async { + storage.values[GoogleDriveTokenStore.storageKey] = jsonEncode({ + 'accessToken': 42, + }); + expect(await store.load(), isNull); + }); +} From 51f65daf115270037777b930ef7114c047f6e3f5 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:20:06 -0400 Subject: [PATCH 05/18] feat(sync): add desktop loopback OAuth authenticator for Google Drive --- .../desktop_oauth_authenticator.dart | 190 ++++++++++++++++ .../desktop_oauth_authenticator_test.dart | 210 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart create mode 100644 test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart new file mode 100644 index 000000000..08ddc9a35 --- /dev/null +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:googleapis_auth/auth_io.dart' as gauth; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher_string.dart'; + +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; +import 'package:submersion/core/services/logger_service.dart'; + +/// Runs the user-consent step of the loopback flow and returns credentials. +typedef ObtainConsentCredentials = + Future Function( + gauth.ClientId clientId, + List scopes, + http.Client client, + void Function(String url) prompt, + ); + +/// Builds an auto-refreshing client from stored credentials. +typedef BuildRefreshingClient = + gauth.AutoRefreshingAuthClient Function( + gauth.ClientId clientId, + gauth.AccessCredentials credentials, + http.Client baseClient, + ); + +/// Loopback-OAuth authenticator for Windows and Linux (RFC 8252 section +/// 7.3): binds an ephemeral 127.0.0.1 port, opens the system browser to +/// Google's consent page, and receives the auth code on the local +/// redirect. Credentials persist in [GoogleDriveTokenStore]; cold-launch +/// re-auth is silent via the stored refresh token. +class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { + DesktopOAuthAuthenticator({ + GoogleDriveTokenStore? tokenStore, + ObtainConsentCredentials? obtainConsent, + BuildRefreshingClient? buildClient, + http.Client Function()? baseClientFactory, + Future Function(String url)? launchBrowser, + }) : _tokenStore = tokenStore ?? GoogleDriveTokenStore(), + _obtainConsent = + obtainConsent ?? gauth.obtainAccessCredentialsViaUserConsent, + _buildClient = buildClient ?? gauth.autoRefreshingClient, + _baseClientFactory = baseClientFactory ?? http.Client.new, + _launchBrowser = launchBrowser ?? launchUrlString; + + static final _log = LoggerService.forClass(DesktopOAuthAuthenticator); + + /// openid + email are included so the id_token carries the account email + /// for the settings tile subtitle; drive.appdata is the only Drive scope. + static const List scopes = [ + drive.DriveApi.driveAppdataScope, + 'openid', + 'email', + ]; + + static const String _revokeEndpoint = 'https://oauth2.googleapis.com/revoke'; + + final GoogleDriveTokenStore _tokenStore; + final ObtainConsentCredentials _obtainConsent; + final BuildRefreshingClient _buildClient; + final http.Client Function() _baseClientFactory; + final Future Function(String url) _launchBrowser; + + gauth.AutoRefreshingAuthClient? _authClient; + StreamSubscription? _updateSubscription; + String? _email; + + gauth.ClientId get _clientId => gauth.ClientId( + GoogleDriveClientConfig.desktopClientId, + GoogleDriveClientConfig.desktopClientSecret, + ); + + @override + http.Client? get authClient => _authClient; + + @override + Future get userEmail async => _email; + + @override + Future authenticate() async { + final base = _baseClientFactory(); + try { + final credentials = await _obtainConsent(_clientId, scopes, base, (url) { + unawaited(_launchBrowser(url)); + }); + await _tokenStore.save(credentials); + _installClient(credentials); + _log.info('Authenticated with Google Drive via browser consent'); + } on CloudStorageException { + rethrow; + } catch (e, stackTrace) { + _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); + throw CloudStorageException('Google Sign-In failed: $e', e, stackTrace); + } finally { + base.close(); + } + } + + @override + Future attemptSilentAuth() async { + try { + if (_authClient != null) return true; + + final credentials = await _tokenStore.load(); + if (credentials == null || credentials.refreshToken == null) { + return false; + } + _installClient(credentials); + return true; + } catch (e) { + _log.warning('Silent sign-in failed: $e'); + return false; + } + } + + void _installClient(gauth.AccessCredentials credentials) { + _teardownClient(); + final client = _buildClient(_clientId, credentials, _baseClientFactory()); + _updateSubscription = client.credentialUpdates.listen( + (updated) => unawaited(_tokenStore.save(updated)), + ); + _authClient = client; + _email = _emailFromIdToken(credentials.idToken) ?? _email; + } + + void _teardownClient() { + unawaited(_updateSubscription?.cancel()); + _updateSubscription = null; + _authClient?.close(); + _authClient = null; + } + + @override + Future signOut() async { + final credentials = await _tokenStore.load(); + final token = + credentials?.refreshToken ?? _authClient?.credentials.accessToken.data; + if (token != null) { + // Best effort: revocation failure (e.g. offline) must not block + // local sign-out. + final base = _baseClientFactory(); + try { + await base.post( + Uri.parse(_revokeEndpoint), + headers: {'content-type': 'application/x-www-form-urlencoded'}, + body: 'token=$token', + ); + } catch (e) { + _log.warning('Token revocation failed (ignored): $e'); + } finally { + base.close(); + } + } + _teardownClient(); + _email = null; + await _tokenStore.clear(); + _log.info('Signed out from Google Drive'); + } + + @override + Future handleAuthFailure() async { + // A 401 that survives the auto-refreshing client means the grant was + // revoked; clear everything so the next attempt re-runs the browser + // flow instead of looping on a dead refresh token. + _teardownClient(); + await _tokenStore.clear(); + } + + /// Extracts the email claim from a JWT id_token, or null. + static String? _emailFromIdToken(String? idToken) { + if (idToken == null) return null; + final parts = idToken.split('.'); + if (parts.length != 3) return null; + try { + final payload = utf8.decode( + base64Url.decode(base64Url.normalize(parts[1])), + ); + final decoded = jsonDecode(payload); + if (decoded is! Map) return null; + return decoded['email'] as String?; + } on FormatException { + return null; + } + } +} diff --git a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart new file mode 100644 index 000000000..2771a5f4a --- /dev/null +++ b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:googleapis_auth/auth_io.dart' as gauth; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_token_store.dart'; + +class _MemoryTokenStore implements GoogleDriveTokenStore { + gauth.AccessCredentials? stored; + int saves = 0; + + @override + Future load() async => stored; + + @override + Future save(gauth.AccessCredentials credentials) async { + stored = credentials; + saves++; + } + + @override + Future clear() async => stored = null; +} + +/// The authenticator only uses credentialUpdates, close(), and passes the +/// client through as an http.Client; nothing sends real requests in tests. +class _FakeRefreshingClient extends Fake + implements gauth.AutoRefreshingAuthClient { + _FakeRefreshingClient(this.credentials); + + @override + final gauth.AccessCredentials credentials; + + final StreamController updates = + StreamController.broadcast(); + + bool closed = false; + + @override + Stream get credentialUpdates => updates.stream; + + @override + void close() => closed = true; +} + +String idTokenWithEmail(String email) { + final payload = base64Url.encode(utf8.encode(jsonEncode({'email': email}))); + return 'header.$payload.signature'; +} + +gauth.AccessCredentials creds({String? refreshToken, String? idToken}) => + gauth.AccessCredentials( + gauth.AccessToken('Bearer', 'at-1', DateTime.utc(2026, 7, 2, 12)), + refreshToken, + DesktopOAuthAuthenticator.scopes, + idToken: idToken, + ); + +void main() { + late _MemoryTokenStore store; + late List revokeRequests; + + MockClient baseClient() => MockClient((request) async { + revokeRequests.add(request); + return http.Response('{}', 200); + }); + + setUp(() { + store = _MemoryTokenStore(); + revokeRequests = []; + }); + + DesktopOAuthAuthenticator authenticator({ + gauth.AccessCredentials? consentResult, + List<_FakeRefreshingClient>? builtClients, + }) => DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async { + if (consentResult == null) { + throw Exception('consent should not run in this test'); + } + prompt('https://accounts.google.com/o/oauth2/auth?fake'); + return consentResult; + }, + buildClient: (clientId, credentials, base) { + final fake = _FakeRefreshingClient(credentials); + builtClients?.add(fake); + return fake; + }, + baseClientFactory: baseClient, + launchBrowser: (url) async {}, + ); + + test('attemptSilentAuth returns false with no stored credentials', () async { + final auth = authenticator(); + expect(await auth.attemptSilentAuth(), isFalse); + expect(auth.authClient, isNull); + }); + + test('attemptSilentAuth returns false without a refresh token', () async { + store.stored = creds(refreshToken: null); + final auth = authenticator(); + expect(await auth.attemptSilentAuth(), isFalse); + }); + + test('attemptSilentAuth installs a client from stored credentials', () async { + store.stored = creds( + refreshToken: 'rt-1', + idToken: idTokenWithEmail('diver@example.com'), + ); + final built = <_FakeRefreshingClient>[]; + final auth = authenticator(builtClients: built); + + expect(await auth.attemptSilentAuth(), isTrue); + expect(auth.authClient, isNotNull); + expect(built, hasLength(1)); + expect(await auth.userEmail, 'diver@example.com'); + }); + + test('authenticate stores credentials and installs a client', () async { + final auth = authenticator( + consentResult: creds( + refreshToken: 'rt-9', + idToken: idTokenWithEmail('new@example.com'), + ), + ); + + await auth.authenticate(); + + expect(store.stored?.refreshToken, 'rt-9'); + expect(auth.authClient, isNotNull); + expect(await auth.userEmail, 'new@example.com'); + }); + + test('a credential update from the refreshing client is persisted', () async { + store.stored = creds(refreshToken: 'rt-1'); + final built = <_FakeRefreshingClient>[]; + final auth = authenticator(builtClients: built); + await auth.attemptSilentAuth(); + + built.single.updates.add(creds(refreshToken: 'rt-2')); + await Future.delayed(Duration.zero); + + expect(store.stored?.refreshToken, 'rt-2'); + expect(store.saves, greaterThanOrEqualTo(1)); + }); + + test('consent failure surfaces as CloudStorageException', () async { + final auth = DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async => + throw Exception('user closed browser'), + buildClient: (clientId, credentials, base) => + _FakeRefreshingClient(credentials), + baseClientFactory: baseClient, + launchBrowser: (url) async {}, + ); + + expect(auth.authenticate(), throwsA(isA())); + }); + + test('signOut revokes the token and clears the store', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = authenticator(); + await auth.attemptSilentAuth(); + + await auth.signOut(); + + expect(store.stored, isNull); + expect(auth.authClient, isNull); + expect(revokeRequests, hasLength(1)); + expect(revokeRequests.single.url.host, 'oauth2.googleapis.com'); + expect(revokeRequests.single.url.path, '/revoke'); + }); + + test('signOut still clears local state when revocation throws', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = DesktopOAuthAuthenticator( + tokenStore: store, + obtainConsent: (clientId, scopes, client, prompt) async => + throw Exception('unused'), + buildClient: (clientId, credentials, base) => + _FakeRefreshingClient(credentials), + baseClientFactory: () => + MockClient((request) async => throw Exception('offline')), + launchBrowser: (url) async {}, + ); + await auth.attemptSilentAuth(); + + await auth.signOut(); + + expect(store.stored, isNull); + expect(auth.authClient, isNull); + }); + + test('handleAuthFailure clears the client and stored credentials', () async { + store.stored = creds(refreshToken: 'rt-1'); + final auth = authenticator(); + await auth.attemptSilentAuth(); + + await auth.handleAuthFailure(); + + expect(auth.authClient, isNull); + expect(store.stored, isNull); + }); +} From 4053d6067fe994e1800dc12044ca6ba2638842cc Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:27:15 -0400 Subject: [PATCH 06/18] fix(sync): clear cached email on Google Drive desktop auth failure --- .../desktop_oauth_authenticator.dart | 1 + .../desktop_oauth_authenticator_test.dart | 28 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart index 08ddc9a35..9c92092b7 100644 --- a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -168,6 +168,7 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { // revoked; clear everything so the next attempt re-runs the browser // flow instead of looping on a dead refresh token. _teardownClient(); + _email = null; await _tokenStore.clear(); } diff --git a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart index 2771a5f4a..fc0c59167 100644 --- a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart +++ b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart @@ -197,14 +197,22 @@ void main() { expect(auth.authClient, isNull); }); - test('handleAuthFailure clears the client and stored credentials', () async { - store.stored = creds(refreshToken: 'rt-1'); - final auth = authenticator(); - await auth.attemptSilentAuth(); - - await auth.handleAuthFailure(); - - expect(auth.authClient, isNull); - expect(store.stored, isNull); - }); + test( + 'handleAuthFailure clears the client, stored credentials, and email', + () async { + store.stored = creds( + refreshToken: 'rt-1', + idToken: idTokenWithEmail('diver@example.com'), + ); + final auth = authenticator(); + await auth.attemptSilentAuth(); + expect(await auth.userEmail, isNotNull); + + await auth.handleAuthFailure(); + + expect(auth.authClient, isNull); + expect(store.stored, isNull); + expect(await auth.userEmail, isNull); + }, + ); } From 240ae0f7f5432edadf156a3ec96e3bcca9260b4c Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:32:03 -0400 Subject: [PATCH 07/18] refactor(sync): Google Drive provider on authenticator seam with 401 retry and error mapping --- .../google_drive_storage_provider.dart | 452 +++++++++--------- .../google_drive_storage_provider_test.dart | 289 +++++++++++ 2 files changed, 526 insertions(+), 215 deletions(-) create mode 100644 test/core/services/cloud_storage/google_drive_storage_provider_test.dart diff --git a/lib/core/services/cloud_storage/google_drive_storage_provider.dart b/lib/core/services/cloud_storage/google_drive_storage_provider.dart index 9010f7430..6a9fce6ac 100644 --- a/lib/core/services/cloud_storage/google_drive_storage_provider.dart +++ b/lib/core/services/cloud_storage/google_drive_storage_provider.dart @@ -1,31 +1,37 @@ +import 'dart:io'; import 'dart:typed_data'; -import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart'; -import 'package:google_sign_in/google_sign_in.dart'; import 'package:googleapis/drive/v3.dart' as drive; -import 'package:googleapis_auth/googleapis_auth.dart' as gapis_auth; -import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart'; +import 'package:submersion/core/services/logger_service.dart'; /// Google Drive implementation of CloudStorageProvider /// /// Uses the Drive API's appDataFolder for app-specific storage. /// This folder is hidden from the user and only accessible by this app. +/// +/// Authentication is delegated to a [GoogleDriveAuthenticator]: +/// google_sign_in on iOS/macOS/Android, loopback OAuth on Windows/Linux. class GoogleDriveStorageProvider with CloudStorageProviderMixin implements CloudStorageProvider { + GoogleDriveStorageProvider({GoogleDriveAuthenticator? authenticator}) + : _authenticator = authenticator ?? _defaultAuthenticator(); + static final _log = LoggerService.forClass(GoogleDriveStorageProvider); - static const _scopes = [drive.DriveApi.driveAppdataScope]; + static GoogleDriveAuthenticator _defaultAuthenticator() => + (Platform.isWindows || Platform.isLinux) + ? DesktopOAuthAuthenticator() + : GoogleSignInAuthenticator(); - // Use the shared instance; configuration is provided per-call via scope hints. - final GoogleSignIn _googleSignIn = GoogleSignIn.instance; - bool _initialized = false; - bool _allowSilentAuth = false; - gapis_auth.AuthClient? _authClient; + final GoogleDriveAuthenticator _authenticator; drive.DriveApi? _driveApi; - GoogleSignInAccount? _currentUser; String? _syncFolderId; @override @@ -36,120 +42,106 @@ class GoogleDriveStorageProvider @override Future isAvailable() async { - // Google Drive is available on all platforms Flutter supports + // Mobile and macOS OAuth config is compile-time (Info.plist / Android + // client registration). Desktop needs the committed Desktop-app client; + // a build without it degrades to a hidden tile instead of crashing. + if (Platform.isWindows || Platform.isLinux) { + return GoogleDriveClientConfig.hasDesktopClient; + } return true; } - Future _ensureInitialized() async { - if (_initialized) return; - await _googleSignIn.initialize(); - _initialized = true; - } - @override Future isAuthenticated() async { - try { - // If we already have an authenticated session in memory, reuse it. - if (_currentUser != null && _authClient != null) { - return true; - } - - // Defer any silent sign-in (which triggers Keychain access) until the user - // has explicitly opted in by signing in once. - if (!_allowSilentAuth) { - return false; - } - - await _ensureInitialized(); - final futureAccount = _googleSignIn.attemptLightweightAuthentication(); - if (futureAccount == null) return false; - - final account = await futureAccount; - if (account == null) return false; - - final authorization = await account.authorizationClient - .authorizationForScopes(_scopes); - if (authorization == null) return false; - - await _initDriveApi(account, authorization); - // The user has already opted in, keep allowing silent auth. - _allowSilentAuth = true; - return true; - } catch (e) { - _log.warning('Silent sign-in failed: $e'); - return false; + if (_api != null) return true; + if (await _authenticator.attemptSilentAuth()) { + return _api != null; } + return false; } @override Future authenticate() async { - try { - await _ensureInitialized(); - final account = await _googleSignIn.authenticate(scopeHint: _scopes); - final authorization = await account.authorizationClient.authorizeScopes( - _scopes, + await _authenticator.authenticate(); + _driveApi = null; // rebuilt lazily from the fresh auth client + if (_api == null) { + throw const CloudStorageException( + 'Google Sign-In did not produce an authorized client', ); - - await _initDriveApi(account, authorization); - _allowSilentAuth = true; - _log.info('Authenticated with Google Drive as ${account.email}'); - } on GoogleSignInException catch (e, stackTrace) { - if (e.code == GoogleSignInExceptionCode.canceled) { - _log.info('Google Sign-In was cancelled by the user'); - throw CloudStorageException( - 'Google Sign-In was cancelled', - e, - stackTrace, - ); - } - _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); - throw CloudStorageException( - 'Google Sign-In failed: ${e.description ?? e.code.name}', - e, - stackTrace, - ); - } catch (e, stackTrace) { - _log.error('Google Sign-In failed', error: e, stackTrace: stackTrace); - throw CloudStorageException('Google Sign-In failed: $e', e, stackTrace); } } @override Future signOut() async { - await _googleSignIn.signOut(); - // Close the auth client if it exists; close is synchronous. - _authClient?.close(); - _authClient = null; - _currentUser = null; + await _authenticator.signOut(); _driveApi = null; _syncFolderId = null; - _allowSilentAuth = false; - _log.info('Signed out from Google Drive'); } @override - Future getUserEmail() async { - return _currentUser?.email; - } - - Future _initDriveApi( - GoogleSignInAccount account, - GoogleSignInClientAuthorization authorization, - ) async { - _authClient?.close(); - _authClient = authorization.authClient(scopes: _scopes); - _driveApi = drive.DriveApi(_authClient!); - _currentUser = account; + Future getUserEmail() => _authenticator.userEmail; + + /// The Drive API bound to the authenticator's current client, or null + /// when not authenticated. Rebuilt lazily so a re-auth (new client) + /// transparently produces a new API instance. + drive.DriveApi? get _api { + final client = _authenticator.authClient; + if (client == null) { + _driveApi = null; + return null; + } + return _driveApi ??= drive.DriveApi(client); } - drive.DriveApi get _api { - final api = _driveApi; + drive.DriveApi get _requireApi { + final api = _api; if (api == null) { throw const CloudStorageException('Not authenticated with Google Drive'); } return api; } + /// Runs a Drive operation with a single 401 retry: access tokens expire + /// hourly mid-session, so one silent re-auth disambiguates a stale token + /// from a revoked grant. On a revoked grant the authenticator clears its + /// stored state and the user is asked to sign in again. + Future _run(Future Function() operation) async { + try { + return await operation(); + } on drive.DetailedApiRequestError catch (e) { + if (e.status != 401) rethrow; + _log.info('Drive API returned 401; attempting silent re-auth'); + await _authenticator.handleAuthFailure(); + _driveApi = null; + if (!await _authenticator.attemptSilentAuth()) { + throw CloudStorageException( + 'Google Drive sign-in expired. Please sign in again.', + e, + ); + } + return await operation(); + } + } + + /// Maps a Drive error to a CloudStorageException with an actionable + /// message where one exists (quota); otherwise a generic wrapper. + CloudStorageException _mapDriveError( + String operation, + Object e, + StackTrace stackTrace, + ) { + if (e is drive.DetailedApiRequestError && + e.status == 403 && + e.errors.any((d) => d.reason == 'storageQuotaExceeded')) { + return CloudStorageException( + 'Google Drive storage is full', + e, + stackTrace, + ); + } + return CloudStorageException('$operation failed: $e', e, stackTrace); + } + @override Future uploadFile( Uint8List data, @@ -157,86 +149,102 @@ class GoogleDriveStorageProvider String? folderId, }) async { try { - final targetFolder = folderId ?? await getOrCreateSyncFolder(); - - // Check if file already exists - final existingFile = await _findFile(filename, targetFolder); - - drive.File result; - final media = drive.Media(Stream.fromIterable([data]), data.length); - - if (existingFile != null) { - // Update existing file - result = await _api.files.update( - drive.File(), - existingFile.id!, - uploadMedia: media, - ); - _log.info('Updated file: $filename (${result.id})'); - } else { - // Create new file - final fileMetadata = drive.File() - ..name = filename - ..parents = [targetFolder]; - - result = await _api.files.create(fileMetadata, uploadMedia: media); - _log.info('Created file: $filename (${result.id})'); - } - - return UploadResult(fileId: result.id!, uploadTime: DateTime.now()); + return await _run(() async { + final targetFolder = folderId ?? await getOrCreateSyncFolder(); + + // Check if file already exists + final existingFile = await _findFile(filename, targetFolder); + + drive.File result; + final media = drive.Media(Stream.fromIterable([data]), data.length); + + if (existingFile != null) { + // Update existing file + result = await _requireApi.files.update( + drive.File(), + existingFile.id!, + uploadMedia: media, + ); + _log.info('Updated file: $filename (${result.id})'); + } else { + // Create new file + final fileMetadata = drive.File() + ..name = filename + ..parents = [targetFolder]; + + result = await _requireApi.files.create( + fileMetadata, + uploadMedia: media, + ); + _log.info('Created file: $filename (${result.id})'); + } + + return UploadResult(fileId: result.id!, uploadTime: DateTime.now()); + }); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error( 'Failed to upload file: $filename', error: e, stackTrace: stackTrace, ); - throw CloudStorageException('Upload failed: $e', e, stackTrace); + throw _mapDriveError('Upload', e, stackTrace); } } @override Future downloadFile(String fileId) async { try { - final response = await _api.files.get( - fileId, - downloadOptions: drive.DownloadOptions.fullMedia, - ); - - if (response is! drive.Media) { - throw const CloudStorageException('Invalid download response'); - } - - final chunks = >[]; - await for (final chunk in response.stream) { - chunks.add(chunk); - } + return await _run(() async { + final response = await _requireApi.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ); - final allBytes = chunks.expand((x) => x).toList(); - _log.info('Downloaded file: $fileId (${allBytes.length} bytes)'); - return Uint8List.fromList(allBytes); + if (response is! drive.Media) { + throw const CloudStorageException('Invalid download response'); + } + + final chunks = >[]; + await for (final chunk in response.stream) { + chunks.add(chunk); + } + + final allBytes = chunks.expand((x) => x).toList(); + _log.info('Downloaded file: $fileId (${allBytes.length} bytes)'); + return Uint8List.fromList(allBytes); + }); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error( 'Failed to download file: $fileId', error: e, stackTrace: stackTrace, ); - throw CloudStorageException('Download failed: $e', e, stackTrace); + throw _mapDriveError('Download', e, stackTrace); } } @override Future getFileInfo(String fileId) async { try { - final file = - await _api.files.get(fileId, $fields: 'id,name,modifiedTime,size') - as drive.File; - - return CloudFileInfo( - id: file.id!, - name: file.name!, - modifiedTime: file.modifiedTime ?? DateTime.now(), - sizeBytes: file.size != null ? int.tryParse(file.size!) : null, - ); + return await _run(() async { + final file = + await _requireApi.files.get( + fileId, + $fields: 'id,name,modifiedTime,size', + ) + as drive.File; + + return CloudFileInfo( + id: file.id!, + name: file.name!, + modifiedTime: file.modifiedTime ?? DateTime.now(), + sizeBytes: file.size != null ? int.tryParse(file.size!) : null, + ); + }); } catch (e) { _log.warning('Failed to get file info: $fileId - $e'); return null; @@ -249,55 +257,61 @@ class GoogleDriveStorageProvider String? namePattern, }) async { try { - final targetFolder = folderId ?? await getOrCreateSyncFolder(); - - var query = "'$targetFolder' in parents and trashed = false"; - if (namePattern != null) { - query += " and name contains '$namePattern'"; - } - - final fileList = await _api.files.list( - spaces: 'appDataFolder', - q: query, - $fields: 'files(id,name,modifiedTime,size)', - ); + return await _run(() async { + final targetFolder = folderId ?? await getOrCreateSyncFolder(); + + var query = "'$targetFolder' in parents and trashed = false"; + if (namePattern != null) { + query += " and name contains '$namePattern'"; + } + + final fileList = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + $fields: 'files(id,name,modifiedTime,size)', + ); - return (fileList.files ?? []) - .where((f) => f.id != null && f.name != null) - .map( - (f) => CloudFileInfo( - id: f.id!, - name: f.name!, - modifiedTime: f.modifiedTime ?? DateTime.now(), - sizeBytes: f.size != null ? int.tryParse(f.size!) : null, - ), - ) - .toList(); + return (fileList.files ?? []) + .where((f) => f.id != null && f.name != null) + .map( + (f) => CloudFileInfo( + id: f.id!, + name: f.name!, + modifiedTime: f.modifiedTime ?? DateTime.now(), + sizeBytes: f.size != null ? int.tryParse(f.size!) : null, + ), + ) + .toList(); + }); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error('Failed to list files', error: e, stackTrace: stackTrace); - throw CloudStorageException('List files failed: $e', e, stackTrace); + throw _mapDriveError('List files', e, stackTrace); } } @override Future deleteFile(String fileId) async { try { - await _api.files.delete(fileId); + await _run(() => _requireApi.files.delete(fileId)); _log.info('Deleted file: $fileId'); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error( 'Failed to delete file: $fileId', error: e, stackTrace: stackTrace, ); - throw CloudStorageException('Delete failed: $e', e, stackTrace); + throw _mapDriveError('Delete', e, stackTrace); } } @override Future fileExists(String fileId) async { try { - await _api.files.get(fileId, $fields: 'id'); + await _run(() => _requireApi.files.get(fileId, $fields: 'id')); return true; } catch (e) { return false; @@ -310,21 +324,25 @@ class GoogleDriveStorageProvider String? parentFolderId, }) async { try { - final folderMetadata = drive.File() - ..name = folderName - ..mimeType = 'application/vnd.google-apps.folder' - ..parents = [parentFolderId ?? 'appDataFolder']; - - final folder = await _api.files.create(folderMetadata); - _log.info('Created folder: $folderName (${folder.id})'); - return folder.id!; + return await _run(() async { + final folderMetadata = drive.File() + ..name = folderName + ..mimeType = 'application/vnd.google-apps.folder' + ..parents = [parentFolderId ?? 'appDataFolder']; + + final folder = await _requireApi.files.create(folderMetadata); + _log.info('Created folder: $folderName (${folder.id})'); + return folder.id!; + }); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error( 'Failed to create folder: $folderName', error: e, stackTrace: stackTrace, ); - throw CloudStorageException('Create folder failed: $e', e, stackTrace); + throw _mapDriveError('Create folder', e, stackTrace); } } @@ -336,42 +354,42 @@ class GoogleDriveStorageProvider } try { - // Look for existing sync folder - const query = - "name = '${CloudStorageProviderMixin.syncFolderName}' " - "and mimeType = 'application/vnd.google-apps.folder' " - "and 'appDataFolder' in parents " - "and trashed = false"; + return await _run(() async { + // Look for existing sync folder + const query = + "name = '${CloudStorageProviderMixin.syncFolderName}' " + "and mimeType = 'application/vnd.google-apps.folder' " + "and 'appDataFolder' in parents " + "and trashed = false"; + + final fileList = await _requireApi.files.list( + spaces: 'appDataFolder', + q: query, + $fields: 'files(id,name)', + ); - final fileList = await _api.files.list( - spaces: 'appDataFolder', - q: query, - $fields: 'files(id,name)', - ); + if (fileList.files != null && fileList.files!.isNotEmpty) { + _syncFolderId = fileList.files!.first.id!; + _log.info('Found existing sync folder: $_syncFolderId'); + return _syncFolderId!; + } - if (fileList.files != null && fileList.files!.isNotEmpty) { - _syncFolderId = fileList.files!.first.id!; - _log.info('Found existing sync folder: $_syncFolderId'); + // Create new sync folder + _syncFolderId = await createFolder( + CloudStorageProviderMixin.syncFolderName, + parentFolderId: 'appDataFolder', + ); return _syncFolderId!; - } - - // Create new sync folder - _syncFolderId = await createFolder( - CloudStorageProviderMixin.syncFolderName, - parentFolderId: 'appDataFolder', - ); - return _syncFolderId!; + }); + } on CloudStorageException { + rethrow; } catch (e, stackTrace) { _log.error( 'Failed to get/create sync folder', error: e, stackTrace: stackTrace, ); - throw CloudStorageException( - 'Get/create sync folder failed: $e', - e, - stackTrace, - ); + throw _mapDriveError('Get/create sync folder', e, stackTrace); } } @@ -383,7 +401,7 @@ class GoogleDriveStorageProvider "and '$folderId' in parents " "and trashed = false"; - final fileList = await _api.files.list( + final fileList = await _requireApi.files.list( spaces: 'appDataFolder', q: query, $fields: 'files(id,name)', @@ -393,6 +411,10 @@ class GoogleDriveStorageProvider return fileList.files!.first; } return null; + } on drive.DetailedApiRequestError { + // Let auth errors reach _run's 401 handling instead of masking them + // as "file not found" (which would create a duplicate). + rethrow; } catch (e) { _log.warning('Failed to find file: $filename - $e'); return null; diff --git a/test/core/services/cloud_storage/google_drive_storage_provider_test.dart b/test/core/services/cloud_storage/google_drive_storage_provider_test.dart new file mode 100644 index 000000000..721e2326b --- /dev/null +++ b/test/core/services/cloud_storage/google_drive_storage_provider_test.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_authenticator.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive_storage_provider.dart'; + +class _FakeAuthenticator implements GoogleDriveAuthenticator { + _FakeAuthenticator(this._client); + + http.Client? _client; + int authenticateCalls = 0; + int silentAuthCalls = 0; + int authFailures = 0; + bool silentAuthResult = true; + bool signedOut = false; + + @override + http.Client? get authClient => _client; + + @override + Future authenticate() async => authenticateCalls++; + + @override + Future attemptSilentAuth() async { + silentAuthCalls++; + return silentAuthResult; + } + + @override + Future handleAuthFailure() async => authFailures++; + + @override + Future signOut() async { + signedOut = true; + _client = null; + } + + @override + Future get userEmail async => 'diver@example.com'; +} + +const _jsonHeaders = {'content-type': 'application/json; charset=utf-8'}; + +/// Minimal fake Drive v3 backend. List responses are keyed by a substring +/// of the q query parameter (folder lookups contain the folder mimeType, +/// file lookups contain the file name). +class _FakeDrive { + final List requests = []; + final Map>> listResponses = {}; + int failuresRemaining = 0; + int failureStatus = 401; + String failureReason = 'authError'; + + MockClient client() => MockClient((request) async { + requests.add(request); + if (failuresRemaining > 0) { + failuresRemaining--; + return http.Response( + jsonEncode({ + 'error': { + 'code': failureStatus, + 'message': 'fake failure', + 'errors': [ + {'reason': failureReason, 'message': 'fake failure'}, + ], + }, + }), + failureStatus, + headers: _jsonHeaders, + ); + } + final path = request.url.path; + if (request.method == 'GET' && path == '/drive/v3/files') { + final q = request.url.queryParameters['q'] ?? ''; + for (final entry in listResponses.entries) { + if (q.contains(entry.key)) { + return http.Response( + jsonEncode({'files': entry.value}), + 200, + headers: _jsonHeaders, + ); + } + } + return http.Response( + jsonEncode({'files': []}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'POST' && path == '/upload/drive/v3/files') { + return http.Response( + jsonEncode({'id': 'created-1', 'name': 'created'}), + 200, + headers: _jsonHeaders, + ); + } + if ((request.method == 'PATCH' || request.method == 'PUT') && + path.startsWith('/upload/drive/v3/files/')) { + return http.Response( + jsonEncode({'id': path.split('/').last, 'name': 'updated'}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'POST' && path == '/drive/v3/files') { + return http.Response( + jsonEncode({'id': 'folder-created-1', 'name': 'Submersion Sync'}), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'GET' && path.startsWith('/drive/v3/files/')) { + if (request.url.queryParameters['alt'] == 'media') { + return http.Response.bytes( + [1, 2, 3], + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + } + return http.Response( + jsonEncode({ + 'id': path.split('/').last, + 'name': 'meta.json', + 'modifiedTime': '2026-07-02T10:00:00.000Z', + 'size': '3', + }), + 200, + headers: _jsonHeaders, + ); + } + if (request.method == 'DELETE') { + return http.Response('', 204); + } + return http.Response('unexpected ${request.method} $path', 500); + }); +} + +const _folderQueryKey = "mimeType = 'application/vnd.google-apps.folder'"; + +void main() { + late _FakeDrive drive; + late _FakeAuthenticator auth; + late GoogleDriveStorageProvider provider; + + setUp(() { + drive = _FakeDrive(); + drive.listResponses[_folderQueryKey] = [ + {'id': 'folder-7', 'name': 'Submersion Sync'}, + ]; + auth = _FakeAuthenticator(drive.client()); + provider = GoogleDriveStorageProvider(authenticator: auth); + }); + + test('isAvailable is platform + desktop-config gated', () async { + final expected = (Platform.isWindows || Platform.isLinux) + ? GoogleDriveClientConfig.hasDesktopClient + : true; + expect(await provider.isAvailable(), expected); + }); + + test('isAuthenticated delegates to silent auth when no client yet', () async { + final unauthenticated = GoogleDriveStorageProvider( + authenticator: _FakeAuthenticator(null)..silentAuthResult = false, + ); + expect(await unauthenticated.isAuthenticated(), isFalse); + }); + + test('getUserEmail delegates to the authenticator', () async { + expect(await provider.getUserEmail(), 'diver@example.com'); + }); + + test('upload creates a new file when none exists by that name', () async { + final result = await provider.uploadFile( + Uint8List.fromList([1, 2]), + 'ssv1.dev.cs.000001.json', + ); + expect(result.fileId, 'created-1'); + expect( + drive.requests.any( + (r) => r.method == 'POST' && r.url.path == '/upload/drive/v3/files', + ), + isTrue, + ); + }); + + test('upload updates in place when the name already exists', () async { + drive.listResponses["name = 'ssv1.dev.manifest.json'"] = [ + {'id': 'existing-9', 'name': 'ssv1.dev.manifest.json'}, + ]; + final result = await provider.uploadFile( + Uint8List.fromList([1, 2]), + 'ssv1.dev.manifest.json', + ); + expect(result.fileId, 'existing-9'); + expect( + drive.requests.any( + (r) => r.url.path == '/upload/drive/v3/files/existing-9', + ), + isTrue, + ); + }); + + test('the sync folder id is cached across calls', () async { + await provider.uploadFile(Uint8List.fromList([1]), 'a.json'); + await provider.uploadFile(Uint8List.fromList([2]), 'b.json'); + final folderQueries = drive.requests.where( + (r) => (r.url.queryParameters['q'] ?? '').contains(_folderQueryKey), + ); + expect(folderQueries, hasLength(1)); + }); + + test('download returns the file bytes', () async { + final bytes = await provider.downloadFile('file-1'); + expect(bytes, Uint8List.fromList([1, 2, 3])); + }); + + test('listFiles maps Drive results to CloudFileInfo', () async { + drive.listResponses['ssv1.'] = [ + { + 'id': 'f1', + 'name': 'ssv1.dev.cs.000001.json', + 'modifiedTime': '2026-07-01T00:00:00.000Z', + 'size': '10', + }, + ]; + final files = await provider.listFiles(namePattern: 'ssv1.'); + expect(files, hasLength(1)); + expect(files.single.id, 'f1'); + expect(files.single.sizeBytes, 10); + }); + + test('deleteFile issues a DELETE', () async { + await provider.deleteFile('f1'); + expect(drive.requests.last.method, 'DELETE'); + expect(drive.requests.last.url.path, '/drive/v3/files/f1'); + }); + + test('a 401 triggers one silent re-auth and a retry', () async { + drive.failuresRemaining = 1; + final files = await provider.listFiles(namePattern: 'ssv1.'); + expect(files, isEmpty); + expect(auth.authFailures, 1); + expect(auth.silentAuthCalls, 1); + }); + + test('a 401 with failed re-auth surfaces a sign-in-again error', () async { + drive.failuresRemaining = 1; + auth.silentAuthResult = false; + expect( + () => provider.listFiles(namePattern: 'ssv1.'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('sign in'), + ), + ), + ); + }); + + test('quota exhaustion maps to a storage-is-full error', () async { + drive.failuresRemaining = 1; + drive.failureStatus = 403; + drive.failureReason = 'storageQuotaExceeded'; + expect( + () => provider.listFiles(namePattern: 'ssv1.'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('storage is full'), + ), + ), + ); + }); + + test('signOut resets provider caches and delegates', () async { + await provider.uploadFile(Uint8List.fromList([1]), 'a.json'); + await provider.signOut(); + expect(auth.signedOut, isTrue); + expect(await provider.getFileInfo('x'), isNull); + }); +} From 98b0ac8438f4f6eb1c53f08c4ce45fe3680fc01a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:38:03 -0400 Subject: [PATCH 08/18] feat(sync): Google Drive availability and account-email providers, capability wiring --- .../providers/storage_providers.dart | 7 +++- .../providers/sync_providers.dart | 17 ++++++++ .../google_drive_ui_providers_test.dart | 41 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/features/settings/presentation/providers/google_drive_ui_providers_test.dart diff --git a/lib/features/settings/presentation/providers/storage_providers.dart b/lib/features/settings/presentation/providers/storage_providers.dart index 652a73ccb..f2da54f40 100644 --- a/lib/features/settings/presentation/providers/storage_providers.dart +++ b/lib/features/settings/presentation/providers/storage_providers.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/domain/entities/storage_config.dart'; +import 'package:submersion/core/services/cloud_storage/google_drive/google_drive_client_config.dart'; import 'package:submersion/core/services/database_location_service.dart'; import 'package:submersion/core/services/database_migration_service.dart'; import 'package:submersion/core/services/database_service.dart'; @@ -41,7 +42,11 @@ final storagePlatformCapabilitiesProvider = // - Android: Uses Storage Access Framework (SAF) supportsCustomFolder: true, supportsICloud: Platform.isIOS || Platform.isMacOS, - supportsGoogleDrive: true, // All platforms + // Mirrors GoogleDriveStorageProvider.isAvailable(): compile-time + // config on mobile/macOS, Desktop-app client required on desktop. + supportsGoogleDrive: + !(Platform.isWindows || Platform.isLinux) || + GoogleDriveClientConfig.hasDesktopClient, isDesktop: Platform.isMacOS || Platform.isWindows || Platform.isLinux, ); }); diff --git a/lib/features/settings/presentation/providers/sync_providers.dart b/lib/features/settings/presentation/providers/sync_providers.dart index e885aec54..bcc779926 100644 --- a/lib/features/settings/presentation/providers/sync_providers.dart +++ b/lib/features/settings/presentation/providers/sync_providers.dart @@ -168,6 +168,23 @@ CloudStorageProvider cloudProviderInstanceFor(CloudProviderType type) { } } +/// Whether Google Drive can be offered on this platform/build. True on +/// iOS/macOS/Android; on Windows/Linux only when the Desktop-app OAuth +/// client is compiled in (GoogleDriveClientConfig). +final googleDriveAvailableProvider = FutureProvider((ref) { + return cloudProviderInstanceFor(CloudProviderType.googledrive).isAvailable(); +}); + +/// Signed-in Google account email for the provider tile subtitle, or null +/// when Google Drive is not the selected provider or no account is known. +/// Watches syncStateProvider so connect/sign-out refresh the subtitle. +final googleDriveAccountEmailProvider = FutureProvider((ref) async { + final type = ref.watch(selectedCloudProviderTypeProvider); + if (type != CloudProviderType.googledrive) return null; + ref.watch(syncStateProvider); + return cloudProviderInstanceFor(CloudProviderType.googledrive).getUserEmail(); +}); + /// Cloud storage provider instance (null if none selected or custom folder mode) /// /// When using custom folder mode, app-managed cloud sync is disabled to prevent diff --git a/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart b/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart new file mode 100644 index 000000000..b71fad4cc --- /dev/null +++ b/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/data/repositories/sync_repository.dart' + show CloudProviderType; +import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; + +void main() { + test( + 'googleDriveAccountEmailProvider is null when Drive not selected', + () async { + final container = ProviderContainer( + overrides: [ + selectedCloudProviderTypeProvider.overrideWith( + (ref) => CloudProviderType.icloud, + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(googleDriveAccountEmailProvider.future), + isNull, + ); + }, + ); + + test( + 'googleDriveAvailableProvider resolves without authentication', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Must not throw and must not require sign-in; the exact value is + // platform-dependent (config-gated on Windows/Linux, true elsewhere). + expect( + await container.read(googleDriveAvailableProvider.future), + isA(), + ); + }, + ); +} From 25c788ed5d8faefb22c0f134c6795c607b1477f0 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:46:00 -0400 Subject: [PATCH 09/18] fix(sync): narrow Google Drive email provider watch to auth flag --- .../providers/sync_providers.dart | 5 +- .../google_drive_ui_providers_test.dart | 104 ++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/lib/features/settings/presentation/providers/sync_providers.dart b/lib/features/settings/presentation/providers/sync_providers.dart index bcc779926..8882e2be8 100644 --- a/lib/features/settings/presentation/providers/sync_providers.dart +++ b/lib/features/settings/presentation/providers/sync_providers.dart @@ -177,11 +177,12 @@ final googleDriveAvailableProvider = FutureProvider((ref) { /// Signed-in Google account email for the provider tile subtitle, or null /// when Google Drive is not the selected provider or no account is known. -/// Watches syncStateProvider so connect/sign-out refresh the subtitle. +/// Watches the authentication flag so connect/sign-out refresh the subtitle +/// without re-running on every sync progress tick. final googleDriveAccountEmailProvider = FutureProvider((ref) async { final type = ref.watch(selectedCloudProviderTypeProvider); if (type != CloudProviderType.googledrive) return null; - ref.watch(syncStateProvider); + ref.watch(syncStateProvider.select((s) => s.isAuthenticated)); return cloudProviderInstanceFor(CloudProviderType.googledrive).getUserEmail(); }); diff --git a/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart b/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart index b71fad4cc..7ddd737c5 100644 --- a/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart +++ b/test/features/settings/presentation/providers/google_drive_ui_providers_test.dart @@ -2,8 +2,70 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/data/repositories/sync_repository.dart' show CloudProviderType; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; +import 'package:submersion/core/services/sync/library_epoch.dart'; +import 'package:submersion/core/services/sync/sync_service.dart' + show ConflictResolution; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +/// Minimal fake [SyncNotifier] whose state the test can drive directly, so +/// the email provider's dependency on the sync state can be pinned without +/// the real notifier's database/cloud wiring. Mirrors the `_FakeSyncNotifier` +/// pattern in `cloud_sync_page_test.dart`. +class _FakeSyncNotifier extends StateNotifier + implements SyncNotifier { + _FakeSyncNotifier(super.state); + + void emit(SyncState newState) => state = newState; + + @override + Future performSync({bool auto = false}) async {} + + @override + Future firstSyncMergeInfo() async => null; + + @override + Future libraryReplaceInfo() async => null; + + @override + Future adoptReplacedLibrary() async {} + + @override + Future acknowledgeMoved() async {} + + @override + Future checkLibraryMoved() async {} + + @override + Future cleanupOldBackendData() async {} + + @override + Future dismissOldBackendCleanup() async {} + + @override + Future recordBackendDeparture({ + required CloudStorageProvider oldProvider, + required String toProviderId, + String? toProviderName, + }) async {} + + @override + Future refreshState() async {} + + @override + Future resetSyncState() async {} + + @override + Future signOut() async {} + + @override + Future resolveConflict( + String entityType, + String recordId, + ConflictResolution resolution, + ) async {} +} + void main() { test( 'googleDriveAccountEmailProvider is null when Drive not selected', @@ -38,4 +100,46 @@ void main() { ); }, ); + + test('googleDriveAccountEmailProvider recomputes on isAuthenticated flips ' + 'but not on progress ticks', () async { + var syncState = const SyncState(); + final fakeSync = _FakeSyncNotifier(syncState); + final container = ProviderContainer( + overrides: [ + selectedCloudProviderTypeProvider.overrideWith( + (ref) => CloudProviderType.googledrive, + ), + syncStateProvider.overrideWith((ref) => fakeSync), + ], + ); + addTearDown(container.dispose); + + // Every recompute of the FutureProvider emits a loading cycle; + // counting those pins exactly when the provider re-runs. + var loadCycles = 0; + container.listen>(googleDriveAccountEmailProvider, ( + previous, + next, + ) { + if (next.isLoading) loadCycles++; + }, fireImmediately: true); + await container.read(googleDriveAccountEmailProvider.future); + expect(loadCycles, 1, reason: 'initial load only'); + + // A progress-only tick (what performSync emits continuously during a + // sync) must NOT re-run getUserEmail(); this fails if the provider + // watches the whole SyncState instead of selecting isAuthenticated. + fakeSync.emit(syncState = syncState.copyWith(progress: 0.5)); + await Future.delayed(Duration.zero); + await container.read(googleDriveAccountEmailProvider.future); + expect(loadCycles, 1, reason: 'progress tick must not recompute'); + + // Flipping the authentication flag (connect/sign-out) MUST recompute; + // this fails if the syncStateProvider watch line is removed entirely. + fakeSync.emit(syncState = syncState.copyWith(isAuthenticated: true)); + await Future.delayed(Duration.zero); + await container.read(googleDriveAccountEmailProvider.future); + expect(loadCycles, 2, reason: 'auth flip must recompute'); + }); } From 62eae266677d2ecde57b56318271002d2b4f3b9b Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:53:24 -0400 Subject: [PATCH 10/18] feat(sync): restore Google Drive provider tile with availability gating and desktop browser flow --- .../presentation/pages/cloud_sync_page.dart | 117 ++++++++++++++++-- lib/l10n/arb/app_ar.arb | 3 + lib/l10n/arb/app_de.arb | 3 + lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_es.arb | 3 + lib/l10n/arb/app_fr.arb | 3 + lib/l10n/arb/app_he.arb | 3 + lib/l10n/arb/app_hu.arb | 3 + lib/l10n/arb/app_it.arb | 3 + lib/l10n/arb/app_localizations.dart | 18 +++ lib/l10n/arb/app_localizations_ar.dart | 12 ++ lib/l10n/arb/app_localizations_de.dart | 12 ++ lib/l10n/arb/app_localizations_en.dart | 12 ++ lib/l10n/arb/app_localizations_es.dart | 12 ++ lib/l10n/arb/app_localizations_fr.dart | 12 ++ lib/l10n/arb/app_localizations_he.dart | 11 ++ lib/l10n/arb/app_localizations_hu.dart | 12 ++ lib/l10n/arb/app_localizations_it.dart | 12 ++ lib/l10n/arb/app_localizations_nl.dart | 12 ++ lib/l10n/arb/app_localizations_pt.dart | 12 ++ lib/l10n/arb/app_localizations_zh.dart | 10 ++ lib/l10n/arb/app_nl.arb | 3 + lib/l10n/arb/app_pt.arb | 3 + lib/l10n/arb/app_zh.arb | 3 + .../pages/cloud_sync_page_test.dart | 82 ++++++++---- 25 files changed, 340 insertions(+), 39 deletions(-) diff --git a/lib/features/settings/presentation/pages/cloud_sync_page.dart b/lib/features/settings/presentation/pages/cloud_sync_page.dart index e331179bb..ef9c6ad39 100644 --- a/lib/features/settings/presentation/pages/cloud_sync_page.dart +++ b/lib/features/settings/presentation/pages/cloud_sync_page.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:go_router/go_router.dart'; @@ -5,6 +8,7 @@ import 'package:intl/intl.dart'; import 'package:submersion/core/data/repositories/sync_repository.dart' show CloudProviderType; +import 'package:submersion/core/services/cloud_storage/cloud_storage_provider.dart'; import 'package:submersion/core/services/cloud_storage/icloud_native_service.dart'; import 'package:submersion/core/services/cloud_storage/s3/s3_config.dart'; import 'package:submersion/core/services/sync/library_moved.dart'; @@ -52,14 +56,7 @@ class CloudSyncPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final syncState = ref.watch(syncStateProvider); - // Google Drive is hidden until its integration is implemented, but a - // persisted selection or SyncRepository's fallback can still surface - // `googledrive`. Treat it as no provider so the page can never show - // Sync Now enabled with no selected tile. - final rawProvider = ref.watch(selectedCloudProviderTypeProvider); - final selectedProvider = rawProvider == CloudProviderType.googledrive - ? null - : rawProvider; + final selectedProvider = ref.watch(selectedCloudProviderTypeProvider); final hasProvider = selectedProvider != null; final isCustomFolderMode = ref.watch( isCloudSyncDisabledByCustomFolderProvider, @@ -463,9 +460,7 @@ class CloudSyncPage extends ConsumerWidget { isAvailable: isApple && !iCloudUnsupported, disabledSubtitle: iCloudDisabledSubtitle, ), - // Google Drive is hidden until its integration is fully - // implemented; the CloudProviderType and provider plumbing remain - // so re-enabling is just restoring this tile. + _buildGoogleDriveProviderTile(context, ref, selectedProvider), _buildS3ProviderTile(context, ref, selectedProvider), ], ); @@ -509,6 +504,45 @@ class CloudSyncPage extends ConsumerWidget { ); } + Widget _buildGoogleDriveProviderTile( + BuildContext context, + WidgetRef ref, + CloudProviderType? selectedProvider, + ) { + final l10n = context.l10n; + final isSelected = selectedProvider == CloudProviderType.googledrive; + // Render from AsyncValue.value so a provider reload does not flash the + // tile through a disabled state. + final isAvailable = ref.watch(googleDriveAvailableProvider).value ?? false; + final email = ref.watch(googleDriveAccountEmailProvider).value; + + return Semantics( + selected: isSelected, + child: ListTile( + leading: const Icon(Icons.add_to_drive), + title: Text(l10n.settings_cloudSync_provider_googleDrive), + subtitle: Text( + !isAvailable + ? l10n.settings_cloudSync_googleDrive_desktopNotConfigured + : (isSelected && email != null + ? email + : l10n.settings_cloudSync_provider_googleDrive_subtitle), + ), + trailing: isSelected + ? const Icon( + Icons.check_circle, + color: Colors.green, + semanticLabel: 'Connected', + ) + : null, + enabled: isAvailable, + onTap: isAvailable + ? () => _selectProvider(context, ref, CloudProviderType.googledrive) + : null, + ), + ); + } + Widget _buildS3ProviderTile( BuildContext context, WidgetRef ref, @@ -610,7 +644,7 @@ class CloudSyncPage extends ConsumerWidget { try { // Authenticate with the provider - await cloudProvider.authenticate(); + await _authenticateWithBrowserWait(context, cloudProvider, provider); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -649,6 +683,65 @@ class CloudSyncPage extends ConsumerWidget { } } + /// On desktop, Google Drive authentication round-trips through the + /// system browser (loopback OAuth); keep a cancellable waiting dialog up + /// while it completes so the page does not look frozen. Other providers + /// and platforms authenticate directly. + Future _authenticateWithBrowserWait( + BuildContext context, + CloudStorageProvider cloudProvider, + CloudProviderType provider, + ) async { + final needsDialog = + provider == CloudProviderType.googledrive && + (Platform.isWindows || Platform.isLinux); + if (!needsDialog) { + await cloudProvider.authenticate(); + return; + } + + var dialogUp = true; + final auth = cloudProvider.authenticate().whenComplete(() { + if (dialogUp && context.mounted) { + Navigator.of(context, rootNavigator: true).pop(false); + } + }); + final cancelled = + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text( + dialogContext + .l10n + .settings_cloudSync_googleDrive_browserWait_title, + ), + content: Text( + dialogContext + .l10n + .settings_cloudSync_googleDrive_browserWait_message, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text( + MaterialLocalizations.of(dialogContext).cancelButtonLabel, + ), + ), + ], + ), + ) ?? + false; + dialogUp = false; + if (cancelled) { + // Abandon the pending flow; the loopback listener times out on its + // own. Swallow its eventual error so nothing surfaces later. + unawaited(auth.catchError((_) {})); + throw const CloudStorageException('Google Sign-In was cancelled'); + } + await auth; + } + /// Localized connection-failure message. For iCloud, the wording reflects /// the real availability state instead of leaking the raw exception. String _connectionErrorMessage( diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 760e25dbc..af2ef1b6a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -3306,6 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "فشل الاتصال بـ {providerName}: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "المزامنة عبر Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "غير متوفر في هذا الإصدار", + "settings_cloudSync_googleDrive_browserWait_title": "تابع في متصفحك", + "settings_cloudSync_googleDrive_browserWait_message": "أكمل تسجيل الدخول إلى Google في متصفح الويب الخاص بك، ثم عد إلى Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "فشل في تهيئة مزود {providerName}", "settings_cloudSync_provider_notAvailable": "غير متاح على هذه المنصة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index fbc1506ed..4eafffa86 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3306,6 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Verbindung zu {providerName} fehlgeschlagen: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Über Google Drive synchronisieren", + "settings_cloudSync_googleDrive_desktopNotConfigured": "In diesem Build nicht verfügbar", + "settings_cloudSync_googleDrive_browserWait_title": "Weiter im Browser", + "settings_cloudSync_googleDrive_browserWait_message": "Schließe die Anmeldung bei Google in deinem Webbrowser ab und kehre dann zu Submersion zurück.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "{providerName}-Anbieter konnte nicht initialisiert werden", "settings_cloudSync_provider_notAvailable": "Auf dieser Plattform nicht verfügbar", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4698122f8..e45dd31ee 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5844,6 +5844,9 @@ "settings_cloudSync_provider_connectionFailed": "{providerName} connection failed: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sync via Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Not available in this build", + "settings_cloudSync_googleDrive_browserWait_title": "Continue in your browser", + "settings_cloudSync_googleDrive_browserWait_message": "Finish signing in to Google in your web browser, then return to Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Failed to initialize {providerName} provider", "settings_cloudSync_provider_notAvailable": "Not available on this platform", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 4d4be158c..46344cb08 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3306,6 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Error de conexion con {providerName}: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sincronizar via Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "No disponible en esta version", + "settings_cloudSync_googleDrive_browserWait_title": "Continua en tu navegador", + "settings_cloudSync_googleDrive_browserWait_message": "Termina de iniciar sesion en Google en tu navegador web y luego vuelve a Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Error al inicializar el proveedor {providerName}", "settings_cloudSync_provider_notAvailable": "No disponible en esta plataforma", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 56861e434..5d97a1b40 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3272,6 +3272,9 @@ "settings_cloudSync_provider_connectionFailed": "Echec de la connexion a {providerName} : {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Synchroniser via Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Indisponible dans cette version", + "settings_cloudSync_googleDrive_browserWait_title": "Continuez dans votre navigateur", + "settings_cloudSync_googleDrive_browserWait_message": "Terminez la connexion a Google dans votre navigateur web, puis revenez a Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Echec de l'initialisation du fournisseur {providerName}", "settings_cloudSync_provider_notAvailable": "Non disponible sur cette plateforme", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index 730cf2a37..3a33fb0c2 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -3310,6 +3310,9 @@ "settings_cloudSync_provider_connectionFailed": "החיבור אל {providerName} נכשל: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "סנכרון באמצעות Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "לא זמין בגרסה זו", + "settings_cloudSync_googleDrive_browserWait_title": "המשך בדפדפן", + "settings_cloudSync_googleDrive_browserWait_message": "סיים את ההתחברות לחשבון Google בדפדפן האינטרנט שלך, ולאחר מכן חזור אל Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "אתחול ספק {providerName} נכשל", "settings_cloudSync_provider_notAvailable": "לא זמין בפלטפורמה זו", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 2901477dc..682d904d4 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3272,6 +3272,9 @@ "settings_cloudSync_provider_connectionFailed": "{providerName} csatlakozas sikertelen: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Szinkronizalas Google Drive-on keresztül", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Ebben a buildben nem érhető el", + "settings_cloudSync_googleDrive_browserWait_title": "Folytasd a böngészőben", + "settings_cloudSync_googleDrive_browserWait_message": "Fejezd be a Google-bejelentkezést a webböngésződben, majd térj vissza a Submersionbe.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Nem sikerült a(z) {providerName} szolgaltato inicializalasa", "settings_cloudSync_provider_notAvailable": "Nem erheto el ezen a platformon", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 6e13ee313..9d2477dff 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -3272,6 +3272,9 @@ "settings_cloudSync_provider_connectionFailed": "Connessione a {providerName} non riuscita: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sincronizza tramite Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Non disponibile in questa build", + "settings_cloudSync_googleDrive_browserWait_title": "Continua nel browser", + "settings_cloudSync_googleDrive_browserWait_message": "Completa l'accesso a Google nel tuo browser web, poi torna a Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Inizializzazione del provider {providerName} non riuscita", "settings_cloudSync_provider_notAvailable": "Non disponibile su questa piattaforma", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 7101711c7..3fb6bd5dc 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -18459,6 +18459,24 @@ abstract class AppLocalizations { /// **'Sync via Google Drive'** String get settings_cloudSync_provider_googleDrive_subtitle; + /// No description provided for @settings_cloudSync_googleDrive_desktopNotConfigured. + /// + /// In en, this message translates to: + /// **'Not available in this build'** + String get settings_cloudSync_googleDrive_desktopNotConfigured; + + /// No description provided for @settings_cloudSync_googleDrive_browserWait_title. + /// + /// In en, this message translates to: + /// **'Continue in your browser'** + String get settings_cloudSync_googleDrive_browserWait_title; + + /// No description provided for @settings_cloudSync_googleDrive_browserWait_message. + /// + /// In en, this message translates to: + /// **'Finish signing in to Google in your web browser, then return to Submersion.'** + String get settings_cloudSync_googleDrive_browserWait_message; + /// No description provided for @settings_cloudSync_provider_icloud. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index 845e6cf3c..df73c78c6 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -10646,6 +10646,18 @@ class AppLocalizationsAr extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'المزامنة عبر Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'غير متوفر في هذا الإصدار'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'تابع في متصفحك'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'أكمل تسجيل الدخول إلى Google في متصفح الويب الخاص بك، ثم عد إلى Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 80fc8951d..071a5a698 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10843,6 +10843,18 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Über Google Drive synchronisieren'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'In diesem Build nicht verfügbar'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Weiter im Browser'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Schließe die Anmeldung bei Google in deinem Webbrowser ab und kehre dann zu Submersion zurück.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index fcdff5736..55b750204 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -10674,6 +10674,18 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Sync via Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Not available in this build'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Continue in your browser'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Finish signing in to Google in your web browser, then return to Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 1ea0d2194..12acc8ad6 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10841,6 +10841,18 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Sincronizar via Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'No disponible en esta version'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Continua en tu navegador'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Termina de iniciar sesion en Google en tu navegador web y luego vuelve a Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index cecc84152..66e0be5e9 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10892,6 +10892,18 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Synchroniser via Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Indisponible dans cette version'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Continuez dans votre navigateur'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Terminez la connexion a Google dans votre navigateur web, puis revenez a Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index eb65de8aa..d76476277 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -10569,6 +10569,17 @@ class AppLocalizationsHe extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'סנכרון באמצעות Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'לא זמין בגרסה זו'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => 'המשך בדפדפן'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'סיים את ההתחברות לחשבון Google בדפדפן האינטרנט שלך, ולאחר מכן חזור אל Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index b2d0213bf..6f2c38723 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10824,6 +10824,18 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Szinkronizalas Google Drive-on keresztül'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Ebben a buildben nem érhető el'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Folytasd a böngészőben'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Fejezd be a Google-bejelentkezést a webböngésződben, majd térj vissza a Submersionbe.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 81129f67b..e0ee5229c 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -10851,6 +10851,18 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Sincronizza tramite Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Non disponibile in questa build'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Continua nel browser'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Completa l\'accesso a Google nel tuo browser web, poi torna a Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index bd9c8b14f..70e4ff17a 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -10770,6 +10770,18 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Synchroniseren via Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Niet beschikbaar in deze build'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Ga verder in je browser'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Rond het inloggen bij Google af in je webbrowser en keer dan terug naar Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 47f340bfe..73c47a592 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10857,6 +10857,18 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => 'Sincronizar via Google Drive'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => + 'Indisponivel nesta versao'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => + 'Continue no navegador'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + 'Conclua a autenticacao com a sua conta Google no navegador e depois regresse ao Submersion.'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 765e8de11..40fc8d0c7 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -10346,6 +10346,16 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_cloudSync_provider_googleDrive_subtitle => '通过 Google Drive 同步'; + @override + String get settings_cloudSync_googleDrive_desktopNotConfigured => '此版本不可用'; + + @override + String get settings_cloudSync_googleDrive_browserWait_title => '请在浏览器中继续'; + + @override + String get settings_cloudSync_googleDrive_browserWait_message => + '请在网页浏览器中完成 Google 登录,然后返回 Submersion。'; + @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 9e64f34c2..0bb7da0f0 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3306,6 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Verbinding met {providerName} mislukt: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Synchroniseren via Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Niet beschikbaar in deze build", + "settings_cloudSync_googleDrive_browserWait_title": "Ga verder in je browser", + "settings_cloudSync_googleDrive_browserWait_message": "Rond het inloggen bij Google af in je webbrowser en keer dan terug naar Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Initialisatie van {providerName}-provider mislukt", "settings_cloudSync_provider_notAvailable": "Niet beschikbaar op dit platform", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 39a515244..4c8e819f4 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3306,6 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Falha na conexao com {providerName}: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sincronizar via Google Drive", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Indisponivel nesta versao", + "settings_cloudSync_googleDrive_browserWait_title": "Continue no navegador", + "settings_cloudSync_googleDrive_browserWait_message": "Conclua a autenticacao com a sua conta Google no navegador e depois regresse ao Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Falha ao inicializar provedor {providerName}", "settings_cloudSync_provider_notAvailable": "Nao disponivel nesta plataforma", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 695c3056b..f8296720a 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -3458,6 +3458,9 @@ "settings_cloudSync_provider_connectionFailed": "{providerName} 连接失败:{error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "通过 Google Drive 同步", + "settings_cloudSync_googleDrive_desktopNotConfigured": "此版本不可用", + "settings_cloudSync_googleDrive_browserWait_title": "请在浏览器中继续", + "settings_cloudSync_googleDrive_browserWait_message": "请在网页浏览器中完成 Google 登录,然后返回 Submersion。", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "无法初始化 {providerName} 提供商", "settings_cloudSync_provider_notAvailable": "在此平台上不可用", diff --git a/test/features/settings/presentation/pages/cloud_sync_page_test.dart b/test/features/settings/presentation/pages/cloud_sync_page_test.dart index 97cc8a45c..f69d5d00c 100644 --- a/test/features/settings/presentation/pages/cloud_sync_page_test.dart +++ b/test/features/settings/presentation/pages/cloud_sync_page_test.dart @@ -326,6 +326,8 @@ void main() { ), ICloudAvailability iCloudAvailability = ICloudAvailability.available, bool applePlatform = true, + bool googleDriveAvailable = true, + String? googleDriveEmail, }) async { final base = await getBaseOverrides(); final fakeSync = _FakeSyncNotifier(syncState); @@ -352,6 +354,12 @@ void main() { (ref) async => iCloudAvailability, ), isApplePlatformProvider.overrideWithValue(applePlatform), + googleDriveAvailableProvider.overrideWith( + (ref) async => googleDriveAvailable, + ), + googleDriveAccountEmailProvider.overrideWith( + (ref) async => googleDriveEmail, + ), isCloudSyncDisabledByCustomFolderProvider.overrideWithValue( customFolderMode, ), @@ -547,11 +555,10 @@ void main() { await pumpPage(tester); expect(find.text('Cloud Sync'), findsOneWidget); - // Provider section header and provider tiles. Google Drive is hidden - // until its integration is fully implemented. + // Provider section header and provider tiles. expect(find.text('Cloud Provider'), findsOneWidget); expect(find.text('iCloud'), findsOneWidget); - expect(find.text('Google Drive'), findsNothing); + expect(find.text('Google Drive'), findsOneWidget); // Behavior section. expect(find.text('Sync Behavior'), findsOneWidget); expect(find.text('Auto Sync'), findsOneWidget); @@ -806,30 +813,51 @@ void main() { expect(find.text('Select a cloud provider to enable sync'), findsNothing); }); - testWidgets( - 'persisted googledrive selection reads as no provider since the tile is hidden', - (tester) async { - // SyncRepository.getCloudProvider() falls back to googledrive when - // the stored enum name does not match, and getLastProvider() returns - // a previously persisted googledrive choice verbatim. With the tile - // removed, the UI must treat that as "no provider" so Sync Now is - // disabled and the select-provider hint stays visible. Otherwise the - // user sees no selected tile but a green Sync Now -- inconsistent. - await pumpPage(tester, selectedProvider: CloudProviderType.googledrive); - - // No tile shows the connected check icon (googledrive tile is hidden). - expect(find.byIcon(Icons.check_circle), findsNothing); - // Sync Now is disabled and the hint is shown. - final button = tester.widget( - find.widgetWithText(FilledButton, 'Sync Now'), - ); - expect(button.onPressed, isNull); - expect( - find.text('Select a cloud provider to enable sync'), - findsOneWidget, - ); - }, - ); + testWidgets('persisted googledrive selection selects the tile', ( + tester, + ) async { + await pumpPage( + tester, + selectedProvider: CloudProviderType.googledrive, + googleDriveEmail: 'diver@example.com', + ); + + // The Google Drive tile shows the connected check icon. + expect(find.byIcon(Icons.check_circle), findsOneWidget); + // The subtitle shows the signed-in account. + expect(find.text('diver@example.com'), findsOneWidget); + // Sync Now is enabled (no coercion to "no provider" anymore). + final button = tester.widget( + find.widgetWithText(FilledButton, 'Sync Now'), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('Google Drive tile is disabled when unavailable', ( + tester, + ) async { + await pumpPage(tester, googleDriveAvailable: false); + + final tile = tester.widget( + find.ancestor( + of: find.text('Google Drive'), + matching: find.byType(ListTile), + ), + ); + expect(tile.enabled, isFalse); + }); + + testWidgets('tapping Google Drive authenticates and connects', ( + tester, + ) async { + final fake = FakeCloudStorageProvider(); + await pumpPage(tester, cloudProvider: fake); + + await tester.tap(find.text('Google Drive')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Connected to'), findsOneWidget); + }); testWidgets( 'tapping the iCloud tile authenticates and shows snackbar', From 548b1074e53314f299696df68b90c1784275a0f8 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 17:55:27 -0400 Subject: [PATCH 11/18] fix(sync): restore proper accents in es/fr/pt Google Drive browser-wait strings --- lib/l10n/arb/app_es.arb | 6 +++--- lib/l10n/arb/app_fr.arb | 2 +- lib/l10n/arb/app_localizations_es.dart | 6 +++--- lib/l10n/arb/app_localizations_fr.dart | 2 +- lib/l10n/arb/app_localizations_pt.dart | 4 ++-- lib/l10n/arb/app_pt.arb | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 46344cb08..5b7957455 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -3306,9 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Error de conexion con {providerName}: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sincronizar via Google Drive", - "settings_cloudSync_googleDrive_desktopNotConfigured": "No disponible en esta version", - "settings_cloudSync_googleDrive_browserWait_title": "Continua en tu navegador", - "settings_cloudSync_googleDrive_browserWait_message": "Termina de iniciar sesion en Google en tu navegador web y luego vuelve a Submersion.", + "settings_cloudSync_googleDrive_desktopNotConfigured": "No disponible en esta versión", + "settings_cloudSync_googleDrive_browserWait_title": "Continúa en tu navegador", + "settings_cloudSync_googleDrive_browserWait_message": "Termina de iniciar sesión en Google en tu navegador web y luego vuelve a Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Error al inicializar el proveedor {providerName}", "settings_cloudSync_provider_notAvailable": "No disponible en esta plataforma", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 5d97a1b40..f46753c62 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3274,7 +3274,7 @@ "settings_cloudSync_provider_googleDrive_subtitle": "Synchroniser via Google Drive", "settings_cloudSync_googleDrive_desktopNotConfigured": "Indisponible dans cette version", "settings_cloudSync_googleDrive_browserWait_title": "Continuez dans votre navigateur", - "settings_cloudSync_googleDrive_browserWait_message": "Terminez la connexion a Google dans votre navigateur web, puis revenez a Submersion.", + "settings_cloudSync_googleDrive_browserWait_message": "Terminez la connexion à Google dans votre navigateur web, puis revenez à Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Echec de l'initialisation du fournisseur {providerName}", "settings_cloudSync_provider_notAvailable": "Non disponible sur cette plateforme", diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 12acc8ad6..d9a99e4ad 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -10843,15 +10843,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_cloudSync_googleDrive_desktopNotConfigured => - 'No disponible en esta version'; + 'No disponible en esta versión'; @override String get settings_cloudSync_googleDrive_browserWait_title => - 'Continua en tu navegador'; + 'Continúa en tu navegador'; @override String get settings_cloudSync_googleDrive_browserWait_message => - 'Termina de iniciar sesion en Google en tu navegador web y luego vuelve a Submersion.'; + 'Termina de iniciar sesión en Google en tu navegador web y luego vuelve a Submersion.'; @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 66e0be5e9..5de88bfc8 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -10902,7 +10902,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_cloudSync_googleDrive_browserWait_message => - 'Terminez la connexion a Google dans votre navigateur web, puis revenez a Submersion.'; + 'Terminez la connexion à Google dans votre navigateur web, puis revenez à Submersion.'; @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 73c47a592..04407fbfe 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -10859,7 +10859,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_cloudSync_googleDrive_desktopNotConfigured => - 'Indisponivel nesta versao'; + 'Indisponível nesta versão'; @override String get settings_cloudSync_googleDrive_browserWait_title => @@ -10867,7 +10867,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_cloudSync_googleDrive_browserWait_message => - 'Conclua a autenticacao com a sua conta Google no navegador e depois regresse ao Submersion.'; + 'Conclua a autenticação com a sua conta Google no navegador e depois regresse ao Submersion.'; @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 4c8e819f4..33c6364f9 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -3306,9 +3306,9 @@ "settings_cloudSync_provider_connectionFailed": "Falha na conexao com {providerName}: {error}", "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Sincronizar via Google Drive", - "settings_cloudSync_googleDrive_desktopNotConfigured": "Indisponivel nesta versao", + "settings_cloudSync_googleDrive_desktopNotConfigured": "Indisponível nesta versão", "settings_cloudSync_googleDrive_browserWait_title": "Continue no navegador", - "settings_cloudSync_googleDrive_browserWait_message": "Conclua a autenticacao com a sua conta Google no navegador e depois regresse ao Submersion.", + "settings_cloudSync_googleDrive_browserWait_message": "Conclua a autenticação com a sua conta Google no navegador e depois regresse ao Submersion.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Falha ao inicializar provedor {providerName}", "settings_cloudSync_provider_notAvailable": "Nao disponivel nesta plataforma", From d3154e3d161bfe26aa1e8b543880e40bb84b7e3f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 21:27:36 -0400 Subject: [PATCH 12/18] fix(sync): formal-register de/hu strings and desktop dialog widget tests --- lib/l10n/arb/app_de.arb | 2 +- lib/l10n/arb/app_hu.arb | 4 +- lib/l10n/arb/app_localizations_de.dart | 2 +- lib/l10n/arb/app_localizations_hu.dart | 4 +- .../pages/cloud_sync_page_test.dart | 76 +++++++++++++++++++ 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 4eafffa86..ddffe0260 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3308,7 +3308,7 @@ "settings_cloudSync_provider_googleDrive_subtitle": "Über Google Drive synchronisieren", "settings_cloudSync_googleDrive_desktopNotConfigured": "In diesem Build nicht verfügbar", "settings_cloudSync_googleDrive_browserWait_title": "Weiter im Browser", - "settings_cloudSync_googleDrive_browserWait_message": "Schließe die Anmeldung bei Google in deinem Webbrowser ab und kehre dann zu Submersion zurück.", + "settings_cloudSync_googleDrive_browserWait_message": "Schließen Sie die Google-Anmeldung in Ihrem Webbrowser ab und kehren Sie dann zu Submersion zurück.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "{providerName}-Anbieter konnte nicht initialisiert werden", "settings_cloudSync_provider_notAvailable": "Auf dieser Plattform nicht verfügbar", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 682d904d4..68088646a 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -3273,8 +3273,8 @@ "settings_cloudSync_provider_googleDrive": "Google Drive", "settings_cloudSync_provider_googleDrive_subtitle": "Szinkronizalas Google Drive-on keresztül", "settings_cloudSync_googleDrive_desktopNotConfigured": "Ebben a buildben nem érhető el", - "settings_cloudSync_googleDrive_browserWait_title": "Folytasd a böngészőben", - "settings_cloudSync_googleDrive_browserWait_message": "Fejezd be a Google-bejelentkezést a webböngésződben, majd térj vissza a Submersionbe.", + "settings_cloudSync_googleDrive_browserWait_title": "Folytassa a böngészőjében", + "settings_cloudSync_googleDrive_browserWait_message": "Fejezze be a Google-bejelentkezést a webböngészőjében, majd térjen vissza a Submersionbe.", "settings_cloudSync_provider_icloud": "iCloud", "settings_cloudSync_provider_initFailed": "Nem sikerült a(z) {providerName} szolgaltato inicializalasa", "settings_cloudSync_provider_notAvailable": "Nem erheto el ezen a platformon", diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index 071a5a698..a0d826d96 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -10853,7 +10853,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_cloudSync_googleDrive_browserWait_message => - 'Schließe die Anmeldung bei Google in deinem Webbrowser ab und kehre dann zu Submersion zurück.'; + 'Schließen Sie die Google-Anmeldung in Ihrem Webbrowser ab und kehren Sie dann zu Submersion zurück.'; @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 6f2c38723..00c09e120 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -10830,11 +10830,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_cloudSync_googleDrive_browserWait_title => - 'Folytasd a böngészőben'; + 'Folytassa a böngészőjében'; @override String get settings_cloudSync_googleDrive_browserWait_message => - 'Fejezd be a Google-bejelentkezést a webböngésződben, majd térj vissza a Submersionbe.'; + 'Fejezze be a Google-bejelentkezést a webböngészőjében, majd térjen vissza a Submersionbe.'; @override String get settings_cloudSync_provider_icloud => 'iCloud'; diff --git a/test/features/settings/presentation/pages/cloud_sync_page_test.dart b/test/features/settings/presentation/pages/cloud_sync_page_test.dart index f69d5d00c..d93c940e7 100644 --- a/test/features/settings/presentation/pages/cloud_sync_page_test.dart +++ b/test/features/settings/presentation/pages/cloud_sync_page_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -211,6 +212,19 @@ class _ThrowingCloudStorageProvider extends FakeCloudStorageProvider { } } +/// [FakeCloudStorageProvider] whose [authenticate] blocks on a completer, so +/// tests can hold the desktop browser-wait dialog open and resolve (or fail) +/// the sign-in at a chosen moment. +class _PendingAuthCloudStorageProvider extends FakeCloudStorageProvider { + final authCompleter = Completer(); + + @override + Future authenticate() async { + await authCompleter.future; + await super.authenticate(); + } +} + /// Fake [SyncBehaviorNotifier] holding fixed settings and recording setter /// invocations, so the behavior switches can be rendered and toggled without /// SharedPreferences. @@ -900,6 +914,68 @@ void main() { ); }); + group('CloudSyncPage - Google Drive desktop browser-wait dialog', () { + // _authenticateWithBrowserWait only shows the dialog on Windows/Linux + // (desktop loopback OAuth). These tests are the inverse of this file's + // Apple-only tile-tap tests: they run on the Linux CI runner and are + // skipped on macOS developer machines, where the branch is unreachable. + final dialogReachable = Platform.isWindows || Platform.isLinux; + + testWidgets( + 'shows the wait dialog and connects when auth completes', + (tester) async { + final fake = _PendingAuthCloudStorageProvider(); + await pumpPage(tester, cloudProvider: fake); + + await tester.tap(find.text('Google Drive')); + await tester.pump(); + + // The browser-wait dialog is up while authenticate() is pending. + expect(find.text('Continue in your browser'), findsOneWidget); + + fake.authCompleter.complete(); + await tester.pumpAndSettle(); + + // Dialog dismissed itself and the success snackbar is shown. + expect(find.text('Continue in your browser'), findsNothing); + expect(find.textContaining('Connected to'), findsOneWidget); + }, + skip: !dialogReachable, + ); + + testWidgets( + 'Cancel abandons the sign-in and clears the selection', + (tester) async { + final fake = _PendingAuthCloudStorageProvider(); + await pumpPage(tester, cloudProvider: fake); + + await tester.tap(find.text('Google Drive')); + await tester.pump(); + + final cancelLabel = MaterialLocalizations.of( + tester.element(find.byType(AlertDialog)), + ).cancelButtonLabel; + await tester.tap(find.text(cancelLabel)); + await tester.pumpAndSettle(); + + // Dialog gone, selection cleared (no connected check icon), and the + // connection-failed snackbar shown. + expect(find.text('Continue in your browser'), findsNothing); + expect(find.byIcon(Icons.check_circle), findsNothing); + expect(find.textContaining('connection failed'), findsOneWidget); + + // The abandoned flow's eventual error must be swallowed; nothing + // may surface after the user has already cancelled. + fake.authCompleter.completeError( + const CloudStorageException('loopback timeout'), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + skip: !dialogReachable, + ); + }); + group('CloudSyncPage - sync actions', () { // The iCloud provider tile is only actionable on Apple platforms // (isAvailable: Platform.isIOS || Platform.isMacOS), so tests that switch From 3c1c19ccc147ca8c1c6cf14d9ff4ce07d8e6c986 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 2 Jul 2026 21:32:33 -0400 Subject: [PATCH 13/18] feat(sync): configure Google Sign-In OAuth on macOS --- macos/Runner/DebugProfile.entitlements | 4 +++- macos/Runner/Info.plist | 13 +++++++++++++ macos/Runner/Release.entitlements | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index bfc8a3997..2fd933270 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -38,6 +38,8 @@ com.apple.security.personal-information.photos-library keychain-access-groups - + + $(AppIdentifierPrefix)com.google.GIDSignIn + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index ce9e0a26a..73b40463b 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -4,6 +4,19 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.433819313354-8sn10bh15pij7hsqbld4tunccrsgi4bf + + + + GIDClientID + 433819313354-8sn10bh15pij7hsqbld4tunccrsgi4bf.apps.googleusercontent.com CFBundleDocumentTypes diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 1308a4c87..ada4d3b6c 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -34,6 +34,8 @@ com.apple.security.personal-information.photos-library keychain-access-groups - + + $(AppIdentifierPrefix)com.google.GIDSignIn + From bee91b97c96eaec964d7333831a4c64d125657a9 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 3 Jul 2026 23:15:00 -0400 Subject: [PATCH 14/18] feat(sync): register Google Drive OAuth clients for Android and desktop --- .../google_drive/google_drive_client_config.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart index b29af810f..073bb06fa 100644 --- a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart +++ b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart @@ -13,14 +13,16 @@ class GoogleDriveClientConfig { /// OAuth 2.0 "Desktop app" client used by the Windows/Linux loopback /// flow. Empty until the client is created in the Google Cloud console; /// an empty value disables Google Drive on desktop instead of crashing. - static const String desktopClientId = ''; + static const String desktopClientId = + '433819313354-eotqmtncg57b836gvc2bls3on5ppiu07.apps.googleusercontent.com'; static const String desktopClientSecret = ''; /// "Web application" client ID passed as serverClientId to /// google_sign_in on Android. Empty means initialize() is called without /// a serverClientId (sufficient for iOS/macOS, which read GIDClientID /// from Info.plist). - static const String androidServerClientId = ''; + static const String androidServerClientId = + '433819313354-qughape9gt872m38lgtjam2u4qgbdv3o.apps.googleusercontent.com'; /// True when the Desktop-app client is configured in this build. static bool get hasDesktopClient => From 05edd5df41a949b33e4cb4276de5758ba47cc1a4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 3 Jul 2026 23:16:09 -0400 Subject: [PATCH 15/18] docs(sync): manual device test checklist for Google Drive sync --- ...google-drive-sync-manual-test-checklist.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md diff --git a/docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md b/docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md new file mode 100644 index 000000000..dd15c5874 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-google-drive-sync-manual-test-checklist.md @@ -0,0 +1,30 @@ +# Google Drive Sync — Manual Device Test Checklist + +Run on real hardware per platform: macOS, iPhone or iPad, Android device, +Windows, Linux. All items must pass before Google Drive sync is considered +done (acceptance gate from the 2026-07-02 design spec). + +For each platform: + +- [ ] 1. Fresh sign-in from Settings > Cloud Sync (native account sheet on + iOS/macOS/Android; system browser + return on Windows/Linux). Tile + shows the account email after connecting. +- [ ] 2. Cold-launch silent auth: force-quit, relaunch, run Sync Now. + No sign-in prompt, no keychain dialog, sync succeeds. +- [ ] 3. Two-device round-trip: edit a dive on device A, Sync Now on A + then B; the change appears on B. Repeat in the other direction. +- [ ] 4. Sign out (Advanced > Sign Out): tile deselects, subsequent + launches show no keychain prompts. +- [ ] 5. Revoke access at myaccount.google.com > Security > Third-party + access, then Sync Now: a "sign in again" error appears; re-auth + via the tile recovers and sync works. +- [ ] 6. (Apple platforms) Backend switch iCloud -> Google Drive: the + departure confirmation appears, the moved-marker lands on iCloud, + and the per-provider cursor does not read stale (first Drive sync + is a full first-contact sync, not an incremental continuation). +- [ ] 7. (Windows/Linux) Cancel the browser dialog mid-sign-in: the tile + stays unselected, no credentials are stored, retrying works. + +Cross-platform matrix (any two platforms with different auth paths, e.g. +macOS + Windows): items 1-3 passing proves both OAuth clients land in the +same appDataFolder (same Google Cloud project). From b5e4f483b02917f91dbc8c31308b1a7182fc746d Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 3 Jul 2026 23:22:10 -0400 Subject: [PATCH 16/18] refactor(sync): single-source Google Drive availability gating and document auth asymmetries --- .../google_drive/google_drive_client_config.dart | 11 +++++++++++ .../google_drive/google_sign_in_authenticator.dart | 7 +++++++ .../cloud_storage/google_drive_storage_provider.dart | 12 ++++++++---- .../presentation/providers/storage_providers.dart | 9 ++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart index 073bb06fa..7dc5b352f 100644 --- a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart +++ b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + /// OAuth client configuration for Google Drive sync. /// /// The desktop client ID and secret are committed to source intentionally: @@ -27,4 +29,13 @@ class GoogleDriveClientConfig { /// True when the Desktop-app client is configured in this build. static bool get hasDesktopClient => desktopClientId.isNotEmpty && desktopClientSecret.isNotEmpty; + + /// Whether Google Drive can be offered on the current platform/build. + /// + /// Single source of truth for both [GoogleDriveStorageProvider.isAvailable] + /// and the `supportsGoogleDrive` capability flag, so the two cannot drift: + /// true on iOS/macOS/Android (OAuth config is compile-time), and on + /// Windows/Linux only when the Desktop-app client is compiled in. + static bool get isSupportedOnThisPlatform => + !(Platform.isWindows || Platform.isLinux) || hasDesktopClient; } diff --git a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart index d0fa73aba..360f7a37e 100644 --- a/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart @@ -132,6 +132,13 @@ class GoogleSignInAuthenticator implements GoogleDriveAuthenticator { Future handleAuthFailure() async { // Drop the stale client; keep _allowSilentAuth so the next // attemptSilentAuth() can rebuild authorization without UI. + // + // Deliberately leaves _currentUser set (unlike DesktopOAuthAuthenticator, + // which clears _email): silent re-auth here normally restores the same + // account and _installClient overwrites it, while a failed re-auth leaves + // isAuthenticated() false so the stale email is never surfaced. Do not + // "fix" this to clear _currentUser -- it would blank a still-valid account + // during a transient token refresh. _authClient?.close(); _authClient = null; } diff --git a/lib/core/services/cloud_storage/google_drive_storage_provider.dart b/lib/core/services/cloud_storage/google_drive_storage_provider.dart index 6a9fce6ac..1d8dbe2c9 100644 --- a/lib/core/services/cloud_storage/google_drive_storage_provider.dart +++ b/lib/core/services/cloud_storage/google_drive_storage_provider.dart @@ -45,10 +45,7 @@ class GoogleDriveStorageProvider // Mobile and macOS OAuth config is compile-time (Info.plist / Android // client registration). Desktop needs the committed Desktop-app client; // a build without it degrades to a hidden tile instead of crashing. - if (Platform.isWindows || Platform.isLinux) { - return GoogleDriveClientConfig.hasDesktopClient; - } - return true; + return GoogleDriveClientConfig.isSupportedOnThisPlatform; } @override @@ -105,6 +102,13 @@ class GoogleDriveStorageProvider /// hourly mid-session, so one silent re-auth disambiguates a stale token /// from a revoked grant. On a revoked grant the authenticator clears its /// stored state and the user is asked to sign in again. + /// + /// The stale-vs-revoked disambiguation is the mobile authenticator's: + /// google_sign_in's lightweight re-auth can mint a fresh token silently. + /// The desktop authenticator's auto-refreshing client already handles + /// hourly expiry itself, so a 401 reaching here means a genuinely revoked + /// refresh token -- its handleAuthFailure() clears the token store, so the + /// retry's attemptSilentAuth() fails and the user is asked to sign in again. Future _run(Future Function() operation) async { try { return await operation(); diff --git a/lib/features/settings/presentation/providers/storage_providers.dart b/lib/features/settings/presentation/providers/storage_providers.dart index f2da54f40..14f6aebb0 100644 --- a/lib/features/settings/presentation/providers/storage_providers.dart +++ b/lib/features/settings/presentation/providers/storage_providers.dart @@ -42,11 +42,10 @@ final storagePlatformCapabilitiesProvider = // - Android: Uses Storage Access Framework (SAF) supportsCustomFolder: true, supportsICloud: Platform.isIOS || Platform.isMacOS, - // Mirrors GoogleDriveStorageProvider.isAvailable(): compile-time - // config on mobile/macOS, Desktop-app client required on desktop. - supportsGoogleDrive: - !(Platform.isWindows || Platform.isLinux) || - GoogleDriveClientConfig.hasDesktopClient, + // Single source of truth shared with + // GoogleDriveStorageProvider.isAvailable(), so the two can't diverge: + // compile-time config on mobile/macOS, Desktop-app client on desktop. + supportsGoogleDrive: GoogleDriveClientConfig.isSupportedOnThisPlatform, isDesktop: Platform.isMacOS || Platform.isWindows || Platform.isLinux, ); }); From bf3f7f629bc957a4f113af348ae5ec9447202c79 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Fri, 3 Jul 2026 23:46:14 -0400 Subject: [PATCH 17/18] refactor(sync): desktop Google Drive uses PKCE loopback with no client secret --- ...-07-02-google-drive-sync-backend-design.md | 40 +++++++++++-------- .../desktop_oauth_authenticator.dart | 15 ++++--- .../google_drive_client_config.dart | 19 +++++---- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md index c1e8c3963..2f4270e0b 100644 --- a/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md +++ b/docs/superpowers/specs/2026-07-02-google-drive-sync-backend-design.md @@ -41,14 +41,18 @@ availability gating, error-handling refinements, and test coverage. | Platforms | All five: iOS, macOS, Android, Windows, Linux | | Data location | Hidden `appDataFolder` (`drive.appdata` scope) — unchanged | | Auth architecture | One provider, pluggable authenticator seam (Approach A) | -| Desktop client secret | Committed in source (RFC 8252 §8.5: installed-app secrets are non-confidential) | +| Desktop auth | PKCE loopback, no client secret (client_secret optional for Google Desktop-app clients) | | Acceptance | Full unit/widget coverage + manual device checklist pass | -The `no hardcoded secrets` rule in CLAUDE.md is explicitly waived for the -desktop OAuth client ID/secret: Google classifies installed-app client -secrets as non-confidential (they ship inside every desktop binary and -cannot protect anything). This matches standard practice for open-source -desktop apps (rclone, the Google Cloud SDK). +No client secret is committed. The desktop flow uses PKCE (code_verifier / +S256 code_challenge, already implemented in `googleapis_auth`) on a +loopback redirect; Google's native-app OAuth lists `client_secret` as +optional for Desktop-app clients, so the token exchange is authenticated by +the PKCE verifier alone. Only OAuth client IDs -- public identifiers -- are +committed, so no `no hardcoded secrets` waiver is needed and GitHub secret +scanning has nothing to flag. (An earlier revision committed the desktop +client secret under an RFC 8252 §8.5 rationale; PKCE removes the need for it +entirely.) ## Architecture @@ -70,7 +74,7 @@ into the REST layer. | `lib/core/services/cloud_storage/google_drive/google_sign_in_authenticator.dart` | iOS/macOS/Android: the existing `google_sign_in` v7 logic lifted out of the provider, behavior unchanged, including the `_allowSilentAuth` deferral that avoids keychain prompts before the user opts in | | `lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart` | Windows/Linux: loopback OAuth via `googleapis_auth` `clientViaUserConsent`, silent re-auth from a stored refresh token | | `lib/core/services/cloud_storage/google_drive/google_drive_token_store.dart` | Desktop-only `AccessCredentials` persistence in `FallbackSecureStorage` under key `sync_gdrive_credentials`; same JSON-blob pattern as `S3CredentialsStore`, including preserving (not deleting) a corrupt blob | -| `lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart` | Committed constants: desktop client ID/secret, Android `serverClientId` (Web client ID), with the RFC 8252 §8.5 comment | +| `lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart` | Committed client IDs only (desktop client ID, Android `serverClientId` Web client ID); no client secret — desktop uses PKCE | ### Changed files @@ -87,12 +91,14 @@ into the REST layer. ## Desktop OAuth flow (Windows/Linux) -1. **First sign-in:** `authenticate()` calls `clientViaUserConsent` with - the desktop client ID and the `drive.appdata` scope. It binds an - ephemeral port on `127.0.0.1`, opens the system browser to Google's - consent page, and receives the auth code on the loopback redirect - (RFC 8252 §7.3). A small in-app dialog shows "Complete sign-in in - your browser…" with a Cancel button that closes the loopback listener. +1. **First sign-in:** `authenticate()` calls `obtainAccessCredentialsViaUserConsent` + with the desktop client ID (no secret) and the `drive.appdata` scope. + It binds an ephemeral port on `127.0.0.1`, opens the system browser to + Google's consent page with a PKCE `code_challenge`, and receives the + auth code on the loopback redirect (RFC 8252 §7.3); the token exchange + sends the `code_verifier` and an empty `client_secret`. A small in-app + dialog shows "Complete sign-in in your browser…" with a Cancel button + that closes the loopback listener. 2. **Persistence:** the resulting `AccessCredentials` (access token, refresh token, expiry, scopes) serialize to one JSON blob in `FallbackSecureStorage` under `sync_gdrive_credentials`. @@ -116,7 +122,7 @@ into the REST layer. | iOS | Existing iOS client (done) | Already in `ios/Runner/Info.plist` | | macOS | Reuses the iOS client (Google treats macOS apps as iOS-type clients) | Add `GIDClientID` + reversed-client-ID URL scheme to `macos/Runner/Info.plist`; add the Keychain Sharing entitlement `google_sign_in` requires on macOS | | Android | New Android client per signing key (debug SHA-1 and release SHA-1), plus a Web application client | Web client ID passed as `serverClientId` to `GoogleSignIn.instance.initialize()` via `google_drive_client_config.dart`. No `google-services.json` (that is a Firebase artifact, not needed) | -| Windows/Linux | One shared Desktop app client | Client ID + secret committed in `google_drive_client_config.dart` | +| Windows/Linux | One shared Desktop app client | Client ID only committed in `google_drive_client_config.dart`; PKCE loopback flow needs no secret | All clients live in the same Google Cloud project (`433819313354`) so every platform shares the same `appDataFolder` — this is what makes cross-device @@ -260,8 +266,10 @@ console.cloud.google.com. Exact UI labels current as of mid-2026. 1. Create Credentials → OAuth client ID → Application type **Desktop app**, name "Submersion Desktop". -2. Copy the client ID and client secret — these are the committed - constants in `google_drive_client_config.dart`. +2. Copy the client ID — the committed `desktopClientId` constant in + `google_drive_client_config.dart`. The client secret Google also issues + is not used: the loopback flow authenticates with PKCE, for which + `client_secret` is optional on Desktop-app clients. Do not commit it. ### A.4 macOS diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart index 9c92092b7..2a964218a 100644 --- a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -32,8 +32,11 @@ typedef BuildRefreshingClient = /// Loopback-OAuth authenticator for Windows and Linux (RFC 8252 section /// 7.3): binds an ephemeral 127.0.0.1 port, opens the system browser to /// Google's consent page, and receives the auth code on the local -/// redirect. Credentials persist in [GoogleDriveTokenStore]; cold-launch -/// re-auth is silent via the stored refresh token. +/// redirect. Uses PKCE with no client secret -- Google lists client_secret +/// as optional for Desktop-app clients, so the code_verifier alone +/// authenticates the token exchange. Credentials persist in +/// [GoogleDriveTokenStore]; cold-launch re-auth is silent via the stored +/// refresh token. class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { DesktopOAuthAuthenticator({ GoogleDriveTokenStore? tokenStore, @@ -70,10 +73,10 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { StreamSubscription? _updateSubscription; String? _email; - gauth.ClientId get _clientId => gauth.ClientId( - GoogleDriveClientConfig.desktopClientId, - GoogleDriveClientConfig.desktopClientSecret, - ); + // No client secret: PKCE authenticates the token exchange (Google lists + // client_secret as optional for Desktop-app clients). + gauth.ClientId get _clientId => + gauth.ClientId(GoogleDriveClientConfig.desktopClientId); @override http.Client? get authClient => _authClient; diff --git a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart index 7dc5b352f..ea7f21e68 100644 --- a/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart +++ b/lib/core/services/cloud_storage/google_drive/google_drive_client_config.dart @@ -2,22 +2,22 @@ import 'dart:io'; /// OAuth client configuration for Google Drive sync. /// -/// The desktop client ID and secret are committed to source intentionally: -/// Google classifies installed-app client secrets as non-confidential -/// (RFC 8252 section 8.5) -- they ship inside every desktop binary and can -/// not protect anything. Committing them matches standard practice for -/// open-source desktop applications (rclone, the Google Cloud SDK). +/// Only client IDs are committed -- never a client secret. The desktop +/// flow uses PKCE on a loopback redirect (RFC 8252 / Google's native-app +/// OAuth), for which Google lists client_secret as optional; the token +/// exchange is authenticated by the PKCE code_verifier alone. OAuth client +/// IDs are public identifiers, safe to commit. /// /// All clients must belong to the same Google Cloud project so every /// platform shares the same Drive appDataFolder (it is scoped per project, /// per user); that is what makes cross-device sync work. class GoogleDriveClientConfig { /// OAuth 2.0 "Desktop app" client used by the Windows/Linux loopback - /// flow. Empty until the client is created in the Google Cloud console; - /// an empty value disables Google Drive on desktop instead of crashing. + /// flow (PKCE, no secret). Empty until the client is created in the + /// Google Cloud console; an empty value disables Google Drive on desktop + /// instead of crashing. static const String desktopClientId = '433819313354-eotqmtncg57b836gvc2bls3on5ppiu07.apps.googleusercontent.com'; - static const String desktopClientSecret = ''; /// "Web application" client ID passed as serverClientId to /// google_sign_in on Android. Empty means initialize() is called without @@ -27,8 +27,7 @@ class GoogleDriveClientConfig { '433819313354-qughape9gt872m38lgtjam2u4qgbdv3o.apps.googleusercontent.com'; /// True when the Desktop-app client is configured in this build. - static bool get hasDesktopClient => - desktopClientId.isNotEmpty && desktopClientSecret.isNotEmpty; + static bool get hasDesktopClient => desktopClientId.isNotEmpty; /// Whether Google Drive can be offered on the current platform/build. /// From 1d5ba305e5f25b98b33b4418a8c648426caff38a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Sat, 4 Jul 2026 00:08:21 -0400 Subject: [PATCH 18/18] fix(sync): address PR review on Google Drive desktop auth and Sync Now gating - URL-encode the refresh token in the desktop revocation request body - restructure the browser-wait dialog around one synchronously-checked guard so auth-completion and Cancel can never race to pop the wrong route - disable Sync Now for a persisted Google Drive selection when Drive is unavailable on the current build --- .../desktop_oauth_authenticator.dart | 4 +- .../presentation/pages/cloud_sync_page.dart | 38 ++++++++++++++----- .../desktop_oauth_authenticator_test.dart | 17 +++++++++ .../pages/cloud_sync_page_test.dart | 22 +++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart index 2a964218a..25db020be 100644 --- a/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart +++ b/lib/core/services/cloud_storage/google_drive/desktop_oauth_authenticator.dart @@ -151,7 +151,9 @@ class DesktopOAuthAuthenticator implements GoogleDriveAuthenticator { await base.post( Uri.parse(_revokeEndpoint), headers: {'content-type': 'application/x-www-form-urlencoded'}, - body: 'token=$token', + // Encode the token: Google refresh tokens contain '/' and other + // characters that must be percent-encoded in a form body. + body: 'token=${Uri.encodeQueryComponent(token)}', ); } catch (e) { _log.warning('Token revocation failed (ignored): $e'); diff --git a/lib/features/settings/presentation/pages/cloud_sync_page.dart b/lib/features/settings/presentation/pages/cloud_sync_page.dart index 9d784facc..9ec1e4631 100644 --- a/lib/features/settings/presentation/pages/cloud_sync_page.dart +++ b/lib/features/settings/presentation/pages/cloud_sync_page.dart @@ -59,7 +59,16 @@ class CloudSyncPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final syncState = ref.watch(syncStateProvider); final selectedProvider = ref.watch(selectedCloudProviderTypeProvider); - final hasProvider = selectedProvider != null; + // A persisted Google Drive selection must not enable Sync Now on a build + // where Drive is unavailable (e.g. a desktop build without the OAuth + // client compiled in) -- the tile is disabled in that case, so an enabled + // Sync Now with no usable provider would be inconsistent. + final googleDriveAvailable = + ref.watch(googleDriveAvailableProvider).value ?? false; + final selectionUsable = + selectedProvider != CloudProviderType.googledrive || + googleDriveAvailable; + final hasProvider = selectedProvider != null && selectionUsable; final isCustomFolderMode = ref.watch( isCloudSyncDisabledByCustomFolderProvider, ); @@ -833,12 +842,24 @@ class CloudSyncPage extends ConsumerWidget { return; } - var dialogUp = true; - final auth = cloudProvider.authenticate().whenComplete(() { - if (dialogUp && context.mounted) { - Navigator.of(context, rootNavigator: true).pop(false); - } - }); + // Single synchronously-checked guard: whichever happens first -- auth + // settling or the user tapping Cancel -- closes the dialog exactly once. + // Because dialogClosed is checked and set with no intervening await, the + // two paths can never interleave, so the pop always targets the dialog + // (showDialog pushes it synchronously below, before any await yields to + // the auth microtask) and never the page route. + final navigator = Navigator.of(context, rootNavigator: true); + var dialogClosed = false; + void closeDialog(bool cancelled) { + if (dialogClosed) return; + dialogClosed = true; + navigator.pop(cancelled); + } + + final auth = cloudProvider.authenticate(); + unawaited( + auth.then((_) => closeDialog(false), onError: (_) => closeDialog(false)), + ); final cancelled = await showDialog( context: context, @@ -856,7 +877,7 @@ class CloudSyncPage extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext, true), + onPressed: () => closeDialog(true), child: Text( MaterialLocalizations.of(dialogContext).cancelButtonLabel, ), @@ -865,7 +886,6 @@ class CloudSyncPage extends ConsumerWidget { ), ) ?? false; - dialogUp = false; if (cancelled) { // Abandon the pending flow; the loopback listener times out on its // own. Swallow its eventual error so nothing surfaces later. diff --git a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart index fc0c59167..1b5a18182 100644 --- a/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart +++ b/test/core/services/cloud_storage/google_drive/desktop_oauth_authenticator_test.dart @@ -177,6 +177,23 @@ void main() { expect(revokeRequests.single.url.path, '/revoke'); }); + test('signOut percent-encodes the token in the revocation body', () async { + // Google refresh tokens contain '/' and other characters that must be + // percent-encoded in an application/x-www-form-urlencoded body. + store.stored = creds(refreshToken: '1//0g-token/with+special=chars'); + final auth = authenticator(); + await auth.attemptSilentAuth(); + + await auth.signOut(); + + expect( + revokeRequests.single.body, + 'token=${Uri.encodeQueryComponent('1//0g-token/with+special=chars')}', + ); + // The raw token must not appear unencoded in the body. + expect(revokeRequests.single.body, isNot(contains('1//0g-token'))); + }); + test('signOut still clears local state when revocation throws', () async { store.stored = creds(refreshToken: 'rt-1'); final auth = DesktopOAuthAuthenticator( diff --git a/test/features/settings/presentation/pages/cloud_sync_page_test.dart b/test/features/settings/presentation/pages/cloud_sync_page_test.dart index 41b6ad9b9..6957edc73 100644 --- a/test/features/settings/presentation/pages/cloud_sync_page_test.dart +++ b/test/features/settings/presentation/pages/cloud_sync_page_test.dart @@ -857,6 +857,28 @@ void main() { expect(button.onPressed, isNotNull); }); + testWidgets( + 'persisted googledrive selection does not enable Sync Now when Drive is unavailable', + (tester) async { + await pumpPage( + tester, + selectedProvider: CloudProviderType.googledrive, + googleDriveAvailable: false, + ); + + // No usable provider: Sync Now stays disabled and the hint shows, + // rather than an enabled Sync Now with a disabled/absent tile. + final button = tester.widget( + find.widgetWithText(FilledButton, 'Sync Now'), + ); + expect(button.onPressed, isNull); + expect( + find.text('Select a cloud provider to enable sync'), + findsOneWidget, + ); + }, + ); + testWidgets('Google Drive tile is disabled when unavailable', ( tester, ) async {