• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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