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