From cba89bcacbbe3e9234cd67f9534bc552b3c5057e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 11:04:40 +0100 Subject: [PATCH] Use google_account_token plugin Swap google_sign_in for the FOSS AccountManager plugin (google_account_token-v0.1.0); drop the local platform gate. Also fix an unmatched coverage:ignore pair that broke --coverage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ --- app/lib/services/firebase_backend.dart | 1 + app/lib/services/firebase_client.dart | 2 - app/lib/services/google_platform.dart | 11 ---- app/lib/services/google_platform_io.dart | 10 --- app/lib/services/google_platform_web.dart | 8 --- app/lib/services/google_sign_in_backend.dart | 65 +++++--------------- app/pubspec.lock | 53 +++------------- app/pubspec.yaml | 7 ++- app/test/google_platform_test.dart | 19 +++--- app/test/google_sign_in_backend_test.dart | 44 ++++++++++++- 10 files changed, 82 insertions(+), 138 deletions(-) delete mode 100644 app/lib/services/google_platform.dart delete mode 100644 app/lib/services/google_platform_io.dart delete mode 100644 app/lib/services/google_platform_web.dart diff --git a/app/lib/services/firebase_backend.dart b/app/lib/services/firebase_backend.dart index 0288c6f..203d588 100644 --- a/app/lib/services/firebase_backend.dart +++ b/app/lib/services/firebase_backend.dart @@ -104,3 +104,4 @@ Future clearAccount() async { /// the desktop wrapper re-provisioning after the next sign-in. Future forgetAccountMarker() => _secure.delete(key: kFirebaseAccountKey); +// coverage:ignore-end diff --git a/app/lib/services/firebase_client.dart b/app/lib/services/firebase_client.dart index 61f2d38..2597931 100644 --- a/app/lib/services/firebase_client.dart +++ b/app/lib/services/firebase_client.dart @@ -181,8 +181,6 @@ Future openFirebaseWithGoogle({ return FirebaseRestClient(databaseUrl: kProject.databaseUrl, auth: auth); } -// coverage:ignore-end - /// Wraps [github] so Firebase is primary and GitHub is kept as a mirror. /// /// Returns [github] unchanged when this device has not been set up for diff --git a/app/lib/services/google_platform.dart b/app/lib/services/google_platform.dart deleted file mode 100644 index f253b2c..0000000 --- a/app/lib/services/google_platform.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// Platform gate for the programmatic Google sign-in flow. -/// -/// Conditional export because the answer differs per platform *and* because -/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws -/// `UnimplementedError` where no implementation is registered -- an `Error`, -/// not an `Exception`, so it escapes an ordinary catch and would take down -/// the settings screen's `build()`. -library; - -export 'google_platform_io.dart' - if (dart.library.js_interop) 'google_platform_web.dart'; diff --git a/app/lib/services/google_platform_io.dart b/app/lib/services/google_platform_io.dart deleted file mode 100644 index 7fa7a81..0000000 --- a/app/lib/services/google_platform_io.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// Whether the programmatic Google sign-in flow exists on this platform. -/// -/// The io half: Android has the Credential Manager flow; Linux desktop does -/// not, and this app's desktop build is the web one anyway. -library; - -import 'dart:io'; - -/// True only on Android, the one platform shipping the programmatic flow. -bool get googleSignInSupported => Platform.isAndroid; diff --git a/app/lib/services/google_platform_web.dart b/app/lib/services/google_platform_web.dart deleted file mode 100644 index 9e7ccd6..0000000 --- a/app/lib/services/google_platform_web.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// Whether the programmatic Google sign-in flow exists on this platform. -/// -/// The web half: Google Identity Services signs in only through its own -/// rendered button, so `authenticate()` throws `UnimplementedError` here. -library; - -/// Always false: the web plugin has no programmatic sign-in. -bool get googleSignInSupported => false; diff --git a/app/lib/services/google_sign_in_backend.dart b/app/lib/services/google_sign_in_backend.dart index 3471ae3..2cb0e78 100644 --- a/app/lib/services/google_sign_in_backend.dart +++ b/app/lib/services/google_sign_in_backend.dart @@ -2,9 +2,10 @@ /// /// Why this is its own file: `crdt_sync` is deliberately pure Dart (only /// `crypto` and `http`), because the same library runs on Linux desktop and -/// headless under systemd, where `google_sign_in` does not exist. So the -/// plugin lives here, in the app, and the library only ever receives a token -/// string through a closure. +/// headless under systemd, where Android's AccountManager does not exist. So +/// the platform side lives in the `google_account_token` plugin (system +/// account chooser, no browser, no Google client library) and the library +/// only ever receives a token string through a closure. /// /// What this buys: on a fresh install the sync account is reached by tapping /// one account-picker row instead of typing a long password on a phone @@ -14,8 +15,7 @@ library; import 'dart:developer'; -import 'package:diet_guard_app/services/google_platform.dart' as platform_gate; -import 'package:google_sign_in/google_sign_in.dart'; +import 'package:google_account_token/google_account_token.dart' as plugin; /// The account the security rules pin. /// @@ -50,73 +50,36 @@ const kServerClientId = /// Whether this platform can sign in programmatically. /// -/// False on web, where Google Identity Services only signs in through its own -/// rendered button: `authenticate()` throws `UnimplementedError` there. The -/// desktop build of this app *is* the web build, so without this check the -/// "Sign in with Google" button would crash the settings screen on desktop -/// rather than falling back; see `google_platform.dart` for why this is a -/// platform check rather than a question asked of the plugin. +/// True only on Android, the one platform with an AccountManager. The desktop +/// build of this app *is* the web build, so without this check the "Sign in +/// with Google" button would show on desktop and always report "cancelled". /// /// Also false when no client id is compiled in: a button that cannot possibly /// succeed is worse than no button, and without this check an unconfigured /// build reports "cancelled" and sends you debugging the OAuth console for a /// problem that is really a missing constant. bool get googleSignInSupported => - platform_gate.googleSignInSupported && kServerClientId.isNotEmpty; + plugin.googleAccountTokenSupported && kServerClientId.isNotEmpty; /// Returns a Google ID token for the signed-in account, or null. /// /// Null rather than throwing when the user dismisses the picker: cancelling is /// an ordinary outcome, and the caller falls back to the password path. /// -/// [signInFn] and [serverClientId] exist for tests, which cannot reach the -/// platform channel the plugin uses. +/// [signInFn] and [serverClientId] exist for tests that bypass the plugin or +/// need another audience. Future googleIdToken({ Future Function()? signInFn, String serverClientId = kServerClientId, }) async { if (signInFn != null) return await signInFn(); if (serverClientId.isEmpty) { - // A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise - // fail inside the plugin with a far less obvious message. log( - 'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at ' - 'build time; falling back to the password path', + 'Google sign-in unavailable: no Web OAuth client id; falling back to ' + 'the password path', level: 900, ); return null; } - // The plugin reaches the OS account picker through a platform channel, - // which `flutter test` has no binding for -- the same reason main.dart and - // firebase_backend.dart's keystore adapters are excluded. Everything above - // this point is pure Dart and is covered. - // coverage:ignore-start - try { - await GoogleSignIn.instance.initialize(serverClientId: serverClientId); - if (!GoogleSignIn.instance.supportsAuthenticate()) { - // Web/desktop: sign-in must go through the rendered GIS button, which - // this app does not host yet. Fall back rather than throwing. - log( - 'Google sign-in is not available on this platform; falling back to ' - 'the password path', - level: 900, - ); - return null; - } - final account = await GoogleSignIn.instance.authenticate(); - return account.authentication.idToken; - } on GoogleSignInException catch (error, stackTrace) { - // Includes the user simply dismissing the picker, which is not an error - // worth surfacing -- but log it, because a *configuration* failure - // (unregistered SHA-1, wrong client id) arrives through the same path and - // is otherwise indistinguishable from "the user changed their mind". - log( - 'Google sign-in did not complete', - level: 900, - error: error, - stackTrace: stackTrace, - ); - return null; - } - // coverage:ignore-end + return await plugin.googleIdToken(serverClientId: serverClientId); } diff --git a/app/pubspec.lock b/app/pubspec.lock index f6c5d26..52095e6 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -443,54 +443,15 @@ packages: url: "https://github.com/kuhyx/utils" source: git version: "0.3.2" - google_identity_services_web: - dependency: transitive - description: - name: google_identity_services_web - sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" - url: "https://pub.dev" - source: hosted - version: "0.3.3+1" - google_sign_in: + google_account_token: dependency: "direct main" description: - name: google_sign_in - sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" - url: "https://pub.dev" - source: hosted - version: "7.2.0" - google_sign_in_android: - dependency: transitive - description: - name: google_sign_in_android - sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" - url: "https://pub.dev" - source: hosted - version: "7.2.16" - google_sign_in_ios: - dependency: transitive - description: - name: google_sign_in_ios - sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 - url: "https://pub.dev" - source: hosted - version: "6.3.0" - google_sign_in_platform_interface: - dependency: transitive - description: - name: google_sign_in_platform_interface - sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - google_sign_in_web: - dependency: transitive - description: - name: google_sign_in_web - sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 - url: "https://pub.dev" - source: hosted - version: "1.1.3" + path: google_account_token + ref: "google_account_token-v0.1.0" + resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1 + url: "https://github.com/kuhyx/utils" + source: git + version: "0.1.0" health: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 71ede90..9d6a180 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -35,7 +35,12 @@ dependencies: url: https://github.com/kuhyx/utils ref: github_device_auth-v0.3.2 path: github_device_auth - google_sign_in: 7.2.0 + # One-tap Google ID token via the framework AccountManager chooser (FOSS). + google_account_token: + git: + url: https://github.com/kuhyx/utils + ref: google_account_token-v0.1.0 + path: google_account_token health: 13.3.2 http: 1.6.0 idb_shim: 2.11.1 diff --git a/app/test/google_platform_test.dart b/app/test/google_platform_test.dart index 81a41b0..daae8b0 100644 --- a/app/test/google_platform_test.dart +++ b/app/test/google_platform_test.dart @@ -1,14 +1,17 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:diet_guard_app/services/google_sign_in_backend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; void main() { - test('reports whether the programmatic Google flow exists here', () { - // Android is the only platform shipping it. Asked as a platform question - // rather than of the plugin, because GoogleSignIn.supportsAuthenticate() - // itself throws UnimplementedError where no implementation is registered - // -- an Error, not an Exception, so it would escape an ordinary catch and - // take down the settings screen's build(). The test host is Linux, so - // this is false here and the password path stays visible. + tearDown(() => debugDefaultTargetPlatformOverride = null); + + test('the programmatic Google flow is Android only', () { + // The plugin's own gate (AccountManager exists only on Android), so + // desktop keeps the password path visible and never shows a button that + // cannot succeed. + debugDefaultTargetPlatformOverride = TargetPlatform.linux; expect(googleSignInSupported, isFalse); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect(googleSignInSupported, isTrue); }); } diff --git a/app/test/google_sign_in_backend_test.dart b/app/test/google_sign_in_backend_test.dart index a787916..50d140a 100644 --- a/app/test/google_sign_in_backend_test.dart +++ b/app/test/google_sign_in_backend_test.dart @@ -1,7 +1,22 @@ -import 'package:flutter_test/flutter_test.dart'; +import 'dart:io'; + import 'package:diet_guard_app/services/google_sign_in_backend.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_account_token/google_account_token.dart' + show kGoogleAccountTokenChannel; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null); + }); + group('googleIdToken', () { test('returns the token the sign-in function yields', () async { // The injected path: proves the closure is what feeds crdt_sync, and @@ -29,5 +44,32 @@ void main() { // be accepted here and then denied every read and write. expect(kSyncUid, 'OvA2REQyLIhAHOEjzwS1o877rgG3'); }); + + test('goes through the shared plugin channel by default', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + MethodCall? seen; + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (call) { + seen = call; + return Future.value('jwt'); + }); + expect(await googleIdToken(), 'jwt'); + expect(seen?.method, 'idToken'); + expect(seen?.arguments, {'serverClientId': kServerClientId}); + }); + + test('a cancelled chooser is null, not an error', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + messenger.setMockMethodCallHandler( + kGoogleAccountTokenChannel, + (_) => Future.value(), + ); + expect(await googleIdToken(), isNull); + }); + }); + + test('depends on the shared plugin, not google_sign_in', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + expect(pubspec, contains('google_account_token-v0.1.0')); + expect(pubspec, isNot(contains('google_sign_in:'))); }); }