Add google_account_token plugin

Android AccountManager ID token for Firebase signInWithIdp, extracted from todo so the sync apps stop copying the Kotlin.

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:32:58 +01:00
parent 5b36e84793
commit db70ba204d
14 changed files with 651 additions and 0 deletions

View File

@ -0,0 +1,36 @@
name: google_account_token tests
on:
push:
branches: [main]
paths: ['google_account_token/**']
pull_request:
branches: [main]
paths: ['google_account_token/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: google_account_token
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

View File

@ -42,6 +42,21 @@ dependencies:
path: sync_settings_ui
```
## google_account_token/
Flutter plugin (Android only; other platforms return null): one-tap Google ID
token via `AccountManager`, replacing `google_sign_in` in the sync apps.
Consume via:
```yaml
dependencies:
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
```
## crdt-sync/
Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same

7
google_account_token/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml
pubspec_overrides.yaml

View File

@ -0,0 +1,61 @@
# Pre-commit Configuration for google_account_token
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Run: pre-commit run --all-files
# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is
# one level up. pre-commit always runs hooks from the git root, so every path
# below is written relative to THAT, not to this directory, and the top-level
# `files` key keeps these hooks off the sibling subprojects. CI invokes this
# config exactly the same way:
# pre-commit run --config google_account_token/.pre-commit-config.yaml --all-files
files: ^google_account_token/
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
- repo: local
hooks:
- id: file-length-cap
name: file length <= 250 lines
entry: bash scripts/check_file_length.sh
language: system
pass_filenames: true
- id: subproject-scope
name: subproject scope regex still matches
entry: scripts/check_subproject_scope.sh google_account_token
language: script
# always_run so the top-level `files` filter cannot skip the one hook
# whose job is to notice that filter has stopped matching anything.
always_run: true
pass_filenames: false
- id: dart-format
name: dart format --set-exit-if-changed
entry: bash -c 'cd google_account_token && dart format --set-exit-if-changed lib test'
language: system
pass_filenames: false
- id: flutter-analyze
name: flutter analyze
entry: bash -c 'cd google_account_token && flutter analyze'
language: system
pass_filenames: false
- id: flutter-test-coverage
name: flutter test --coverage with 100% line-coverage gate
entry: >-
bash -c 'cd google_account_token && flutter test --coverage &&
scripts/check_coverage.sh coverage/lcov.info 100'
language: system
pass_filenames: false
require_serial: true
stages: [pre-push]

View File

@ -0,0 +1,59 @@
# google_account_token
Android one-tap Google ID token via `AccountManager`, owned once for every
sync app. The system account chooser, then
`getAuthToken(account, "audience:server:client_id:<WEB client id>")`. The
result is a Google ID token for Firebase `signInWithIdp`: one dialog, no
browser, no `google_sign_in`, no Play Services SDK in the APK. Verified on a
Pixel: aud = the Web client, Firebase accepted it.
Other platforms (web, Linux desktop, iOS) return `null`, so the caller falls
back to its password path.
## Install
```yaml
dependencies:
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
```
Android plugins auto-register: the app's `MainActivity` needs no code.
## API
```dart
import 'package:google_account_token/google_account_token.dart';
if (googleAccountTokenSupported) {
final idToken = await googleIdToken(serverClientId: kWebClientId);
// null = cancelled / unsupported / failed (logged); use the password path
}
```
- `googleIdToken({required String serverClientId, String? accountHint})`
returns the ID token or `null`. `serverClientId` must be the **Web** OAuth
client id (an Android client id gives `audience mismatch` at Firebase).
`accountHint` (an email) only preselects a row; the chooser always shows.
- `googleAccountTokenSupported` is true only on Android.
- There is no sign-out: the plugin holds no session. Each call is a fresh
chooser plus `getAuthToken`; forgetting the account is the app's own
stored credentials.
Failures are logged (level 900) and returned as `null`; a misconfigured
client id looks the same as "user cancelled" except in the log.
## Development
```bash
flutter test --coverage && scripts/check_coverage.sh coverage/lcov.info 100
flutter analyze
```
The Kotlin side (`android/.../GoogleAccountTokenPlugin.kt`) has no JVM unit
test: utils has no Kotlin test convention and Android unit tests hit the
MockableJarTransform / android-36 / JDK 21 breakage. It was compile-checked
through a throwaway app (`:google_account_token:compileDebugKotlin`).

View File

@ -0,0 +1,38 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -0,0 +1,53 @@
group = "dev.kuhy.google_account_token"
version = "0.1.0"
buildscript {
val kotlinVersion = "2.4.0"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:9.1.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins {
id("com.android.library")
}
android {
namespace = "dev.kuhy.google_account_token"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
}
defaultConfig {
minSdk = 24
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}

View File

@ -0,0 +1 @@
rootProject.name = "google_account_token"

View File

@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@ -0,0 +1,152 @@
package dev.kuhy.google_account_token
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.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
/**
* System account chooser, then a Google ID token from AccountManager.
* No browser and no Google client library. The Dart side is
* `lib/google_account_token.dart`; apps register nothing by hand.
*/
class GoogleAccountTokenPlugin :
FlutterPlugin,
ActivityAware,
MethodChannel.MethodCallHandler,
PluginRegistry.ActivityResultListener {
private var channel: MethodChannel? = null
private var binding: ActivityPluginBinding? = null
private var pending: MethodChannel.Result? = null
private var audience: String = ""
override fun onAttachedToEngine(flutter: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutter.binaryMessenger, CHANNEL).also {
it.setMethodCallHandler(this)
}
}
override fun onDetachedFromEngine(flutter: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
channel = null
}
override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) {
binding = activityBinding
activityBinding.addActivityResultListener(this)
}
// A rotation recreates the Activity. The chooser result is re-delivered
// to the new one, so a pending request is kept across the swap.
override fun onDetachedFromActivityForConfigChanges() = detach()
override fun onReattachedToActivityForConfigChanges(
activityBinding: ActivityPluginBinding,
) = onAttachedToActivity(activityBinding)
override fun onDetachedFromActivity() {
detach()
finish { it.error("no_activity", "activity went away", null) }
}
private fun detach() {
binding?.removeActivityResultListener(this)
binding = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "idToken") {
result.notImplemented()
return
}
val activity = binding?.activity
val clientId = call.argument<String>("serverClientId")
when {
activity == null ->
result.error("no_activity", "no foreground activity", null)
clientId.isNullOrEmpty() ->
result.error("bad_args", "serverClientId is required", null)
pending != null ->
result.error("busy", "sign-in already running", null)
else -> {
audience = clientId
pending = result
val hint = call.argument<String>("accountHint")
?.takeIf { it.isNotEmpty() }
?.let { Account(it, ACCOUNT_TYPE) }
// First argument only preselects `hint`; the chooser always shows.
val chooser = AccountManager.newChooseAccountIntent(
hint, null, arrayOf(ACCOUNT_TYPE), null, null, null, null,
)
activity.startActivityForResult(chooser, REQUEST_CHOOSE)
}
}
}
override 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 activity = binding?.activity
if (activity == null) {
finish { it.error("no_activity", "activity went away", null) }
return
}
// Activity argument: the framework launches any consent screen itself.
AccountManager.get(activity).getAuthToken(
account, tokenType(audience), 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/google_account_token"
const val ACCOUNT_TYPE = "com.google"
private const val REQUEST_CHOOSE = 7421
/** The AccountManager token type that mints an ID token for [clientId]. */
fun tokenType(clientId: String): String =
"audience:server:client_id:$clientId"
}
}

View File

@ -0,0 +1,84 @@
/// One-tap Google ID token on Android, owned once for every app.
///
/// The system account chooser (`AccountManager.newChooseAccountIntent`), then
/// `getAuthToken(account, "audience:server:client_id:<web client id>")`. The
/// result is a Google ID token Firebase `signInWithIdp` accepts: one dialog,
/// no browser, no Google client library, no Play Services SDK in the APK.
///
/// Why a plugin: the Kotlin used to be copied into each app, and per-repo
/// OAuth code drifts. Apps depend on this package instead and keep only their
/// own constants (the pinned uid and the Web client id).
///
/// Other platforms (web, Linux desktop, iOS) have no AccountManager.
/// [googleIdToken] returns null there, so the caller falls back to its
/// password path exactly as it does after the user dismisses the chooser.
library;
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// The channel `GoogleAccountTokenPlugin.kt` listens on.
const kGoogleAccountTokenChannel = MethodChannel(
'dev.kuhy/google_account_token',
);
/// Whether this platform can produce a token at all.
///
/// True only on Android. Use it to hide a "Sign in with Google" button that
/// could never succeed, rather than showing one that always reports
/// "cancelled".
bool get googleAccountTokenSupported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
/// A Google ID token for the signed-in account, or null.
///
/// Null rather than throwing for every ordinary outcome: the user dismissed
/// the chooser, the platform is unsupported, [serverClientId] is empty, the
/// native side is not registered, or the token request failed. Failures are
/// logged (level 900) because a configuration error (unregistered SHA-1,
/// wrong client id) arrives through the same path as "the user changed their
/// mind" and is otherwise indistinguishable.
///
/// [serverClientId] must be the project's **Web** OAuth client id. An Android
/// client id yields a token Firebase rejects with `audience mismatch`.
///
/// [accountHint] is an account name (an email) pre-selected in the chooser.
/// The chooser is always shown, so this saves a tap but never skips consent.
Future<String?> googleIdToken({
required String serverClientId,
String? accountHint,
}) async {
if (!googleAccountTokenSupported) return null;
if (serverClientId.isEmpty) {
log(
'Google sign-in unavailable: no server client id; '
'falling back to the password path',
level: 900,
);
return null;
}
try {
return await kGoogleAccountTokenChannel.invokeMethod<String>('idToken', {
'serverClientId': serverClientId,
'accountHint': ?accountHint,
});
} on PlatformException catch (error, stackTrace) {
log(
'AccountManager token failed',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
} on MissingPluginException catch (error, stackTrace) {
log(
'google_account_token native side is not registered',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
}

View File

@ -0,0 +1,27 @@
name: google_account_token
description: >-
Android one-tap Google ID token via AccountManager: the system account
chooser, then an `audience:server:client_id:` token for Firebase
signInWithIdp. No browser, no Google client library. Other platforms
return null so callers fall back to their password path.
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.12.2
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^11.0.0
flutter:
plugin:
platforms:
android:
package: dev.kuhy.google_account_token
pluginClass: GoogleAccountTokenPlugin

View File

@ -0,0 +1,32 @@
#!/bin/bash
# ============================================================================
# Fail if lcov line coverage drops below a threshold.
#
# Thin delegate to the shared gate in ~/src/utils/scripts, which owns the parsing
# and the threshold comparison. This file used to be a byte-identical copy of
# that logic; three such copies existed, which is how one package's idea of
# "covered" drifts from every other package's.
#
# Usage: check_coverage.sh <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
# Repo-relative, resolved from this script's own location: an absolute
# /home/... path exits 127 on an Actions runner.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh"
main() {
if [[ ! -f "$SHARED_GATE" ]]; then
echo "Error: shared coverage gate not found at $SHARED_GATE" >&2
echo " It lives in the utils repo at scripts/check_coverage.sh." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,85 @@
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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final calls = <MethodCall>[];
void fakeChannel(Future<Object?> Function(MethodCall) handler) {
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (call) {
calls.add(call);
return handler(call);
});
}
tearDown(() {
calls.clear();
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
});
group('googleIdToken on Android', () {
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('asks the platform for a token for the web client', () async {
fakeChannel((_) async => 'jwt');
final token = await googleIdToken(serverClientId: 'web-id');
expect(token, 'jwt');
expect(calls.single.method, 'idToken');
expect(calls.single.arguments, {'serverClientId': 'web-id'});
});
test('passes the account hint when given', () async {
fakeChannel((_) async => 'jwt');
await googleIdToken(serverClientId: 'web-id', accountHint: 'a@b.c');
expect(calls.single.arguments, {
'serverClientId': 'web-id',
'accountHint': 'a@b.c',
});
});
test('a cancelled chooser is null, not an error', () async {
fakeChannel((_) async => null);
expect(await googleIdToken(serverClientId: 'x'), isNull);
});
test('a platform failure is null, not a throw', () async {
fakeChannel((_) async => throw PlatformException(code: 'auth_failed'));
expect(await googleIdToken(serverClientId: 'x'), isNull);
});
test('a missing native plugin is null, not a throw', () async {
expect(await googleIdToken(serverClientId: 'x'), isNull);
});
test('an empty client id is null and never reaches the platform', () async {
fakeChannel((_) async => 'jwt');
expect(await googleIdToken(serverClientId: ''), isNull);
expect(calls, isEmpty);
});
});
group('off Android', () {
for (final platform in [TargetPlatform.linux, TargetPlatform.iOS]) {
test(
'$platform is unsupported and returns null without a call',
() async {
debugDefaultTargetPlatformOverride = platform;
fakeChannel((_) async => 'jwt');
expect(googleAccountTokenSupported, isFalse);
expect(await googleIdToken(serverClientId: 'x'), isNull);
expect(calls, isEmpty);
},
);
}
test('Android is supported', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(googleAccountTokenSupported, isTrue);
});
});
}