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
This commit is contained in:
Santo Shakil authored and Codex committed 2026-11-05 20:35:01 +03:00
1 parent 99cda00260
commit f2f2093e41
5 files changed
+143

No files matched your search

+5
View File
@@ -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"
@@ -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) {
@@ -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<String?>) -> 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<String?>) -> 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<String?>) -> 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
}
}
+3
View File
@@ -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" }
+23
View File
@@ -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);
}