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/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/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/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/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..25ef4669 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/security/BiometricHelper.kt @@ -0,0 +1,86 @@ +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 + +fun verifyBiometricIdentity( + activity: Context, + biometricPromptParams: BiometricPromptParams, + onAuthSucceed: () -> Unit, + onAuthFailed: (() -> Unit)? = null, + onAuthError: ((errorCode: Int, errString: CharSequence) -> Unit)? = null, +) { + if (isBiometricAvailable(activity)) { + showBiometricPrompt( + activity = activity, + params = biometricPromptParams, + onAuthSucceed = onAuthSucceed, + onAuthFailed = onAuthFailed, + onAuthError = onAuthError, + ) + } else { + onAuthSucceed() + } +} + +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, + 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 = + BiometricPrompt( + activity as FragmentActivity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onAuthSucceed() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onAuthError?.invoke(errorCode, errString) + } + + override fun onAuthenticationFailed() { + onAuthFailed?.invoke() + } + }, + ) + + biometricPrompt.authenticate(promptInfo) +} + +data class BiometricPromptParams( + val title: 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 7c450565..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 @@ -22,10 +22,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 @@ -37,8 +39,11 @@ 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.BiometricPrompt import net.primal.android.core.compose.IconText import net.primal.android.core.compose.PrimalDivider import net.primal.android.core.compose.PrimalTopAppBar @@ -178,40 +183,97 @@ 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) } - 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, - ) + LaunchedEffect(authenticated) { + if (authenticated) { + delay(1.minutes) + authenticated = false + } + } - Text( - modifier = Modifier - .padding(horizontal = 8.dp) - .clickable { - privateKeyVisible = !privateKeyVisible + PrivateKeyTextTitle( + privateKeyVisible = privateKeyVisible, + onKeyVisibilityChanged = { privateKeyVisible = it }, + authenticated = authenticated, + onAuthenticated = { authenticated = true }, + ) + + 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) @@ -244,38 +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 = { - val clipboard = context.getSystemService(ClipboardManager::class.java) - val clip = ClipData.newPlainText("", nsec) - clipboard.setPrimaryClip(clip) - keyCopied = true - }, + 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/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..8848f23b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -772,6 +772,9 @@ Version + Authenticate + This ensures that only you can access the private key. + 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" }