1 /* 2 * 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 593 private fun dlog(s: String) { 594 if (DEBUG) { 595 Log.d(TAG, s) 596 } 597 } 598 599 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 614 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 624 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 ) { 654 fun shouldShowDot(): Boolean { 655 return systemPrivacyEventIsActive && !shadeExpanded && !qsExpanded 656 } 657 658 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 667 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