• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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