From db70ba204d124c6eadf9b8808cd3d3b3e70363d1 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 10:32:58 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ --- .../workflows/google-account-token-tests.yml | 36 +++++ README.md | 15 ++ google_account_token/.gitignore | 7 + google_account_token/.pre-commit-config.yaml | 61 +++++++ google_account_token/README.md | 59 +++++++ google_account_token/analysis_options.yaml | 38 +++++ google_account_token/android/build.gradle.kts | 53 ++++++ .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 1 + .../GoogleAccountTokenPlugin.kt | 152 ++++++++++++++++++ .../lib/google_account_token.dart | 84 ++++++++++ google_account_token/pubspec.yaml | 27 ++++ .../scripts/check_coverage.sh | 32 ++++ .../test/google_account_token_test.dart | 85 ++++++++++ 14 files changed, 651 insertions(+) create mode 100644 .github/workflows/google-account-token-tests.yml create mode 100644 google_account_token/.gitignore create mode 100644 google_account_token/.pre-commit-config.yaml create mode 100644 google_account_token/README.md create mode 100644 google_account_token/analysis_options.yaml create mode 100644 google_account_token/android/build.gradle.kts create mode 100644 google_account_token/android/settings.gradle.kts create mode 100644 google_account_token/android/src/main/AndroidManifest.xml create mode 100644 google_account_token/android/src/main/kotlin/dev/kuhy/google_account_token/GoogleAccountTokenPlugin.kt create mode 100644 google_account_token/lib/google_account_token.dart create mode 100644 google_account_token/pubspec.yaml create mode 100755 google_account_token/scripts/check_coverage.sh create mode 100644 google_account_token/test/google_account_token_test.dart diff --git a/.github/workflows/google-account-token-tests.yml b/.github/workflows/google-account-token-tests.yml new file mode 100644 index 0000000..3abb008 --- /dev/null +++ b/.github/workflows/google-account-token-tests.yml @@ -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 diff --git a/README.md b/README.md index 7691ea1..64196da 100644 --- a/README.md +++ b/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 diff --git a/google_account_token/.gitignore b/google_account_token/.gitignore new file mode 100644 index 0000000..fd5818f --- /dev/null +++ b/google_account_token/.gitignore @@ -0,0 +1,7 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ +*.iml +pubspec_overrides.yaml diff --git a/google_account_token/.pre-commit-config.yaml b/google_account_token/.pre-commit-config.yaml new file mode 100644 index 0000000..0c5d19b --- /dev/null +++ b/google_account_token/.pre-commit-config.yaml @@ -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] diff --git a/google_account_token/README.md b/google_account_token/README.md new file mode 100644 index 0000000..9ea3867 --- /dev/null +++ b/google_account_token/README.md @@ -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:")`. 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`). diff --git a/google_account_token/analysis_options.yaml b/google_account_token/analysis_options.yaml new file mode 100644 index 0000000..286e24c --- /dev/null +++ b/google_account_token/analysis_options.yaml @@ -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 diff --git a/google_account_token/android/build.gradle.kts b/google_account_token/android/build.gradle.kts new file mode 100644 index 0000000..6fbcd54 --- /dev/null +++ b/google_account_token/android/build.gradle.kts @@ -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 + } +} diff --git a/google_account_token/android/settings.gradle.kts b/google_account_token/android/settings.gradle.kts new file mode 100644 index 0000000..a11f88b --- /dev/null +++ b/google_account_token/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "google_account_token" diff --git a/google_account_token/android/src/main/AndroidManifest.xml b/google_account_token/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/google_account_token/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/google_account_token/android/src/main/kotlin/dev/kuhy/google_account_token/GoogleAccountTokenPlugin.kt b/google_account_token/android/src/main/kotlin/dev/kuhy/google_account_token/GoogleAccountTokenPlugin.kt new file mode 100644 index 0000000..b67c4b2 --- /dev/null +++ b/google_account_token/android/src/main/kotlin/dev/kuhy/google_account_token/GoogleAccountTokenPlugin.kt @@ -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("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("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) { + 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" + } +} diff --git a/google_account_token/lib/google_account_token.dart b/google_account_token/lib/google_account_token.dart new file mode 100644 index 0000000..8da9696 --- /dev/null +++ b/google_account_token/lib/google_account_token.dart @@ -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:")`. 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 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('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; + } +} diff --git a/google_account_token/pubspec.yaml b/google_account_token/pubspec.yaml new file mode 100644 index 0000000..82a5180 --- /dev/null +++ b/google_account_token/pubspec.yaml @@ -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 diff --git a/google_account_token/scripts/check_coverage.sh b/google_account_token/scripts/check_coverage.sh new file mode 100755 index 0000000..219fa5f --- /dev/null +++ b/google_account_token/scripts/check_coverage.sh @@ -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 +# ============================================================================ + +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 "$@" diff --git a/google_account_token/test/google_account_token_test.dart b/google_account_token/test/google_account_token_test.dart new file mode 100644 index 0000000..2b2a391 --- /dev/null +++ b/google_account_token/test/google_account_token_test.dart @@ -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 = []; + + void fakeChannel(Future 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); + }); + }); +}