1 /*
2  * Copyright (C) 2023 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.shade
18 
19 import android.content.Context
20 import android.graphics.Rect
21 import android.os.PowerManager
22 import android.os.SystemClock
23 import android.util.ArraySet
24 import android.view.GestureDetector
25 import android.view.MotionEvent
26 import android.view.View
27 import android.view.ViewGroup
28 import android.widget.FrameLayout
29 import androidx.activity.OnBackPressedDispatcher
30 import androidx.activity.OnBackPressedDispatcherOwner
31 import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
32 import androidx.compose.ui.platform.ComposeView
33 import androidx.lifecycle.Lifecycle
34 import androidx.lifecycle.LifecycleOwner
35 import androidx.lifecycle.LifecycleRegistry
36 import androidx.lifecycle.lifecycleScope
37 import androidx.lifecycle.repeatOnLifecycle
38 import com.android.compose.theme.PlatformTheme
39 import com.android.internal.annotations.VisibleForTesting
40 import com.android.systemui.Flags.glanceableHubFullscreenSwipe
41 import com.android.systemui.ambient.touch.TouchMonitor
42 import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent
43 import com.android.systemui.communal.dagger.Communal
44 import com.android.systemui.communal.domain.interactor.CommunalInteractor
45 import com.android.systemui.communal.ui.compose.CommunalContainer
46 import com.android.systemui.communal.ui.compose.CommunalContent
47 import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
48 import com.android.systemui.communal.util.CommunalColors
49 import com.android.systemui.dagger.SysUISingleton
50 import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor
51 import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
52 import com.android.systemui.keyguard.shared.model.KeyguardState
53 import com.android.systemui.lifecycle.repeatWhenAttached
54 import com.android.systemui.res.R
55 import com.android.systemui.scene.shared.flag.SceneContainerFlag
56 import com.android.systemui.scene.shared.model.SceneDataSourceDelegator
57 import com.android.systemui.shade.domain.interactor.ShadeInteractor
58 import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController
59 import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf
60 import com.android.systemui.util.kotlin.BooleanFlowOperators.anyOf
61 import com.android.systemui.util.kotlin.BooleanFlowOperators.not
62 import com.android.systemui.util.kotlin.collectFlow
63 import java.util.function.Consumer
64 import javax.inject.Inject
65 import kotlinx.coroutines.flow.Flow
66 import kotlinx.coroutines.launch
67 
68 /**
69  * Controller that's responsible for the glanceable hub container view and its touch handling.
70  *
71  * This will be used until the glanceable hub is integrated into Flexiglass.
72  */
73 @SysUISingleton
74 class GlanceableHubContainerController
75 @Inject
76 constructor(
77     private val communalInteractor: CommunalInteractor,
78     private val communalViewModel: CommunalViewModel,
79     private val keyguardInteractor: KeyguardInteractor,
80     private val shadeInteractor: ShadeInteractor,
81     private val powerManager: PowerManager,
82     private val communalColors: CommunalColors,
83     private val ambientTouchComponentFactory: AmbientTouchComponent.Factory,
84     private val communalContent: CommunalContent,
85     @Communal private val dataSourceDelegator: SceneDataSourceDelegator,
86     private val notificationStackScrollLayoutController: NotificationStackScrollLayoutController,
87 ) : LifecycleOwner {
88 
89     private class CommunalWrapper(context: Context) : FrameLayout(context) {
90         private val consumers: MutableSet<Consumer<Boolean>> = ArraySet()
91 
requestDisallowInterceptTouchEventnull92         override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
93             consumers.forEach { it.accept(disallowIntercept) }
94             super.requestDisallowInterceptTouchEvent(disallowIntercept)
95         }
96 
dispatchTouchEventnull97         fun dispatchTouchEvent(
98             ev: MotionEvent?,
99             disallowInterceptConsumer: Consumer<Boolean>?
100         ): Boolean {
101             disallowInterceptConsumer?.apply { consumers.add(this) }
102 
103             try {
104                 return super.dispatchTouchEvent(ev)
105             } finally {
106                 consumers.clear()
107             }
108         }
109     }
110 
111     /** The container view for the hub. This will not be initialized until [initView] is called. */
112     private var communalContainerView: View? = null
113 
114     /** Wrapper around the communal container to intercept touch events */
115     private var communalContainerWrapper: CommunalWrapper? = null
116 
117     /**
118      * This lifecycle is used to control when the [touchMonitor] listens to touches. The lifecycle
119      * should only be [Lifecycle.State.RESUMED] when the hub is showing and not covered by anything,
120      * such as the notification shade or bouncer.
121      */
122     private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this)
123 
124     /**
125      * This [TouchMonitor] listens for top and bottom swipe gestures globally when the hub is open.
126      * When a top or bottom swipe is detected, they will be intercepted and used to open the
127      * notification shade/bouncer.
128      */
129     private var touchMonitor: TouchMonitor? = null
130 
131     /**
132      * The width of the area in which a right edge swipe can open the hub, in pixels. Read from
133      * resources when [initView] is called.
134      */
135     // TODO(b/320786721): support RTL layouts
136     private var rightEdgeSwipeRegionWidth: Int = 0
137 
138     /**
139      * True if we are currently tracking a touch intercepted by the hub, either because the hub is
140      * open or being opened.
141      */
142     private var isTrackingHubTouch = false
143 
144     /**
145      * True if the hub UI is fully open, meaning it should receive touch input.
146      *
147      * Tracks [CommunalInteractor.isCommunalShowing].
148      */
149     private var hubShowing = false
150 
151     /**
152      * True if either the primary or alternate bouncer are open, meaning the hub should not receive
153      * any touch input.
154      *
155      * Tracks [KeyguardTransitionInteractor.isFinishedInState] for [KeyguardState.isBouncerState].
156      */
157     private var anyBouncerShowing = false
158 
159     /**
160      * True if the shade is fully expanded and the user is not interacting with it anymore, meaning
161      * the hub should not receive any touch input.
162      *
163      * We need to not pause the touch handling lifecycle as soon as the shade opens because if the
164      * user swipes down, then back up without lifting their finger, the lifecycle will be paused
165      * then resumed, and resuming force-stops all active touch sessions. This means the shade will
166      * not receive the end of the gesture and will be stuck open.
167      *
168      * Based on [ShadeInteractor.isAnyFullyExpanded] and [ShadeInteractor.isUserInteracting].
169      */
170     private var shadeShowing = false
171 
172     /**
173      * True if the device is dreaming, in which case we shouldn't do anything for top/bottom swipes
174      * and just let the dream overlay's touch handling deal with them.
175      *
176      * Tracks [KeyguardInteractor.isDreaming].
177      */
178     private var isDreaming = false
179 
180     /** Returns a flow that tracks whether communal hub is available. */
communalAvailablenull181     fun communalAvailable(): Flow<Boolean> =
182         anyOf(communalInteractor.isCommunalAvailable, communalInteractor.editModeOpen)
183 
184     /**
185      * Creates the container view containing the glanceable hub UI.
186      *
187      * @throws RuntimeException if the view is already initialized
188      */
189     fun initView(
190         context: Context,
191     ): View {
192         return initView(
193             ComposeView(context).apply {
194                 repeatWhenAttached {
195                     lifecycleScope.launch {
196                         repeatOnLifecycle(Lifecycle.State.CREATED) {
197                             setViewTreeOnBackPressedDispatcherOwner(
198                                 object : OnBackPressedDispatcherOwner {
199                                     override val onBackPressedDispatcher =
200                                         OnBackPressedDispatcher().apply {
201                                             setOnBackInvokedDispatcher(
202                                                 viewRootImpl.onBackInvokedDispatcher
203                                             )
204                                         }
205 
206                                     override val lifecycle: Lifecycle =
207                                         this@repeatWhenAttached.lifecycle
208                                 }
209                             )
210 
211                             setContent {
212                                 PlatformTheme {
213                                     CommunalContainer(
214                                         viewModel = communalViewModel,
215                                         colors = communalColors,
216                                         dataSourceDelegator = dataSourceDelegator,
217                                         content = communalContent,
218                                     )
219                                 }
220                             }
221                         }
222                     }
223                 }
224             }
225         )
226     }
227 
228     /** Override for testing. */
229     @VisibleForTesting
initViewnull230     internal fun initView(containerView: View): View {
231         SceneContainerFlag.assertInLegacyMode()
232         if (communalContainerView != null) {
233             throw RuntimeException("Communal view has already been initialized")
234         }
235 
236         if (touchMonitor == null) {
237             touchMonitor =
238                 ambientTouchComponentFactory.create(this, HashSet()).getTouchMonitor().apply {
239                     init()
240                 }
241         }
242         lifecycleRegistry.currentState = Lifecycle.State.CREATED
243 
244         communalContainerView = containerView
245 
246         rightEdgeSwipeRegionWidth =
247             containerView.resources.getDimensionPixelSize(
248                 R.dimen.communal_right_edge_swipe_region_width
249             )
250 
251         val topEdgeSwipeRegionWidth =
252             containerView.resources.getDimensionPixelSize(
253                 R.dimen.communal_top_edge_swipe_region_height
254             )
255         val bottomEdgeSwipeRegionWidth =
256             containerView.resources.getDimensionPixelSize(
257                 R.dimen.communal_bottom_edge_swipe_region_height
258             )
259 
260         // BouncerSwipeTouchHandler has a larger gesture area than we want, set an exclusion area so
261         // the gesture area doesn't overlap with widgets.
262         // TODO(b/323035776): adjust gesture area for portrait mode
263         containerView.repeatWhenAttached {
264             // Run when the touch handling lifecycle is RESUMED, meaning the hub is visible and not
265             // occluded.
266             lifecycleRegistry.repeatOnLifecycle(Lifecycle.State.RESUMED) {
267                 val exclusionRect =
268                     Rect(
269                         0,
270                         topEdgeSwipeRegionWidth,
271                         containerView.right,
272                         containerView.bottom - bottomEdgeSwipeRegionWidth
273                     )
274 
275                 containerView.systemGestureExclusionRects = listOf(exclusionRect)
276             }
277         }
278 
279         // Listen to bouncer visibility directly as these flows become true as soon as any portion
280         // of the bouncers are visible when the transition starts. The keyguard transition state
281         // only changes once transitions are fully finished, which would mean touches during a
282         // transition to the bouncer would be incorrectly intercepted by the hub.
283         collectFlow(
284             containerView,
285             anyOf(
286                 keyguardInteractor.primaryBouncerShowing,
287                 keyguardInteractor.alternateBouncerShowing
288             ),
289             {
290                 anyBouncerShowing = it
291                 updateTouchHandlingState()
292             }
293         )
294         collectFlow(
295             containerView,
296             communalInteractor.isCommunalVisible,
297             {
298                 hubShowing = it
299                 updateTouchHandlingState()
300             }
301         )
302         collectFlow(
303             containerView,
304             allOf(shadeInteractor.isAnyFullyExpanded, not(shadeInteractor.isUserInteracting)),
305             {
306                 shadeShowing = it
307                 updateTouchHandlingState()
308             }
309         )
310         collectFlow(containerView, keyguardInteractor.isDreaming, { isDreaming = it })
311 
312         if (glanceableHubFullscreenSwipe()) {
313             communalContainerWrapper = CommunalWrapper(containerView.context)
314             communalContainerWrapper?.addView(communalContainerView)
315             return communalContainerWrapper!!
316         } else {
317             return containerView
318         }
319     }
320 
321     /**
322      * Updates the lifecycle stored by the [lifecycleRegistry] to control when the [touchMonitor]
323      * should listen for and intercept top and bottom swipes.
324      *
325      * Also clears gesture exclusion zones when the hub is occluded or gone.
326      */
updateTouchHandlingStatenull327     private fun updateTouchHandlingState() {
328         val shouldInterceptGestures = hubShowing && !(shadeShowing || anyBouncerShowing)
329         if (shouldInterceptGestures) {
330             lifecycleRegistry.currentState = Lifecycle.State.RESUMED
331         } else {
332             // Hub is either occluded or no longer showing, turn off touch handling.
333             lifecycleRegistry.currentState = Lifecycle.State.STARTED
334 
335             // Clear exclusion rects if the hub is not showing or is covered, so we don't interfere
336             // with back gestures when the bouncer or shade. We do this here instead of with
337             // repeatOnLifecycle as repeatOnLifecycle does not run when going from RESUMED back to
338             // STARTED, only when going from CREATED to STARTED.
339             communalContainerView!!.systemGestureExclusionRects = emptyList()
340         }
341     }
342 
343     /** Removes the container view from its parent. */
disposeViewnull344     fun disposeView() {
345         SceneContainerFlag.assertInLegacyMode()
346         communalContainerView?.let {
347             (it.parent as ViewGroup).removeView(it)
348             lifecycleRegistry.currentState = Lifecycle.State.CREATED
349             communalContainerView = null
350         }
351 
352         communalContainerWrapper?.let {
353             (it.parent as ViewGroup).removeView(it)
354             communalContainerWrapper = null
355         }
356     }
357 
358     /**
359      * Notifies the hub container of a touch event. Returns true if it's determined that the touch
360      * should go to the hub container and no one else.
361      *
362      * Special handling is needed because the hub container sits at the lowest z-order in
363      * [NotificationShadeWindowView] and would not normally receive touches. We also cannot use a
364      * [GestureDetector] as the hub container's SceneTransitionLayout is a Compose view that expects
365      * to be fully in control of its own touch handling.
366      */
onTouchEventnull367     fun onTouchEvent(ev: MotionEvent): Boolean {
368         SceneContainerFlag.assertInLegacyMode()
369 
370         // In the case that we are handling full swipes on the lockscreen, are on the lockscreen,
371         // and the touch is within the horizontal notification band on the screen, do not process
372         // the touch.
373         if (
374             glanceableHubFullscreenSwipe() &&
375                 !hubShowing &&
376                 !notificationStackScrollLayoutController.isBelowLastNotification(ev.x, ev.y)
377         ) {
378             return false
379         }
380 
381         return communalContainerView?.let { handleTouchEventOnCommunalView(it, ev) } ?: false
382     }
383 
handleTouchEventOnCommunalViewnull384     private fun handleTouchEventOnCommunalView(view: View, ev: MotionEvent): Boolean {
385         val isDown = ev.actionMasked == MotionEvent.ACTION_DOWN
386         val isUp = ev.actionMasked == MotionEvent.ACTION_UP
387         val isCancel = ev.actionMasked == MotionEvent.ACTION_CANCEL
388 
389         val hubOccluded = anyBouncerShowing || shadeShowing
390 
391         if (isDown && !hubOccluded) {
392             if (glanceableHubFullscreenSwipe()) {
393                 isTrackingHubTouch = true
394             } else {
395                 val x = ev.rawX
396                 val inOpeningSwipeRegion: Boolean = x >= view.width - rightEdgeSwipeRegionWidth
397                 if (inOpeningSwipeRegion || hubShowing) {
398                     // Steal touch events when the hub is open, or if the touch started in the
399                     // opening gesture region.
400                     isTrackingHubTouch = true
401                 }
402             }
403         }
404 
405         if (isTrackingHubTouch) {
406             if (isUp || isCancel) {
407                 isTrackingHubTouch = false
408             }
409             return dispatchTouchEvent(view, ev)
410         }
411 
412         return false
413     }
414 
415     /**
416      * Dispatches the touch event to the communal container and sends a user activity event to reset
417      * the screen timeout.
418      */
dispatchTouchEventnull419     private fun dispatchTouchEvent(view: View, ev: MotionEvent): Boolean {
420         try {
421             var handled = false
422             if (glanceableHubFullscreenSwipe()) {
423                 communalContainerWrapper?.dispatchTouchEvent(ev) {
424                     if (it) {
425                         handled = true
426                     }
427                 }
428                 return handled || hubShowing
429             } else {
430                 view.dispatchTouchEvent(ev)
431                 // Return true regardless of dispatch result as some touches at the start of a
432                 // gesture
433                 // may return false from dispatchTouchEvent.
434                 return true
435             }
436         } finally {
437             powerManager.userActivity(
438                 SystemClock.uptimeMillis(),
439                 PowerManager.USER_ACTIVITY_EVENT_TOUCH,
440                 0
441             )
442         }
443     }
444 
445     override val lifecycle: Lifecycle
446         get() = lifecycleRegistry
447 }
448