/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.statusbar.events import androidx.core.animation.Animator import android.annotation.UiThread import android.graphics.Point import android.graphics.Rect import android.util.Log import android.view.Gravity import android.view.View import android.widget.FrameLayout import com.android.internal.annotations.GuardedBy import com.android.systemui.R import com.android.app.animation.Interpolators import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.plugins.statusbar.StatusBarStateController import com.android.systemui.shade.ShadeExpansionStateManager import com.android.systemui.statusbar.StatusBarState.SHADE import com.android.systemui.statusbar.StatusBarState.SHADE_LOCKED import com.android.systemui.statusbar.phone.StatusBarContentInsetsChangedListener import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider import com.android.systemui.statusbar.policy.ConfigurationController import com.android.systemui.util.concurrency.DelayableExecutor import com.android.systemui.util.leak.RotationUtils import com.android.systemui.util.leak.RotationUtils.ROTATION_LANDSCAPE import com.android.systemui.util.leak.RotationUtils.ROTATION_NONE import com.android.systemui.util.leak.RotationUtils.ROTATION_SEASCAPE import com.android.systemui.util.leak.RotationUtils.ROTATION_UPSIDE_DOWN import com.android.systemui.util.leak.RotationUtils.Rotation import java.util.concurrent.Executor import javax.inject.Inject /** * Understands how to keep the persistent privacy dot in the corner of the screen in * ScreenDecorations, which does not rotate with the device. * * The basic principle here is that each dot will sit in a box that is equal to the margins of the * status bar (specifically the status_bar_contents view in PhoneStatusBarView). Each dot container * will have its gravity set towards the corner (i.e., top-right corner gets top|right gravity), and * the contained ImageView will be set to center_vertical and away from the corner horizontally. The * Views will match the status bar top padding and status bar height so that the dot can appear to * reside directly after the status bar system contents (basically after the battery). * * NOTE: any operation that modifies views directly must run on the provided executor, because * these views are owned by ScreenDecorations and it runs in its own thread */ @SysUISingleton open class PrivacyDotViewController @Inject constructor( @Main private val mainExecutor: Executor, private val stateController: StatusBarStateController, private val configurationController: ConfigurationController, private val contentInsetsProvider: StatusBarContentInsetsProvider, private val animationScheduler: SystemStatusAnimationScheduler, shadeExpansionStateManager: ShadeExpansionStateManager ) { private lateinit var tl: View private lateinit var tr: View private lateinit var bl: View private lateinit var br: View // Only can be modified on @UiThread var currentViewState: ViewState = ViewState() get() = field @GuardedBy("lock") private var nextViewState: ViewState = currentViewState.copy() set(value) { field = value scheduleUpdate() } private val lock = Object() private var cancelRunnable: Runnable? = null // Privacy dots are created in ScreenDecoration's UiThread, which is not the main thread private var uiExecutor: DelayableExecutor? = null private val views: Sequence get() = if (!this::tl.isInitialized) sequenceOf() else sequenceOf(tl, tr, br, bl) var showingListener: ShowingListener? = null set(value) { field = value } get() = field init { contentInsetsProvider.addCallback(object : StatusBarContentInsetsChangedListener { override fun onStatusBarContentInsetsChanged() { dlog("onStatusBarContentInsetsChanged: ") setNewLayoutRects() } }) configurationController.addCallback(object : ConfigurationController.ConfigurationListener { override fun onLayoutDirectionChanged(isRtl: Boolean) { uiExecutor?.execute { // If rtl changed, hide all dotes until the next state resolves setCornerVisibilities(View.INVISIBLE) synchronized(this) { val corner = selectDesignatedCorner(nextViewState.rotation, isRtl) nextViewState = nextViewState.copy( layoutRtl = isRtl, designatedCorner = corner ) } } } }) stateController.addCallback(object : StatusBarStateController.StateListener { override fun onExpandedChanged(isExpanded: Boolean) { updateStatusBarState() } override fun onStateChanged(newState: Int) { updateStatusBarState() } }) shadeExpansionStateManager.addQsExpansionListener { isQsExpanded -> dlog("setQsExpanded $isQsExpanded") synchronized(lock) { nextViewState = nextViewState.copy(qsExpanded = isQsExpanded) } } } fun setUiExecutor(e: DelayableExecutor) { uiExecutor = e } fun getUiExecutor(): DelayableExecutor? { return uiExecutor } @UiThread fun setNewRotation(rot: Int) { dlog("updateRotation: $rot") val isRtl: Boolean synchronized(lock) { if (rot == nextViewState.rotation) { return } isRtl = nextViewState.layoutRtl } // If we rotated, hide all dotes until the next state resolves setCornerVisibilities(View.INVISIBLE) val newCorner = selectDesignatedCorner(rot, isRtl) val index = newCorner.cornerIndex() val paddingTop = contentInsetsProvider.getStatusBarPaddingTop(rot) synchronized(lock) { nextViewState = nextViewState.copy( rotation = rot, paddingTop = paddingTop, designatedCorner = newCorner, cornerIndex = index) } } @UiThread fun hideDotView(dot: View, animate: Boolean) { dot.clearAnimation() if (animate) { dot.animate() .setDuration(DURATION) .setInterpolator(Interpolators.ALPHA_OUT) .alpha(0f) .withEndAction { dot.visibility = View.INVISIBLE showingListener?.onPrivacyDotHidden(dot) } .start() } else { dot.visibility = View.INVISIBLE showingListener?.onPrivacyDotHidden(dot) } } @UiThread fun showDotView(dot: View, animate: Boolean) { dot.clearAnimation() if (animate) { dot.visibility = View.VISIBLE dot.alpha = 0f dot.animate() .alpha(1f) .setDuration(DURATION) .setInterpolator(Interpolators.ALPHA_IN) .start() } else { dot.visibility = View.VISIBLE dot.alpha = 1f } showingListener?.onPrivacyDotShown(dot) } // Update the gravity and margins of the privacy views @UiThread open fun updateRotations(rotation: Int, paddingTop: Int) { // To keep a view in the corner, its gravity is always the description of its current corner // Therefore, just figure out which view is in which corner. This turns out to be something // like (myCorner - rot) mod 4, where topLeft = 0, topRight = 1, etc. and portrait = 0, and // rotating the device counter-clockwise increments rotation by 1 views.forEach { corner -> corner.setPadding(0, paddingTop, 0, 0) val rotatedCorner = rotatedCorner(cornerForView(corner), rotation) (corner.layoutParams as FrameLayout.LayoutParams).apply { gravity = rotatedCorner.toGravity() } // Set the dot's view gravity to hug the status bar (corner.requireViewById(R.id.privacy_dot) .layoutParams as FrameLayout.LayoutParams) .gravity = rotatedCorner.innerGravity() } } @UiThread private fun updateCornerSizes(l: Int, r: Int, rotation: Int) { views.forEach { corner -> val rotatedCorner = rotatedCorner(cornerForView(corner), rotation) val w = widthForCorner(rotatedCorner, l, r) (corner.layoutParams as FrameLayout.LayoutParams).width = w } } @UiThread open fun setCornerSizes(state: ViewState) { // StatusBarContentInsetsProvider can tell us the location of the privacy indicator dot // in every rotation. The only thing we need to check is rtl val rtl = state.layoutRtl val size = Point() tl.context.display?.getRealSize(size) val currentRotation = RotationUtils.getExactRotation(tl.context) val displayWidth: Int val displayHeight: Int if (currentRotation == ROTATION_LANDSCAPE || currentRotation == ROTATION_SEASCAPE) { displayWidth = size.y displayHeight = size.x } else { displayWidth = size.x displayHeight = size.y } var rot = activeRotationForCorner(tl, rtl) var contentInsets = state.contentRectForRotation(rot) tl.setPadding(0, state.paddingTop, 0, 0) (tl.layoutParams as FrameLayout.LayoutParams).apply { height = contentInsets.height() if (rtl) { width = contentInsets.left } else { width = displayHeight - contentInsets.right } } rot = activeRotationForCorner(tr, rtl) contentInsets = state.contentRectForRotation(rot) tr.setPadding(0, state.paddingTop, 0, 0) (tr.layoutParams as FrameLayout.LayoutParams).apply { height = contentInsets.height() if (rtl) { width = contentInsets.left } else { width = displayWidth - contentInsets.right } } rot = activeRotationForCorner(br, rtl) contentInsets = state.contentRectForRotation(rot) br.setPadding(0, state.paddingTop, 0, 0) (br.layoutParams as FrameLayout.LayoutParams).apply { height = contentInsets.height() if (rtl) { width = contentInsets.left } else { width = displayHeight - contentInsets.right } } rot = activeRotationForCorner(bl, rtl) contentInsets = state.contentRectForRotation(rot) bl.setPadding(0, state.paddingTop, 0, 0) (bl.layoutParams as FrameLayout.LayoutParams).apply { height = contentInsets.height() if (rtl) { width = contentInsets.left } else { width = displayWidth - contentInsets.right } } } // Designated view will be the one at statusbar's view.END @UiThread private fun selectDesignatedCorner(r: Int, isRtl: Boolean): View? { if (!this::tl.isInitialized) { return null } return when (r) { 0 -> if (isRtl) tl else tr 1 -> if (isRtl) tr else br 2 -> if (isRtl) br else bl 3 -> if (isRtl) bl else tl else -> throw IllegalStateException("unknown rotation") } } // Track the current designated corner and maybe animate to a new rotation @UiThread private fun updateDesignatedCorner(newCorner: View?, shouldShowDot: Boolean) { if (shouldShowDot) { showingListener?.onPrivacyDotShown(newCorner) newCorner?.apply { clearAnimation() visibility = View.VISIBLE alpha = 0f animate() .alpha(1.0f) .setDuration(300) .start() } } } @UiThread private fun setCornerVisibilities(vis: Int) { views.forEach { corner -> corner.visibility = vis if (vis == View.VISIBLE) { showingListener?.onPrivacyDotShown(corner) } else { showingListener?.onPrivacyDotHidden(corner) } } } private fun cornerForView(v: View): Int { return when (v) { tl -> TOP_LEFT tr -> TOP_RIGHT bl -> BOTTOM_LEFT br -> BOTTOM_RIGHT else -> throw IllegalArgumentException("not a corner view") } } private fun rotatedCorner(corner: Int, rotation: Int): Int { var modded = corner - rotation if (modded < 0) { modded += 4 } return modded } @Rotation private fun activeRotationForCorner(corner: View, rtl: Boolean): Int { // Each corner will only be visible in a single rotation, based on rtl return when (corner) { tr -> if (rtl) ROTATION_LANDSCAPE else ROTATION_NONE tl -> if (rtl) ROTATION_NONE else ROTATION_SEASCAPE br -> if (rtl) ROTATION_UPSIDE_DOWN else ROTATION_LANDSCAPE else /* bl */ -> if (rtl) ROTATION_SEASCAPE else ROTATION_UPSIDE_DOWN } } private fun widthForCorner(corner: Int, left: Int, right: Int): Int { return when (corner) { TOP_LEFT, BOTTOM_LEFT -> left TOP_RIGHT, BOTTOM_RIGHT -> right else -> throw IllegalArgumentException("Unknown corner") } } fun initialize(topLeft: View, topRight: View, bottomLeft: View, bottomRight: View) { if (this::tl.isInitialized && this::tr.isInitialized && this::bl.isInitialized && this::br.isInitialized) { if (tl == topLeft && tr == topRight && bl == bottomLeft && br == bottomRight) { return } } tl = topLeft tr = topRight bl = bottomLeft br = bottomRight val rtl = configurationController.isLayoutRtl val dc = selectDesignatedCorner(0, rtl) val index = dc.cornerIndex() mainExecutor.execute { animationScheduler.addCallback(systemStatusAnimationCallback) } val left = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_SEASCAPE) val top = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_NONE) val right = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_LANDSCAPE) val bottom = contentInsetsProvider .getStatusBarContentAreaForRotation(ROTATION_UPSIDE_DOWN) val paddingTop = contentInsetsProvider.getStatusBarPaddingTop() synchronized(lock) { nextViewState = nextViewState.copy( viewInitialized = true, designatedCorner = dc, cornerIndex = index, seascapeRect = left, portraitRect = top, landscapeRect = right, upsideDownRect = bottom, paddingTop = paddingTop, layoutRtl = rtl ) } } private fun updateStatusBarState() { synchronized(lock) { nextViewState = nextViewState.copy(shadeExpanded = isShadeInQs()) } } /** * If we are unlocked with an expanded shade, QS is showing. On keyguard, the shade is always * expanded so we use other signals from the panel view controller to know if QS is expanded */ @GuardedBy("lock") private fun isShadeInQs(): Boolean { return (stateController.isExpanded && stateController.state == SHADE) || (stateController.state == SHADE_LOCKED) } private fun scheduleUpdate() { dlog("scheduleUpdate: ") cancelRunnable?.run() cancelRunnable = uiExecutor?.executeDelayed({ processNextViewState() }, 100) } @UiThread private fun processNextViewState() { dlog("processNextViewState: ") val newState: ViewState synchronized(lock) { newState = nextViewState.copy() } resolveState(newState) } @UiThread private fun resolveState(state: ViewState) { dlog("resolveState $state") if (!state.viewInitialized) { dlog("resolveState: view is not initialized. skipping") return } if (state == currentViewState) { dlog("resolveState: skipping") return } if (state.rotation != currentViewState.rotation) { // A rotation has started, hide the views to avoid flicker updateRotations(state.rotation, state.paddingTop) } if (state.needsLayout(currentViewState)) { setCornerSizes(state) views.forEach { it.requestLayout() } } if (state.designatedCorner != currentViewState.designatedCorner) { currentViewState.designatedCorner?.contentDescription = null state.designatedCorner?.contentDescription = state.contentDescription updateDesignatedCorner(state.designatedCorner, state.shouldShowDot()) } else if (state.contentDescription != currentViewState.contentDescription) { state.designatedCorner?.contentDescription = state.contentDescription } updateDotView(state) currentViewState = state } @UiThread open fun updateDotView(state: ViewState) { val shouldShow = state.shouldShowDot() if (shouldShow != currentViewState.shouldShowDot()) { if (shouldShow && state.designatedCorner != null) { showDotView(state.designatedCorner, true) } else if (!shouldShow && state.designatedCorner != null) { hideDotView(state.designatedCorner, true) } } } private val systemStatusAnimationCallback: SystemStatusAnimationCallback = object : SystemStatusAnimationCallback { override fun onSystemStatusAnimationTransitionToPersistentDot( contentDescr: String? ): Animator? { synchronized(lock) { nextViewState = nextViewState.copy( systemPrivacyEventIsActive = true, contentDescription = contentDescr) } return null } override fun onHidePersistentDot(): Animator? { synchronized(lock) { nextViewState = nextViewState.copy(systemPrivacyEventIsActive = false) } return null } } private fun View?.cornerIndex(): Int { if (this != null) { return cornerForView(this) } return -1 } // Returns [left, top, right, bottom] aka [seascape, none, landscape, upside-down] private fun getLayoutRects(): List { val left = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_SEASCAPE) val top = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_NONE) val right = contentInsetsProvider.getStatusBarContentAreaForRotation(ROTATION_LANDSCAPE) val bottom = contentInsetsProvider .getStatusBarContentAreaForRotation(ROTATION_UPSIDE_DOWN) return listOf(left, top, right, bottom) } private fun setNewLayoutRects() { val rects = getLayoutRects() synchronized(lock) { nextViewState = nextViewState.copy( seascapeRect = rects[0], portraitRect = rects[1], landscapeRect = rects[2], upsideDownRect = rects[3] ) } } interface ShowingListener { fun onPrivacyDotShown(v: View?) fun onPrivacyDotHidden(v: View?) } } private fun dlog(s: String) { if (DEBUG) { Log.d(TAG, s) } } private fun vlog(s: String) { if (DEBUG_VERBOSE) { Log.d(TAG, s) } } const val TOP_LEFT = 0 const val TOP_RIGHT = 1 const val BOTTOM_RIGHT = 2 const val BOTTOM_LEFT = 3 private const val DURATION = 160L private const val TAG = "PrivacyDotViewController" private const val DEBUG = false private const val DEBUG_VERBOSE = false private fun Int.toGravity(): Int { return when (this) { TOP_LEFT -> Gravity.TOP or Gravity.LEFT TOP_RIGHT -> Gravity.TOP or Gravity.RIGHT BOTTOM_LEFT -> Gravity.BOTTOM or Gravity.LEFT BOTTOM_RIGHT -> Gravity.BOTTOM or Gravity.RIGHT else -> throw IllegalArgumentException("Not a corner") } } private fun Int.innerGravity(): Int { return when (this) { TOP_LEFT -> Gravity.CENTER_VERTICAL or Gravity.RIGHT TOP_RIGHT -> Gravity.CENTER_VERTICAL or Gravity.LEFT BOTTOM_LEFT -> Gravity.CENTER_VERTICAL or Gravity.RIGHT BOTTOM_RIGHT -> Gravity.CENTER_VERTICAL or Gravity.LEFT else -> throw IllegalArgumentException("Not a corner") } } data class ViewState( val viewInitialized: Boolean = false, val systemPrivacyEventIsActive: Boolean = false, val shadeExpanded: Boolean = false, val qsExpanded: Boolean = false, val portraitRect: Rect? = null, val landscapeRect: Rect? = null, val upsideDownRect: Rect? = null, val seascapeRect: Rect? = null, val layoutRtl: Boolean = false, val rotation: Int = 0, val paddingTop: Int = 0, val cornerIndex: Int = -1, val designatedCorner: View? = null, val contentDescription: String? = null ) { fun shouldShowDot(): Boolean { return systemPrivacyEventIsActive && !shadeExpanded && !qsExpanded } fun needsLayout(other: ViewState): Boolean { return rotation != other.rotation || layoutRtl != other.layoutRtl || portraitRect != other.portraitRect || landscapeRect != other.landscapeRect || upsideDownRect != other.upsideDownRect || seascapeRect != other.seascapeRect } fun contentRectForRotation(@Rotation rot: Int): Rect { return when (rot) { ROTATION_NONE -> portraitRect!! ROTATION_LANDSCAPE -> landscapeRect!! ROTATION_UPSIDE_DOWN -> upsideDownRect!! ROTATION_SEASCAPE -> seascapeRect!! else -> throw IllegalArgumentException("not a rotation ($rot)") } } }