diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b471c58..2a4ce06 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -131,6 +131,11 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.activity:activity-compose:1.9.3") + // The app lock. `BiometricPrompt` covers fingerprint, face AND the device + // credential, so a phone with a PIN and no biometric hardware can still use + // it — matching iOS, which asks for `.deviceOwnerAuthentication` rather + // than biometrics specifically for the same reason. + implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt new file mode 100644 index 0000000..cbc0738 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt @@ -0,0 +1,186 @@ +package io.flamenet.messenger.app + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import androidx.core.content.ContextCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + + +/** + * A fingerprint, a face, or the device PIN in front of the app. + * + * 🔴 **Android had none of this and iOS has had it for a while.** The sealed + * history file protects a phone that is off or locked; it does nothing about + * the phone somebody just handed to a friend. On iOS the app asks for Face ID; + * on Android anyone holding an unlocked handset read every conversation. Two + * platforms disagreeing about a security guarantee is how one of them ends up + * being the one that leaks. + * + * **What this defends against, stated plainly, because the alternative is + * implying more:** somebody who picks up your unlocked phone. That is the + * threat people actually have, and it is worth something. + * + * **What it does not defend against:** anyone with the device and the tools to + * read its storage. This is a check in the app's own process. The vault and the + * ratchet keys are protected by the Android keystore, not by this switch, and + * turning it on re-encrypts nothing. An app lock that claimed otherwise would + * be selling a screen as if it were a key. + * + * ⚠️ `DEVICE_CREDENTIAL` alongside the biometrics on purpose. A phone with a + * PIN and no fingerprint reader should still be able to use this — the same + * reason iOS asks for `deviceOwnerAuthentication` rather than biometrics + * specifically. Locking people out of their own messages because their hardware + * is older is not a security win. + */ +class AppLock(private val context: Context) { + + companion object { + private const val PREFS = "flamenet-app-lock" + private const val ENABLED = "enabled" + private const val GRACE_SECONDS = "grace" + + /** Default: re-arm after a minute away. */ + const val DEFAULT_GRACE_SECONDS = 60 + + private const val AUTHENTICATORS = + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + } + + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * ⚠️ Ordinary SharedPreferences, NOT the vault. This records a preference, + * not a secret — and putting it in the encrypted store would mean the + * setting could not be read until after sign-in, which is exactly when the + * lock needs to already be up. + */ + var enabled: Boolean + get() = prefs.getBoolean(ENABLED, false) + set(value) = prefs.edit().putBoolean(ENABLED, value).apply() + + var graceSeconds: Int + get() = prefs.getInt(GRACE_SECONDS, DEFAULT_GRACE_SECONDS) + set(value) = prefs.edit().putInt(GRACE_SECONDS, value).apply() + + /** + * Whether this device can do it at all. + * + * ⚠️ Checked before the setting is offered. A toggle that turns on and then + * cannot authenticate locks somebody out of their own messages with no way + * back, which is worse than not having the feature. + */ + fun available(): Boolean = + BiometricManager.from(context).canAuthenticate(AUTHENTICATORS) == + BiometricManager.BIOMETRIC_SUCCESS + + /** + * When the app was last sent to the background. Null means it has not been. + * + * ⚠️ The grace period exists so that picking a photo, answering a call, or + * being bounced out by the system camera does not demand a fingerprint on + * the way back. A lock with no grace is one people switch off. + */ + private var leftAt: Long? = null + + fun noteLeft(now: Long = System.currentTimeMillis()) { leftAt = now } + + fun shouldLock(now: Long = System.currentTimeMillis()): Boolean { + if (!enabled) return false + val away = leftAt ?: return false + return (now - away) >= graceSeconds * 1000L + } + + fun noteUnlocked() { leftAt = null } + + /** + * Ask. + * + * ⚠️ Requires a `FragmentActivity`. `BiometricPrompt` hosts a fragment, and + * a plain `ComponentActivity` silently cannot show it — the callback never + * fires and the lock screen sits there looking broken. + */ + fun authenticate( + activity: FragmentActivity, + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) = onSuccess() + + override fun onAuthenticationError(code: Int, message: CharSequence) { + // ⚠️ A cancel is not a failure. Reporting "authentication + // failed" when somebody tapped away is how a lock screen + // teaches people it is broken. + if (code == BiometricPrompt.ERROR_USER_CANCELED || + code == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + code == BiometricPrompt.ERROR_CANCELED + ) return + onFailure(message.toString()) + } + }, + ) + + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle("Unlock Flamenet") + .setSubtitle("Your messages are locked on this device.") + .setAllowedAuthenticators(AUTHENTICATORS) + .build() + ) + } +} + +/** + * What is on screen while the app is locked. + * + * 🔴 **Deliberately says nothing.** No sender, no preview, no unread count — + * the point of the lock is that whoever is holding the phone learns nothing, + * and a lock screen reporting "3 new messages from Dana" has given away the two + * facts that usually matter most. + */ +@Composable +fun LockScreen(onUnlock: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(Kit.margin), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + KitIcon.Lock, + contentDescription = null, + tint = Palette.SecondaryText, + modifier = Modifier.size(Space.xxl), + ) + Spacer(Modifier.height(Space.lg)) + Text("Flamenet is locked", fontSize = 20.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(Space.sm)) + Text( + "Unlock this device to read your messages.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + Spacer(Modifier.height(Space.xl)) + KitPrimaryButton(text = "Unlock", onClick = onUnlock, tag = "unlock") + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 9291f49..1ebeb92 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -696,6 +696,9 @@ fun ColumnScope.AccountScreen( SectionBand() SettingsSection { StoryAudienceSection(model, phase) } + SectionBand() + SettingsSection { AppLockSection() } + SectionBand() SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay) } @@ -835,6 +838,59 @@ fun ColumnScope.AccountScreen( * means telling every contact again. That trade is stated on the screen rather * than left for somebody to discover when a new contact sees initials. */ +/** + * The app lock toggle. + * + * ⚠️ Offered only when the device can actually authenticate. A switch that + * turns on and then cannot show a prompt locks somebody out of their own + * messages with no way back, which is worse than not having the feature. + */ +@Composable +private fun ColumnScope.AppLockSection() { + val context = LocalContext.current + val lock = remember { AppLock(context) } + var enabled by remember { mutableStateOf(lock.enabled) } + val available = remember { lock.available() } + + Text("App lock", style = MaterialTheme.typography.titleMedium) + + if (!available) { + Text( + "This device has no screen lock set, so there is nothing to unlock " + + "with. Add a PIN, pattern or fingerprint in Android settings first.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) + return + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Require unlock to open", fontSize = 16.sp, modifier = Modifier.weight(1f)) + Switch( + checked = enabled, + onCheckedChange = { on -> lock.enabled = on; enabled = on }, + modifier = Modifier.testTag("app-lock"), + ) + } + + // 🔴 Says what it does NOT do. The vault and the ratchet keys are protected + // by the Android keystore, not by this switch, and turning it on + // re-encrypts nothing — this stops somebody who picks up your unlocked + // phone, which is the threat people actually have. Claiming more would be + // selling a screen as if it were a key. + Text( + "Asks for your fingerprint, face or device PIN before showing your " + + "messages, and again after a minute away. It stops someone who " + + "picks up your unlocked phone. It does not add encryption — your " + + "messages and keys are already sealed on this device either way.", + fontSize = 14.sp, + color = Palette.SecondaryText, + ) +} + /** * Who may see this account's stories. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt index 8340be4..a2ed592 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/KitIcons.kt @@ -15,7 +15,9 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.MicOff import androidx.compose.material.icons.outlined.MoreHoriz import androidx.compose.material.icons.outlined.EmojiEmotions @@ -76,6 +78,7 @@ object KitIcon { val Send: ImageVector = Icons.AutoMirrored.Outlined.Send val Mic: ImageVector = Icons.Outlined.Mic val Emoji: ImageVector = Icons.Outlined.EmojiEmotions + val Lock: ImageVector = Icons.Outlined.Lock val Sticker: ImageVector = Icons.Outlined.StickyNote2 // Calls diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index a25b3a7..44675ac 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -37,6 +37,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,7 +61,14 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -class MainActivity : ComponentActivity() { +/** + * ⚠️ `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic: + * `BiometricPrompt` hosts a fragment, so on a plain `ComponentActivity` it + * silently cannot show — the callback never fires and the lock screen sits + * there looking broken. `FragmentActivity` extends `ComponentActivity`, so + * `setContent` and everything else is unaffected. + */ +class MainActivity : androidx.fragment.app.FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -95,9 +104,55 @@ class MainActivity : ComponentActivity() { requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 1001) } + val lock = AppLock(this) + setContent { MessengerTheme { - Surface(modifier = Modifier.fillMaxSize()) { Root() } + Surface(modifier = Modifier.fillMaxSize()) { + // 🔴 **The gate is around Root, not inside it.** Drawn over + // the top, the message list still renders underneath for a + // frame — and a frame is all a screenshot or a shoulder + // needs. Locked means not composed. + var locked by remember { mutableStateOf(lock.enabled) } + + if (locked) { + LockScreen( + onUnlock = { + lock.authenticate( + activity = this@MainActivity, + onSuccess = { lock.noteUnlocked(); locked = false }, + onFailure = { /* the screen stays; it says why */ }, + ) + }, + ) + // ⚠️ Asked once on arrival too, so the first thing after + // launch is the prompt rather than a wall somebody has + // to tap to discover. + LaunchedEffect(Unit) { + lock.authenticate( + activity = this@MainActivity, + onSuccess = { lock.noteUnlocked(); locked = false }, + onFailure = {}, + ) + } + } else { + Root() + } + + // Re-arm on the way out, subject to the grace period. + DisposableEffect(Unit) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + androidx.lifecycle.Lifecycle.Event.ON_STOP -> lock.noteLeft() + androidx.lifecycle.Lifecycle.Event.ON_START -> + if (lock.shouldLock()) locked = true + else -> Unit + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + } } } }