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.layout.Arrangement
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.filled.Lock
16import androidx.compose.material3.Button
17import androidx.compose.material3.CircularProgressIndicator
18import androidx.compose.material3.Icon
19import androidx.compose.material3.MaterialTheme
20import androidx.compose.material3.Surface
21import androidx.compose.material3.Text
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.DisposableEffect
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableStateOf
26import androidx.compose.runtime.remember
27import androidx.compose.runtime.saveable.rememberSaveableStateHolder
28import androidx.compose.runtime.setValue
29import androidx.compose.ui.Alignment
30import androidx.compose.ui.Modifier
31import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.semantics.LiveRegionMode
34import androidx.compose.ui.semantics.liveRegion
35import androidx.compose.ui.semantics.semantics
36import androidx.compose.ui.unit.dp
37import androidx.core.content.ContextCompat
38import androidx.fragment.app.FragmentActivity
39import androidx.lifecycle.Lifecycle
40import androidx.lifecycle.LifecycleEventObserver
41import androidx.lifecycle.compose.LocalLifecycleOwner
42import soy.iko.opencode.R
45 * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
46 * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
47 * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
49private val lockAuthenticators: Int
50 get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
51 BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
53 BiometricManager.Authenticators.BIOMETRIC_STRONG
56/** Whether device authentication is currently possible, so Settings can gate the toggle. */
57fun canAuthenticateForAppLock(context: Context): Boolean =
58 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
61 * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
62 * enrolled, or a pending security update). Only these states may fail the gate open. A transient
63 * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
64 * a momentary glitch — so the gate stays locked and lets the user retry.
66private fun appLockPermanentlyUnavailable(context: Context): Boolean =
67 when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
68 BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
69 BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
70 BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
74/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
75private fun Context.findFragmentActivity(): FragmentActivity? {
76 var ctx: Context? = this
77 while (ctx is ContextWrapper) {
78 if (ctx is FragmentActivity) return ctx
85 * Gates [content] behind device authentication when [enabled]. The content stays composed
86 * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
87 * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
88 * device can't authenticate, the gate is a no-op — the setting is only offered when
89 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
92fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
97 val context = LocalContext.current
98 val activity = remember(context) { context.findFragmentActivity() }
99 if (activity == null) {
103 // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
104 // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
105 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
106 // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
107 if (appLockPermanentlyUnavailable(context)) {
112 // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
113 // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
114 // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
115 // process-kill and restore the app would come back unlocked with no biometric prompt — an
116 // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
117 // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
118 var unlocked by remember { mutableStateOf(false) }
120 val title = stringResource(R.string.app_lock_prompt_title)
121 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
123 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
124 // BiometricPrompts, which throws.
125 var authInFlight by remember { mutableStateOf(false) }
126 // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
127 // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
128 // stale error could land after ON_START already started a NEW prompt and clear authInFlight
129 // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
130 // a second prompt. Each prompt captures its id and only mutates state while it's still current.
131 val promptId = remember { intArrayOf(0) }
134 if (authInFlight || unlocked) return
136 val myPrompt = ++promptId[0]
137 val executor = ContextCompat.getMainExecutor(activity)
138 val callback = object : BiometricPrompt.AuthenticationCallback() {
139 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
140 if (myPrompt != promptId[0]) return
145 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
146 // User cancelled / lockout / hardware error: stay locked and let them retry
147 // via the button rather than looping the system prompt. Ignore a stale error
148 // from a prompt already superseded by a newer one (see promptId above).
149 if (myPrompt != promptId[0]) return
153 override fun onAuthenticationFailed() {
154 // A single non-matching attempt; the system UI lets the user try again.
157 val info = BiometricPrompt.PromptInfo.Builder()
159 .setSubtitle(subtitle)
160 .setAllowedAuthenticators(lockAuthenticators)
162 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
163 .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
166 // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
167 // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
168 // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
169 // change on return and never re-fire, stranding the user on the manual "Unlock" button.
170 // addObserver also delivers the current state's ON_START to a freshly-added observer, so
171 // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
172 // authenticate() make the overlapping triggers safe.
173 val lifecycleOwner = LocalLifecycleOwner.current
174 DisposableEffect(lifecycleOwner) {
175 val observer = LifecycleEventObserver { _, event ->
177 Lifecycle.Event.ON_STOP -> {
179 // The system dismisses an in-flight prompt on background and posts its
180 // onAuthenticationError asynchronously; clear the flag now so ON_START re-prompts
181 // instead of no-opping on a stale authInFlight and stranding the manual button.
184 Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
188 lifecycleOwner.lifecycle.addObserver(observer)
189 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
192 // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
193 // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
194 // separate windows z-ordered above the Activity content, so an open permission dialog or picker
195 // sheet would stay visible and interactive above the lock — an authentication bypass. Not
196 // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
197 // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
198 // they were, replacing the previous "keep composed underneath" approach.
199 val stateHolder = rememberSaveableStateHolder()
201 stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
204 modifier = Modifier.fillMaxSize(),
205 color = MaterialTheme.colorScheme.background,
208 modifier = Modifier.fillMaxSize().padding(32.dp),
209 horizontalAlignment = Alignment.CenterHorizontally,
210 verticalArrangement = Arrangement.Center,
214 contentDescription = stringResource(R.string.app_locked),
215 modifier = Modifier.size(48.dp),
216 tint = MaterialTheme.colorScheme.onSurfaceVariant,
219 stringResource(R.string.app_locked),
220 style = MaterialTheme.typography.titleMedium,
222 .padding(top = 16.dp)
223 .semantics { liveRegion = LiveRegionMode.Polite },
226 onClick = { authenticate() },
227 enabled = !authInFlight,
228 modifier = Modifier.padding(top = 24.dp),
231 CircularProgressIndicator(
232 modifier = Modifier.size(18.dp),
234 color = MaterialTheme.colorScheme.primary,
236 Spacer(Modifier.size(8.dp))
238 Text(stringResource(R.string.app_unlock))
245private 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