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.temporarydisplay.chipbar 18 19 import android.animation.ObjectAnimator 20 import android.animation.ValueAnimator 21 import android.content.Context 22 import android.graphics.Rect 23 import android.os.PowerManager 24 import android.view.Gravity 25 import android.view.MotionEvent 26 import android.view.View 27 import android.view.View.ACCESSIBILITY_LIVE_REGION_ASSERTIVE 28 import android.view.View.ACCESSIBILITY_LIVE_REGION_NONE 29 import android.view.ViewGroup 30 import android.view.WindowManager 31 import android.view.accessibility.AccessibilityManager 32 import android.widget.ImageView 33 import android.widget.TextView 34 import androidx.annotation.IdRes 35 import androidx.annotation.VisibleForTesting 36 import com.android.internal.widget.CachingIconView 37 import com.android.systemui.Gefingerpoken 38 import com.android.systemui.R 39 import com.android.systemui.animation.Interpolators 40 import com.android.systemui.classifier.FalsingCollector 41 import com.android.systemui.common.shared.model.ContentDescription.Companion.loadContentDescription 42 import com.android.systemui.common.shared.model.Text.Companion.loadText 43 import com.android.systemui.common.ui.binder.TextViewBinder 44 import com.android.systemui.common.ui.binder.TintedIconViewBinder 45 import com.android.systemui.dagger.SysUISingleton 46 import com.android.systemui.dagger.qualifiers.Main 47 import com.android.systemui.dump.DumpManager 48 import com.android.systemui.plugins.FalsingManager 49 import com.android.systemui.statusbar.VibratorHelper 50 import com.android.systemui.statusbar.policy.ConfigurationController 51 import com.android.systemui.temporarydisplay.TemporaryViewDisplayController 52 import com.android.systemui.util.concurrency.DelayableExecutor 53 import com.android.systemui.util.time.SystemClock 54 import com.android.systemui.util.view.ViewUtil 55 import com.android.systemui.util.wakelock.WakeLock 56 import javax.inject.Inject 57 58 /** 59 * A coordinator for showing/hiding the chipbar. 60 * 61 * The chipbar is a UI element that displays on top of all content. It appears at the top of the 62 * screen and consists of an icon, one line of text, and an optional end icon or action. It will 63 * auto-dismiss after some amount of seconds. The user is *not* able to manually dismiss the 64 * chipbar. 65 * 66 * It should be only be used for critical and temporary information that the user *must* be aware 67 * of. In general, prefer using heads-up notifications, since they are dismissable and will remain 68 * in the list of notifications until the user dismisses them. 69 * 70 * Only one chipbar may be shown at a time. 71 */ 72 @SysUISingleton 73 open class ChipbarCoordinator 74 @Inject 75 constructor( 76 context: Context, 77 logger: ChipbarLogger, 78 windowManager: WindowManager, 79 @Main mainExecutor: DelayableExecutor, 80 accessibilityManager: AccessibilityManager, 81 configurationController: ConfigurationController, 82 dumpManager: DumpManager, 83 powerManager: PowerManager, 84 private val chipbarAnimator: ChipbarAnimator, 85 private val falsingManager: FalsingManager, 86 private val falsingCollector: FalsingCollector, 87 private val swipeChipbarAwayGestureHandler: SwipeChipbarAwayGestureHandler?, 88 private val viewUtil: ViewUtil, 89 private val vibratorHelper: VibratorHelper, 90 wakeLockBuilder: WakeLock.Builder, 91 systemClock: SystemClock, 92 ) : 93 TemporaryViewDisplayController<ChipbarInfo, ChipbarLogger>( 94 context, 95 logger, 96 windowManager, 97 mainExecutor, 98 accessibilityManager, 99 configurationController, 100 dumpManager, 101 powerManager, 102 R.layout.chipbar, 103 wakeLockBuilder, 104 systemClock, 105 ) { 106 107 private lateinit var parent: ChipbarRootView 108 109 /** The current loading information, or null we're not currently loading. */ 110 @VisibleForTesting 111 internal var loadingDetails: LoadingDetails? = null 112 private set(value) { 113 // Always cancel the old one before updating 114 field?.animator?.cancel() 115 field = value 116 } 117 118 override val windowLayoutParams = 119 commonWindowLayoutParams.apply { gravity = Gravity.TOP.or(Gravity.CENTER_HORIZONTAL) } 120 121 override fun updateView(newInfo: ChipbarInfo, currentView: ViewGroup) { 122 updateGestureListening() 123 124 logger.logViewUpdate( 125 newInfo.windowTitle, 126 newInfo.text.loadText(context), 127 when (newInfo.endItem) { 128 null -> "null" 129 is ChipbarEndItem.Loading -> "loading" 130 is ChipbarEndItem.Error -> "error" 131 is ChipbarEndItem.Button -> "button(${newInfo.endItem.text.loadText(context)})" 132 } 133 ) 134 135 currentView.setTag(INFO_TAG, newInfo) 136 137 // Detect falsing touches on the chip. 138 parent = currentView.requireViewById(R.id.chipbar_root_view) 139 parent.touchHandler = 140 object : Gefingerpoken { 141 override fun onTouchEvent(ev: MotionEvent?): Boolean { 142 falsingCollector.onTouchEvent(ev) 143 return false 144 } 145 } 146 147 // ---- Start icon ---- 148 val iconView = currentView.requireViewById<CachingIconView>(R.id.start_icon) 149 TintedIconViewBinder.bind(newInfo.startIcon, iconView) 150 151 // ---- Text ---- 152 val textView = currentView.requireViewById<TextView>(R.id.text) 153 TextViewBinder.bind(textView, newInfo.text) 154 // Updates text view bounds to make sure it perfectly fits the new text 155 // (If the new text is smaller than the previous text) see b/253228632. 156 textView.requestLayout() 157 158 // ---- End item ---- 159 // Loading 160 val isLoading = newInfo.endItem == ChipbarEndItem.Loading 161 val loadingView = currentView.requireViewById<ImageView>(R.id.loading) 162 loadingView.visibility = isLoading.visibleIfTrue() 163 164 if (isLoading) { 165 val currentLoadingDetails = loadingDetails 166 // Since there can be multiple chipbars, we need to check if the loading view is the 167 // same and possibly re-start the loading animation on the new view. 168 if (currentLoadingDetails == null || currentLoadingDetails.loadingView != loadingView) { 169 val newDetails = createLoadingDetails(loadingView) 170 newDetails.animator.start() 171 loadingDetails = newDetails 172 } 173 } else { 174 loadingDetails = null 175 } 176 177 // Error 178 currentView.requireViewById<View>(R.id.error).visibility = 179 (newInfo.endItem == ChipbarEndItem.Error).visibleIfTrue() 180 181 // Button 182 val buttonView = currentView.requireViewById<TextView>(R.id.end_button) 183 if (newInfo.endItem is ChipbarEndItem.Button) { 184 TextViewBinder.bind(buttonView, newInfo.endItem.text) 185 186 val onClickListener = 187 View.OnClickListener { clickedView -> 188 if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) 189 return@OnClickListener 190 newInfo.endItem.onClickListener.onClick(clickedView) 191 } 192 193 buttonView.setOnClickListener(onClickListener) 194 buttonView.visibility = View.VISIBLE 195 } else { 196 buttonView.visibility = View.GONE 197 } 198 199 // ---- Overall accessibility ---- 200 val iconDesc = newInfo.startIcon.icon.contentDescription 201 val loadedIconDesc = 202 if (iconDesc != null) { 203 "${iconDesc.loadContentDescription(context)} " 204 } else { 205 "" 206 } 207 val endItemDesc = 208 if (newInfo.endItem is ChipbarEndItem.Loading) { 209 ". ${context.resources.getString(R.string.media_transfer_loading)}." 210 } else { 211 "" 212 } 213 214 val chipInnerView = currentView.getInnerView() 215 chipInnerView.contentDescription = 216 "$loadedIconDesc${newInfo.text.loadText(context)}$endItemDesc" 217 chipInnerView.accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_ASSERTIVE 218 maybeGetAccessibilityFocus(newInfo, currentView) 219 220 // ---- Haptics ---- 221 newInfo.vibrationEffect?.let { vibratorHelper.vibrate(it) } 222 } 223 224 private fun maybeGetAccessibilityFocus(info: ChipbarInfo?, view: ViewGroup) { 225 // Don't steal focus unless the chipbar has something interactable. 226 // (The chipbar is marked as a live region, so its content will be announced whenever the 227 // content changes.) 228 if (info?.endItem is ChipbarEndItem.Button) { 229 view.getInnerView().requestAccessibilityFocus() 230 } else { 231 view.getInnerView().clearAccessibilityFocus() 232 } 233 } 234 235 override fun animateViewIn(view: ViewGroup) { 236 // We can only request focus once the animation finishes. 237 val onAnimationEnd = Runnable { 238 maybeGetAccessibilityFocus(view.getTag(INFO_TAG) as ChipbarInfo?, view) 239 } 240 val animatedIn = chipbarAnimator.animateViewIn(view.getInnerView(), onAnimationEnd) 241 242 // If the view doesn't get animated, the [onAnimationEnd] runnable won't get run and the 243 // views would remain un-displayed. So, just force-set/run those items immediately. 244 if (!animatedIn) { 245 logger.logAnimateInFailure() 246 chipbarAnimator.forceDisplayView(view.getInnerView()) 247 onAnimationEnd.run() 248 } 249 } 250 251 override fun animateViewOut(view: ViewGroup, removalReason: String?, onAnimationEnd: Runnable) { 252 val innerView = view.getInnerView() 253 innerView.accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_NONE 254 255 val fullEndRunnable = Runnable { 256 loadingDetails = null 257 onAnimationEnd.run() 258 } 259 val removed = chipbarAnimator.animateViewOut(innerView, fullEndRunnable) 260 // If the view doesn't get animated, the [onAnimationEnd] runnable won't get run. So, just 261 // run it immediately. 262 if (!removed) { 263 logger.logAnimateOutFailure() 264 fullEndRunnable.run() 265 } 266 267 updateGestureListening() 268 } 269 270 private fun updateGestureListening() { 271 if (swipeChipbarAwayGestureHandler == null) { 272 return 273 } 274 275 val currentDisplayInfo = getCurrentDisplayInfo() 276 if (currentDisplayInfo != null && currentDisplayInfo.info.allowSwipeToDismiss) { 277 swipeChipbarAwayGestureHandler.setViewFetcher { currentDisplayInfo.view } 278 swipeChipbarAwayGestureHandler.addOnGestureDetectedCallback(TAG) { 279 onSwipeUpGestureDetected() 280 } 281 } else { 282 swipeChipbarAwayGestureHandler.resetViewFetcher() 283 swipeChipbarAwayGestureHandler.removeOnGestureDetectedCallback(TAG) 284 } 285 } 286 287 private fun onSwipeUpGestureDetected() { 288 val currentDisplayInfo = getCurrentDisplayInfo() 289 if (currentDisplayInfo == null) { 290 logger.logSwipeGestureError(id = null, errorMsg = "No info is being displayed") 291 return 292 } 293 if (!currentDisplayInfo.info.allowSwipeToDismiss) { 294 logger.logSwipeGestureError( 295 id = currentDisplayInfo.info.id, 296 errorMsg = "This view prohibits swipe-to-dismiss", 297 ) 298 return 299 } 300 removeView(currentDisplayInfo.info.id, SWIPE_UP_GESTURE_REASON) 301 updateGestureListening() 302 } 303 304 private fun ViewGroup.getInnerView(): ViewGroup { 305 return this.requireViewById(R.id.chipbar_inner) 306 } 307 308 override fun getTouchableRegion(view: View, outRect: Rect) { 309 viewUtil.setRectToViewWindowLocation(view, outRect) 310 } 311 312 private fun Boolean.visibleIfTrue(): Int { 313 return if (this) { 314 View.VISIBLE 315 } else { 316 View.GONE 317 } 318 } 319 320 private fun createLoadingDetails(loadingView: View): LoadingDetails { 321 // Ideally, we would use a <ProgressBar> view, which would automatically handle the loading 322 // spinner rotation for us. However, due to b/243983980, the ProgressBar animation 323 // unexpectedly pauses when SysUI starts another window. ObjectAnimator is a workaround that 324 // won't pause. 325 val animator = 326 ObjectAnimator.ofFloat(loadingView, View.ROTATION, 0f, 360f).apply { 327 duration = LOADING_ANIMATION_DURATION_MS 328 repeatCount = ValueAnimator.INFINITE 329 interpolator = Interpolators.LINEAR 330 } 331 return LoadingDetails(loadingView, animator) 332 } 333 334 internal data class LoadingDetails( 335 val loadingView: View, 336 val animator: ObjectAnimator, 337 ) 338 } 339 340 @IdRes private val INFO_TAG = R.id.tag_chipbar_info 341 private const val SWIPE_UP_GESTURE_REASON = "SWIPE_UP_GESTURE_DETECTED" 342 private const val TAG = "ChipbarCoordinator" 343 private const val LOADING_ANIMATION_DURATION_MS = 1000L 344