git.iko.soy/opencode-android
on 5d56632 help sign in
194 lines 8.6 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.interaction.MutableInteractionSource
9import androidx.compose.foundation.clickable
10import androidx.compose.foundation.layout.Arrangement
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.fillMaxSize
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.material.icons.Icons
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.getValue
26import androidx.compose.runtime.mutableStateOf
27import androidx.compose.runtime.remember
28import androidx.compose.runtime.saveable.rememberSaveable
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.unit.dp
35import androidx.core.content.ContextCompat
36import androidx.fragment.app.FragmentActivity
37import androidx.lifecycle.Lifecycle
38import androidx.lifecycle.LifecycleEventObserver
39import androidx.lifecycle.compose.LocalLifecycleOwner
40import soy.iko.opencode.R
42/**
43 * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
44 * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
45 * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
46 */
47private val lockAuthenticators: Int
48 get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
49 BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
50 } else {
51 BiometricManager.Authenticators.BIOMETRIC_STRONG
52 }
54/** Whether device authentication is currently possible, so Settings can gate the toggle. */
55fun canAuthenticateForAppLock(context: Context): Boolean =
56 BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
58/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
59private fun Context.findFragmentActivity(): FragmentActivity? {
60 var ctx: Context? = this
61 while (ctx is ContextWrapper) {
62 if (ctx is FragmentActivity) return ctx
63 ctx = ctx.baseContext
64 }
65 return null
68/**
69 * Gates [content] behind device authentication when [enabled]. The content stays composed
70 * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
71 * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
72 * device can't authenticate, the gate is a no-op — the setting is only offered when
73 * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
74 */
75@Composable
76fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
77 if (!enabled) {
78 content()
79 return
80 }
81 val context = LocalContext.current
82 val activity = remember(context) { context.findFragmentActivity() }
83 if (activity == null) {
84 content()
85 return
86 }
87 // Fail open if device authentication is no longer possible (e.g. the user enabled app-lock
88 // while a credential was enrolled and later removed their PIN/biometric): authenticate()
89 // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
90 if (!canAuthenticateForAppLock(context)) {
91 content()
92 return
93 }
95 // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
96 // defaults to locked on a fresh start.
97 var unlocked by rememberSaveable { mutableStateOf(false) }
99 val title = stringResource(R.string.app_lock_prompt_title)
100 val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
102 // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
103 // BiometricPrompts, which throws.
104 var authInFlight by remember { mutableStateOf(false) }
106 fun authenticate() {
107 if (authInFlight || unlocked) return
108 authInFlight = true
109 val executor = ContextCompat.getMainExecutor(activity)
110 val callback = object : BiometricPrompt.AuthenticationCallback() {
111 override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
112 authInFlight = false
113 unlocked = true
114 }
116 override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
117 // User cancelled / lockout / hardware error: stay locked and let them retry
118 // via the button rather than looping the system prompt.
119 authInFlight = false
120 }
122 override fun onAuthenticationFailed() {
123 // A single non-matching attempt; the system UI lets the user try again.
124 }
125 }
126 val info = BiometricPrompt.PromptInfo.Builder()
127 .setTitle(title)
128 .setSubtitle(subtitle)
129 .setAllowedAuthenticators(lockAuthenticators)
130 .build()
131 runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
132 .onFailure { authInFlight = false }
133 }
135 // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
136 // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
137 // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
138 // change on return and never re-fire, stranding the user on the manual "Unlock" button.
139 // addObserver also delivers the current state's ON_START to a freshly-added observer, so
140 // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
141 // authenticate() make the overlapping triggers safe.
142 val lifecycleOwner = LocalLifecycleOwner.current
143 DisposableEffect(lifecycleOwner) {
144 val observer = LifecycleEventObserver { _, event ->
145 when (event) {
146 Lifecycle.Event.ON_STOP -> unlocked = false
147 Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
148 else -> {}
149 }
150 }
151 lifecycleOwner.lifecycle.addObserver(observer)
152 onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
153 }
155 Box(modifier = Modifier.fillMaxSize()) {
156 content()
157 if (!unlocked) {
158 // Opaque overlay covering the content. The no-op clickable swallows touches so
159 // the hidden UI beneath can't be interacted with while locked.
160 val interaction = remember { MutableInteractionSource() }
161 Surface(
162 modifier = Modifier
163 .fillMaxSize()
164 .clickable(interactionSource = interaction, indication = null) {},
165 color = MaterialTheme.colorScheme.background,
166 ) {
167 Column(
168 modifier = Modifier.fillMaxSize().padding(32.dp),
169 horizontalAlignment = Alignment.CenterHorizontally,
170 verticalArrangement = Arrangement.Center,
171 ) {
172 Icon(
173 Icons.Filled.Lock,
174 contentDescription = null,
175 modifier = Modifier.size(48.dp),
176 tint = MaterialTheme.colorScheme.onSurfaceVariant,
177 )
178 Text(
179 stringResource(R.string.app_locked),
180 style = MaterialTheme.typography.titleMedium,
181 modifier = Modifier.padding(top = 16.dp),
182 )
183 Button(
184 onClick = { authenticate() },
185 enabled = !authInFlight,
186 modifier = Modifier.padding(top = 24.dp),
187 ) {
188 Text(stringResource(R.string.app_unlock))
189 }
190 }
191 }
192 }
193 }