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