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