• 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.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