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