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