From 860d78986449bcac1417cdbdc7e9efc90ea84fdc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 10:42:42 +0100 Subject: [PATCH] Use google_account_token plugin Replace the app-local AccountManager channel with the shared plugin (google_account_token-v0.1.0); kServerClientId stays put. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ --- .../dev/kuhy/todo/AccountTokenChannel.kt | 83 ------------------- .../main/kotlin/dev/kuhy/todo/MainActivity.kt | 24 +----- lib/sync/account_token.dart | 27 ------ lib/sync/google_sign_in_backend.dart | 10 +-- pubspec.lock | 9 ++ pubspec.yaml | 7 ++ test/account_token_test.dart | 46 +++++----- 7 files changed, 44 insertions(+), 162 deletions(-) delete mode 100644 android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt delete mode 100644 lib/sync/account_token.dart diff --git a/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt b/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt deleted file mode 100644 index 3cf1240..0000000 --- a/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt +++ /dev/null @@ -1,83 +0,0 @@ -package dev.kuhy.todo - -import android.accounts.Account -import android.accounts.AccountManager -import android.accounts.AccountManagerFuture -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -/** System account chooser, then an ID token from AccountManager. No browser. */ -class AccountTokenChannel(private val activity: Activity) : - MethodChannel.MethodCallHandler { - - private var pending: MethodChannel.Result? = null - private var audience: String = "" - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - if (call.method != "idToken") { - result.notImplemented() - return - } - if (pending != null) { - result.error("busy", "sign-in already running", null) - return - } - audience = call.argument("serverClientId") ?: "" - pending = result - val chooser = AccountManager.newChooseAccountIntent( - null, null, arrayOf("com.google"), null, null, null, null, - ) - activity.startActivityForResult(chooser, REQUEST_CHOOSE) - } - - fun onActivityResult(request: Int, code: Int, data: Intent?): Boolean { - if (request != REQUEST_CHOOSE) return false - val name = data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) - val type = data?.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE) - if (code != Activity.RESULT_OK || name == null || type == null) { - finish { it.success(null) } - } else { - fetchToken(Account(name, type)) - } - return true - } - - private fun fetchToken(account: Account) { - val manager = AccountManager.get(activity) - val type = "audience:server:client_id:$audience" - // Activity argument: the framework launches any consent screen itself. - manager.getAuthToken(account, type, Bundle(), activity, { future -> - deliver(future) - }, Handler(Looper.getMainLooper())) - } - - private fun deliver(future: AccountManagerFuture) { - try { - val bundle = future.result - val token = bundle.getString(AccountManager.KEY_AUTHTOKEN) - if (token != null) { - finish { it.success(token) } - } else { - finish { it.error("no_token", "keys: ${bundle.keySet()}", null) } - } - } catch (e: Exception) { - finish { it.error("auth_failed", e.toString(), null) } - } - } - - private fun finish(block: (MethodChannel.Result) -> Unit) { - val result = pending ?: return - pending = null - block(result) - } - - companion object { - const val CHANNEL = "dev.kuhy.todo/account_token" - private const val REQUEST_CHOOSE = 7421 - } -} diff --git a/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt b/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt index c42c0a7..d17addb 100644 --- a/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt @@ -1,27 +1,5 @@ package dev.kuhy.todo -import android.content.Intent import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() { - private var tokens: AccountTokenChannel? = null - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - val channel = AccountTokenChannel(this) - tokens = channel - MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - AccountTokenChannel.CHANNEL, - ).setMethodCallHandler(channel) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(request: Int, code: Int, data: Intent?) { - if (tokens?.onActivityResult(request, code, data) == true) return - @Suppress("DEPRECATION") - super.onActivityResult(request, code, data) - } -} +class MainActivity : FlutterActivity() diff --git a/lib/sync/account_token.dart b/lib/sync/account_token.dart deleted file mode 100644 index 9e357e2..0000000 --- a/lib/sync/account_token.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/services.dart'; - -/// Talks to `AccountTokenChannel.kt`: system account chooser, then -/// AccountManager's `audience:server:client_id:` token. No browser. -const kAccountTokenChannel = MethodChannel('dev.kuhy.todo/account_token'); - -/// A Google ID token for [serverClientId], or null on cancel or failure. -Future accountManagerIdToken({ - required String serverClientId, - MethodChannel channel = kAccountTokenChannel, -}) async { - try { - return await channel.invokeMethod('idToken', { - 'serverClientId': serverClientId, - }); - } on PlatformException catch (error, stackTrace) { - log( - 'AccountManager token failed', - level: 900, - error: error, - stackTrace: stackTrace, - ); - return null; - } -} diff --git a/lib/sync/google_sign_in_backend.dart b/lib/sync/google_sign_in_backend.dart index a7bfa2f..d122e22 100644 --- a/lib/sync/google_sign_in_backend.dart +++ b/lib/sync/google_sign_in_backend.dart @@ -3,8 +3,8 @@ /// 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 Android's AccountManager does not exist. So -/// the platform channel lives in the app, and the library only ever receives -/// a token string through a closure. +/// the platform channel lives in the google_account_token plugin, 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,7 +14,7 @@ library; import 'dart:developer'; -import 'package:todo/sync/account_token.dart'; +import 'package:google_account_token/google_account_token.dart' as plugin; import 'package:todo/sync/google_platform.dart' as platform_gate; /// The account the security rules pin. @@ -70,7 +70,7 @@ bool get googleSignInSupported => /// an ordinary outcome, and the caller falls back to the password path. /// /// [signInFn] and [serverClientId] exist for tests, which cannot reach the -/// platform channel `account_token.dart` uses. +/// platform channel the plugin uses. Future googleIdToken({ Future Function()? signInFn, String serverClientId = kServerClientId, @@ -86,5 +86,5 @@ Future googleIdToken({ ); return null; } - return await accountManagerIdToken(serverClientId: serverClientId); + return await plugin.googleIdToken(serverClientId: serverClientId); } diff --git a/pubspec.lock b/pubspec.lock index bac6a63..40002fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -403,6 +403,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_account_token: + dependency: "direct main" + description: + 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" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 96e6e24..3bf1b6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,13 @@ dependencies: url: https://github.com/kuhyx/utils ref: github_device_auth-v0.3.2 path: github_device_auth + # One-tap Google ID token via the framework AccountManager chooser (FOSS, + # no Play Services sign-in SDK). Used by the sync backend in lib/sync/. + google_account_token: + git: + url: https://github.com/kuhyx/utils + ref: google_account_token-v0.1.0 + path: google_account_token http: 1.6.0 idb_shim: 2.11.1 # Re-encodes attached images (resize, EXIF strip) in pure Dart, so the diff --git a/test/account_token_test.dart b/test/account_token_test.dart index 0d5bd96..ce33745 100644 --- a/test/account_token_test.dart +++ b/test/account_token_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:todo/sync/account_token.dart'; +import 'package:google_account_token/google_account_token.dart' + show kGoogleAccountTokenChannel; import 'package:todo/sync/google_sign_in_backend.dart'; void main() { @@ -12,47 +14,43 @@ void main() { MethodCall? seen; void fakeChannel(Future Function(MethodCall) handler) { - messenger.setMockMethodCallHandler(kAccountTokenChannel, (call) { + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (call) { seen = call; return handler(call); }); } + setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android); + tearDown(() { + debugDefaultTargetPlatformOverride = null; seen = null; - messenger.setMockMethodCallHandler(kAccountTokenChannel, null); + messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null); }); - group('accountManagerIdToken', () { - test('asks the platform for a token for the web client', () async { - fakeChannel((_) async => 'jwt'); - final token = await accountManagerIdToken(serverClientId: 'web-id'); - expect(token, 'jwt'); + group('googleIdToken', () { + test('goes through the shared plugin channel by default', () async { + fakeChannel((_) async => 'jwt-2'); + expect(await googleIdToken(), 'jwt-2'); expect(seen?.method, 'idToken'); - expect(seen?.arguments, {'serverClientId': 'web-id'}); + expect(seen?.arguments, {'serverClientId': kServerClientId}); }); test('a cancelled chooser is null, not an error', () async { fakeChannel((_) async => null); - expect(await accountManagerIdToken(serverClientId: 'x'), isNull); - }); - - test('a platform failure is null, not a throw', () async { - fakeChannel((_) async => throw PlatformException(code: 'auth_failed')); - expect(await accountManagerIdToken(serverClientId: 'x'), isNull); + expect(await googleIdToken(), isNull); }); }); - group('googleIdToken', () { - test('goes through the AccountManager channel by default', () async { - fakeChannel((_) async => 'jwt-2'); - expect(await googleIdToken(), 'jwt-2'); - expect(seen?.arguments, {'serverClientId': kServerClientId}); - }); - }); - - test('no proprietary sign-in dependency is declared', () { + test('sign-in comes from the shared plugin, not a local copy', () { final pubspec = File('pubspec.yaml').readAsStringSync(); + expect(pubspec, contains('google_account_token-v0.1.0')); expect(pubspec, isNot(contains('google_sign_in'))); + expect( + File('android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt') + .existsSync(), + isFalse, + ); + expect(File('lib/sync/account_token.dart').existsSync(), isFalse); }); }