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