mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 16:48:54 +01:00
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:
parent
5b36e84793
commit
db70ba204d
36
.github/workflows/google-account-token-tests.yml
vendored
Normal file
36
.github/workflows/google-account-token-tests.yml
vendored
Normal 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
|
||||
15
README.md
15
README.md
@ -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
7
google_account_token/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
coverage/
|
||||
*.iml
|
||||
pubspec_overrides.yaml
|
||||
61
google_account_token/.pre-commit-config.yaml
Normal file
61
google_account_token/.pre-commit-config.yaml
Normal 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]
|
||||
59
google_account_token/README.md
Normal file
59
google_account_token/README.md
Normal 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`).
|
||||
38
google_account_token/analysis_options.yaml
Normal file
38
google_account_token/analysis_options.yaml
Normal 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
|
||||
53
google_account_token/android/build.gradle.kts
Normal file
53
google_account_token/android/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
1
google_account_token/android/settings.gradle.kts
Normal file
1
google_account_token/android/settings.gradle.kts
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = "google_account_token"
|
||||
@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
84
google_account_token/lib/google_account_token.dart
Normal file
84
google_account_token/lib/google_account_token.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
27
google_account_token/pubspec.yaml
Normal file
27
google_account_token/pubspec.yaml
Normal 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
|
||||
32
google_account_token/scripts/check_coverage.sh
Executable file
32
google_account_token/scripts/check_coverage.sh
Executable 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 "$@"
|
||||
85
google_account_token/test/google_account_token_test.dart
Normal file
85
google_account_token/test/google_account_token_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user