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