diff --git a/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt b/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt new file mode 100644 index 0000000..3cf1240 --- /dev/null +++ b/android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt @@ -0,0 +1,83 @@ +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 d17addb..c42c0a7 100644 --- a/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/kuhy/todo/MainActivity.kt @@ -1,5 +1,27 @@ 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() +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) + } +} diff --git a/lib/sync/account_token.dart b/lib/sync/account_token.dart new file mode 100644 index 0000000..9e357e2 --- /dev/null +++ b/lib/sync/account_token.dart @@ -0,0 +1,27 @@ +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_platform_io.dart b/lib/sync/google_platform_io.dart index 7fa7a81..036e719 100644 --- a/lib/sync/google_platform_io.dart +++ b/lib/sync/google_platform_io.dart @@ -1,6 +1,6 @@ /// Whether the programmatic Google sign-in flow exists on this platform. /// -/// The io half: Android has the Credential Manager flow; Linux desktop does +/// The io half: Android has the AccountManager flow; Linux desktop does /// not, and this app's desktop build is the web one anyway. library; diff --git a/lib/sync/google_sign_in_backend.dart b/lib/sync/google_sign_in_backend.dart index f1f9e65..a7bfa2f 100644 --- a/lib/sync/google_sign_in_backend.dart +++ b/lib/sync/google_sign_in_backend.dart @@ -2,9 +2,9 @@ /// /// 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 channel lives in the app, 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:google_sign_in/google_sign_in.dart'; +import 'package:todo/sync/account_token.dart'; 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 the plugin uses. +/// platform channel `account_token.dart` uses. Future googleIdToken({ Future Function()? signInFn, String serverClientId = kServerClientId, @@ -78,7 +78,7 @@ Future googleIdToken({ 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. + // fail on the platform side 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', @@ -86,37 +86,5 @@ Future googleIdToken({ ); 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 accountManagerIdToken(serverClientId: serverClientId); } diff --git a/pubspec.lock b/pubspec.lock index d33d61d..bac6a63 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -403,54 +403,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" - 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: - 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" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2324ed5..efc89d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,10 +61,6 @@ dependencies: url: https://github.com/kuhyx/utils ref: github_device_auth-v0.3.2 path: github_device_auth - # One-tap sign-in for the sync account. Lives only in the app: `crdt_sync` - # stays pure Dart so it keeps working on Linux desktop and headless, where - # this plugin does not exist. See lib/sync/google_sign_in_backend.dart. - google_sign_in: 7.2.0 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 new file mode 100644 index 0000000..0d5bd96 --- /dev/null +++ b/test/account_token_test.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/sync/account_token.dart'; +import 'package:todo/sync/google_sign_in_backend.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + MethodCall? seen; + + void fakeChannel(Future Function(MethodCall) handler) { + messenger.setMockMethodCallHandler(kAccountTokenChannel, (call) { + seen = call; + return handler(call); + }); + } + + tearDown(() { + seen = null; + messenger.setMockMethodCallHandler(kAccountTokenChannel, 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'); + expect(seen?.method, 'idToken'); + expect(seen?.arguments, {'serverClientId': 'web-id'}); + }); + + 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); + }); + }); + + 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', () { + final pubspec = File('pubspec.yaml').readAsStringSync(); + expect(pubspec, isNot(contains('google_sign_in'))); + }); +}