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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-28 06:21:03 -04:00
co-authored by Claude Opus 5
parent 98e2580d02
commit e5815d27e2
5 changed files with 307 additions and 2 deletions
+5
View File
@@ -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")
@@ -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")
}
}
@@ -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.
*
@@ -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
@@ -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) }
}
}
}
}
}