Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c2887cf7 authored by Andreas Miko's avatar Andreas Miko
Browse files

Sync BundleHeader STL state with external expand/collapse

The state is now co-located with STL in @Composable. We had to pass
the ComposeScope because it has to be the remembered scope from the
@Composable otherwise there is a crash "A MonotonicFrameClock is not
available in this CoroutineContext"

I discussed this with Ale, as SceneContainer deals with this by using
a DataSource which is a heavy setup. We discussed several variants and
ended up with this one.

Bug: b/389839492 b/416516808
Test: Closed the shade while Bundle is expanded -> Bundle gets collapsed
  after opening the shade again the Bundle is in correct state.
Flag: com.android.systemui.notification_bundle_ui
Change-Id: Icf9054ab3980204ce002ef02033121f16381f821
parent de198a89
Loading
Loading
Loading
Loading
+34 −3
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.notifications.ui.composable.row

import android.content.Context
import android.graphics.drawable.Drawable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -28,6 +29,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -51,6 +53,8 @@ import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.SceneTransitionLayout
import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
import com.android.compose.animation.scene.transitions
import com.android.compose.theme.PlatformTheme
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.systemui.initOnBackPressedDispatcherOwner
@@ -86,16 +90,43 @@ fun createComposeView(viewModel: BundleHeaderViewModel, context: Context): Compo
    }
}

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundleHeader(viewModel: BundleHeaderViewModel, modifier: Modifier = Modifier) {
    val state =
        rememberMutableSceneTransitionLayoutState(
            initialScene = BundleHeader.Scenes.Collapsed,
            transitions =
                transitions {
                    from(BundleHeader.Scenes.Collapsed, to = BundleHeader.Scenes.Expanded) {
                        spec = tween(500)
                        translate(BundleHeader.Elements.PreviewIcon3, x = 32.dp)
                        translate(BundleHeader.Elements.PreviewIcon2, x = 16.dp)
                        fade(BundleHeader.Elements.PreviewIcon1)
                        fade(BundleHeader.Elements.PreviewIcon2)
                        fade(BundleHeader.Elements.PreviewIcon3)
                    }
                },
        )

    DisposableEffect(viewModel, state) {
        viewModel.state = state
        onDispose { viewModel.state = null }
    }

    val scope = rememberCoroutineScope()
    DisposableEffect(viewModel, state) {
        viewModel.composeScope = scope
        onDispose { viewModel.composeScope = null }
    }

    Box(modifier) {
        Background(background = viewModel.backgroundDrawable, modifier = Modifier.matchParentSize())
        val scope = rememberCoroutineScope()
        SceneTransitionLayout(
            state = viewModel.state,
            state = state,
            modifier =
                Modifier.clickable(
                    onClick = { viewModel.onHeaderClicked(scope) },
                    onClick = { viewModel.onHeaderClicked() },
                    interactionSource = null,
                    indication = null,
                ),
+92 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)

package com.android.systemui.statusbar.notification.row.domain.interactor

import android.platform.test.annotations.EnableFlags
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MotionScheme
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.SysuiTestCase
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.row.domain.bundleInteractor
import com.android.systemui.statusbar.notification.row.icon.appIconProvider
import com.android.systemui.statusbar.notification.row.icon.mockAppIconProvider
import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import platform.test.motion.compose.runMonotonicClockTest

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(NotificationBundleUi.FLAG_NAME)
class BundleInteractorTest : SysuiTestCase() {

    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()

    private val kosmos = testKosmos()

    private lateinit var underTest: BundleInteractor

    @Before
    fun setUp() {
        kosmos.appIconProvider = kosmos.mockAppIconProvider
        underTest = kosmos.bundleInteractor
    }

    @Test
    fun setExpansionState_sets_state_to_expanded() = runMonotonicClockTest {
        // Arrange
        underTest.composeScope = this
        underTest.state =
            MutableSceneTransitionLayoutState(
                BundleHeader.Scenes.Collapsed,
                MotionScheme.standard(),
            )
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Collapsed)

        // Act
        underTest.setExpansionState(true)

        // Assert
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Expanded)
    }

    @Test
    fun setExpansionState_sets_state_to_collapsed() = runMonotonicClockTest {
        // Arrange
        underTest.composeScope = this
        underTest.state =
            MutableSceneTransitionLayoutState(BundleHeader.Scenes.Expanded, MotionScheme.standard())
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Expanded)

        // Act
        underTest.setExpansionState(false)

        // Assert
        assertThat(underTest.state?.currentScene).isEqualTo(BundleHeader.Scenes.Collapsed)
    }
}
+95 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.notification.row.ui.viewmodel

import android.platform.test.annotations.EnableFlags
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.shared.NotificationBundleUi
import com.android.systemui.testKosmos
import kotlinx.coroutines.CoroutineScope
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(NotificationBundleUi.FLAG_NAME)
class BundleHeaderViewModelTest : SysuiTestCase() {

    @get:Rule val rule: MockitoRule = MockitoJUnit.rule()

    private val kosmos = testKosmos()

    @Mock lateinit var mockSceneTransitionLayoutState: MutableSceneTransitionLayoutState
    @Mock lateinit var mockComposeScope: CoroutineScope
    @Mock lateinit var onExpandClickListener: View.OnClickListener

    private lateinit var underTest: BundleHeaderViewModel

    @Before
    fun setup() {
        underTest = kosmos.bundleHeaderViewModelFactory.create()
        underTest.activateIn(kosmos.testScope)

        underTest.state = mockSceneTransitionLayoutState
        underTest.composeScope = mockComposeScope
        underTest.onExpandClickListener = onExpandClickListener
    }

    @Test
    fun onHeaderClicked_toggles_expansion_state_to_expanded() {
        // Arrange
        whenever(mockSceneTransitionLayoutState.currentScene)
            .thenReturn(BundleHeader.Scenes.Collapsed)

        // Act
        underTest.onHeaderClicked()

        // Assert
        verify(mockSceneTransitionLayoutState)
            .setTargetScene(BundleHeader.Scenes.Expanded, mockComposeScope)
        verify(onExpandClickListener).onClick(null)
    }

    @Test
    fun onHeaderClicked_toggles_expansion_state_to_collapsed() {
        // Arrange
        whenever(mockSceneTransitionLayoutState.currentScene)
            .thenReturn(BundleHeader.Scenes.Expanded)

        // Act
        underTest.onHeaderClicked()

        // Assert
        verify(mockSceneTransitionLayoutState)
            .setTargetScene(BundleHeader.Scenes.Collapsed, mockComposeScope)
        verify(onExpandClickListener).onClick(null)
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.systemui.statusbar.notification.row.data.model.AppData

/** Holds information about a BundleEntry that is relevant to UI. */
@@ -35,4 +36,6 @@ class BundleRepository(
    var numberOfChildren by mutableStateOf<Int?>(0)

    var appDataList by mutableStateOf(listOf<AppData>())

    var state by mutableStateOf<MutableSceneTransitionLayoutState?>(null)
}
+24 −8
Original line number Diff line number Diff line
@@ -23,7 +23,10 @@ import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.snapshotFlow
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.compose.animation.scene.SceneKey
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.notifications.ui.composable.row.BundleHeader
import com.android.systemui.statusbar.notification.row.dagger.BundleRowScope
import com.android.systemui.statusbar.notification.row.data.model.AppData
import com.android.systemui.statusbar.notification.row.data.repository.BundleRepository
@@ -31,11 +34,10 @@ import com.android.systemui.statusbar.notification.row.icon.AppIconProvider
import com.android.systemui.utils.coroutines.flow.mapLatestConflated
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext

private const val TAG = "BundleInteractor"

/** Provides functionality for UI to interact with a Notification Bundle. */
@BundleRowScope
class BundleInteractor
@@ -57,10 +59,6 @@ constructor(
    val bundleIcon: Int
        get() = repository.bundleIcon

    /**
     * A cold flow of app icon [Drawable]s fetched asynchronously based on changes to
     * `repository.appDataList` each time this flow is collected.
     */
    val previewIcons: Flow<List<Drawable>> =
        snapshotFlow { repository.appDataList }
            .mapLatestConflated { appList ->
@@ -69,8 +67,21 @@ constructor(
                }
            }

    private fun fetchAppIcon(appData: AppData): Drawable? =
        try {
    var state: MutableSceneTransitionLayoutState? by repository::state

    var composeScope: CoroutineScope? = null

    fun setExpansionState(isExpanded: Boolean) {
        state?.setTargetScene(
            if (isExpanded) BundleHeader.Scenes.Expanded else BundleHeader.Scenes.Collapsed,
            composeScope!!,
        )
    }

    fun setTargetScene(scene: SceneKey) = state?.setTargetScene(scene, composeScope!!)

    private fun fetchAppIcon(appData: AppData): Drawable? {
        return try {
            appIconProvider.getOrFetchAppIcon(
                packageName = appData.packageName,
                // TODO(b/416126107) remove context and withWorkProfileBadge after we add them to
@@ -84,3 +95,8 @@ constructor(
            null
        }
    }

    companion object {
        private const val TAG = "BundleInteractor"
    }
}
Loading