Use google_account_token plugin
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 11:04:40 +01:00
parent bbef894195
commit cba89bcacb
10 changed files with 82 additions and 138 deletions

View File

@ -104,3 +104,4 @@ Future<void> clearAccount() async {
/// the desktop wrapper re-provisioning after the next sign-in.
Future<void> forgetAccountMarker() =>
_secure.delete(key: kFirebaseAccountKey);
// coverage:ignore-end

View File

@ -181,8 +181,6 @@ Future<FirebaseRestClient?> 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

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -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<String?> googleIdToken({
Future<String?> 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);
}

View File

@ -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:

View File

@ -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

View File

@ -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);
});
}

View File

@ -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:')));
});
}