1 /*
<lambda>null2 * Copyright (C) 2021 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 package com.android.systemui.statusbar.events
18
19 import android.annotation.UiThread
20 import android.graphics.Point
21 import android.graphics.Rect
22 import android.util.Log
23 import android.view.Gravity
24 import android.view.View
25 import android.widget.FrameLayout
26 import androidx.core.animation.Animator
27 import com.android.app.animation.Interpolators
28 import com.android.internal.annotations.GuardedBy
29 import com.android.systemui.Flags.privacyDotUnfoldWrongCornerFix
30 import com.android.systemui.dagger.SysUISingleton
31 import com.android.systemui.dagger.qualifiers.Application
32 import com.android.systemui.dagger.qualifiers.Main
33 import com.android.systemui.plugins.statusbar.StatusBarStateController
34 import com.android.systemui.res.R
35 import com.android.systemui.shade.domain.interactor.ShadeInteractor
36 import com.android.systemui.statusbar.StatusBarState.SHADE
37 import com.android.systemui.statusbar.StatusBarState.SHADE_LOCKED
38 import com.android.systemui.statusbar.phone.StatusBarContentInsetsChangedListener
39 import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider
40 import com.android.systemui.statusbar.policy.ConfigurationController
41 import com.android.systemui.util.concurrency.DelayableExecutor
42 import com.android.systemui.util.leak.RotationUtils
43 import com.android.systemui.util.leak.RotationUtils.ROTATION_LANDSCAPE
44 import com.android.systemui.util.leak.RotationUtils.ROTATION_NONE
45 import com.android.systemui.util.leak.RotationUtils.ROTATION_SEASCAPE
46 import com.android.systemui.util.leak.RotationUtils.ROTATION_UPSIDE_DOWN
47 import com.android.systemui.util.leak.RotationUtils.Rotation
48 import kotlinx.coroutines.CoroutineScope
49 import kotlinx.coroutines.launch
50 import java.util.concurrent.Executor
51 import javax.inject.Inject
52
53 /**
54 * Understands how to keep the persistent privacy dot in the corner of the screen in
55 * ScreenDecorations, which does not rotate with the device.
56 *
57 * The basic principle here is that each dot will sit in a box that is equal to the margins of the
58 * status bar (specifically the status_bar_contents view in PhoneStatusBarView). Each dot container
59 * will have its gravity set towards the corner (i.e., top-right corner gets top|right gravity), and
60 * the contained ImageView will be set to center_vertical and away from the corner horizontally. The
61 * Views will match the status bar top padding and status bar height so that the dot can appear to
62 * reside directly after the status bar system contents (basically after the battery).
63 *
64 * NOTE: any operation that modifies views directly must run on the provided executor, because
65 * these views are owned by ScreenDecorations and it runs in its own thread
66 */
67
68 @SysUISingleton
69 open class PrivacyDotViewController @Inject constructor(
70 @Main private val mainExecutor: Executor,
71 @Application scope: CoroutineScope,
72 private val stateController: StatusBarStateController,
73 private val configurationController: ConfigurationController,
74 private val contentInsetsProvider: StatusBarContentInsetsProvider,
75 private val animationScheduler: SystemStatusAnimationScheduler,
76 shadeInteractor: ShadeInteractor?
77 ) {
78 private lateinit var tl: View
79 private lateinit var tr: View
80 private lateinit var bl: View
81 private lateinit var br: View
82
83 // Only can be modified on @UiThread
84 var currentViewState: ViewState = ViewState()
85 get() = field
86
87 @GuardedBy("lock")
88 private var nextViewState: ViewState = currentViewState.copy()
89 set(value) {
90 field = value
91 scheduleUpdate()
92 }
93 private val lock = Object()
94 private var cancelRunnable: Runnable? = null
95
96 // Privacy dots are created in ScreenDecoration's UiThread, which is not the main thread
97 private var uiExecutor: DelayableExecutor? = null
98
99 private val views: Sequence<View>
100 get() = if (!this::tl.isInitialized) sequenceOf() else sequenceOf(tl, tr, br, bl)
101
102 var showingListener: ShowingListener? = null
103 set(value) {
104 field = value
105 }
106 get() = field
107
108 init {
109 contentInsetsProvider.addCallback(object : StatusBarContentInsetsChangedListener {
110 override fun onStatusBarContentInsetsChanged() {
111 dlog("onStatusBarContentInsetsChanged: ")
112 setNewLayoutRects()
113 }
114 })
115
116 configurationController.addCallback(object : ConfigurationController.ConfigurationListener {
117 override fun onLayoutDirectionChanged(isRtl: Boolean) {
118 uiExecutor?.execute {
119 // If rtl changed, hide all dotes until the next state resolves
120 setCornerVisibilities(View.INVISIBLE)
121
122 synchronized(this) {
123 val corner = selectDesignatedCorner(nextViewState.rotation, isRtl)
124 nextViewState = nextViewState.copy(
125 layoutRtl = isRtl,
126 designatedCorner = corner
127 )
128 }
129 }
130 }
131 })
132
133 stateController.addCallback(object : StatusBarStateController.StateListener {
134 override fun onExpandedChanged(isExpanded: Boolean) {
135 updateStatusBarState()
136 }
137
138 override fun onStateChanged(newState: Int) {
139 updateStatusBarState()
140 }
141 })
142
143 scope.launch {
144 shadeInteractor?.isQsExpanded?.collect { isQsExpanded ->
145 dlog("setQsExpanded $isQsExpanded")
146 synchronized(lock) {
147 nextViewState = nextViewState.copy(qsExpanded = isQsExpanded)
148 }
149 }
150 }
151 }
152
153 fun setUiExecutor(e: DelayableExecutor) {
154 uiExecutor = e
155 }
156
157 fun getUiExecutor(): DelayableExecutor? {
158 return uiExecutor
159 }
160
161 @UiThread
162 fun setNewRotation(rot: Int) {
163 dlog("updateRotation: $rot")
164
165 val isRtl: Boolean
166 synchronized(lock) {
167 if (rot == nextViewState.rotation) {
168 return
169 }
170
171 isRtl = nextViewState.layoutRtl
172 }
173
174 // If we rotated, hide all dotes until the next state resolves
175 setCornerVisibilities(View.INVISIBLE)
176
177 val newCorner = selectDesignatedCorner(rot, isRtl)
178 val index = newCorner.cornerIndex()
179 val paddingTop = contentInsetsProvider.getStatusBarPaddingTop(rot)
180
181 synchronized(lock) {
182 nextViewState = nextViewState.copy(
183 rotation = rot,
184 paddingTop = paddingTop,
185 designatedCorner = newCorner,
186 cornerIndex = index)
187 }
188 }
189
190 @UiThread
191 fun hideDotView(dot: View, animate: Boolean) {
192 dot.clearAnimation()
193 if (animate) {
194 dot.animate()
195 .setDuration(DURATION)
196 .setInterpolator(Interpolators.ALPHA_OUT)
197 .alpha(0f)
198 .withEndAction {
199 dot.visibility = View.INVISIBLE
200 showingListener?.onPrivacyDotHidden(dot)
201 }
202 .start()
203 } else {
204 dot.visibility = View.INVISIBLE
205 showingListener?.onPrivacyDotHidden(dot)
206 }
207 }
208
209 @UiThread
210 fun showDotView(dot: View, animate: Boolean) {
211 dot.clearAnimation()
212 if (animate) {
213 dot.visibility = View.VISIBLE
214 dot.alpha = 0f
215 dot.animate()
216 .alpha(1f)
217 .setDuration(DURATION)
218 .setInterpolator(Interpolators.ALPHA_IN)
219 .start()
220 } else {
221 dot.visibility = View.VISIBLE
222 dot.alpha = 1f
223 }
224 showingListener?.onPrivacyDotShown(dot)
225 }
226
227 // Update the gravity and margins of the privacy views
228 @UiThread
229 open fun updateRotations(rotation: Int, paddingTop: Int) {
230 // To keep a view in the corner, its gravity is always the description of its current corner
231 // Therefore, just figure out which view is in which corner. This turns out to be something
232 // like (myCorner - rot) mod 4, where topLeft = 0, topRight = 1, etc. and portrait = 0, and
233 // rotating the device counter-clockwise increments rotation by 1
234
235 views.forEach { corner ->
236 corner.setPadding(0, paddingTop, 0, 0)
237
238 val rotatedCorner = rotatedCorner(cornerForView(corner), rotation)
239 (corner.layoutParams as FrameLayout.LayoutParams).apply {
240 gravity = rotatedCorner.toGravity()
241 }
242
243 // Set the dot's view gravity to hug the status bar
244 (corner.requireViewById<View>(R.id.privacy_dot)
245 .layoutParams as FrameLayout.LayoutParams)
246 .gravity = rotatedCorner.innerGravity()
247 }
248 }
249
250 @UiThread
251 private fun updateCornerSizes(l: Int, r: Int, rotation: Int) {
252 views.forEach { corner ->
253 val rotatedCorner = rotatedCorner(cornerForView(corner), rotation)
254 val w = widthForCorner(rotatedCorner, l, r)
255 (corner.layoutParams as FrameLayout.LayoutParams).width = w
256 }
257 }
258
259 @UiThread
260 open fun setCornerSizes(state: ViewState) {
261 // StatusBarContentInsetsProvider can tell us the location of the privacy indicator dot
262 // in every rotation. The only thing we need to check is rtl
263 val rtl = state.layoutRtl
264 val size = Point()
265 tl.context.display?.getRealSize(size)
266 val currentRotation = RotationUtils.getExactRotation(tl.context)
267
268 val displayWidth: Int
269 val displayHeight: Int
270 if (currentRotation == ROTATION_LANDSCAPE || currentRotation == ROTATION_SEASCAPE) {
271 displayWidth = size.y
272 displayHeight = size.x
273 } else {
274 displayWidth = size.x
275 displayHeight = size.y
276 }
277
278 var rot = activeRotationForCorner(tl, rtl)
279 var contentInsets = state.contentRectForRotation(rot)
280 tl.setPadding(0, state.paddingTop, 0, 0)
281 (tl.layoutParams as FrameLayout.LayoutParams).apply {
282 topMargin = contentInsets.top
283 height = contentInsets.height()
284 if (rtl) {
285 width = contentInsets.left
286 } else {
287 width = displayHeight - contentInsets.right
288 }
289 }
290
291 rot = activeRotationForCorner(tr, rtl)
292 contentInsets = state.contentRectForRotation(rot)
293 tr.setPadding(0, state.paddingTop, 0, 0)
294 (tr.layoutParams as FrameLayout.LayoutParams).apply {
295 topMargin = contentInsets.top
296 height = contentInsets.height()
297 if (rtl) {
298 width = contentInsets.left
299 } else {
300 width = displayWidth - contentInsets.right
301 }
302 }
303
304 rot = activeRotationForCorner(br, rtl)
305 contentInsets = state.contentRectForRotation(rot)
306 br.setPadding(0, state.paddingTop, 0, 0)
307 (br.layoutParams as FrameLayout.LayoutParams).apply {
308 topMargin = contentInsets.top
309 height = contentInsets.height()
310 if (rtl) {
311 width = contentInsets.left
312 } else {
313 width = displayHeight - contentInsets.right
314 }
315 }
316
317 rot = activeRotationForCorner(bl, rtl)
318 contentInsets = state.contentRectForRotation(rot)
319 bl.setPadding(0, state.paddingTop, 0, 0)
320 (bl.layoutParams as FrameLayout.LayoutParams).apply {
321 topMargin = contentInsets.top
322 height = contentInsets.height()
323 if (rtl) {
324 width = contentInsets.left
325 } else {
326 width = displayWidth - contentInsets.right
327 }
328 }
329 }
330
331 // Designated view will be the one at statusbar's view.END
332 @UiThread
333 private fun selectDesignatedCorner(r: Int, isRtl: Boolean): View? {
334 if (!this::tl.isInitialized) {
335 return null
336 }
337
338 return when (r) {
339 0 -> if (isRtl) tl else tr
340 1 -> if (isRtl) tr else br
341 2 -> if (isRtl) br else bl
342 3 -> if (isRtl) bl else tl
343 else -> throw IllegalStateException("unknown rotation")
344 }
345 }
346
347 // Track the current designated corner and maybe animate to a new rotation
348 @UiThread
349 private fun updateDesignatedCorner(newCorner: View?, shouldShowDot: Boolean) {
350 if (shouldShowDot) {
351 showingListener?.onPrivacyDotShown(newCorner)
352 newCorner?.apply {
353 clearAnimation()
354 visibility = View.VISIBLE
355 alpha = 0f
356 animate()
357 .alpha(1.0f)
358 .setDuration(300)
359 .start()
360 }
361 }
362 }
363
364 @UiThread
365 private fun setCornerVisibilities(vis: Int) {
366 views.forEach { corner ->
367 corner.visibility = vis
368 if (vis == View.VISIBLE) {
369 showingListener?.onPrivacyDotShown(corner)
370 } else {
371 showingListener?.onPrivacyDotHidden(corner)
372 }
373 }
374 }
375
376 private fun cornerForView(v: View): Int {
377 return when (v) {
378 tl -> TOP_LEFT
379 tr -> TOP_RIGHT
380 bl -> BOTTOM_LEFT
381 br -> BOTTOM_RIGHT
382 else -> throw IllegalArgumentException("not a corner view")
383 }
384 }
385
386 private fun rotatedCorner(corner: Int, rotation: Int): Int {
387 var modded = corner - rotation
388 if (modded < 0) {
389 modded += 4
390 }
391
392 return modded
393 }
394
395 @Rotation
396 private fun activeRotationForCorner(corner: View, rtl: Boolean): Int {
397 // Each corner will only be visible in a single rotation, based on rtl
398 return when (corner) {
399 tr -> if (rtl) ROTATION_LANDSCAPE else ROTATION_NONE
400 tl -> if (rtl) ROTATION_NONE else ROTATION_SEASCAPE
401 br -> if (rtl) ROTATION_UPSIDE_DOWN else ROTATION_LANDSCAPE
402 else /* bl */ -> if (rtl) ROTATION_SEASCAPE else ROTATION_UPSIDE_DOWN
403 }
404 }
405
406 private fun widthForCorner(corner: Int, left: Int, right: Int): Int {
407 return when (corner) {
408 TOP_LEFT, BOTTOM_LEFT -> left
409 TOP_RIGHT, BOTTOM_RIGHT -> right
410 else -> throw IllegalArgumentException("Unknown corner")
411 }
412 }
413
414 fun initialize(topLeft: View, topRight: View, bottomLeft: View, bottomRight: View) {
415 if (this::tl.isInitialized && this::tr.isInitialized &&
416 this::bl.isInitialized && this::br.isInitialized) {
417 if (tl == topLeft && tr == topRight && bl == bottomLeft && br == bottomRight) {
418 return
419 }
420 }
421
422 tl = topLeft
423 tr = topRight
424 bl = bottomLeft
425 br = bottomRight
426
427 val rtl = configurationController.isLayoutRtl
428 val currentRotation = RotationUtils.getExactRotation(tl.context)
429 val dc = selectDesignatedCorner(currentRotation, rtl)
430
431 val index = dc.cornerIndex()
432
433 mainExecutor.execute {
434 animationScheduler.addCallback(systemStatusAnimationCallback)
435 }
436
437 val left = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_SEASCAPE)
438 val top = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_NONE)
439 val right = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_LANDSCAPE)
440 val bottom = contentInsetsProvider
441 .getStatusBarContentAreaForRotation(ROTATION_UPSIDE_DOWN)
442 val paddingTop = contentInsetsProvider.getStatusBarPaddingTop()
443
444 synchronized(lock) {
445 nextViewState = nextViewState.copy(
446 viewInitialized = true,
447 designatedCorner = dc,
448 cornerIndex = index,
449 seascapeRect = left,
450 portraitRect = top,
451 landscapeRect = right,
452 upsideDownRect = bottom,
453 paddingTop = paddingTop,
454 layoutRtl = rtl
455 )
456 }
457 }
458
459 private fun updateStatusBarState() {
460 synchronized(lock) {
461 nextViewState = nextViewState.copy(shadeExpanded = isShadeInQs())
462 }
463 }
464
465 /**
466 * If we are unlocked with an expanded shade, QS is showing. On keyguard, the shade is always
467 * expanded so we use other signals from the panel view controller to know if QS is expanded
468 */
469 @GuardedBy("lock")
470 private fun isShadeInQs(): Boolean {
471 return (stateController.isExpanded && stateController.state == SHADE) ||
472 (stateController.state == SHADE_LOCKED)
473 }
474
475 private fun scheduleUpdate() {
476 dlog("scheduleUpdate: ")
477
478 cancelRunnable?.run()
479 cancelRunnable = uiExecutor?.executeDelayed({
480 processNextViewState()
481 }, 100)
482 }
483
484 @UiThread
485 private fun processNextViewState() {
486 dlog("processNextViewState: ")
487
488 val newState: ViewState
489 synchronized(lock) {
490 newState = nextViewState.copy()
491 }
492
493 resolveState(newState)
494 }
495
496 @UiThread
497 private fun resolveState(state: ViewState) {
498 dlog("resolveState $state")
499 if (!state.viewInitialized) {
500 dlog("resolveState: view is not initialized. skipping")
501 return
502 }
503
504 if (state == currentViewState) {
505 dlog("resolveState: skipping")
506 return
507 }
508
509 val designatedCornerChanged = state.designatedCorner != currentViewState.designatedCorner
510 val rotationChanged = state.rotation != currentViewState.rotation
511 if (rotationChanged || (designatedCornerChanged && privacyDotUnfoldWrongCornerFix())) {
512 // A rotation has started, hide the views to avoid flicker
513 updateRotations(state.rotation, state.paddingTop)
514 }
515
516 if (state.needsLayout(currentViewState)) {
517 setCornerSizes(state)
518 views.forEach { it.requestLayout() }
519 }
520
521 if (designatedCornerChanged) {
522 currentViewState.designatedCorner?.contentDescription = null
523 state.designatedCorner?.contentDescription = state.contentDescription
524
525 updateDesignatedCorner(state.designatedCorner, state.shouldShowDot())
526 } else if (state.contentDescription != currentViewState.contentDescription) {
527 state.designatedCorner?.contentDescription = state.contentDescription
528 }
529
530 updateDotView(state)
531
532 currentViewState = state
533 }
534
535 @UiThread
536 open fun updateDotView(state: ViewState) {
537 val shouldShow = state.shouldShowDot()
538 if (shouldShow != currentViewState.shouldShowDot()) {
539 if (shouldShow && state.designatedCorner != null) {
540 showDotView(state.designatedCorner, true)
541 } else if (!shouldShow && state.designatedCorner != null) {
542 hideDotView(state.designatedCorner, true)
543 }
544 }
545 }
546
547 private val systemStatusAnimationCallback: SystemStatusAnimationCallback =
548 object : SystemStatusAnimationCallback {
549 override fun onSystemStatusAnimationTransitionToPersistentDot(
550 contentDescr: String?
551 ): Animator? {
552 synchronized(lock) {
553 nextViewState = nextViewState.copy(
554 systemPrivacyEventIsActive = true,
555 contentDescription = contentDescr)
556 }
557
558 return null
559 }
560
561 override fun onHidePersistentDot(): Animator? {
562 synchronized(lock) {
563 nextViewState = nextViewState.copy(systemPrivacyEventIsActive = false)
564 }
565
566 return null
567 }
568 }
569
570 private fun View?.cornerIndex(): Int {
571 if (this != null) {
572 return cornerForView(this)
573 }
574 return -1
575 }
576
577 // Returns [left, top, right, bottom] aka [seascape, none, landscape, upside-down]
578 private fun getLayoutRects(): List<Rect> {
579 val left = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_SEASCAPE)
580 val top = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_NONE)
581 val right = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_LANDSCAPE)
582 val bottom = contentInsetsProvider
583 .getStatusBarContentAreaForRotation(ROTATION_UPSIDE_DOWN)
584
585 return listOf(left, top, right, bottom)
586 }
587
588 private fun setNewLayoutRects() {
589 val rects = getLayoutRects()
590
591 synchronized(lock) {
592 nextViewState = nextViewState.copy(
593 seascapeRect = rects[0],
594 portraitRect = rects[1],
595 landscapeRect = rects[2],
596 upsideDownRect = rects[3]
597 )
598 }
599 }
600
601 interface ShowingListener {
602 fun onPrivacyDotShown(v: View?)
603 fun onPrivacyDotHidden(v: View?)
604 }
605 }
606
dlognull607 private fun dlog(s: String) {
608 if (DEBUG) {
609 Log.d(TAG, s)
610 }
611 }
612
vlognull613 private fun vlog(s: String) {
614 if (DEBUG_VERBOSE) {
615 Log.d(TAG, s)
616 }
617 }
618
619 const val TOP_LEFT = 0
620 const val TOP_RIGHT = 1
621 const val BOTTOM_RIGHT = 2
622 const val BOTTOM_LEFT = 3
623 private const val DURATION = 160L
624 private const val TAG = "PrivacyDotViewController"
625 private const val DEBUG = false
626 private const val DEBUG_VERBOSE = false
627
toGravitynull628 private fun Int.toGravity(): Int {
629 return when (this) {
630 TOP_LEFT -> Gravity.TOP or Gravity.LEFT
631 TOP_RIGHT -> Gravity.TOP or Gravity.RIGHT
632 BOTTOM_LEFT -> Gravity.BOTTOM or Gravity.LEFT
633 BOTTOM_RIGHT -> Gravity.BOTTOM or Gravity.RIGHT
634 else -> throw IllegalArgumentException("Not a corner")
635 }
636 }
637
Intnull638 private fun Int.innerGravity(): Int {
639 return when (this) {
640 TOP_LEFT -> Gravity.CENTER_VERTICAL or Gravity.RIGHT
641 TOP_RIGHT -> Gravity.CENTER_VERTICAL or Gravity.LEFT
642 BOTTOM_LEFT -> Gravity.CENTER_VERTICAL or Gravity.RIGHT
643 BOTTOM_RIGHT -> Gravity.CENTER_VERTICAL or Gravity.LEFT
644 else -> throw IllegalArgumentException("Not a corner")
645 }
646 }
647
648 data class ViewState(
649 val viewInitialized: Boolean = false,
650
651 val systemPrivacyEventIsActive: Boolean = false,
652 val shadeExpanded: Boolean = false,
653 val qsExpanded: Boolean = false,
654
655 val portraitRect: Rect? = null,
656 val landscapeRect: Rect? = null,
657 val upsideDownRect: Rect? = null,
658 val seascapeRect: Rect? = null,
659 val layoutRtl: Boolean = false,
660
661 val rotation: Int = 0,
662 val paddingTop: Int = 0,
663 val cornerIndex: Int = -1,
664 val designatedCorner: View? = null,
665
666 val contentDescription: String? = null
667 ) {
shouldShowDotnull668 fun shouldShowDot(): Boolean {
669 return systemPrivacyEventIsActive && !shadeExpanded && !qsExpanded
670 }
671
needsLayoutnull672 fun needsLayout(other: ViewState): Boolean {
673 return rotation != other.rotation ||
674 layoutRtl != other.layoutRtl ||
675 portraitRect != other.portraitRect ||
676 landscapeRect != other.landscapeRect ||
677 upsideDownRect != other.upsideDownRect ||
678 seascapeRect != other.seascapeRect
679 }
680
contentRectForRotationnull681 fun contentRectForRotation(@Rotation rot: Int): Rect {
682 return when (rot) {
683 ROTATION_NONE -> portraitRect!!
684 ROTATION_LANDSCAPE -> landscapeRect!!
685 ROTATION_UPSIDE_DOWN -> upsideDownRect!!
686 ROTATION_SEASCAPE -> seascapeRect!!
687 else -> throw IllegalArgumentException("not a rotation ($rot)")
688 }
689 }
690 }
691