1 /*
<lambda>null2 * Copyright (C) 2024 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.qs.composefragment
18
19 import android.annotation.SuppressLint
20 import android.content.Context
21 import android.content.res.Configuration
22 import android.graphics.Canvas
23 import android.graphics.Path
24 import android.graphics.PointF
25 import android.graphics.Rect
26 import android.os.Bundle
27 import android.os.Trace
28 import android.util.IndentingPrintWriter
29 import android.view.LayoutInflater
30 import android.view.MotionEvent
31 import android.view.View
32 import android.view.ViewConfiguration
33 import android.view.ViewGroup
34 import android.widget.FrameLayout
35 import androidx.activity.OnBackPressedDispatcher
36 import androidx.activity.OnBackPressedDispatcherOwner
37 import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
38 import androidx.annotation.VisibleForTesting
39 import androidx.compose.animation.core.tween
40 import androidx.compose.foundation.ScrollState
41 import androidx.compose.foundation.isSystemInDarkTheme
42 import androidx.compose.foundation.layout.Arrangement.spacedBy
43 import androidx.compose.foundation.layout.Box
44 import androidx.compose.foundation.layout.Column
45 import androidx.compose.foundation.layout.Row
46 import androidx.compose.foundation.layout.Spacer
47 import androidx.compose.foundation.layout.fillMaxSize
48 import androidx.compose.foundation.layout.fillMaxWidth
49 import androidx.compose.foundation.layout.offset
50 import androidx.compose.foundation.layout.padding
51 import androidx.compose.foundation.layout.requiredHeightIn
52 import androidx.compose.foundation.verticalScroll
53 import androidx.compose.material3.MaterialTheme
54 import androidx.compose.runtime.Composable
55 import androidx.compose.runtime.CompositionLocalProvider
56 import androidx.compose.runtime.DisposableEffect
57 import androidx.compose.runtime.LaunchedEffect
58 import androidx.compose.runtime.getValue
59 import androidx.compose.runtime.mutableFloatStateOf
60 import androidx.compose.runtime.mutableStateOf
61 import androidx.compose.runtime.remember
62 import androidx.compose.runtime.setValue
63 import androidx.compose.runtime.snapshotFlow
64 import androidx.compose.ui.Alignment
65 import androidx.compose.ui.Modifier
66 import androidx.compose.ui.graphics.Color
67 import androidx.compose.ui.graphics.graphicsLayer
68 import androidx.compose.ui.input.pointer.PointerEventPass
69 import androidx.compose.ui.input.pointer.PointerInputChange
70 import androidx.compose.ui.input.pointer.pointerInput
71 import androidx.compose.ui.layout.approachLayout
72 import androidx.compose.ui.layout.layout
73 import androidx.compose.ui.layout.onPlaced
74 import androidx.compose.ui.layout.onSizeChanged
75 import androidx.compose.ui.layout.positionInRoot
76 import androidx.compose.ui.layout.positionOnScreen
77 import androidx.compose.ui.platform.ComposeView
78 import androidx.compose.ui.platform.LocalConfiguration
79 import androidx.compose.ui.platform.LocalContext
80 import androidx.compose.ui.res.dimensionResource
81 import androidx.compose.ui.res.stringResource
82 import androidx.compose.ui.semantics.CustomAccessibilityAction
83 import androidx.compose.ui.semantics.customActions
84 import androidx.compose.ui.semantics.semantics
85 import androidx.compose.ui.unit.Dp
86 import androidx.compose.ui.unit.IntOffset
87 import androidx.compose.ui.unit.dp
88 import androidx.compose.ui.unit.round
89 import androidx.compose.ui.util.fastRoundToInt
90 import androidx.compose.ui.viewinterop.AndroidView
91 import androidx.lifecycle.Lifecycle
92 import androidx.lifecycle.compose.collectAsStateWithLifecycle
93 import androidx.lifecycle.lifecycleScope
94 import androidx.lifecycle.repeatOnLifecycle
95 import com.android.compose.animation.scene.ContentKey
96 import com.android.compose.animation.scene.ContentScope
97 import com.android.compose.animation.scene.ElementKey
98 import com.android.compose.animation.scene.ElementMatcher
99 import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
100 import com.android.compose.animation.scene.SceneKey
101 import com.android.compose.animation.scene.SceneTransitionLayout
102 import com.android.compose.animation.scene.content.state.TransitionState
103 import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
104 import com.android.compose.animation.scene.transitions
105 import com.android.compose.modifiers.height
106 import com.android.compose.modifiers.padding
107 import com.android.compose.modifiers.thenIf
108 import com.android.compose.theme.PlatformTheme
109 import com.android.mechanics.GestureContext
110 import com.android.systemui.Dumpable
111 import com.android.systemui.Flags
112 import com.android.systemui.Flags.notificationShadeBlur
113 import com.android.systemui.brightness.ui.compose.BrightnessSliderContainer
114 import com.android.systemui.brightness.ui.compose.ContainerColors
115 import com.android.systemui.compose.modifiers.sysuiResTag
116 import com.android.systemui.dump.DumpManager
117 import com.android.systemui.keyboard.shortcut.ui.composable.InteractionsConfig
118 import com.android.systemui.keyboard.shortcut.ui.composable.ProvideShortcutHelperIndication
119 import com.android.systemui.lifecycle.repeatWhenAttached
120 import com.android.systemui.lifecycle.setSnapshotBinding
121 import com.android.systemui.media.controls.ui.view.MediaHost
122 import com.android.systemui.plugins.qs.QS
123 import com.android.systemui.plugins.qs.QSContainerController
124 import com.android.systemui.qs.composefragment.SceneKeys.QuickQuickSettings
125 import com.android.systemui.qs.composefragment.SceneKeys.QuickSettings
126 import com.android.systemui.qs.composefragment.SceneKeys.debugName
127 import com.android.systemui.qs.composefragment.SceneKeys.toIdleSceneKey
128 import com.android.systemui.qs.composefragment.ui.GridAnchor
129 import com.android.systemui.qs.composefragment.ui.NotificationScrimClipParams
130 import com.android.systemui.qs.composefragment.ui.quickQuickSettingsToQuickSettings
131 import com.android.systemui.qs.composefragment.ui.toEditMode
132 import com.android.systemui.qs.composefragment.viewmodel.QSFragmentComposeViewModel
133 import com.android.systemui.qs.flags.QSComposeFragment
134 import com.android.systemui.qs.footer.ui.compose.FooterActions
135 import com.android.systemui.qs.panels.ui.compose.EditMode
136 import com.android.systemui.qs.panels.ui.compose.QuickQuickSettings
137 import com.android.systemui.qs.panels.ui.compose.TileGrid
138 import com.android.systemui.qs.shared.ui.ElementKeys
139 import com.android.systemui.qs.ui.composable.QuickSettingsShade
140 import com.android.systemui.qs.ui.composable.QuickSettingsShade.systemGestureExclusionInShade
141 import com.android.systemui.qs.ui.composable.QuickSettingsTheme
142 import com.android.systemui.res.R
143 import com.android.systemui.util.LifecycleFragment
144 import com.android.systemui.util.animation.UniqueObjectHostView
145 import com.android.systemui.util.asIndenting
146 import com.android.systemui.util.printSection
147 import com.android.systemui.util.println
148 import java.io.PrintWriter
149 import java.util.function.Consumer
150 import javax.inject.Inject
151 import kotlinx.coroutines.CompletableDeferred
152 import kotlinx.coroutines.awaitCancellation
153 import kotlinx.coroutines.coroutineScope
154 import kotlinx.coroutines.flow.Flow
155 import kotlinx.coroutines.flow.MutableStateFlow
156 import kotlinx.coroutines.flow.collectLatest
157 import kotlinx.coroutines.flow.combine
158 import kotlinx.coroutines.flow.map
159 import kotlinx.coroutines.launch
160
161 @SuppressLint("ValidFragment")
162 class QSFragmentCompose
163 @Inject
164 constructor(
165 private val qsFragmentComposeViewModelFactory: QSFragmentComposeViewModel.Factory,
166 private val dumpManager: DumpManager,
167 ) : LifecycleFragment(), QS, Dumpable {
168
169 private val scrollListener = MutableStateFlow<QS.ScrollListener?>(null)
170 private val collapsedMediaVisibilityChangedListener =
171 MutableStateFlow<(Consumer<Boolean>)?>(null)
172 private val heightListener = MutableStateFlow<QS.HeightListener?>(null)
173 private val qsContainerController = MutableStateFlow<QSContainerController?>(null)
174
175 private lateinit var viewModel: QSFragmentComposeViewModel
176
177 private val qqsVisible = MutableStateFlow(false)
178 private val qqsPositionOnRoot = Rect()
179 private val composeViewPositionOnScreen = Rect()
180 private val scrollState = ScrollState(0)
181 private val locationTemp = IntArray(2)
182
183 // Inside object for namespacing
184 private val notificationScrimClippingParams =
185 object {
186 var isEnabled by mutableStateOf(false)
187 var params by mutableStateOf(NotificationScrimClipParams())
188
189 fun dump(pw: IndentingPrintWriter) {
190 pw.printSection("NotificationScrimClippingParams") {
191 pw.println("isEnabled", isEnabled)
192 pw.println("params", params)
193 }
194 }
195 }
196
197 override fun onStart() {
198 super.onStart()
199 registerDumpable()
200 }
201
202 override fun onCreate(savedInstanceState: Bundle?) {
203 super.onCreate(savedInstanceState)
204
205 QSComposeFragment.isUnexpectedlyInLegacyMode()
206 viewModel = qsFragmentComposeViewModelFactory.create(lifecycleScope)
207
208 setListenerCollections()
209 lifecycleScope.launch { viewModel.activate() }
210 }
211
212 override fun onCreateView(
213 inflater: LayoutInflater,
214 container: ViewGroup?,
215 savedInstanceState: Bundle?,
216 ): View {
217 val context = inflater.context
218 val composeView =
219 ComposeView(context).apply {
220 id = R.id.quick_settings_container
221 repeatWhenAttached {
222 repeatOnLifecycle(Lifecycle.State.CREATED) {
223 setViewTreeOnBackPressedDispatcherOwner(
224 object : OnBackPressedDispatcherOwner {
225 override val onBackPressedDispatcher =
226 OnBackPressedDispatcher().apply {
227 setOnBackInvokedDispatcher(
228 it.viewRootImpl.onBackInvokedDispatcher
229 )
230 }
231
232 override val lifecycle: Lifecycle =
233 this@repeatWhenAttached.lifecycle
234 }
235 )
236 setContent { this@QSFragmentCompose.Content() }
237 }
238 }
239 }
240
241 val frame =
242 FrameLayoutTouchPassthrough(
243 context,
244 { notificationScrimClippingParams.isEnabled },
245 snapshotFlow { notificationScrimClippingParams.params },
246 // Only allow scrolling when we are fully expanded. That way, we don't intercept
247 // swipes in lockscreen (when somehow QS is receiving touches).
248 { (scrollState.canScrollForward && viewModel.isQsFullyExpanded) || isCustomizing },
249 viewModel::emitMotionEventForFalsingSwipeNested,
250 )
251 frame.addView(
252 composeView,
253 FrameLayout.LayoutParams.MATCH_PARENT,
254 FrameLayout.LayoutParams.MATCH_PARENT,
255 )
256 return frame
257 }
258
259 @Composable
260 private fun Content() {
261 PlatformTheme(isDarkTheme = if (notificationShadeBlur()) isSystemInDarkTheme() else true) {
262 ProvideShortcutHelperIndication(interactionsConfig = interactionsConfig()) {
263 // TODO(b/389985793): Make sure that there is no coroutine work or recompositions
264 // happening when alwaysCompose is true but isQsVisibleAndAnyShadeExpanded is false.
265 if (alwaysCompose || viewModel.isQsVisibleAndAnyShadeExpanded) {
266 Box(
267 modifier =
268 Modifier.thenIf(alwaysCompose) {
269 Modifier.layout { measurable, constraints ->
270 measurable.measure(constraints).run {
271 layout(width, height) {
272 if (viewModel.isQsVisibleAndAnyShadeExpanded) {
273 place(0, 0)
274 }
275 }
276 }
277 }
278 }
279 .graphicsLayer { alpha = viewModel.viewAlpha }
280 .thenIf(!Flags.notificationShadeBlur()) {
281 Modifier.offset {
282 IntOffset(
283 x = 0,
284 y = viewModel.viewTranslationY.fastRoundToInt(),
285 )
286 }
287 }
288 // Disable touches in the whole composable while the mirror is
289 // showing. While the mirror is showing, an ancestor of the
290 // ComposeView is made alpha 0, but touches are still being captured
291 // by the composables.
292 .gesturesDisabled(viewModel.showingMirror)
293 ) {
294 CollapsableQuickSettingsSTL()
295 }
296 }
297 }
298 }
299 }
300
301 /**
302 * STL that contains both QQS (tiles) and QS (brightness, tiles, footer actions), but no Edit
303 * mode. It tracks [QSFragmentComposeViewModel.expansionState] to drive the transition between
304 * [SceneKeys.QuickQuickSettings] and [SceneKeys.QuickSettings].
305 */
306 @Composable
307 private fun CollapsableQuickSettingsSTL() {
308 val nextCookie = remember {
309 object {
310 var value = 0
311 }
312 }
313 val transitionToCookie = remember { mutableMapOf<TransitionState.Transition, Int>() }
314 val sceneState =
315 rememberMutableSceneTransitionLayoutState(
316 initialScene = remember { viewModel.expansionState.toIdleSceneKey() },
317 transitions =
318 transitions {
319 from(QuickQuickSettings, QuickSettings) {
320 quickQuickSettingsToQuickSettings(viewModel::animateTilesExpansion::get)
321 }
322 to(SceneKeys.EditMode) {
323 spec = tween(durationMillis = EDIT_MODE_TIME_MILLIS)
324 toEditMode()
325 }
326 },
327 onTransitionStart = { transition ->
328 val cookie = nextCookie.value++
329 transitionToCookie[transition] = cookie
330 Trace.beginAsyncSection(
331 "CollapsableQuickSettingsSTL ${transition.debugName}",
332 cookie,
333 )
334 },
335 onTransitionEnd = { transition ->
336 Trace.endAsyncSection(
337 "CollapsableQuickSettingsSTL ${transition.debugName}",
338 transitionToCookie.remove(transition) ?: -1,
339 )
340 },
341 )
342
343 LaunchedEffect(Unit) {
344 synchronizeQsState(
345 sceneState,
346 viewModel.containerViewModel.editModeViewModel.isEditing,
347 snapshotFlow { viewModel.expansionState }.map { it.progress },
348 )
349 }
350
351 SceneTransitionLayout(state = sceneState, modifier = Modifier.fillMaxSize()) {
352 scene(QuickSettings, alwaysCompose = alwaysCompose) {
353 LaunchedEffect(Unit) { viewModel.onQSOpen() }
354 Element(QuickSettings.rootElementKey, Modifier) { QuickSettingsElement() }
355 }
356
357 scene(QuickQuickSettings, alwaysCompose = alwaysCompose) {
358 LaunchedEffect(Unit) { viewModel.onQQSOpen() }
359 // Cannot pass the element modifier in because the top element has a `testTag`
360 // and this would overwrite it.
361 Element(QuickQuickSettings.rootElementKey, Modifier) { QuickQuickSettingsElement() }
362 }
363
364 scene(SceneKeys.EditMode) {
365 Element(SceneKeys.EditMode.rootElementKey, Modifier) { EditModeElement() }
366 }
367 }
368 }
369
370 override fun setPanelView(notificationPanelView: QS.HeightListener?) {
371 heightListener.value = notificationPanelView
372 }
373
374 override fun hideImmediately() {
375 // view?.animate()?.cancel()
376 // view?.y = -qsMinExpansionHeight.toFloat()
377 }
378
379 override fun getQsMinExpansionHeight(): Int {
380 return if (viewModel.isInSplitShade) {
381 getQsMinExpansionHeightForSplitShade()
382 } else {
383 viewModel.qqsHeight
384 }
385 }
386
387 /**
388 * Returns the min expansion height for split shade.
389 *
390 * On split shade, QS is always expanded and goes from the top of the screen to the bottom of
391 * the QS container.
392 */
393 private fun getQsMinExpansionHeightForSplitShade(): Int {
394 view?.getLocationOnScreen(locationTemp)
395 val top = locationTemp.get(1)
396 // We want to get the original top position, so we subtract any translation currently set.
397 val originalTop = (top - (view?.translationY ?: 0f)).toInt()
398 // On split shade the QS view doesn't start at the top of the screen, so we need to add the
399 // top margin.
400 return originalTop + (view?.height ?: 0)
401 }
402
403 override fun getDesiredHeight(): Int {
404 /*
405 * Looking at the code, it seems that
406 * * If customizing, then the height is that of the view post-layout, which is set by
407 * QSContainerImpl.calculateContainerHeight, which is the height the customizer takes
408 * * If not customizing, it's the measured height. So we may want to surface that.
409 */
410 return view?.height ?: 0
411 }
412
413 override fun setHeightOverride(desiredHeight: Int) {
414 viewModel.heightOverride = desiredHeight
415 }
416
417 override fun setHeaderClickable(qsExpansionEnabled: Boolean) {
418 // Empty method
419 }
420
421 override fun isCustomizing(): Boolean {
422 return viewModel.isEditing
423 }
424
425 override fun closeCustomizer() {
426 viewModel.containerViewModel.editModeViewModel.stopEditing()
427 }
428
429 override fun setOverscrolling(overscrolling: Boolean) {
430 viewModel.isStackScrollerOverscrolling = overscrolling
431 }
432
433 override fun setExpanded(qsExpanded: Boolean) {
434 viewModel.isQsExpanded = qsExpanded
435 }
436
437 override fun setListening(listening: Boolean) {
438 // Not needed, views start listening and collection when composed
439 }
440
441 override fun setQsVisible(qsVisible: Boolean) {
442 viewModel.isQsVisible = qsVisible
443 }
444
445 override fun isShowingDetail(): Boolean {
446 return isCustomizing
447 }
448
449 override fun closeDetail() {
450 closeCustomizer()
451 }
452
453 override fun animateHeaderSlidingOut() {
454 // TODO(b/353254353)
455 }
456
457 override fun setQsExpansion(
458 qsExpansionFraction: Float,
459 panelExpansionFraction: Float,
460 headerTranslation: Float,
461 squishinessFraction: Float,
462 ) {
463 viewModel.setQsExpansionValue(qsExpansionFraction)
464 viewModel.panelExpansionFraction = panelExpansionFraction
465 viewModel.squishinessFraction = squishinessFraction
466 viewModel.proposedTranslation = headerTranslation
467 }
468
469 override fun setHeaderListening(listening: Boolean) {
470 // Not needed, header will start listening as soon as it's composed
471 }
472
473 override fun notifyCustomizeChanged() {
474 // Not needed, only called from inside customizer
475 }
476
477 override fun setContainerController(controller: QSContainerController?) {
478 qsContainerController.value = controller
479 }
480
481 override fun setCollapseExpandAction(action: Runnable?) {
482 viewModel.collapseExpandAccessibilityAction = action
483 }
484
485 override fun getHeightDiff(): Int {
486 return viewModel.heightDiff
487 }
488
489 override fun getHeader(): View? {
490 QSComposeFragment.isUnexpectedlyInLegacyMode()
491 return null
492 }
493
494 override fun setShouldUpdateSquishinessOnMedia(shouldUpdate: Boolean) {
495 viewModel.shouldUpdateSquishinessOnMedia = shouldUpdate
496 }
497
498 override fun setInSplitShade(isInSplitShade: Boolean) {
499 viewModel.isInSplitShade = isInSplitShade
500 }
501
502 override fun setTransitionToFullShadeProgress(
503 isTransitioningToFullShade: Boolean,
504 qsTransitionFraction: Float,
505 qsSquishinessFraction: Float,
506 ) {
507 viewModel.isTransitioningToFullShade = isTransitioningToFullShade
508 viewModel.lockscreenToShadeProgress = qsTransitionFraction
509 if (isTransitioningToFullShade) {
510 viewModel.squishinessFraction = qsSquishinessFraction
511 }
512 }
513
514 override fun setFancyClipping(
515 leftInset: Int,
516 top: Int,
517 rightInset: Int,
518 bottom: Int,
519 cornerRadius: Int,
520 visible: Boolean,
521 fullWidth: Boolean,
522 ) {
523 notificationScrimClippingParams.isEnabled = visible
524 notificationScrimClippingParams.params =
525 NotificationScrimClipParams(
526 top,
527 bottom,
528 if (fullWidth) 0 else leftInset,
529 if (fullWidth) 0 else rightInset,
530 cornerRadius,
531 )
532 }
533
534 override fun isFullyCollapsed(): Boolean {
535 return viewModel.isQsFullyCollapsed
536 }
537
538 override fun setCollapsedMediaVisibilityChangedListener(listener: Consumer<Boolean>?) {
539 collapsedMediaVisibilityChangedListener.value = listener
540 }
541
542 override fun setScrollListener(scrollListener: QS.ScrollListener?) {
543 this.scrollListener.value = scrollListener
544 }
545
546 override fun setOverScrollAmount(overScrollAmount: Int) {
547 viewModel.overScrollAmount = overScrollAmount
548 }
549
550 override fun setIsNotificationPanelFullWidth(isFullWidth: Boolean) {
551 viewModel.isSmallScreen = isFullWidth
552 }
553
554 override fun getHeaderTop(): Int {
555 return qqsPositionOnRoot.top
556 }
557
558 override fun getHeaderBottom(): Int {
559 return qqsPositionOnRoot.bottom
560 }
561
562 override fun getHeaderLeft(): Int {
563 return qqsPositionOnRoot.left
564 }
565
566 override fun getHeaderBoundsOnScreen(outBounds: Rect) {
567 outBounds.set(qqsPositionOnRoot)
568 view?.getBoundsOnScreen(composeViewPositionOnScreen)
569 ?: run { composeViewPositionOnScreen.setEmpty() }
570 outBounds.offset(composeViewPositionOnScreen.left, composeViewPositionOnScreen.top)
571 }
572
573 override fun isHeaderShown(): Boolean {
574 return qqsVisible.value
575 }
576
577 private fun setListenerCollections() {
578 lifecycleScope.launch {
579 lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
580 this@QSFragmentCompose.view?.setSnapshotBinding {
581 scrollListener.value?.onQsPanelScrollChanged(scrollState.value)
582 collapsedMediaVisibilityChangedListener.value?.accept(viewModel.qqsMediaVisible)
583 }
584 launch {
585 setListenerJob(
586 heightListener,
587 viewModel.containerViewModel.editModeViewModel.isEditing,
588 ) {
589 onQsHeightChanged()
590 }
591 }
592 launch {
593 setListenerJob(
594 qsContainerController,
595 viewModel.containerViewModel.editModeViewModel.isEditing,
596 ) {
597 setCustomizerShowing(it, EDIT_MODE_TIME_MILLIS.toLong())
598 }
599 }
600 }
601 }
602 }
603
604 @Composable
605 private fun ContentScope.QuickQuickSettingsElement(modifier: Modifier = Modifier) {
606 val qqsPadding = viewModel.qqsHeaderHeight
607 val bottomPadding = viewModel.qqsBottomPadding
608 DisposableEffect(Unit) {
609 qqsVisible.value = true
610
611 onDispose { qqsVisible.value = false }
612 }
613 val squishiness by
614 viewModel.quickQuickSettingsViewModel.squishinessViewModel.squishiness
615 .collectAsStateWithLifecycle()
616
617 Column(modifier = modifier.sysuiResTag(ResIdTags.quickQsPanel)) {
618 Box(
619 modifier =
620 Modifier.fillMaxWidth()
621 .onPlaced { coordinates ->
622 val (leftFromRoot, topFromRoot) = coordinates.positionInRoot().round()
623 qqsPositionOnRoot.set(
624 leftFromRoot,
625 topFromRoot,
626 leftFromRoot + coordinates.size.width,
627 topFromRoot + coordinates.size.height,
628 )
629 if (squishiness == 1f) {
630 viewModel.qqsHeight = coordinates.size.height
631 }
632 }
633 // Use an approach layout to determien the height without squishiness, as
634 // that's the value that NPVC and QuickSettingsController care about
635 // (measured height).
636 .approachLayout(isMeasurementApproachInProgress = { squishiness < 1f }) {
637 measurable,
638 constraints ->
639 viewModel.qqsHeight = lookaheadSize.height
640 val placeable = measurable.measure(constraints)
641 layout(placeable.width, placeable.height) { placeable.place(0, 0) }
642 }
643 .padding(top = { qqsPadding }, bottom = { bottomPadding })
644 ) {
645 val Tiles =
646 @Composable {
647 QuickQuickSettings(
648 viewModel = viewModel.quickQuickSettingsViewModel,
649 listening = {
650 /*
651 * When always compose is false, this will always be true, and we'll be
652 * listening whenever this is composed.
653 * When always compose is true, we listen if we are visible and not
654 * fully expanded
655 */
656 !alwaysCompose ||
657 (viewModel.isQsVisibleAndAnyShadeExpanded &&
658 viewModel.expansionState.progress < 1f &&
659 !viewModel.isEditing)
660 },
661 )
662 }
663 val Media =
664 @Composable {
665 if (viewModel.qqsMediaVisible) {
666 MediaObject(
667 // In order to have stable constraints passed to the AndroidView
668 // during expansion (available height changing due to squishiness),
669 // We always allow the media here to be as tall as it wants.
670 // (b/383085298)
671 modifier = Modifier.requiredHeightIn(max = Dp.Infinity),
672 mediaHost = viewModel.qqsMediaHost,
673 )
674 }
675 }
676
677 if (viewModel.isQsEnabled) {
678 Box(
679 modifier =
680 Modifier.collapseExpandSemanticAction(
681 stringResource(
682 id = R.string.accessibility_quick_settings_expand
683 )
684 )
685 .padding(horizontal = qsHorizontalMargin())
686 ) {
687 QuickQuickSettingsLayout(
688 tiles = Tiles,
689 media = Media,
690 mediaInRow = viewModel.qqsMediaInRow,
691 )
692 }
693 }
694 }
695 Spacer(modifier = Modifier.weight(1f))
696 }
697 }
698
699 @Composable
700 private fun ContentScope.QuickSettingsElement(modifier: Modifier = Modifier) {
701 val qqsPadding = viewModel.qqsHeaderHeight
702 val qsExtraPadding = dimensionResource(R.dimen.qs_panel_padding_top)
703 Column(
704 modifier =
705 modifier.collapseExpandSemanticAction(
706 stringResource(id = R.string.accessibility_quick_settings_collapse)
707 )
708 ) {
709 if (viewModel.isQsEnabled) {
710 Element(ElementKeys.QuickSettingsContent, modifier = Modifier.weight(1f)) {
711 DisposableEffect(Unit) {
712 lifecycleScope.launch { scrollState.scrollTo(0) }
713 onDispose { lifecycleScope.launch { scrollState.scrollTo(0) } }
714 }
715
716 Column(
717 modifier =
718 Modifier.fillMaxSize()
719 .onPlaced { coordinates ->
720 val positionOnScreen = coordinates.positionOnScreen()
721 val left = positionOnScreen.x
722 val right = left + coordinates.size.width
723 val top = positionOnScreen.y
724 val bottom = top + coordinates.size.height
725 viewModel.applyNewQsScrollerBounds(
726 left = left,
727 top = top,
728 right = right,
729 bottom = bottom,
730 )
731 }
732 .offset {
733 IntOffset(
734 x = 0,
735 y = viewModel.qsScrollTranslationY.fastRoundToInt(),
736 )
737 }
738 .onSizeChanged { viewModel.qsScrollHeight = it.height }
739 .verticalScroll(scrollState)
740 .sysuiResTag(ResIdTags.qsScroll)
741 ) {
742 val containerViewModel = viewModel.containerViewModel
743 Spacer(
744 modifier = Modifier.height { qqsPadding + qsExtraPadding.roundToPx() }
745 )
746 val BrightnessSlider =
747 @Composable {
748 Box(
749 Modifier.systemGestureExclusionInShade(
750 enabled = {
751 /*
752 * While we are transitioning into QS (either from QQS
753 * or from gone), the global position of the brightness
754 * slider will change in every frame. This causes
755 * the modifier to send a new gesture exclusion
756 * rectangle on every frame. Instead, only apply the
757 * modifier when this is settled.
758 */
759 layoutState.transitionState is TransitionState.Idle &&
760 viewModel.isNotTransitioning
761 }
762 )
763 ) {
764 AlwaysDarkMode {
765 BrightnessSliderContainer(
766 viewModel =
767 containerViewModel.brightnessSliderViewModel,
768 containerColors =
769 ContainerColors(
770 Color.Transparent,
771 ContainerColors.defaultContainerColor,
772 ),
773 modifier = Modifier.fillMaxWidth(),
774 )
775 }
776 }
777 }
778 val TileGrid =
779 @Composable {
780 Box {
781 GridAnchor()
782 TileGrid(
783 viewModel = containerViewModel.tileGridViewModel,
784 modifier = Modifier.fillMaxWidth(),
785 listening = {
786 /*
787 * When always compose is false, this will always be true,
788 * and we'll be listening whenever this is composed.
789 * When always compose is true, we look a the second
790 * condition and we'll listen if QS is visible AND we are
791 * not fully collapsed.
792 */
793 !alwaysCompose ||
794 (viewModel.isQsVisibleAndAnyShadeExpanded &&
795 viewModel.expansionState.progress > 0f &&
796 !viewModel.isEditing)
797 },
798 )
799 }
800 }
801 val Media =
802 @Composable {
803 if (viewModel.qsMediaVisible) {
804 MediaObject(
805 mediaHost = viewModel.qsMediaHost,
806 update = { translationY = viewModel.qsMediaTranslationY },
807 )
808 }
809 }
810 Box(
811 modifier =
812 Modifier.fillMaxWidth()
813 .sysuiResTag(ResIdTags.quickSettingsPanel)
814 .padding(
815 top = QuickSettingsShade.Dimensions.Padding,
816 start = qsHorizontalMargin(),
817 end = qsHorizontalMargin(),
818 )
819 ) {
820 QuickSettingsLayout(
821 brightness = BrightnessSlider,
822 tiles = TileGrid,
823 media = Media,
824 mediaInRow = viewModel.qsMediaInRow,
825 )
826 }
827 }
828 }
829 QuickSettingsTheme {
830 Element(
831 ElementKeys.FooterActions,
832 Modifier.sysuiResTag(ResIdTags.qsFooterActions),
833 ) {
834 FooterActions(
835 viewModel = viewModel.footerActionsViewModel,
836 qsVisibilityLifecycleOwner = this@QSFragmentCompose,
837 )
838 }
839 }
840 }
841 }
842 }
843
844 @Composable
845 private fun EditModeElement(modifier: Modifier = Modifier) {
846 // No need for top padding, the Scaffold inside takes care of the correct insets
847 EditMode(
848 viewModel = viewModel.containerViewModel.editModeViewModel,
849 modifier =
850 modifier
851 .fillMaxWidth()
852 .padding(horizontal = { QuickSettingsShade.Dimensions.Padding.roundToPx() }),
853 )
854 }
855
856 private fun Modifier.collapseExpandSemanticAction(label: String): Modifier {
857 return viewModel.collapseExpandAccessibilityAction?.let {
858 semantics {
859 customActions =
860 listOf(
861 CustomAccessibilityAction(label) {
862 it.run()
863 true
864 }
865 )
866 }
867 } ?: this
868 }
869
870 private fun registerDumpable() {
871 val instanceId = instanceProvider.getNextId()
872 // Add an instanceId because the system may have more than 1 of these when re-inflating and
873 // DumpManager doesn't like repeated identifiers. Also, put it first because DumpHandler
874 // matches by end.
875 val stringId = "$instanceId-QSFragmentCompose"
876 lifecycleScope.launch {
877 lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
878 try {
879 dumpManager.registerNormalDumpable(stringId, this@QSFragmentCompose)
880 awaitCancellation()
881 } finally {
882 dumpManager.unregisterDumpable(stringId)
883 }
884 }
885 }
886 }
887
888 override fun dump(pw: PrintWriter, args: Array<out String>) {
889 pw.asIndenting().run {
890 notificationScrimClippingParams.dump(this)
891 printSection("QQS positioning") {
892 println("qqsHeight", "${headerHeight}px")
893 println("qqsTop", "${headerTop}px")
894 println("qqsBottom", "${headerBottom}px")
895 println("qqsLeft", "${headerLeft}px")
896 println("qqsPositionOnRoot", qqsPositionOnRoot)
897 val rect = Rect()
898 getHeaderBoundsOnScreen(rect)
899 println("qqsPositionOnScreen", rect)
900 }
901 println("QQS visible", qqsVisible.value)
902 println("Always composed", alwaysCompose)
903 if (::viewModel.isInitialized) {
904 printSection("View Model") { viewModel.dump(this@run, args) }
905 }
906 }
907 }
908 }
909
setListenerJobnull910 private suspend inline fun <Listener : Any, Data> setListenerJob(
911 listenerFlow: MutableStateFlow<Listener?>,
912 dataFlow: Flow<Data>,
913 crossinline onCollect: suspend Listener.(Data) -> Unit,
914 ) {
915 coroutineScope {
916 try {
917 listenerFlow.collectLatest { listenerOrNull ->
918 listenerOrNull?.let { currentListener ->
919 launch {
920 // Called when editing mode changes
921 dataFlow.collect { currentListener.onCollect(it) }
922 }
923 }
924 }
925 awaitCancellation()
926 } finally {
927 listenerFlow.value = null
928 }
929 }
930 }
931
932 private val instanceProvider =
933 object {
934 private var currentId = 0
935
getNextIdnull936 fun getNextId(): Int {
937 return currentId++
938 }
939 }
940
941 object SceneKeys {
942 val QuickQuickSettings = SceneKey("QuickQuickSettingsScene")
943 val QuickSettings = SceneKey("QuickSettingsScene")
944 val EditMode = SceneKey("EditModeScene")
945
946 val TransitionState.Transition.debugName: String
947 get() = "[from=${fromContent.debugName}, to=${toContent.debugName}]"
948
QSFragmentComposeViewModelnull949 fun QSFragmentComposeViewModel.QSExpansionState.toIdleSceneKey(): SceneKey {
950 return when {
951 progress < 0.5f -> QuickQuickSettings
952 else -> QuickSettings
953 }
954 }
955
956 val QqsTileElementMatcher =
957 object : ElementMatcher {
matchesnull958 override fun matches(key: ElementKey, content: ContentKey): Boolean {
959 return content == SceneKeys.QuickQuickSettings &&
960 ElementKeys.TileElementMatcher.matches(key, content)
961 }
962 }
963 }
964
synchronizeQsStatenull965 private suspend fun synchronizeQsState(
966 state: MutableSceneTransitionLayoutState,
967 editMode: Flow<Boolean>,
968 expansion: Flow<Float>,
969 ) {
970 coroutineScope {
971 val animationScope = this
972
973 var currentTransition: ExpansionTransition? = null
974
975 fun snapTo(scene: SceneKey) {
976 state.snapTo(scene)
977 currentTransition = null
978 }
979
980 editMode.combine(expansion, ::Pair).collectLatest { (editMode, progress) ->
981 if (editMode && state.currentScene != SceneKeys.EditMode) {
982 state.setTargetScene(SceneKeys.EditMode, animationScope)?.second?.join()
983 } else if (!editMode && state.currentScene == SceneKeys.EditMode) {
984 state.setTargetScene(SceneKeys.QuickSettings, animationScope)?.second?.join()
985 }
986 if (!editMode) {
987 when (progress) {
988 0f -> snapTo(QuickQuickSettings)
989 1f -> snapTo(QuickSettings)
990 else -> {
991 val transition = currentTransition
992 if (transition != null) {
993 transition.progress = progress
994 return@collectLatest
995 }
996
997 val newTransition =
998 ExpansionTransition(progress).also { currentTransition = it }
999 state.startTransitionImmediately(
1000 animationScope = animationScope,
1001 transition = newTransition,
1002 )
1003 }
1004 }
1005 }
1006 }
1007 }
1008 }
1009
1010 private class ExpansionTransition(currentProgress: Float) :
1011 TransitionState.Transition.ChangeScene(
1012 fromScene = QuickQuickSettings,
1013 toScene = QuickSettings,
1014 ) {
1015 override val currentScene: SceneKey
1016 get() {
1017 // This should return the logical scene. If the QS STLState is only driven by
1018 // synchronizeQSState() then it probably does not matter which one we return, this is
1019 // only used to compute the current user actions of a STL.
1020 return QuickQuickSettings
1021 }
1022
1023 override var progress: Float by mutableFloatStateOf(currentProgress)
1024
1025 override val progressVelocity: Float
1026 get() = 0f
1027
1028 override val isInitiatedByUserInput: Boolean
1029 get() = true
1030
1031 override val isUserInputOngoing: Boolean
1032 get() = true
1033
1034 override val gestureContext: GestureContext? = null
1035
1036 private val finishCompletable = CompletableDeferred<Unit>()
1037
runnull1038 override suspend fun run() {
1039 // This transition runs until it is interrupted by another one.
1040 finishCompletable.await()
1041 }
1042
freezeAndAnimateToCurrentStatenull1043 override fun freezeAndAnimateToCurrentState() {
1044 finishCompletable.complete(Unit)
1045 }
1046 }
1047
1048 private const val EDIT_MODE_TIME_MILLIS = 500
1049
1050 /**
1051 * Performs different touch handling based on the state of the ComposeView:
1052 * * Ignore touches below the value returned by [clippingTopProvider], when clipping is enabled, as
1053 * per [clippingEnabledProvider].
1054 * * Intercept touches that would overscroll QS forward and instead allow them to be used to close
1055 * the shade.
1056 */
1057 private class FrameLayoutTouchPassthrough(
1058 context: Context,
1059 private val clippingEnabledProvider: () -> Boolean,
1060 private val clippingParams: Flow<NotificationScrimClipParams>,
1061 private val canScrollForwardQs: () -> Boolean,
1062 private val emitMotionEventForFalsing: () -> Unit,
1063 ) : FrameLayout(context) {
1064
1065 init {
<lambda>null1066 repeatWhenAttached {
1067 repeatOnLifecycle(Lifecycle.State.STARTED) {
1068 clippingParams.collect { currentClipParams = it }
1069 }
1070 }
1071 }
1072
1073 private val currentClippingPath = Path()
1074 private var lastWidth = -1
1075 set(value) {
1076 if (field != value) {
1077 field = value
1078 updateClippingPath()
1079 }
1080 }
1081
1082 private var currentClipParams = NotificationScrimClipParams()
1083 set(value) {
1084 if (field != value) {
1085 field = value
1086 updateClippingPath()
1087 }
1088 }
1089
updateClippingPathnull1090 private fun updateClippingPath() {
1091 currentClippingPath.rewind()
1092 if (clippingEnabledProvider()) {
1093 val right = width + currentClipParams.rightInset
1094 val left = -currentClipParams.leftInset
1095 val top = currentClipParams.top
1096 val bottom = currentClipParams.bottom
1097 currentClippingPath.addRoundRect(
1098 left.toFloat(),
1099 top.toFloat(),
1100 right.toFloat(),
1101 bottom.toFloat(),
1102 currentClipParams.radius.toFloat(),
1103 currentClipParams.radius.toFloat(),
1104 Path.Direction.CW,
1105 )
1106 }
1107 invalidate()
1108 }
1109
onLayoutnull1110 override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1111 super.onLayout(changed, left, top, right, bottom)
1112 lastWidth = right - left
1113 }
1114
dispatchDrawnull1115 override fun dispatchDraw(canvas: Canvas) {
1116 if (!currentClippingPath.isEmpty) {
1117 canvas.clipOutPath(currentClippingPath)
1118 }
1119 super.dispatchDraw(canvas)
1120 }
1121
isTransformedTouchPointInViewnull1122 override fun isTransformedTouchPointInView(
1123 x: Float,
1124 y: Float,
1125 child: View?,
1126 outLocalPoint: PointF?,
1127 ): Boolean {
1128 return if (clippingEnabledProvider() && y + translationY > currentClipParams.top) {
1129 false
1130 } else {
1131 super.isTransformedTouchPointInView(x, y, child, outLocalPoint)
1132 }
1133 }
1134
1135 val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
1136 var downY = 0f
1137 var preventingIntercept = false
1138
onTouchEventnull1139 override fun onTouchEvent(event: MotionEvent): Boolean {
1140 val action = event.actionMasked
1141 when (action) {
1142 MotionEvent.ACTION_DOWN -> {
1143 preventingIntercept = false
1144 if (canScrollVertically(1)) {
1145 // If we can scroll down, make sure we're not intercepted by the parent
1146 preventingIntercept = true
1147 parent?.requestDisallowInterceptTouchEvent(true)
1148 } else if (!canScrollVertically(-1)) {
1149 // Don't pass on the touch to the view, because scrolling will unconditionally
1150 // disallow interception even if we can't scroll.
1151 // if a user can't scroll at all, we should never listen to the touch.
1152 return false
1153 }
1154 }
1155 MotionEvent.ACTION_UP -> {
1156 if (preventingIntercept) {
1157 emitMotionEventForFalsing()
1158 }
1159 }
1160 }
1161 return super.onTouchEvent(event)
1162 }
1163
onInterceptTouchEventnull1164 override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
1165 // If there's a touch on this view and we can scroll down, we don't want to be intercepted
1166 val action = ev.actionMasked
1167
1168 when (action) {
1169 MotionEvent.ACTION_DOWN -> {
1170 preventingIntercept = false
1171 // If we can scroll down, make sure none of our parents intercepts us.
1172 if (canScrollForwardQs()) {
1173 preventingIntercept = true
1174 parent?.requestDisallowInterceptTouchEvent(true)
1175 }
1176 downY = ev.y
1177 }
1178
1179 MotionEvent.ACTION_MOVE -> {
1180 val y = ev.y.toInt()
1181 val yDiff: Float = y - downY
1182 if (yDiff < -touchSlop && !canScrollForwardQs()) {
1183 // Intercept touches that are overscrolling.
1184 return true
1185 }
1186 }
1187 }
1188 return super.onInterceptTouchEvent(ev)
1189 }
1190 }
1191
gesturesDisablednull1192 private fun Modifier.gesturesDisabled(disabled: Boolean) =
1193 if (disabled) {
1194 pointerInput(Unit) {
1195 awaitPointerEventScope {
1196 // we should wait for all new pointer events
1197 while (true) {
1198 awaitPointerEvent(pass = PointerEventPass.Initial)
1199 .changes
1200 .forEach(PointerInputChange::consume)
1201 }
1202 }
1203 }
1204 } else {
1205 this
1206 }
1207
1208 @Composable
MediaObjectnull1209 private fun MediaObject(
1210 mediaHost: MediaHost,
1211 modifier: Modifier = Modifier,
1212 update: UniqueObjectHostView.() -> Unit = {},
1213 ) {
<lambda>null1214 Box {
1215 AndroidView(
1216 modifier = modifier,
1217 factory = {
1218 mediaHost.hostView.apply {
1219 layoutParams =
1220 FrameLayout.LayoutParams(
1221 FrameLayout.LayoutParams.MATCH_PARENT,
1222 FrameLayout.LayoutParams.WRAP_CONTENT,
1223 )
1224 }
1225 },
1226 update = { view -> view.update() },
1227 onReset = {},
1228 )
1229 }
1230 }
1231
1232 @Composable
1233 @VisibleForTesting
QuickQuickSettingsLayoutnull1234 fun QuickQuickSettingsLayout(
1235 tiles: @Composable () -> Unit,
1236 media: @Composable () -> Unit,
1237 mediaInRow: Boolean,
1238 ) {
1239 if (mediaInRow) {
1240 Row(
1241 horizontalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)),
1242 verticalAlignment = Alignment.CenterVertically,
1243 ) {
1244 Box(modifier = Modifier.weight(1f)) { tiles() }
1245 Box(modifier = Modifier.weight(1f)) { media() }
1246 }
1247 } else {
1248 Column(verticalArrangement = spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical))) {
1249 tiles()
1250 media()
1251 }
1252 }
1253 }
1254
1255 @Composable
1256 @VisibleForTesting
QuickSettingsLayoutnull1257 fun QuickSettingsLayout(
1258 brightness: @Composable () -> Unit,
1259 tiles: @Composable () -> Unit,
1260 media: @Composable () -> Unit,
1261 mediaInRow: Boolean,
1262 ) {
1263 if (mediaInRow) {
1264 Column(
1265 verticalArrangement = spacedBy(QuickSettingsShade.Dimensions.Padding),
1266 horizontalAlignment = Alignment.CenterHorizontally,
1267 ) {
1268 brightness()
1269 Row(
1270 horizontalArrangement = spacedBy(QuickSettingsShade.Dimensions.Padding),
1271 verticalAlignment = Alignment.CenterVertically,
1272 ) {
1273 Box(modifier = Modifier.weight(1f)) { tiles() }
1274 Box(modifier = Modifier.weight(1f)) { media() }
1275 }
1276 }
1277 } else {
1278 Column(
1279 verticalArrangement = spacedBy(QuickSettingsShade.Dimensions.Padding),
1280 horizontalAlignment = Alignment.CenterHorizontally,
1281 ) {
1282 brightness()
1283 tiles()
1284 media()
1285 }
1286 }
1287 }
1288
1289 private object ResIdTags {
1290 const val quickSettingsPanel = "quick_settings_panel"
1291 const val quickQsPanel = "quick_qs_panel"
1292 const val qsScroll = "expanded_qs_scroll_view"
1293 const val qsFooterActions = "qs_footer_actions"
1294 }
1295
qsHorizontalMarginnull1296 @Composable private fun qsHorizontalMargin() = dimensionResource(id = R.dimen.qs_horizontal_margin)
1297
1298 @Composable
1299 private fun interactionsConfig() =
1300 InteractionsConfig(
1301 hoverOverlayColor = MaterialTheme.colorScheme.onSurface,
1302 hoverOverlayAlpha = 0.11f,
1303 pressedOverlayColor = MaterialTheme.colorScheme.onSurface,
1304 pressedOverlayAlpha = 0.15f,
1305 // we are OK using this as our content is clipped and all corner radius are larger than this
1306 surfaceCornerRadius = 28.dp,
1307 )
1308
1309 private inline val alwaysCompose
1310 get() = Flags.alwaysComposeQsUiFragment()
1311
1312 /**
1313 * Forces the configuration and themes to be dark theme. This is needed in order to have
1314 * [colorResource] retrieve the dark mode colors.
1315 *
1316 * This should be removed when [notificationShadeBlur] is removed
1317 */
1318 @Composable
1319 private fun AlwaysDarkMode(content: @Composable () -> Unit) {
1320 if (notificationShadeBlur()) {
1321 content()
1322 } else {
1323 val currentConfig = LocalConfiguration.current
1324 val darkConfig =
1325 Configuration(currentConfig).apply {
1326 uiMode =
1327 (uiMode and (Configuration.UI_MODE_NIGHT_MASK.inv())) or
1328 Configuration.UI_MODE_NIGHT_YES
1329 }
1330 val newContext = LocalContext.current.createConfigurationContext(darkConfig)
1331 CompositionLocalProvider(
1332 LocalConfiguration provides darkConfig,
1333 LocalContext provides newContext,
1334 ) {
1335 content()
1336 }
1337 }
1338 }
1339