From 066cfae551f6e8aeb4580a9b55f8c81d75182b54 Mon Sep 17 00:00:00 2001 From: Mehmedalija Karisik Date: Tue, 17 Dec 2024 03:12:44 +0100 Subject: [PATCH 1/3] implement initial biometric auth --- app/build.gradle.kts | 1 + .../kotlin/net/primal/android/MainActivity.kt | 4 +- .../net/primal/android/core/utils/UriUtils.kt | 2 +- .../android/nostr/ext/NostrResources.kt | 2 +- .../android/security/BiometricHelper.kt | 98 +++++++++++++++++++ .../settings/keys/KeysSettingsScreen.kt | 53 ++++++++-- .../ui/HighlightActivityBottomSheet.kt | 1 - app/src/main/res/values/strings.xml | 7 ++ gradle/libs.versions.toml | 4 + 9 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12ecfbcd..a9511ee6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -291,6 +291,7 @@ dependencies { testImplementation(libs.secp256k1.kmp.jni.jvm) implementation(libs.spongycastle.core) implementation(libs.androidx.security.crypto) + implementation(libs.androidx.biometric) implementation(libs.url.detector) diff --git a/app/src/main/kotlin/net/primal/android/MainActivity.kt b/app/src/main/kotlin/net/primal/android/MainActivity.kt index eae398e1..09fb9696 100644 --- a/app/src/main/kotlin/net/primal/android/MainActivity.kt +++ b/app/src/main/kotlin/net/primal/android/MainActivity.kt @@ -2,7 +2,6 @@ package net.primal.android import android.graphics.drawable.ColorDrawable import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -16,6 +15,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.produceState import androidx.compose.ui.graphics.toArgb import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -33,7 +33,7 @@ import net.primal.android.user.accounts.active.ActiveAccountStore import net.primal.android.user.domain.ContentDisplaySettings @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { @Inject lateinit var themeStore: ActiveThemeStore diff --git a/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt b/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt index ad35164b..3451316e 100644 --- a/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt +++ b/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt @@ -5,8 +5,8 @@ import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import java.net.MalformedURLException import java.net.URL -import net.primal.android.nostr.ext.parseNostrUris import net.primal.android.nostr.ext.detectUrls +import net.primal.android.nostr.ext.parseNostrUris import timber.log.Timber fun String.parseUris(): List { diff --git a/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt b/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt index 15bda6a7..2b974423 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt @@ -45,7 +45,7 @@ private val nostrUriRegexPattern: Pattern = Pattern.compile( private val urlRegexPattern: Pattern = Pattern.compile( "https?://(www\\.)?[-a-zA-Z0-9@:%.+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()_@:%+.~#?&//=]*)", - Pattern.CASE_INSENSITIVE + Pattern.CASE_INSENSITIVE, ) fun String.isNostrUri(): Boolean { diff --git a/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt new file mode 100644 index 00000000..58b9cbd0 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt @@ -0,0 +1,98 @@ +package net.primal.android.security + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +object BiometricHelper { + + fun biometricAuthentication( + activity: Context, + onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, + biometricPromptParams: BiometricPromptParams, + ) { + showBiometricPrompt( + activity = activity, + onAuthSucceed = onAuthSucceed, + params = biometricPromptParams, + ) + } + + fun isBiometricAvailable(context: Context): Boolean { + val biometricManager = BiometricManager.from(context) + return when ( + biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK, + ) + ) { + BiometricManager.BIOMETRIC_SUCCESS -> true + else -> false + } + } + + private fun showBiometricPrompt( + activity: Context, + onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, + params: BiometricPromptParams, + ) { + val promptInfo = getPromptInfo( + title = params.title, + subtitle = params.subtitle, + description = params.description, + cancelButtonText = params.cancelButtonText, + ) + + val biometricPrompt = getBiometricPrompt(context = activity as FragmentActivity, onAuthSucceed = onAuthSucceed) + + biometricPrompt.authenticate(promptInfo) + } + + private fun getPromptInfo( + title: String, + subtitle: String, + description: String, + cancelButtonText: String, + ): BiometricPrompt.PromptInfo { + return BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setDescription(description) + .setNegativeButtonText(cancelButtonText) + .setConfirmationRequired(false) + .build() + } + + private fun getBiometricPrompt( + context: FragmentActivity, + onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, + ): BiometricPrompt { + val biometricPrompt = + BiometricPrompt( + context, + ContextCompat.getMainExecutor(context), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onAuthSucceed(result) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + } + }, + ) + return biometricPrompt + } +} + +data class BiometricPromptParams( + val title: String, + val subtitle: String, + val description: String, + val cancelButtonText: String, +) diff --git a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt index 7c450565..520a9484 100644 --- a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt @@ -2,6 +2,7 @@ package net.primal.android.settings.keys import android.content.ClipData import android.content.ClipboardManager +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -49,6 +50,9 @@ import net.primal.android.core.compose.icons.primaliconpack.ArrowBack import net.primal.android.core.compose.icons.primaliconpack.Key import net.primal.android.core.compose.preview.PrimalPreview import net.primal.android.premium.legend.LegendaryCustomization +import net.primal.android.security.BiometricHelper +import net.primal.android.security.BiometricHelper.biometricAuthentication +import net.primal.android.security.BiometricPromptParams import net.primal.android.theme.AppTheme import net.primal.android.theme.domain.PrimalTheme @@ -181,6 +185,13 @@ fun PrivateKeySection(nsec: String) { val context = LocalContext.current var privateKeyVisible by remember { mutableStateOf(false) } + val title = stringResource(id = R.string.biometric_prompt_title) + val subtitleShowPrivateKey = stringResource(id = R.string.biometric_prompt_subtitle_show_private_key) + val subtitleCopyPrivateKey = stringResource(id = R.string.biometric_prompt_subtitle_copy_private_key) + val descriptionShowPrivateKey = stringResource(id = R.string.biometric_prompt_description_show_private_key) + val descriptionCopyPrivateKey = stringResource(id = R.string.biometric_prompt_description_copy_private_key) + val cancelButtonText = stringResource(id = R.string.biometric_prompt_cancel) + Row( modifier = Modifier .fillMaxWidth() @@ -194,12 +205,32 @@ fun PrivateKeySection(nsec: String) { color = AppTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) - Text( modifier = Modifier .padding(horizontal = 8.dp) .clickable { - privateKeyVisible = !privateKeyVisible + if (!privateKeyVisible) { + if (BiometricHelper.isBiometricAvailable(context)) { + biometricAuthentication( + activity = context, + onAuthSucceed = { privateKeyVisible = true }, + biometricPromptParams = BiometricPromptParams( + title = title, + subtitle = subtitleShowPrivateKey, + description = descriptionShowPrivateKey, + cancelButtonText = cancelButtonText, + ), + ) + } else { + Toast.makeText( + context, + "Biometric authentication not available.", + Toast.LENGTH_SHORT, + ).show() + } + } else { + privateKeyVisible = false + } }, text = if (privateKeyVisible) { stringResource(id = R.string.settings_keys_hide_key) @@ -258,10 +289,20 @@ fun PrivateKeySection(nsec: String) { stringResource(id = R.string.settings_keys_copy_private_key) }, onClick = { - val clipboard = context.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("", nsec) - clipboard.setPrimaryClip(clip) - keyCopied = true + if (BiometricHelper.isBiometricAvailable(context)) { + biometricAuthentication( + activity = context, + onAuthSucceed = { privateKeyVisible = true }, + biometricPromptParams = BiometricPromptParams( + title = title, + subtitle = subtitleCopyPrivateKey, + description = descriptionCopyPrivateKey, + cancelButtonText = cancelButtonText, + ), + ) + } else { + Toast.makeText(context, "Biometric authentication not available.", Toast.LENGTH_SHORT).show() + } }, ) } diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/HighlightActivityBottomSheet.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/HighlightActivityBottomSheet.kt index 2c34dd77..8d2ca0ad 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/HighlightActivityBottomSheet.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/HighlightActivityBottomSheet.kt @@ -216,7 +216,6 @@ private fun RowScope.ActionButton( text: String, onClick: () -> Unit, ) { - val isDarkTheme = isAppInDarkPrimalTheme() Button( colors = ButtonDefaults.buttonColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74996581..7a26705a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -772,6 +772,13 @@ Version + Authenticate + Please verify your fingerprint to view the private key + This ensures that only you can access the private key. + Please verify your fingerprint to copy the private key + This ensures that only you can securely copy the private key. + Cancel + following followers reads diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f4a69d2..b8408e04 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ activity-ktx = "1.9.3" agp = "8.7.3" appcompat = "1.7.0" bitcoinj-core = "0.16.3" +biometric = "1.4.0-alpha02" camera = "1.4.1" compose-bom = "2024.12.01" compose-lottie = "6.4.0" @@ -165,9 +166,12 @@ spongycastle-core = { module = "com.madgag.spongycastle:core", version.ref = "sp bitcoinj-core = { module = "org.bitcoinj:bitcoinj-core", version.ref = "bitcoinj-core" } lightning-kmp = { module = "fr.acinq.lightning:lightning-kmp", version = "1.6.1" } + +#Security androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } androidx-security-app-authenticator = { module = "androidx.security:security-app-authenticator", version.ref = "security-app-authenticator" } androidx-security-identity-credential = { module = "androidx.security:security-identity-credential", version.ref = "security-identity-credential" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref="biometric" } # Tests libs junit = { group = "junit", name = "junit", version.ref = "junit" } From e99d2546b81cd8a78f228f2f8944443f1025fa20 Mon Sep 17 00:00:00 2001 From: Mehmedalija Karisik Date: Wed, 18 Dec 2024 04:31:26 +0100 Subject: [PATCH 2/3] implement verifyBiometricIdentity and resolve comments --- .../android/security/BiometricHelper.kt | 128 ++++++++---------- .../settings/keys/KeysSettingsScreen.kt | 107 ++++++++++----- app/src/main/res/values/strings.xml | 5 +- 3 files changed, 137 insertions(+), 103 deletions(-) diff --git a/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt index 58b9cbd0..06725070 100644 --- a/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt +++ b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt @@ -6,93 +6,81 @@ import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -object BiometricHelper { - - fun biometricAuthentication( - activity: Context, - onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, - biometricPromptParams: BiometricPromptParams, - ) { +fun verifyBiometricIdentity( + activity: Context, + biometricPromptParams: BiometricPromptParams, + onAuthSucceed: () -> Unit, + onAuthFailed: (() -> Unit)? = null, + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit)? = null, +) { + if (isBiometricAvailable(activity)) { showBiometricPrompt( activity = activity, - onAuthSucceed = onAuthSucceed, params = biometricPromptParams, + onAuthSucceed = onAuthSucceed, + onAuthFailed = onAuthFailed, + onAuthError = onAuthError, ) + } else { + onAuthSucceed() } +} - fun isBiometricAvailable(context: Context): Boolean { - val biometricManager = BiometricManager.from(context) - return when ( - biometricManager.canAuthenticate( - BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK, - ) - ) { - BiometricManager.BIOMETRIC_SUCCESS -> true - else -> false - } +private fun isBiometricAvailable(context: Context): Boolean { + val biometricManager = BiometricManager.from(context) + return when ( + biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG + or BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + ) { + BiometricManager.BIOMETRIC_SUCCESS -> true + else -> false } +} - private fun showBiometricPrompt( - activity: Context, - onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, - params: BiometricPromptParams, - ) { - val promptInfo = getPromptInfo( - title = params.title, - subtitle = params.subtitle, - description = params.description, - cancelButtonText = params.cancelButtonText, +private fun showBiometricPrompt( + activity: Context, + params: BiometricPromptParams, + onAuthSucceed: () -> Unit, + onAuthFailed: (() -> Unit)?, + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit)?, +) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(params.title) + .setSubtitle(params.subtitle) + .setDescription(params.description) + .setConfirmationRequired(false) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG + or BiometricManager.Authenticators.DEVICE_CREDENTIAL, ) + .build() - val biometricPrompt = getBiometricPrompt(context = activity as FragmentActivity, onAuthSucceed = onAuthSucceed) - - biometricPrompt.authenticate(promptInfo) - } + val biometricPrompt = + BiometricPrompt( + activity as FragmentActivity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onAuthSucceed() + } - private fun getPromptInfo( - title: String, - subtitle: String, - description: String, - cancelButtonText: String, - ): BiometricPrompt.PromptInfo { - return BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setDescription(description) - .setNegativeButtonText(cancelButtonText) - .setConfirmationRequired(false) - .build() - } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onAuthError?.invoke(errorCode, errString) + } - private fun getBiometricPrompt( - context: FragmentActivity, - onAuthSucceed: (BiometricPrompt.AuthenticationResult) -> Unit, - ): BiometricPrompt { - val biometricPrompt = - BiometricPrompt( - context, - ContextCompat.getMainExecutor(context), - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - onAuthSucceed(result) - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - } + override fun onAuthenticationFailed() { + onAuthFailed?.invoke() + } + }, + ) - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - } - }, - ) - return biometricPrompt - } + biometricPrompt.authenticate(promptInfo) } data class BiometricPromptParams( val title: String, val subtitle: String, val description: String, - val cancelButtonText: String, ) diff --git a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt index 520a9484..3d8c5c3d 100644 --- a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt @@ -23,10 +23,12 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +40,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.delay import net.primal.android.R import net.primal.android.attachments.domain.CdnImage import net.primal.android.core.compose.IconText @@ -50,9 +54,8 @@ import net.primal.android.core.compose.icons.primaliconpack.ArrowBack import net.primal.android.core.compose.icons.primaliconpack.Key import net.primal.android.core.compose.preview.PrimalPreview import net.primal.android.premium.legend.LegendaryCustomization -import net.primal.android.security.BiometricHelper -import net.primal.android.security.BiometricHelper.biometricAuthentication import net.primal.android.security.BiometricPromptParams +import net.primal.android.security.verifyBiometricIdentity import net.primal.android.theme.AppTheme import net.primal.android.theme.domain.PrimalTheme @@ -183,14 +186,22 @@ fun PublicKeySection( @Composable fun PrivateKeySection(nsec: String) { val context = LocalContext.current - var privateKeyVisible by remember { mutableStateOf(false) } + var privateKeyVisible by rememberSaveable { mutableStateOf(false) } + var authenticated by rememberSaveable { mutableStateOf(false) } - val title = stringResource(id = R.string.biometric_prompt_title) - val subtitleShowPrivateKey = stringResource(id = R.string.biometric_prompt_subtitle_show_private_key) - val subtitleCopyPrivateKey = stringResource(id = R.string.biometric_prompt_subtitle_copy_private_key) - val descriptionShowPrivateKey = stringResource(id = R.string.biometric_prompt_description_show_private_key) - val descriptionCopyPrivateKey = stringResource(id = R.string.biometric_prompt_description_copy_private_key) - val cancelButtonText = stringResource(id = R.string.biometric_prompt_cancel) + LaunchedEffect(authenticated) { + if (authenticated) { + delay(1.minutes) + authenticated = false + } + } + + val biometricPromptTitle = stringResource(id = R.string.biometric_prompt_title) + val biometricPromptShowKeySubtitle = stringResource(id = R.string.biometric_prompt_subtitle_show_private_key) + val biometricPromptShowKeyDescription = stringResource(id = R.string.biometric_prompt_description_show_private_key) + val biometricPromptCopySubtitle = stringResource(id = R.string.biometric_prompt_subtitle_copy_private_key) + val biometricPromptCopyDescription = stringResource(id = R.string.biometric_prompt_description_copy_private_key) + val biometricPromptNotRecognized = stringResource(id = R.string.biometric_prompt_not_recognized) Row( modifier = Modifier @@ -210,23 +221,35 @@ fun PrivateKeySection(nsec: String) { .padding(horizontal = 8.dp) .clickable { if (!privateKeyVisible) { - if (BiometricHelper.isBiometricAvailable(context)) { - biometricAuthentication( + if (authenticated) { + privateKeyVisible = true + } else { + verifyBiometricIdentity( activity = context, - onAuthSucceed = { privateKeyVisible = true }, + onAuthSucceed = { + privateKeyVisible = true + authenticated = true + }, + onAuthFailed = { + Toast.makeText( + context, + biometricPromptNotRecognized, + Toast.LENGTH_SHORT, + ).show() + }, + onAuthError = { _: Int, errString: CharSequence -> + Toast.makeText( + context, + errString, + Toast.LENGTH_SHORT, + ).show() + }, biometricPromptParams = BiometricPromptParams( - title = title, - subtitle = subtitleShowPrivateKey, - description = descriptionShowPrivateKey, - cancelButtonText = cancelButtonText, + title = biometricPromptTitle, + subtitle = biometricPromptShowKeySubtitle, + description = biometricPromptShowKeyDescription, ), ) - } else { - Toast.makeText( - context, - "Biometric authentication not available.", - Toast.LENGTH_SHORT, - ).show() } } else { privateKeyVisible = false @@ -289,19 +312,41 @@ fun PrivateKeySection(nsec: String) { stringResource(id = R.string.settings_keys_copy_private_key) }, onClick = { - if (BiometricHelper.isBiometricAvailable(context)) { - biometricAuthentication( + if (authenticated) { + val clipboard = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("", nsec) + clipboard.setPrimaryClip(clip) + keyCopied = true + } else { + verifyBiometricIdentity( activity = context, - onAuthSucceed = { privateKeyVisible = true }, + onAuthSucceed = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("", nsec) + clipboard.setPrimaryClip(clip) + keyCopied = true + authenticated = true + }, + onAuthFailed = { + Toast.makeText( + context, + biometricPromptNotRecognized, + Toast.LENGTH_SHORT, + ).show() + }, + onAuthError = { _: Int, errString: CharSequence -> + Toast.makeText( + context, + errString, + Toast.LENGTH_SHORT, + ).show() + }, biometricPromptParams = BiometricPromptParams( - title = title, - subtitle = subtitleCopyPrivateKey, - description = descriptionCopyPrivateKey, - cancelButtonText = cancelButtonText, + title = biometricPromptTitle, + subtitle = biometricPromptCopySubtitle, + description = biometricPromptCopyDescription, ), ) - } else { - Toast.makeText(context, "Biometric authentication not available.", Toast.LENGTH_SHORT).show() } }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a26705a..c5610d8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -773,11 +773,12 @@ Version Authenticate - Please verify your fingerprint to view the private key + Please verify your fingerprint to view the private key. This ensures that only you can access the private key. - Please verify your fingerprint to copy the private key + Please verify your fingerprint to copy the private key. This ensures that only you can securely copy the private key. Cancel + Biometric authentication not recognized as belonging to the user. following followers From eccff291a932b9329978694ce429807ec2358673 Mon Sep 17 00:00:00 2001 From: Aleksandar Ilic Date: Thu, 19 Dec 2024 13:07:37 +0100 Subject: [PATCH 3/3] Implement compose wrapper for biometric prompt; Fix detekt issue in KeysSettingsScreen; --- app/detekt-baseline.xml | 1 - .../android/core/compose/BiometricPrompt.kt | 39 +++ .../android/security/BiometricHelper.kt | 4 +- .../settings/keys/KeysSettingsScreen.kt | 259 +++++++++--------- app/src/main/res/values/strings.xml | 7 +- 5 files changed, 174 insertions(+), 136 deletions(-) create mode 100644 app/src/main/kotlin/net/primal/android/core/compose/BiometricPrompt.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 6e574eb4..f6c9bdb0 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -49,7 +49,6 @@ LongMethod:FeedNoteCard.kt$@Composable private fun FeedNote( data: FeedPostUi, fullWidthContent: Boolean, avatarSizeDp: Dp, avatarPaddingValues: PaddingValues, notePaddingValues: PaddingValues, enableTweetsMode: Boolean, headerSingleLine: Boolean, showReplyTo: Boolean, forceContentIndent: Boolean, expanded: Boolean, textSelectable: Boolean, showNoteStatCounts: Boolean, noteCallbacks: NoteCallbacks, onPostAction: ((FeedPostAction) -> Unit)? = null, onPostLongClickAction: ((FeedPostAction) -> Unit)? = null, contentFooter: @Composable () -> Unit = {}, ) LongMethod:FeedNoteCard.kt$@ExperimentalMaterial3Api @Composable private fun FeedNoteCard( data: FeedPostUi, state: NoteContract.UiState, eventPublisher: (UiEvent) -> Unit, modifier: Modifier = Modifier, shape: Shape = CardDefaults.shape, colors: CardColors = noteCardColors(), cardPadding: PaddingValues = PaddingValues(all = 0.dp), enableTweetsMode: Boolean = false, headerSingleLine: Boolean = true, fullWidthContent: Boolean = false, forceContentIndent: Boolean = false, drawLineAboveAvatar: Boolean = false, drawLineBelowAvatar: Boolean = false, expanded: Boolean = false, textSelectable: Boolean = false, showReplyTo: Boolean = true, noteOptionsMenuEnabled: Boolean = true, showNoteStatCounts: Boolean = true, noteCallbacks: NoteCallbacks = NoteCallbacks(), onGoToWallet: (() -> Unit)? = null, contentFooter: @Composable () -> Unit = {}, ) LongMethod:HomeFeedScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeFeedScreen( state: HomeFeedContract.UiState, onTopLevelDestinationChanged: (PrimalTopLevelDestination) -> Unit, onDrawerScreenClick: (DrawerScreenDestination) -> Unit, onDrawerQrCodeClick: () -> Unit, onSearchClick: () -> Unit, noteCallbacks: NoteCallbacks, onGoToWallet: () -> Unit, onNewPostClick: (content: TextFieldValue?) -> Unit, eventPublisher: (UiEvent) -> Unit, ) - LongMethod:KeysSettingsScreen.kt$@Composable fun PrivateKeySection(nsec: String) LongMethod:LegendaryProfileCustomizationScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun LegendaryProfileCustomizationScreen( state: LegendaryProfileCustomizationContract.UiState, eventPublisher: (LegendaryProfileCustomizationContract.UiEvent) -> Unit, onClose: () -> Unit, ) LongMethod:MessageConversationListScreen.kt$@Composable private fun ConversationListItem( conversation: MessageConversationUi, onConversationClick: (String) -> Unit, onProfileClick: (profileId: String) -> Unit, ) LongMethod:MessageConversationListScreen.kt$@Composable private fun MessagesTabs( relation: ConversationRelation, onFollowsTabClick: () -> Unit, onOtherTabClick: () -> Unit, onMarkAllRead: () -> Unit, ) diff --git a/app/src/main/kotlin/net/primal/android/core/compose/BiometricPrompt.kt b/app/src/main/kotlin/net/primal/android/core/compose/BiometricPrompt.kt new file mode 100644 index 00000000..f003e609 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/core/compose/BiometricPrompt.kt @@ -0,0 +1,39 @@ +package net.primal.android.core.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import net.primal.android.R +import net.primal.android.security.BiometricPromptParams +import net.primal.android.security.verifyBiometricIdentity + +@Composable +fun BiometricPrompt( + onAuthSuccess: () -> Unit, + onAuthDismiss: () -> Unit, + title: String = stringResource(id = R.string.biometric_prompt_title), + subtitle: String? = null, + description: String? = stringResource(id = R.string.biometric_prompt_description), + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit)? = null, +) { + val context = LocalContext.current + LaunchedEffect(Unit) { + verifyBiometricIdentity( + activity = context, + onAuthSucceed = { + onAuthDismiss() + onAuthSuccess() + }, + onAuthError = { errorCode, errorMessage -> + onAuthDismiss() + onAuthError?.invoke(errorCode, errorMessage) + }, + biometricPromptParams = BiometricPromptParams( + title = title, + subtitle = subtitle, + description = description, + ), + ) + } +} diff --git a/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt index 06725070..25ef4669 100644 --- a/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt +++ b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt @@ -81,6 +81,6 @@ private fun showBiometricPrompt( data class BiometricPromptParams( val title: String, - val subtitle: String, - val description: String, + val subtitle: String?, + val description: String?, ) diff --git a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt index 3d8c5c3d..8adacf4b 100644 --- a/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/settings/keys/KeysSettingsScreen.kt @@ -2,7 +2,6 @@ package net.primal.android.settings.keys import android.content.ClipData import android.content.ClipboardManager -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -44,6 +43,7 @@ import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.delay import net.primal.android.R import net.primal.android.attachments.domain.CdnImage +import net.primal.android.core.compose.BiometricPrompt import net.primal.android.core.compose.IconText import net.primal.android.core.compose.PrimalDivider import net.primal.android.core.compose.PrimalTopAppBar @@ -54,8 +54,6 @@ import net.primal.android.core.compose.icons.primaliconpack.ArrowBack import net.primal.android.core.compose.icons.primaliconpack.Key import net.primal.android.core.compose.preview.PrimalPreview import net.primal.android.premium.legend.LegendaryCustomization -import net.primal.android.security.BiometricPromptParams -import net.primal.android.security.verifyBiometricIdentity import net.primal.android.theme.AppTheme import net.primal.android.theme.domain.PrimalTheme @@ -185,7 +183,6 @@ fun PublicKeySection( @Composable fun PrivateKeySection(nsec: String) { - val context = LocalContext.current var privateKeyVisible by rememberSaveable { mutableStateOf(false) } var authenticated by rememberSaveable { mutableStateOf(false) } @@ -196,76 +193,87 @@ fun PrivateKeySection(nsec: String) { } } - val biometricPromptTitle = stringResource(id = R.string.biometric_prompt_title) - val biometricPromptShowKeySubtitle = stringResource(id = R.string.biometric_prompt_subtitle_show_private_key) - val biometricPromptShowKeyDescription = stringResource(id = R.string.biometric_prompt_description_show_private_key) - val biometricPromptCopySubtitle = stringResource(id = R.string.biometric_prompt_subtitle_copy_private_key) - val biometricPromptCopyDescription = stringResource(id = R.string.biometric_prompt_description_copy_private_key) - val biometricPromptNotRecognized = stringResource(id = R.string.biometric_prompt_not_recognized) + PrivateKeyTextTitle( + privateKeyVisible = privateKeyVisible, + onKeyVisibilityChanged = { privateKeyVisible = it }, + authenticated = authenticated, + onAuthenticated = { authenticated = true }, + ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.settings_keys_private_key_title).uppercase(), - style = AppTheme.typography.bodySmall, - color = AppTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - modifier = Modifier - .padding(horizontal = 8.dp) - .clickable { - if (!privateKeyVisible) { - if (authenticated) { - privateKeyVisible = true - } else { - verifyBiometricIdentity( - activity = context, - onAuthSucceed = { - privateKeyVisible = true - authenticated = true - }, - onAuthFailed = { - Toast.makeText( - context, - biometricPromptNotRecognized, - Toast.LENGTH_SHORT, - ).show() - }, - onAuthError = { _: Int, errString: CharSequence -> - Toast.makeText( - context, - errString, - Toast.LENGTH_SHORT, - ).show() - }, - biometricPromptParams = BiometricPromptParams( - title = biometricPromptTitle, - subtitle = biometricPromptShowKeySubtitle, - description = biometricPromptShowKeyDescription, - ), - ) - } - } else { - privateKeyVisible = false - } + PrivateKeyTextValue( + nsec = nsec, + privateKeyVisible = privateKeyVisible, + ) + + PrivateKeyCopyButton( + nsec = nsec, + authenticated = authenticated, + onAuthenticated = { authenticated = true }, + ) + + IconText( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + text = stringResource(id = R.string.settings_keys_private_key_hint), + leadingIcon = Icons.Outlined.Warning, + iconSize = 16.sp, + lineHeight = 20.sp, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + leadingIconTintColor = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + style = AppTheme.typography.bodySmall, + ) +} + +@Composable +private fun PrivateKeyCopyButton( + nsec: String, + authenticated: Boolean, + onAuthenticated: () -> Unit, +) { + val context = LocalContext.current + Box(modifier = Modifier.padding(vertical = 8.dp)) { + var keyCopied by remember { mutableStateOf(false) } + var showCopyBiometricPrompt by rememberSaveable { mutableStateOf(false) } + if (showCopyBiometricPrompt) { + BiometricPrompt( + onAuthSuccess = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("", nsec) + clipboard.setPrimaryClip(clip) + keyCopied = true + onAuthenticated() + showCopyBiometricPrompt = false }, - text = if (privateKeyVisible) { - stringResource(id = R.string.settings_keys_hide_key) + onAuthDismiss = { + showCopyBiometricPrompt = false + }, + ) + } + PrimalLoadingButton( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + leadingIcon = if (keyCopied) Icons.Outlined.Check else Icons.Outlined.ContentCopy, + text = if (keyCopied) { + stringResource(id = R.string.settings_keys_key_copied) } else { - stringResource(id = R.string.settings_keys_show_key) - }.lowercase(), - style = AppTheme.typography.bodySmall, - color = AppTheme.colorScheme.secondary, - fontWeight = FontWeight.Medium, + stringResource(id = R.string.settings_keys_copy_private_key) + }, + onClick = { + if (authenticated) { + val clipboard = context.getSystemService(ClipboardManager::class.java) + val clip = ClipData.newPlainText("", nsec) + clipboard.setPrimaryClip(clip) + keyCopied = true + } else { + showCopyBiometricPrompt = true + } + }, ) } +} +@Composable +private fun PrivateKeyTextValue(privateKeyVisible: Boolean, nsec: String) { Row( modifier = Modifier .padding(vertical = 16.dp) @@ -298,70 +306,67 @@ fun PrivateKeySection(nsec: String) { overflow = if (privateKeyVisible) TextOverflow.Ellipsis else TextOverflow.Clip, ) } +} - Box(modifier = Modifier.padding(vertical = 8.dp)) { - var keyCopied by remember { mutableStateOf(false) } - PrimalLoadingButton( +@Composable +private fun PrivateKeyTextTitle( + privateKeyVisible: Boolean, + onKeyVisibilityChanged: (Boolean) -> Unit, + authenticated: Boolean, + onAuthenticated: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + var showPrivateKeyBiometricPrompt by rememberSaveable { mutableStateOf(false) } + if (showPrivateKeyBiometricPrompt) { + BiometricPrompt( + onAuthSuccess = { + onKeyVisibilityChanged(true) + onAuthenticated() + showPrivateKeyBiometricPrompt = false + }, + onAuthDismiss = { + showPrivateKeyBiometricPrompt = false + }, + ) + } + + Text( + text = stringResource(id = R.string.settings_keys_private_key_title).uppercase(), + style = AppTheme.typography.bodySmall, + color = AppTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + + Text( modifier = Modifier - .fillMaxWidth() - .height(56.dp), - leadingIcon = if (keyCopied) Icons.Outlined.Check else Icons.Outlined.ContentCopy, - text = if (keyCopied) { - stringResource(id = R.string.settings_keys_key_copied) + .padding(horizontal = 8.dp) + .clickable { + if (!privateKeyVisible) { + if (authenticated) { + onKeyVisibilityChanged(true) + } else { + showPrivateKeyBiometricPrompt = true + } + } else { + onKeyVisibilityChanged(false) + } + }, + text = if (privateKeyVisible) { + stringResource(id = R.string.settings_keys_hide_key) } else { - stringResource(id = R.string.settings_keys_copy_private_key) - }, - onClick = { - if (authenticated) { - val clipboard = context.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("", nsec) - clipboard.setPrimaryClip(clip) - keyCopied = true - } else { - verifyBiometricIdentity( - activity = context, - onAuthSucceed = { - val clipboard = context.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("", nsec) - clipboard.setPrimaryClip(clip) - keyCopied = true - authenticated = true - }, - onAuthFailed = { - Toast.makeText( - context, - biometricPromptNotRecognized, - Toast.LENGTH_SHORT, - ).show() - }, - onAuthError = { _: Int, errString: CharSequence -> - Toast.makeText( - context, - errString, - Toast.LENGTH_SHORT, - ).show() - }, - biometricPromptParams = BiometricPromptParams( - title = biometricPromptTitle, - subtitle = biometricPromptCopySubtitle, - description = biometricPromptCopyDescription, - ), - ) - } - }, + stringResource(id = R.string.settings_keys_show_key) + }.lowercase(), + style = AppTheme.typography.bodySmall, + color = AppTheme.colorScheme.secondary, + fontWeight = FontWeight.Medium, ) } - - IconText( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), - text = stringResource(id = R.string.settings_keys_private_key_hint), - leadingIcon = Icons.Outlined.Warning, - iconSize = 16.sp, - lineHeight = 20.sp, - color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, - leadingIconTintColor = AppTheme.extraColorScheme.onSurfaceVariantAlt3, - style = AppTheme.typography.bodySmall, - ) } @Preview diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5610d8c..8848f23b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -773,12 +773,7 @@ Version Authenticate - Please verify your fingerprint to view the private key. - This ensures that only you can access the private key. - Please verify your fingerprint to copy the private key. - This ensures that only you can securely copy the private key. - Cancel - Biometric authentication not recognized as belonging to the user. + This ensures that only you can access the private key. following followers