mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Sign in via AccountManager, drop google_sign_in
System account chooser plus getAuthToken with an audience:server:client_id token; no Google client library. Pins bumped to newest stable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
This commit is contained in:
parent
d14b7e03e3
commit
0ff3d6f95d
@ -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<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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
27
lib/sync/account_token.dart
Normal file
27
lib/sync/account_token.dart
Normal file
@ -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<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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<String?> googleIdToken({
|
||||
Future<String?> Function()? signInFn,
|
||||
String serverClientId = kServerClientId,
|
||||
@ -78,7 +78,7 @@ Future<String?> 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<String?> 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);
|
||||
}
|
||||
|
||||
48
pubspec.lock
48
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:
|
||||
|
||||
@ -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
|
||||
|
||||
58
test/account_token_test.dart
Normal file
58
test/account_token_test.dart
Normal file
@ -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<Object?> 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')));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user