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
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 10:42:42 +01:00
parent d09978a338
commit 860d789864
7 changed files with 44 additions and 162 deletions

View File

@ -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<String>("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<Bundle>) {
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
}
}

View File

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

View File

@ -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<String?> accountManagerIdToken({
required String serverClientId,
MethodChannel channel = kAccountTokenChannel,
}) async {
try {
return await channel.invokeMethod<String>('idToken', {
'serverClientId': serverClientId,
});
} on PlatformException catch (error, stackTrace) {
log(
'AccountManager token failed',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
}

View File

@ -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<String?> googleIdToken({
Future<String?> Function()? signInFn,
String serverClientId = kServerClientId,
@ -86,5 +86,5 @@ Future<String?> googleIdToken({
);
return null;
}
return await accountManagerIdToken(serverClientId: serverClientId);
return await plugin.googleIdToken(serverClientId: serverClientId);
}

View File

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

View File

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

View File

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