From e5815d27e26abfa10bb6022b7b779cf381a5b151 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 28 Aug 2026 06:21:03 -0400 Subject: [PATCH] Android app lock, matching iOS The last of the four privacy features Android was missing that iOS already had. The sealed history file protects a phone that is off or locked; it does nothing about the phone somebody just handed to a friend. iOS asked for Face ID; on Android anyone holding an unlocked handset read every conversation. Biometrics OR the device credential, deliberately - 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. 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. Two traps worth naming. MainActivity had to become a FragmentActivity - BiometricPrompt hosts a fragment, so on a plain ComponentActivity it silently cannot show and the callback never fires, which looks like a broken lock rather than a missing base class. And the gate wraps Root rather than drawing over it: composed underneath, the message list still renders for a frame, and a frame is all a screenshot or a shoulder needs. The lock screen deliberately says nothing - no sender, no preview, no count. A lock screen reporting "3 new messages from Dana" has given away the two facts that usually matter most. The setting states what it does NOT do. This stops someone who picks up your unlocked phone; it adds no encryption, because the vault and ratchet keys are already sealed by the keystore either way. Claiming more would be selling a screen as if it were a key. Android engine, client and app suites green. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 5 + .../io/flamenet/messenger/app/AppLock.kt | 186 ++++++++++++++++++ .../io/flamenet/messenger/app/ChatScreens.kt | 56 ++++++ .../io/flamenet/messenger/app/KitIcons.kt | 3 + .../io/flamenet/messenger/app/MainActivity.kt | 59 +++++- 5 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/io/flamenet/messenger/app/AppLock.kt 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) } + } + } } } }