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