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