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:
1 parent
99cda00260
commit
f2f2093e41
5 files changed
+143
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user