1 /*
<lambda>null2 * Copyright (C) 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 @file:OptIn(ExperimentalAnimationGraphicsApi::class)
18
19 package com.android.systemui.bouncer.ui.composable
20
21 import android.app.AlertDialog
22 import android.app.Dialog
23 import android.view.Gravity
24 import android.view.WindowManager
25 import android.widget.TextView
26 import androidx.compose.animation.core.Animatable
27 import androidx.compose.animation.core.VectorConverter
28 import androidx.compose.animation.core.tween
29 import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
30 import androidx.compose.animation.graphics.res.animatedVectorResource
31 import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
32 import androidx.compose.animation.graphics.vector.AnimatedImageVector
33 import androidx.compose.foundation.Image
34 import androidx.compose.foundation.layout.Arrangement
35 import androidx.compose.foundation.layout.Box
36 import androidx.compose.foundation.layout.Row
37 import androidx.compose.foundation.layout.heightIn
38 import androidx.compose.foundation.layout.padding
39 import androidx.compose.foundation.layout.wrapContentSize
40 import androidx.compose.material3.MaterialTheme
41 import androidx.compose.material3.Text
42 import androidx.compose.runtime.Composable
43 import androidx.compose.runtime.DisposableEffect
44 import androidx.compose.runtime.LaunchedEffect
45 import androidx.compose.runtime.getValue
46 import androidx.compose.runtime.key
47 import androidx.compose.runtime.mutableStateListOf
48 import androidx.compose.runtime.mutableStateOf
49 import androidx.compose.runtime.remember
50 import androidx.compose.runtime.setValue
51 import androidx.compose.runtime.snapshotFlow
52 import androidx.compose.runtime.toMutableStateList
53 import androidx.compose.ui.Alignment
54 import androidx.compose.ui.Modifier
55 import androidx.compose.ui.graphics.ColorFilter
56 import androidx.compose.ui.layout.ContentScale
57 import androidx.compose.ui.layout.layout
58 import androidx.compose.ui.platform.LocalView
59 import androidx.compose.ui.res.colorResource
60 import androidx.compose.ui.res.dimensionResource
61 import androidx.compose.ui.res.painterResource
62 import androidx.compose.ui.res.stringResource
63 import androidx.compose.ui.unit.Constraints
64 import androidx.compose.ui.unit.Dp
65 import androidx.compose.ui.unit.dp
66 import androidx.compose.ui.window.Dialog
67 import androidx.lifecycle.compose.collectAsStateWithLifecycle
68 import com.android.compose.PlatformOutlinedButton
69 import com.android.compose.animation.Easings
70 import com.android.keyguard.PinShapeAdapter
71 import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit
72 import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel
73 import com.android.systemui.bouncer.ui.viewmodel.PinInputViewModel
74 import com.android.systemui.res.R
75 import kotlin.time.Duration.Companion.milliseconds
76 import kotlinx.coroutines.CoroutineScope
77 import kotlinx.coroutines.async
78 import kotlinx.coroutines.awaitAll
79 import kotlinx.coroutines.coroutineScope
80 import kotlinx.coroutines.delay
81 import kotlinx.coroutines.joinAll
82 import kotlinx.coroutines.launch
83
84 @Composable
85 fun PinInputDisplay(
86 viewModel: PinBouncerViewModel,
87 modifier: Modifier = Modifier,
88 ) {
89 val hintedPinLength: Int? by viewModel.hintedPinLength.collectAsStateWithLifecycle()
90 val shapeAnimations = rememberShapeAnimations(viewModel.pinShapes)
91
92 // The display comes in two different flavors:
93 // 1) hinting: shows a circle (◦) per expected pin input, and dot (●) per entered digit.
94 // This has a fixed width, and uses two distinct types of AVDs to animate the addition and
95 // removal of digits.
96 // 2) regular, shows a dot (●) per entered digit.
97 // This grows/shrinks as digits are added deleted. Uses the same type of AVDs to animate the
98 // addition of digits, but simply center-shrinks the dot (●) shape to zero to animate the
99 // removal.
100 // Because of all these differences, there are two separate implementations, rather than
101 // unifying into a single, more complex implementation.
102
103 when (val length = hintedPinLength) {
104 null -> RegularPinInputDisplay(viewModel, shapeAnimations, modifier)
105 else -> HintingPinInputDisplay(viewModel, shapeAnimations, length, modifier)
106 }
107 }
108
109 /**
110 * A pin input display that shows a placeholder circle (◦) for every digit in the pin not yet
111 * entered.
112 *
113 * Used for auto-confirmed pins of a specific length, see design: http://shortn/_jS8kPzQ7QV
114 */
115 @Composable
HintingPinInputDisplaynull116 private fun HintingPinInputDisplay(
117 viewModel: PinBouncerViewModel,
118 shapeAnimations: ShapeAnimations,
119 hintedPinLength: Int,
120 modifier: Modifier = Modifier,
121 ) {
122 val pinInput: PinInputViewModel by viewModel.pinInput.collectAsStateWithLifecycle()
123 // [ClearAll] marker pointing at the beginning of the current pin input.
124 // When a new [ClearAll] token is added to the [pinInput], the clear-all animation is played
125 // and the marker is advanced manually to the most recent marker. See LaunchedEffect below.
126 var currentClearAll by remember { mutableStateOf(pinInput.mostRecentClearAll()) }
127 // The length of the pin currently entered by the user.
128 val currentPinLength = pinInput.getDigits(currentClearAll).size
129
130 // The animated vector drawables for each of the [hintedPinLength] slots.
131 // The first [currentPinLength] drawables end in a dot (●) shape, the remaining drawables up to
132 // [hintedPinLength] end in the circle (◦) shape.
133 // This list is re-generated upon each pin entry, it is modelled as a [MutableStateList] to
134 // allow the clear-all animation to replace the shapes asynchronously, see LaunchedEffect below.
135 // Note that when a [ClearAll] token is added to the input (and the clear-all animation plays)
136 // the [currentPinLength] does not change; the [pinEntryDrawable] is remembered until the
137 // clear-all animation finishes and the [currentClearAll] state is manually advanced.
138 val pinEntryDrawable =
139 remember(currentPinLength) {
140 buildList {
141 repeat(currentPinLength) { add(shapeAnimations.getShapeToDot(it)) }
142 repeat(hintedPinLength - currentPinLength) { add(shapeAnimations.dotToCircle) }
143 }
144 .toMutableStateList()
145 }
146
147 val mostRecentClearAll = pinInput.mostRecentClearAll()
148 // Whenever a new [ClearAll] marker is added to the input, the clear-all animation needs to
149 // be played.
150 LaunchedEffect(mostRecentClearAll) {
151 if (currentClearAll == mostRecentClearAll) {
152 // Except during the initial composition.
153 return@LaunchedEffect
154 }
155
156 // Staggered replace of all dot (●) shapes with an animation from dot (●) to circle (◦).
157 for (index in 0 until hintedPinLength) {
158 if (!shapeAnimations.isDotShape(pinEntryDrawable[index])) break
159
160 pinEntryDrawable[index] = shapeAnimations.dotToCircle
161 delay(shapeAnimations.dismissStaggerDelay)
162 }
163
164 // Once the animation is done, start processing the next pin input again.
165 currentClearAll = mostRecentClearAll
166 }
167
168 // During the initial composition, do not play the [pinEntryDrawable] animations. This prevents
169 // the dot (●) to circle (◦) animation when the empty display becomes first visible, and a
170 // superfluous shape to dot (●) animation after for example device rotation.
171 var playAnimation by remember { mutableStateOf(false) }
172 LaunchedEffect(Unit) { playAnimation = true }
173
174 val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
175 Row(modifier = modifier.heightIn(min = shapeAnimations.shapeSize)) {
176 pinEntryDrawable.forEachIndexed { index, drawable ->
177 // Key the loop by [index] and [drawable], so that updating a shape drawable at the same
178 // index will play the new animation (by remembering a new [atEnd]).
179 key(index, drawable) {
180 // [rememberAnimatedVectorPainter] requires a `atEnd` boolean to switch from `false`
181 // to `true` for the animation to play. This animation is suppressed when
182 // playAnimation is false, always rendering the end-state of the animation.
183 var atEnd by remember { mutableStateOf(!playAnimation) }
184 LaunchedEffect(Unit) { atEnd = true }
185
186 Image(
187 painter = rememberAnimatedVectorPainter(drawable, atEnd),
188 contentDescription = null,
189 contentScale = ContentScale.Crop,
190 colorFilter = ColorFilter.tint(dotColor),
191 )
192 }
193 }
194 }
195 }
196
197 /**
198 * A pin input that shows a dot (●) for each entered pin, horizontally centered and growing /
199 * shrinking as more digits are entered and deleted.
200 *
201 * Used for pin input when the pin length is not hinted, see design http://shortn/_wNP7SrBD78
202 */
203 @Composable
RegularPinInputDisplaynull204 private fun RegularPinInputDisplay(
205 viewModel: PinBouncerViewModel,
206 shapeAnimations: ShapeAnimations,
207 modifier: Modifier = Modifier,
208 ) {
209 if (viewModel.isSimAreaVisible) {
210 SimArea(viewModel = viewModel)
211 }
212
213 // Holds all currently [VisiblePinEntry] composables. This cannot be simply derived from
214 // `viewModel.pinInput` at composition, since deleting a pin entry needs to play a remove
215 // animation, thus the composable to be removed has to remain in the composition until fully
216 // disappeared (see `prune` launched effect below)
217 val pinInputRow = remember(shapeAnimations) { PinInputRow(shapeAnimations) }
218
219 // Processed `viewModel.pinInput` updates and applies them to [pinDigitShapes]
220 LaunchedEffect(viewModel.pinInput, pinInputRow) {
221 // Initial setup: capture the most recent [ClearAll] marker and create the visuals for the
222 // existing digits (if any) without animation..
223 var currentClearAll =
224 with(viewModel.pinInput.value) {
225 val initialClearAll = mostRecentClearAll()
226 pinInputRow.setDigits(getDigits(initialClearAll))
227 initialClearAll
228 }
229
230 viewModel.pinInput.collect { input ->
231 // Process additions and removals of pins within the current input block.
232 pinInputRow.updateDigits(input.getDigits(currentClearAll), scope = this@LaunchedEffect)
233
234 val mostRecentClearAll = input.mostRecentClearAll()
235 if (currentClearAll != mostRecentClearAll) {
236 // A new [ClearAll] token is added to the [input], play the clear-all animation
237 pinInputRow.playClearAllAnimation()
238
239 // Animation finished, advance manually to the next marker.
240 currentClearAll = mostRecentClearAll
241 }
242 }
243 }
244
245 LaunchedEffect(pinInputRow) {
246 // Prunes unused VisiblePinEntries once they are no longer visible.
247 snapshotFlow { pinInputRow.hasUnusedEntries() }
248 .collect { hasUnusedEntries ->
249 if (hasUnusedEntries) {
250 pinInputRow.prune()
251 }
252 }
253 }
254
255 pinInputRow.Content(modifier)
256 }
257
258 @Composable
SimAreanull259 private fun SimArea(viewModel: PinBouncerViewModel) {
260 val isLockedEsim by viewModel.isLockedEsim.collectAsStateWithLifecycle()
261 val isSimUnlockingDialogVisible by
262 viewModel.isSimUnlockingDialogVisible.collectAsStateWithLifecycle()
263 val errorDialogMessage by viewModel.errorDialogMessage.collectAsStateWithLifecycle()
264 var unlockDialog: Dialog? by remember { mutableStateOf(null) }
265 var errorDialog: Dialog? by remember { mutableStateOf(null) }
266 val context = LocalView.current.context
267
268 DisposableEffect(isSimUnlockingDialogVisible) {
269 if (isSimUnlockingDialogVisible) {
270 val builder =
271 AlertDialog.Builder(context).apply {
272 setMessage(context.getString(R.string.kg_sim_unlock_progress_dialog_message))
273 setCancelable(false)
274 }
275 unlockDialog =
276 builder.create().apply {
277 window?.setType(WindowManager.LayoutParams.TYPE_KEYGUARD_DIALOG)
278 show()
279 findViewById<TextView>(android.R.id.message)?.gravity = Gravity.CENTER
280 }
281 } else {
282 unlockDialog?.hide()
283 unlockDialog = null
284 }
285
286 onDispose {
287 unlockDialog?.hide()
288 unlockDialog = null
289 }
290 }
291
292 DisposableEffect(errorDialogMessage) {
293 if (errorDialogMessage != null) {
294 val builder = AlertDialog.Builder(context)
295 builder.setMessage(errorDialogMessage)
296 builder.setCancelable(false)
297 builder.setNeutralButton(R.string.ok, null)
298 errorDialog =
299 builder.create().apply {
300 window?.setType(WindowManager.LayoutParams.TYPE_KEYGUARD_DIALOG)
301 setOnDismissListener { viewModel.onErrorDialogDismissed() }
302 show()
303 }
304 } else {
305 errorDialog?.hide()
306 errorDialog = null
307 }
308
309 onDispose {
310 errorDialog?.hide()
311 errorDialog = null
312 }
313 }
314
315 Box(modifier = Modifier.padding(bottom = 20.dp)) {
316 // If isLockedEsim is null, then we do not show anything.
317 if (isLockedEsim == true) {
318 PlatformOutlinedButton(
319 onClick = { viewModel.onDisableEsimButtonClicked() },
320 ) {
321 Row(
322 horizontalArrangement = Arrangement.spacedBy(10.dp),
323 verticalAlignment = Alignment.CenterVertically
324 ) {
325 Image(
326 painter = painterResource(id = R.drawable.ic_no_sim),
327 contentDescription = null,
328 colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface)
329 )
330 Text(
331 text = stringResource(R.string.disable_carrier_button_text),
332 style = MaterialTheme.typography.bodyMedium,
333 color = MaterialTheme.colorScheme.onSurface,
334 )
335 }
336 }
337 } else if (isLockedEsim == false) {
338 Image(
339 painter = painterResource(id = R.drawable.ic_lockscreen_sim),
340 contentDescription = null,
341 colorFilter = ColorFilter.tint(colorResource(id = R.color.background_protected))
342 )
343 }
344 }
345 }
346
347 private class PinInputRow(
348 val shapeAnimations: ShapeAnimations,
349 ) {
350 private val entries = mutableStateListOf<PinInputEntry>()
351
352 @Composable
Contentnull353 fun Content(modifier: Modifier) {
354 Row(
355 modifier =
356 modifier
357 .heightIn(min = shapeAnimations.shapeSize)
358 // Pins overflowing horizontally should still be shown as scrolling.
359 .wrapContentSize(unbounded = true),
360 ) {
361 entries.forEach { entry -> key(entry.digit) { entry.Content() } }
362 }
363 }
364
365 /**
366 * Replaces all current [PinInputEntry] composables with new instances for each digit.
367 *
368 * Does not play the entry expansion animation.
369 */
setDigitsnull370 fun setDigits(digits: List<Digit>) {
371 entries.clear()
372 entries.addAll(digits.map { PinInputEntry(it, shapeAnimations) })
373 }
374
375 /**
376 * Adds [PinInputEntry] composables for new digits and plays an entry animation, and starts the
377 * exit animation for digits not in [updated] anymore.
378 *
379 * The function return immediately, playing the animations in the background.
380 *
381 * Removed entries have to be [prune]d once the exit animation completes, [hasUnusedEntries] can
382 * be used in a [SnapshotFlow] to discover when its time to do so.
383 */
updateDigitsnull384 fun updateDigits(updated: List<Digit>, scope: CoroutineScope) {
385 val incoming = updated.minus(entries.map { it.digit }.toSet()).toList()
386 val outgoing = entries.filterNot { entry -> updated.any { entry.digit == it } }.toList()
387
388 entries.addAll(
389 incoming.map {
390 PinInputEntry(it, shapeAnimations).apply { scope.launch { animateAppearance() } }
391 }
392 )
393
394 outgoing.forEach { entry -> scope.launch { entry.animateRemoval() } }
395
396 entries.sortWith(compareBy { it.digit })
397 }
398
399 /**
400 * Plays a staggered remove animation, and upon completion removes the [PinInputEntry]
401 * composables.
402 *
403 * This function returns once the animation finished playing and the entries are removed.
404 */
<lambda>null405 suspend fun playClearAllAnimation() = coroutineScope {
406 val entriesToRemove = entries.toList()
407 entriesToRemove
408 .mapIndexed { index, entry ->
409 launch {
410 delay(shapeAnimations.dismissStaggerDelay * index)
411 entry.animateClearAllCollapse()
412 }
413 }
414 .joinAll()
415
416 // Remove all [PinInputEntry] composables for which the staggered remove animation was
417 // played. Note that up to now, each PinInputEntry still occupied the full width.
418 entries.removeAll(entriesToRemove)
419 }
420
421 /**
422 * Whether there are [PinInputEntry] that can be removed from the composition since they were
423 * fully animated out.
424 */
hasUnusedEntriesnull425 fun hasUnusedEntries(): Boolean {
426 return entries.any { it.isUnused }
427 }
428
429 /** Remove all no longer visible [PinInputEntry]s from the composition. */
prunenull430 fun prune() {
431 entries.removeAll { it.isUnused }
432 }
433 }
434
435 private class PinInputEntry(
436 val digit: Digit,
437 val shapeAnimations: ShapeAnimations,
438 ) {
439 private val shape = shapeAnimations.getShapeToDot(digit.sequenceNumber)
440 // horizontal space occupied, used to shift contents as individual digits are animated in/out
441 private val entryWidth =
442 Animatable(shapeAnimations.shapeSize, Dp.VectorConverter, label = "Width of pin ($digit)")
443 // intrinsic width and height of the shape, used to collapse the shape during exit animations.
444 private val shapeSize =
445 Animatable(shapeAnimations.shapeSize, Dp.VectorConverter, label = "Size of pin ($digit)")
446
447 /**
448 * Whether the is fully animated out. When `true`, removing this from the composable won't have
449 * visual effects.
450 */
451 val isUnused: Boolean
452 get() {
453 return entryWidth.targetValue == 0.dp && !entryWidth.isRunning
454 }
455
456 /** Animate the shape appearance by growing the entry width from 0.dp to the intrinsic width. */
<lambda>null457 suspend fun animateAppearance() = coroutineScope {
458 entryWidth.snapTo(0.dp)
459 entryWidth.animateTo(shapeAnimations.shapeSize, shapeAnimations.inputShiftAnimationSpec)
460 }
461
462 /**
463 * Animates shape disappearance by collapsing the shape and occupied horizontal space.
464 *
465 * Once complete, [isUnused] will return `true`.
466 */
<lambda>null467 suspend fun animateRemoval() = coroutineScope {
468 awaitAll(
469 async { entryWidth.animateTo(0.dp, shapeAnimations.inputShiftAnimationSpec) },
470 async { shapeSize.animateTo(0.dp, shapeAnimations.deleteShapeSizeAnimationSpec) }
471 )
472 }
473
474 /** Collapses the shape in place, while still holding on to the horizontal space. */
<lambda>null475 suspend fun animateClearAllCollapse() = coroutineScope {
476 shapeSize.animateTo(0.dp, shapeAnimations.clearAllShapeSizeAnimationSpec)
477 }
478
479 @Composable
Contentnull480 fun Content() {
481 val animatedShapeSize by shapeSize.asState()
482 val animatedEntryWidth by entryWidth.asState()
483
484 val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
485 val shapeHeight = shapeAnimations.shapeSize
486 var atEnd by remember { mutableStateOf(false) }
487 LaunchedEffect(Unit) { atEnd = true }
488 Image(
489 painter = rememberAnimatedVectorPainter(shape, atEnd),
490 contentDescription = null,
491 contentScale = ContentScale.Crop,
492 colorFilter = ColorFilter.tint(dotColor),
493 modifier =
494 Modifier.layout { measurable, _ ->
495 val shapeSizePx = animatedShapeSize.roundToPx()
496 val placeable = measurable.measure(Constraints.fixed(shapeSizePx, shapeSizePx))
497
498 layout(animatedEntryWidth.roundToPx(), shapeHeight.roundToPx()) {
499 placeable.place(
500 ((animatedEntryWidth - animatedShapeSize) / 2f).roundToPx(),
501 ((shapeHeight - animatedShapeSize) / 2f).roundToPx()
502 )
503 }
504 },
505 )
506 }
507 }
508
509 /** Animated Vector Drawables used to render the pin input. */
510 private class ShapeAnimations(
511 /** Width and height for all the animation images listed here. */
512 val shapeSize: Dp,
513 /** Transitions from the dot (●) to the circle (◦). Used for the hinting pin input only. */
514 val dotToCircle: AnimatedImageVector,
515 /** Each of the animations transition from nothing via a shape to the dot (●). */
516 private val shapesToDot: List<AnimatedImageVector>,
517 ) {
518 /**
519 * Returns a transition from nothing via shape to the dot (●)., specific to the input position.
520 */
getShapeToDotnull521 fun getShapeToDot(position: Int): AnimatedImageVector {
522 return shapesToDot[position.mod(shapesToDot.size)]
523 }
524
525 /**
526 * Whether the [shapeAnimation] is a image returned by [getShapeToDot], and thus is ending in
527 * the dot (●) shape.
528 *
529 * `false` if the shape's end state is the circle (◦).
530 */
isDotShapenull531 fun isDotShape(shapeAnimation: AnimatedImageVector): Boolean {
532 return shapeAnimation != dotToCircle
533 }
534
535 // spec: http://shortn/_DEhE3Xl2bi
536 val dismissStaggerDelay = 33.milliseconds
537 val inputShiftAnimationSpec = tween<Dp>(durationMillis = 250, easing = Easings.Standard)
538 val deleteShapeSizeAnimationSpec =
539 tween<Dp>(durationMillis = 200, easing = Easings.StandardDecelerate)
540 val clearAllShapeSizeAnimationSpec = tween<Dp>(durationMillis = 450, easing = Easings.Legacy)
541 }
542
543 @Composable
rememberShapeAnimationsnull544 private fun rememberShapeAnimations(pinShapes: PinShapeAdapter): ShapeAnimations {
545 // NOTE: `animatedVectorResource` does remember the returned AnimatedImageVector.
546 val dotToCircle = AnimatedImageVector.animatedVectorResource(R.drawable.pin_dot_delete_avd)
547 val shapesToDot = pinShapes.shapes.map { AnimatedImageVector.animatedVectorResource(it) }
548 val shapeSize = dimensionResource(R.dimen.password_shape_size)
549
550 return remember(dotToCircle, shapesToDot, shapeSize) {
551 ShapeAnimations(shapeSize, dotToCircle, shapesToDot)
552 }
553 }
554