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