From f2f2093e415af7a5c0cd8826253da2bb6cf424c2 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 5 Nov 2026 20:47:23 +0600 Subject: [PATCH] feat(mobile): passkey bridge for android (#32779) * feat(mobile): passkey bridge for android * fix(mobile): passkey plugin on the main engine only * register it in configureFlutterEngine and cancel its scope on detach * pass coroutine cancellation through instead of treating it as an error * check for Play services 23.08+ before Android 14 in isSupported * explain why NotAllowedError counts as a cancel * share one perform function between register and authenticate --- mobile/android/app/build.gradle | 5 + .../app/alextran/immich/MainActivity.kt | 2 + .../alextran/immich/passkey/PasskeyPlugin.kt | 110 ++++++++++++++++++ mobile/android/gradle/libs.versions.toml | 3 + mobile/pigeon/passkey_api.dart | 23 ++++ 5 files changed, 143 insertions(+) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/passkey/PasskeyPlugin.kt create mode 100644 mobile/pigeon/passkey_api.dart diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 4ab2d67e4..8f41dc9a3 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -43,6 +43,7 @@ android { versionCode flutter.versionCode versionName flutter.versionName resValue "string", "app_name", "Immich" + buildConfigField "boolean", "PASSKEYS", "true" } signingConfigs { @@ -96,6 +97,8 @@ dependencies { implementation libs.guava implementation libs.glide implementation libs.kotlinx.serialization.json + implementation libs.credentials + implementation libs.credentials.play.services.auth ksp libs.glide.ksp coreLibraryDesugaring libs.desugar.jdk.libs @@ -116,4 +119,6 @@ dependencies { // This is uncommented in F-Droid build script //f configurations.all { //f exclude group: 'com.google.android.gms' +//f exclude group: 'androidx.credentials', module: 'credentials-play-services-auth' //f } +//f android.defaultConfig.buildConfigField "boolean", "PASSKEYS", "false" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 97d9cd2c1..bd44425e5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -18,6 +18,7 @@ import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImagesImpl +import app.alextran.immich.passkey.PasskeyPlugin import app.alextran.immich.permission.PermissionApi import app.alextran.immich.permission.PermissionApiImpl import app.alextran.immich.sync.NativeSyncApi @@ -33,6 +34,7 @@ class MainActivity : FlutterFragmentActivity() { AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger) flutterEngine.plugins.add(app.alextran.immich.files.FileVaultPlugin()) registerPlugins(this, flutterEngine) + flutterEngine.plugins.add(PasskeyPlugin()) } override fun onNewIntent(intent: Intent) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/passkey/PasskeyPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/passkey/PasskeyPlugin.kt new file mode 100644 index 000000000..83f22713f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/passkey/PasskeyPlugin.kt @@ -0,0 +1,110 @@ +package app.alextran.immich.passkey + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.domerrors.NotAllowedError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException +import app.alextran.immich.BuildConfig +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +class PasskeyPlugin : FlutterPlugin, ActivityAware, PasskeyHostApi { + private lateinit var scope: CoroutineScope + private lateinit var context: Context + private var activity: Activity? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + scope = MainScope() + context = binding.applicationContext + PasskeyHostApi.setUp(binding.binaryMessenger, this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + PasskeyHostApi.setUp(binding.binaryMessenger, null) + scope.cancel() + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun isSupported(): Boolean = BuildConfig.PASSKEYS && when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> true + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> hasPlayServicesForPasskeys() + else -> false + } + + override fun register(options: String, callback: (Result) -> Unit) = perform(callback) { + val request = CreatePublicKeyCredentialRequest(options) + val response = CredentialManager.create(it).createCredential(it, request) as CreatePublicKeyCredentialResponse + response.registrationResponseJson + } + + override fun authenticate(options: String, callback: (Result) -> Unit) = perform(callback) { + val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(options))) + val credential = CredentialManager.create(it).getCredential(it, request).credential as PublicKeyCredential + credential.authenticationResponseJson + } + + private fun perform(callback: (Result) -> Unit, request: suspend (Activity) -> String) { + val currentActivity = activity ?: return callback(Result.failure(IllegalStateException("No activity"))) + scope.launch { + val result = try { + Result.success(request(currentActivity)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isCancel(e)) Result.success(null) else Result.failure(e) + } + callback(result) + } + } + + // Before Android 14 passkeys go through Google Play services, which androidx.credentials only uses from 230815045 on + @RequiresApi(Build.VERSION_CODES.P) + private fun hasPlayServicesForPasskeys(): Boolean = try { + val info = context.packageManager.getPackageInfo("com.google.android.gms", 0) + info.applicationInfo?.enabled == true && info.longVersionCode >= 230815045 + } catch (_: PackageManager.NameNotFoundException) { + false + } + + // WebAuthn uses NotAllowedError for a cancel, a timeout and other refusals without telling them apart, and Play + // services sends it when the user cancels on Android 9 to 13, so like the web client it counts as a cancel + private fun isCancel(e: Exception) = when (e) { + is CreateCredentialCancellationException, is GetCredentialCancellationException -> true + is CreatePublicKeyCredentialDomException -> e.domError is NotAllowedError + is GetPublicKeyCredentialDomException -> e.domError is NotAllowedError + else -> false + } +} diff --git a/mobile/android/gradle/libs.versions.toml b/mobile/android/gradle/libs.versions.toml index fa4ba34a2..63d1a0201 100644 --- a/mobile/android/gradle/libs.versions.toml +++ b/mobile/android/gradle/libs.versions.toml @@ -19,6 +19,7 @@ compose-ui = "1.1.1" material3 = "1.2.1" lifecycle = "2.6.2" material = "1.12.0" +credentials = "1.6.0" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -41,6 +42,8 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } material = { module = "com.google.android.material:material", version.ref = "material" } +credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" } +credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/mobile/pigeon/passkey_api.dart b/mobile/pigeon/passkey_api.dart new file mode 100644 index 000000000..a327ce580 --- /dev/null +++ b/mobile/pigeon/passkey_api.dart @@ -0,0 +1,23 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/passkey_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/passkey/Passkey.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.passkey'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class PasskeyHostApi { + bool isSupported(); + + // Register and authenticate using the server provided WebAuthn options JSON. Returns the resulting credential + // as JSON, or null if the user cancels + @async + String? register(String options); + + @async + String? authenticate(String options); +}