Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move reading/writing into background thread #64

Merged
merged 3 commits into from
Jun 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 4.1.2

* Android: Move File I/O and encryption to background thread. (Previously used UI Thread)
https://github.com/authpass/biometric_storage/pull/64

## 4.1.1

* Fix building on all platforms, add github actions to test building.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ class BiometricStorageFile(
if (useCipher != null && fileV2.exists()) {
return try {
val bytes = fileV2.readBytes()
logger.debug { "read ${bytes.size}" }
cryptographyManager.decryptData(bytes, useCipher)
} catch (ex: IOException) {
logger.error(ex) { "Error while writing encrypted file $fileV2" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import android.content.Context
import android.os.*
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.UserNotAuthenticatedException
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.biometric.*
import androidx.biometric.BiometricManager.Authenticators.*
import androidx.fragment.app.FragmentActivity
Expand Down Expand Up @@ -55,6 +58,7 @@ enum class AuthenticationError(vararg val code: Int) {
Timeout(BiometricPrompt.ERROR_TIMEOUT),
UserCanceled(BiometricPrompt.ERROR_USER_CANCELED, BiometricPrompt.ERROR_NEGATIVE_BUTTON),
Unknown(-1),

/** Authentication valid, but unknown */
Failed(-2),
;
Expand All @@ -66,14 +70,14 @@ enum class AuthenticationError(vararg val code: Int) {
}

data class AuthenticationErrorInfo(
val error: AuthenticationError,
val message: CharSequence,
val errorDetails: String? = null
val error: AuthenticationError,
val message: CharSequence,
val errorDetails: String? = null
) {
constructor(
error: AuthenticationError,
message: CharSequence,
e: Throwable
error: AuthenticationError,
message: CharSequence,
e: Throwable
) : this(error, message, e.toCompleteString())
}

Expand All @@ -92,10 +96,12 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
const val PARAM_WRITE_CONTENT = "content"
const val PARAM_ANDROID_PROMPT_INFO = "androidPromptInfo"

val executor : ExecutorService = Executors.newSingleThreadExecutor()
private val handler: Handler = Handler(Looper.getMainLooper())
}

private val executor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
private val handler: Handler by lazy { Handler(Looper.getMainLooper()) }


private var attachedActivity: FragmentActivity? = null

private val storageFiles = mutableMapOf<String, BiometricStorageFile>()
Expand All @@ -105,16 +111,13 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
private lateinit var applicationContext: Context

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
initialize(binding.binaryMessenger, binding.applicationContext)
this.applicationContext = binding.applicationContext
val channel = MethodChannel(binding.binaryMessenger, "biometric_storage")
channel.setMethodCallHandler(this)
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
}

private fun initialize(messenger: BinaryMessenger, context: Context) {
this.applicationContext = context
val channel = MethodChannel(messenger, "biometric_storage")
channel.setMethodCallHandler(this)
executor.shutdown()
}

override fun onMethodCall(call: MethodCall, result: Result) {
Expand All @@ -131,11 +134,11 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
val getAndroidPromptInfo = {
requiredArgument<Map<String, Any>>(PARAM_ANDROID_PROMPT_INFO).let {
AndroidPromptInfo(
title = it["title"] as String,
subtitle = it["subtitle"] as String?,
description = it["description"] as String?,
negativeButton = it["negativeButton"] as String,
confirmationRequired = it["confirmationRequired"] as Boolean,
title = it["title"] as String,
subtitle = it["subtitle"] as String?,
description = it["description"] as String?,
negativeButton = it["negativeButton"] as String,
confirmationRequired = it["confirmationRequired"] as Boolean,
)
}
}
Expand All @@ -148,9 +151,21 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
return
}
}

val resultError: ErrorCallback = { errorInfo ->
result.error(
"AuthError:${errorInfo.error}",
errorInfo.message.toString(),
errorInfo.errorDetails
)
logger.error("AuthError: $errorInfo")

}

@UiThread
fun BiometricStorageFile.withAuth(
mode: CipherMode,
cb: BiometricStorageFile.(cipher: Cipher?) -> Unit
@WorkerThread cb: BiometricStorageFile.(cipher: Cipher?) -> Unit
) {
if (!options.authenticationRequired) {
return cb(null)
Expand Down Expand Up @@ -179,17 +194,14 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
try {
return cb(null)
} catch (e: UserNotAuthenticatedException) {
logger.debug(e) { "User requires (re)authentication. showing prompt ..."}
logger.debug(e) { "User requires (re)authentication. showing prompt ..." }
}
}

val promptInfo = getAndroidPromptInfo()
authenticate(cipher, promptInfo, options, {
cb(cipher)
}) { info ->
result.error("AuthError:${info.error}", info.message.toString(), info.errorDetails)
logger.error("AuthError: $info")
}
}, onError = resultError)
}

when (call.method) {
Expand All @@ -210,9 +222,9 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {

val options = call.argument<Map<String, Any>>("options")?.let { it ->
InitOptions(
authenticationValidityDurationSeconds = it["authenticationValidityDurationSeconds"] as Int,
authenticationRequired = it["authenticationRequired"] as Boolean,
androidBiometricOnly = it["androidBiometricOnly"] as Boolean,
authenticationValidityDurationSeconds = it["authenticationValidityDurationSeconds"] as Int,
authenticationRequired = it["authenticationRequired"] as Boolean,
androidBiometricOnly = it["androidBiometricOnly"] as Boolean,
)
} ?: InitOptions()
// val options = moshi.adapter(InitOptions::class.java)
Expand All @@ -224,13 +236,37 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
"dispose" -> storageFiles.remove(getName())?.apply {
dispose()
result.success(true)
} ?: throw MethodCallException("NoSuchStorage", "Tried to dispose non existing storage.", null)
"read" -> withStorage { if (exists()) { withAuth(CipherMode.Decrypt) { result.success(readFile(it, applicationContext)) } } else { result.success(null) } }
"delete" -> withStorage { if (exists()) { result.success(deleteFile()) } else { result.success(false) } }
"write" -> withStorage { withAuth(CipherMode.Encrypt) {
writeFile(it, requiredArgument(PARAM_WRITE_CONTENT))
result.success(true)
} }
} ?: throw MethodCallException(
"NoSuchStorage",
"Tried to dispose non existing storage.",
null
)
"read" -> withStorage {
if (exists()) {
withAuth(CipherMode.Decrypt) {
val ret = readFile(
it,
applicationContext
)
ui(resultError) { result.success(ret) }
}
} else {
result.success(null)
}
}
"delete" -> withStorage {
if (exists()) {
result.success(deleteFile())
} else {
result.success(false)
}
}
"write" -> withStorage {
withAuth(CipherMode.Encrypt) {
writeFile(it, requiredArgument(PARAM_WRITE_CONTENT))
ui(resultError) { result.success(true) }
}
}
else -> result.notImplemented()
}
} catch (e: MethodCallException) {
Expand All @@ -242,12 +278,42 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
}
}

private inline fun ui(crossinline onError: ErrorCallback, crossinline cb: () -> Unit) = handler.post {
@AnyThread
private inline fun ui(
@UiThread crossinline onError: ErrorCallback,
@UiThread crossinline cb: () -> Unit
) = handler.post {
try {
cb()
} catch (e: Throwable) {
logger.error(e) { "Error while calling UI callback. This must not happen." }
onError(AuthenticationErrorInfo(AuthenticationError.Unknown, "Unexpected authentication error. ${e.localizedMessage}", e))
onError(
AuthenticationErrorInfo(
AuthenticationError.Unknown,
"Unexpected authentication error. ${e.localizedMessage}",
e
)
)
}
}

private inline fun worker(
@UiThread crossinline onError: ErrorCallback,
@WorkerThread crossinline cb: () -> Unit
) = executor.submit {
try {
cb()
} catch (e: Throwable) {
logger.error(e) { "Error while calling worker callback. This must not happen." }
handler.post {
onError(
AuthenticationErrorInfo(
AuthenticationError.Unknown,
"Unexpected authentication error. ${e.localizedMessage}",
e
)
)
}
}
}

Expand All @@ -256,56 +322,76 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler {
BIOMETRIC_STRONG or BIOMETRIC_WEAK
)
return CanAuthenticateResponse.values().firstOrNull { it.code == response }
?: throw Exception("Unknown response code {$response} (available: ${
?: throw Exception(
"Unknown response code {$response} (available: ${
CanAuthenticateResponse
.values()
.contentToString()
}")
}"
)
}

@UiThread
private fun authenticate(
cipher: Cipher?,
promptInfo: AndroidPromptInfo,
options: InitOptions,
onSuccess: (cipher: Cipher?) -> Unit,
@WorkerThread onSuccess: (cipher: Cipher?) -> Unit,
onError: ErrorCallback
) {
logger.trace("authenticate()")
val activity = attachedActivity ?: return run {
logger.error { "We are not attached to an activity." }
onError(AuthenticationErrorInfo(AuthenticationError.Failed, "Plugin not attached to any activity."))
onError(
AuthenticationErrorInfo(
AuthenticationError.Failed,
"Plugin not attached to any activity."
)
)
}
val prompt = BiometricPrompt(activity, executor, object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
logger.trace("onAuthenticationError($errorCode, $errString)")
ui(onError) { onError(AuthenticationErrorInfo(AuthenticationError.forCode(errorCode), errString)) }
}
val prompt =
BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
logger.trace("onAuthenticationError($errorCode, $errString)")
ui(onError) {
onError(
AuthenticationErrorInfo(
AuthenticationError.forCode(
errorCode
), errString
)
)
}
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
logger.trace("onAuthenticationSucceeded($result)")
ui(onError) { onSuccess(result.cryptoObject?.cipher) }
}
@WorkerThread
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
logger.trace("onAuthenticationSucceeded($result)")
worker(onError) { onSuccess(result.cryptoObject?.cipher) }
}

override fun onAuthenticationFailed() {
logger.trace("onAuthenticationFailed()")
// this can happen multiple times, so we don't want to communicate an error.
override fun onAuthenticationFailed() {
logger.trace("onAuthenticationFailed()")
// this can happen multiple times, so we don't want to communicate an error.
// ui(onError) { onError(AuthenticationErrorInfo(AuthenticationError.Failed, "biometric is valid but not recognized")) }
}
})
}
})

val promptBuilder = BiometricPrompt.PromptInfo.Builder()
.setTitle(promptInfo.title)
.setSubtitle(promptInfo.subtitle)
.setDescription(promptInfo.description)
.setConfirmationRequired(promptInfo.confirmationRequired)
.setTitle(promptInfo.title)
.setSubtitle(promptInfo.subtitle)
.setDescription(promptInfo.description)
.setConfirmationRequired(promptInfo.confirmationRequired)

val biometricOnly =
options.androidBiometricOnly || Build.VERSION.SDK_INT < Build.VERSION_CODES.R

if (biometricOnly) {
if (!options.androidBiometricOnly) {
logger.debug { "androidBiometricOnly was false, but prior " +
"to ${Build.VERSION_CODES.R} this was not supported. ignoring." }
logger.debug {
"androidBiometricOnly was false, but prior " +
"to ${Build.VERSION_CODES.R} this was not supported. ignoring."
}
}
promptBuilder
.setAllowedAuthenticators(BIOMETRIC_STRONG)
Expand Down
8 changes: 4 additions & 4 deletions example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,12 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

android {
compileSdkVersion 31
ndkVersion "21.0.6113669"
ndkVersion "21.1.6352462"

sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}

lintOptions {
disable 'InvalidPackage'
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
Expand All @@ -62,6 +59,9 @@ android {
'proguard-rules.pro'
}
}
lint {
disable 'InvalidPackage'
}
}

flutter {
Expand Down
2 changes: 1 addition & 1 deletion example/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ buildscript {
}

dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
classpath 'com.android.tools.build:gradle:7.2.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
Expand Down
2 changes: 1 addition & 1 deletion example/android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
Loading