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" }