1package soy.iko.opencode.ui
3import android.content.Context
4import android.content.ContextWrapper
6import androidx.biometric.BiometricManager
7import androidx.biometric.BiometricPrompt
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.filled.Fingerprint
17import androidx.compose.material.icons.filled.Lock
18import androidx.compose.material3.Button
19import androidx.compose.material3.Icon
20import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.Surface
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.DisposableEffect
25import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.saveable.rememberSaveable
30import androidx.compose.runtime.saveable.rememberSaveableStateHolder
31import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.semantics.LiveRegionMode
37import androidx.compose.ui.semantics.Role
38import androidx.compose.ui.semantics.liveRegion
39import androidx.compose.ui.semantics.semantics
40import androidx.compose.ui.unit.dp
41import androidx.core.content.ContextCompat
42import androidx.fragment.app.FragmentActivity
43import androidx.lifecycle.Lifecycle
44import androidx.lifecycle.LifecycleEventObserver
45import androidx.lifecycle.compose.LocalLifecycleOwner
46import soy.iko.opencode.R
47import soy.iko.opencode.ui.components.LoadingSize
48import soy.iko.opencode.ui.components.LoadingSpinner
51 * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
52 * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
53 * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
55private val lockAuthenticators: Int
56 get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
57 BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
59 BiometricManager.Authenticators.BIOMETRIC_STRONG
62/** Whether device authentication is currently possible, so Settings can gate the toggle. */
63fun canAuthenticateForAppLock(context: Context): Boolean =
64 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
67 * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
68 * enrolled, or a pending security update). Only these states may fail the gate open. A transient
69 * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
70 * a momentary glitch — so the gate stays locked and lets the user retry.
72private fun appLockPermanentlyUnavailable(context: Context): Boolean =
73 when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
74 BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
75 BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
76 BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
80/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
81private fun Context.findFragmentActivity(): FragmentActivity? {
82 var ctx: Context? = this
83 while (ctx is ContextWrapper) {
84 if (ctx is FragmentActivity) return ctx
91 * Gates [content] behind device authentication when [enabled]. The content stays composed
92 * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
93 * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
94 * device can't authenticate, the gate is a no-op — the setting is only offered when
95 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
98fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
103 val context = LocalContext.current
104 val activity = remember(context) { context.findFragmentActivity() }
105 if (activity == null) {
109 // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
110 // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
111 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
112 // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
113 if (appLockPermanentlyUnavailable(context)) {
114 // Surface a one-time warning so the user knows their app-lock setting is on but isn't
115 // actually protecting the app — without this they'd have to discover it by opening
116 // Settings and seeing the "unavailable" state. A Toast (not a snackbar) because the
117 // gate sits above the Scaffold/SnackbarHost; the warning is informational, not an action.
118 LaunchedEffect(Unit) {
119 android.widget.Toast.makeText(
121 context.getString(R.string.app_lock_disabled_warning),
122 android.widget.Toast.LENGTH_LONG,
129 // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
130 // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
131 // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
132 // process-kill and restore the app would come back unlocked with no biometric prompt — an
133 // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
134 // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
135 var unlocked by remember { mutableStateOf(false) }
136 // Wall-clock time of the most recent ON_STOP. Used with [reLockDelaySeconds] so a quick
137 // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
138 // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
139 // grace period only governs the in-memory re-lock decision across a surviving background stint.
141 // Plain remember (not rememberSaveable), matching `unlocked` above: the grace check gates on
142 // `unlocked`, which is non-saveable, so persisting only the stop timestamp through the Bundle
143 // is inconsistent — on a config change NOT in the manifest's configChanges (e.g. a locale
144 // change) `unlocked` resets to false while `lastStopTimeMs` would survive, and the grace
145 // check `if (unlocked && ...)` short-circuits to false anyway, re-prompting the user despite
146 // having unlocked seconds ago. Resetting both together on recreation is consistent: the new
147 // DisposableEffect observer gets ON_START synchronously, sees lastStopTimeMs == 0, and prompts
148 // cleanly (a recreation mid-grace re-prompts, which is the safe default for an app lock).
149 val lastStopTimeMs = remember { longArrayOf(0L) }
150 val reLockDelayMs = reLockDelaySeconds * 1000L
152 val title = stringResource(R.string.app_lock_prompt_title)
153 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
155 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
156 // BiometricPrompts, which throws.
157 var authInFlight by remember { mutableStateOf(false) }
158 // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
159 // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
160 // stale error could land after ON_START already started a NEW prompt and clear authInFlight
161 // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
162 // a second prompt. Each prompt captures its id and only mutates state while it's still current.
163 val promptId = remember { intArrayOf(0) }
166 if (authInFlight || unlocked) return
168 val myPrompt = ++promptId[0]
169 val executor = ContextCompat.getMainExecutor(activity)
170 val callback = object : BiometricPrompt.AuthenticationCallback() {
171 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
172 if (myPrompt != promptId[0]) return
177 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
178 // User cancelled / lockout / hardware error: stay locked and let them retry
179 // via the button rather than looping the system prompt. Ignore a stale error
180 // from a prompt already superseded by a newer one (see promptId above).
181 if (myPrompt != promptId[0]) return
185 override fun onAuthenticationFailed() {
186 // A single non-matching attempt; the system UI lets the user try again.
189 val info = BiometricPrompt.PromptInfo.Builder()
191 .setSubtitle(subtitle)
192 .setAllowedAuthenticators(lockAuthenticators)
194 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
195 .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
198 // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
199 // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
200 // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
201 // change on return and never re-fire, stranding the user on the manual "Unlock" button.
202 // addObserver also delivers the current state's ON_START to a freshly-added observer, so
203 // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
204 // authenticate() make the overlapping triggers safe.
205 val lifecycleOwner = LocalLifecycleOwner.current
206 DisposableEffect(lifecycleOwner) {
207 val observer = LifecycleEventObserver { _, event ->
209 Lifecycle.Event.ON_STOP -> {
210 // Record the stop time but DON'T clear unlocked yet — the re-lock decision is
211 // made on the next ON_START, so a brief background stint under the grace period
212 // returns the user straight to their session without a re-prompt. authInFlight
213 // is still cleared because the system dismisses an in-flight prompt on background.
214 lastStopTimeMs[0] = System.currentTimeMillis()
217 Lifecycle.Event.ON_START -> {
218 // Only re-lock (and re-prompt) if more than the grace period elapsed since the
219 // last ON_STOP, or the app is freshly starting (lastStopTimeMs == 0).
220 val elapsed = System.currentTimeMillis() - lastStopTimeMs[0]
221 if (unlocked && lastStopTimeMs[0] != 0L && elapsed < reLockDelayMs) {
222 // Within the grace period — stay unlocked, no re-prompt.
231 lifecycleOwner.lifecycle.addObserver(observer)
232 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
235 // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
236 // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
237 // separate windows z-ordered above the Activity content, so an open permission dialog or picker
238 // sheet would stay visible and interactive above the lock — an authentication bypass. Not
239 // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
240 // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
241 // they were, replacing the previous "keep composed underneath" approach.
242 val stateHolder = rememberSaveableStateHolder()
244 stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
246 // A Fingerprint glyph hints at the primary unlock method when a biometric is enrolled;
247 // fall back to the generic lock on credential-only setups. Computed once: enrollment
248 // doesn't change while the gate is shown, and re-prompting on each frame is wasteful.
249 val biometricAvailable = remember(context) {
250 BiometricManager.from(context)
251 .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
252 BiometricManager.BIOMETRIC_SUCCESS
254 val unlockLabel = stringResource(R.string.app_unlock)
256 modifier = Modifier.fillMaxSize(),
257 color = MaterialTheme.colorScheme.background,
259 // The whole surface is tappable to re-trigger the prompt (in addition to the button),
260 // so a user who dismissed the system prompt can retry by tapping anywhere — not just
261 // by finding the Unlock button.
266 enabled = !authInFlight,
268 onClickLabel = unlockLabel,
271 horizontalAlignment = Alignment.CenterHorizontally,
272 verticalArrangement = Arrangement.Center,
275 if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
276 contentDescription = null,
277 modifier = Modifier.size(56.dp),
278 tint = MaterialTheme.colorScheme.primary,
281 stringResource(R.string.app_locked),
282 style = MaterialTheme.typography.titleMedium,
284 .padding(top = 16.dp)
285 .semantics { liveRegion = LiveRegionMode.Polite },
288 stringResource(R.string.app_locked_hint),
289 style = MaterialTheme.typography.bodyMedium,
290 color = MaterialTheme.colorScheme.onSurfaceVariant,
291 modifier = Modifier.padding(top = 4.dp),
294 onClick = { authenticate() },
295 enabled = !authInFlight,
296 modifier = Modifier.padding(top = 24.dp),
300 size = LoadingSize.Small,
301 color = MaterialTheme.colorScheme.primary,
303 Spacer(Modifier.size(8.dp))
312private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list