• 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.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