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