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

Commit 92e223f1 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Remove dream button from hub" into main

parents dc4f5ab3 e896c153
Loading
Loading
Loading
Loading
+0 −22
Original line number Diff line number Diff line
@@ -23,7 +23,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntRect
@@ -35,7 +34,6 @@ import com.android.systemui.communal.smartspace.SmartspaceInteractionHandler
import com.android.systemui.communal.ui.compose.section.AmbientStatusBarSection
import com.android.systemui.communal.ui.compose.section.CommunalLockSection
import com.android.systemui.communal.ui.compose.section.CommunalPopupSection
import com.android.systemui.communal.ui.compose.section.CommunalToDreamButtonSection
import com.android.systemui.communal.ui.compose.section.HubOnboardingSection
import com.android.systemui.communal.ui.view.layout.sections.CommunalAppWidgetSection
import com.android.systemui.communal.ui.viewmodel.CommunalViewModel
@@ -61,7 +59,6 @@ constructor(
    private val ambientStatusBarSection: AmbientStatusBarSection,
    private val communalPopupSection: CommunalPopupSection,
    private val widgetSection: CommunalAppWidgetSection,
    private val communalToDreamButtonSection: CommunalToDreamButtonSection,
    private val hubOnboardingSection: HubOnboardingSection,
) {

@@ -103,13 +100,11 @@ constructor(
                            Modifier.element(Communal.Elements.IndicationArea).fillMaxWidth()
                        )
                    }
                    with(communalToDreamButtonSection) { Button() }
                },
            ) { measurables, constraints ->
                val communalGridMeasurable = measurables[0]
                val lockIconMeasurable = measurables[1]
                val bottomAreaMeasurable = measurables[2]
                val screensaverButtonMeasurable: Measurable? = measurables.getOrNull(3)

                val noMinConstraints = constraints.copy(minWidth = 0, minHeight = 0)

@@ -152,9 +147,6 @@ constructor(

                val bottomAreaPlaceable = bottomAreaMeasurable.measure(noMinConstraints)

                val screensaverButtonPlaceable =
                    screensaverButtonMeasurable?.measure(noMinConstraints)

                val communalGridPlaceable =
                    communalGridMeasurable.measure(
                        noMinConstraints.copy(maxHeight = lockIconBounds.top)
@@ -166,26 +158,12 @@ constructor(

                    val bottomAreaTop = constraints.maxHeight - bottomAreaPlaceable.height
                    bottomAreaPlaceable.place(x = 0, y = bottomAreaTop)

                    val screensaverButtonPaddingInt = screensaverButtonPadding.roundToPx()
                    screensaverButtonPlaceable?.place(
                        x =
                            constraints.maxWidth -
                                screensaverButtonPaddingInt -
                                screensaverButtonPlaceable.width,
                        y =
                            constraints.maxHeight -
                                screensaverButtonPaddingInt -
                                screensaverButtonPlaceable.height,
                    )
                }
            }
        }
    }

    companion object {
        private val screensaverButtonPadding: Dp = 24.dp

        // TODO(b/382739998): Remove these hardcoded values once lock icon size and bottom area
        // position are sorted.
        private val lockIconSize: Dp = 54.dp
+0 −231
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.communal.ui.compose.section

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.android.compose.PlatformIconButton
import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor
import com.android.systemui.communal.ui.compose.extensions.observeTaps
import com.android.systemui.communal.ui.viewmodel.CommunalToDreamButtonViewModel
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.res.R
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay

class CommunalToDreamButtonSection
@Inject
constructor(
    private val communalSettingsInteractor: CommunalSettingsInteractor,
    private val viewModelFactory: CommunalToDreamButtonViewModel.Factory,
) {
    @Composable
    fun Button() {
        if (!communalSettingsInteractor.isV2FlagEnabled()) {
            return
        }

        val viewModel =
            rememberViewModel("CommunalToDreamButtonSection") { viewModelFactory.create() }

        if (!viewModel.shouldShowDreamButtonOnHub) {
            return
        }

        val buttonSize = dimensionResource(R.dimen.communal_to_dream_button_size)

        if (viewModel.shouldShowTooltip) {
            val tooltipVisibleState = remember { MutableTransitionState(false) }

            Column(
                modifier =
                    Modifier.widthIn(max = tooltipMaxWidth).pointerInput(Unit) {
                        observeTaps {
                            if (tooltipVisibleState.isCurrentlyVisible()) {
                                tooltipVisibleState.targetState = false
                            }
                        }
                    }
            ) {
                var waitingToShowTooltip by remember { mutableStateOf(true) }

                LaunchedEffect(tooltipVisibleState.targetState) {
                    delay(3.seconds)
                    tooltipVisibleState.targetState = true
                    waitingToShowTooltip = false
                }

                // This LaunchedEffect is used to wait for the tooltip dismiss animation to
                // complete before setting the tooltip dismissed. Otherwise, the composable would
                // be removed before the animation can start.
                LaunchedEffect(
                    tooltipVisibleState.currentState,
                    tooltipVisibleState.isIdle,
                    waitingToShowTooltip,
                ) {
                    if (
                        !waitingToShowTooltip &&
                            !tooltipVisibleState.currentState &&
                            tooltipVisibleState.isIdle
                    ) {
                        viewModel.setDreamButtonTooltipDismissed()
                    }
                }

                AnimatedVisibility(
                    visibleState = tooltipVisibleState,
                    enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom),
                    exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom),
                ) {
                    Tooltip(
                        pointerOffsetDp = buttonSize.div(2),
                        text = stringResource(R.string.glanceable_hub_to_dream_button_tooltip),
                    )
                }

                GoToDreamButton(
                    modifier = Modifier.width(buttonSize).height(buttonSize).align(Alignment.End)
                ) {
                    viewModel.onShowDreamButtonTap()
                }
            }
        } else {
            GoToDreamButton(modifier = Modifier.width(buttonSize).height(buttonSize)) {
                viewModel.onShowDreamButtonTap()
            }
        }
    }

    private fun MutableTransitionState<Boolean>.isCurrentlyVisible() = currentState && isIdle

    companion object {
        private val tooltipMaxWidth = 350.dp
    }
}

@Composable
private fun GoToDreamButton(modifier: Modifier, onClick: () -> Unit) {
    PlatformIconButton(
        modifier = modifier,
        onClick = onClick,
        iconResource = R.drawable.ic_screensaver_auto,
        contentDescription = stringResource(R.string.accessibility_glanceable_hub_to_dream_button),
        colors =
            IconButtonDefaults.filledIconButtonColors(
                contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
                containerColor = MaterialTheme.colorScheme.primaryContainer,
            ),
    )
}

@Composable
private fun Tooltip(pointerOffsetDp: Dp, text: String) {
    Surface(
        color = MaterialTheme.colorScheme.surface,
        shape = TooltipShape(pointerSizeDp = 12.dp, pointerOffsetDp = pointerOffsetDp),
    ) {
        Text(
            modifier = Modifier.padding(start = 32.dp, top = 16.dp, end = 32.dp, bottom = 32.dp),
            color = MaterialTheme.colorScheme.onSurface,
            text = text,
        )
    }

    Spacer(modifier = Modifier.height(4.dp))
}

private class TooltipShape(private val pointerSizeDp: Dp, private val pointerOffsetDp: Dp) : Shape {

    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density,
    ): Outline {

        val pointerSizePx = with(density) { pointerSizeDp.toPx() }
        val pointerOffsetPx = with(density) { pointerOffsetDp.toPx() }
        val cornerRadius = CornerRadius(CornerSize(16.dp).toPx(size, density))
        val bubbleSize = size.copy(height = size.height - pointerSizePx)

        val path =
            Path().apply {
                addRoundRect(
                    RoundRect(
                        rect = bubbleSize.toRect(),
                        topLeft = cornerRadius,
                        topRight = cornerRadius,
                        bottomRight = cornerRadius,
                        bottomLeft = cornerRadius,
                    )
                )
                addPath(
                    Path().apply {
                        moveTo(0f, 0f)
                        lineTo(pointerSizePx / 2f, pointerSizePx)
                        lineTo(pointerSizePx, 0f)
                        close()
                    },
                    offset =
                        Offset(
                            x = bubbleSize.width - pointerOffsetPx - pointerSizePx / 2f,
                            y = bubbleSize.height,
                        ),
                )
            }

        return Outline.Generic(path)
    }
}
+0 −173
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.communal.ui.viewmodel

import android.content.pm.UserInfo
import android.platform.test.annotations.EnableFlags
import android.provider.Settings
import android.service.dream.dreamManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.uiEventLoggerFake
import com.android.systemui.Flags.FLAG_GLANCEABLE_HUB_V2
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.data.repository.fakeCommunalPrefsRepository
import com.android.systemui.communal.domain.interactor.HubOnboardingInteractorTest.Companion.MAIN_USER
import com.android.systemui.communal.shared.log.CommunalUiEvent
import com.android.systemui.flags.Flags.COMMUNAL_SERVICE_ENABLED
import com.android.systemui.flags.fakeFeatureFlagsClassic
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.plugins.activityStarter
import com.android.systemui.settings.fakeUserTracker
import com.android.systemui.statusbar.policy.batteryController
import com.android.systemui.statusbar.policy.fake
import com.android.systemui.testKosmos
import com.android.systemui.user.data.repository.fakeUserRepository
import com.android.systemui.util.settings.fakeSettings
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.verify
import org.mockito.kotlin.any

@SmallTest
@EnableFlags(FLAG_GLANCEABLE_HUB_V2)
@RunWith(AndroidJUnit4::class)
class CommunalToDreamButtonViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val underTest: CommunalToDreamButtonViewModel by lazy {
        kosmos.communalToDreamButtonViewModel
    }

    @Before
    fun setUp() {
        kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true)
        underTest.activateIn(testScope)
    }

    @Test
    fun shouldShowDreamButtonOnHub_trueWhenPluggedIn() =
        with(kosmos) {
            runTest {
                batteryController.fake._isPluggedIn = true
                runCurrent()

                assertThat(underTest.shouldShowDreamButtonOnHub).isTrue()
            }
        }

    @Test
    fun shouldShowDreamButtonOnHub_falseWhenNotPluggedIn() =
        with(kosmos) {
            runTest {
                batteryController.fake._isPluggedIn = false

                assertThat(underTest.shouldShowDreamButtonOnHub).isFalse()
            }
        }

    @Test
    fun onShowDreamButtonTap_dreamsEnabled_startsDream() =
        with(kosmos) {
            runTest {
                val currentUser = fakeUserRepository.asMainUser()
                kosmos.fakeSettings.putIntForUser(
                    Settings.Secure.SCREENSAVER_ENABLED,
                    1,
                    currentUser.id,
                )
                runCurrent()

                underTest.onShowDreamButtonTap()
                runCurrent()

                verify(dreamManager).startDream()
            }
        }

    @Test
    fun onShowDreamButtonTap_dreamsDisabled_startsActivity() =
        with(kosmos) {
            runTest {
                val currentUser = fakeUserRepository.asMainUser()
                kosmos.fakeSettings.putIntForUser(
                    Settings.Secure.SCREENSAVER_ENABLED,
                    0,
                    currentUser.id,
                )
                runCurrent()

                underTest.onShowDreamButtonTap()
                runCurrent()

                verify(activityStarter).postStartActivityDismissingKeyguard(any(), anyInt())
            }
        }

    @Test
    fun shouldShowDreamButtonTooltip_trueWhenNotDismissedAndHubOnboardingDismissed() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            fakeCommunalPrefsRepository.setHubOnboardingDismissed(MAIN_USER)
            runCurrent()

            assertThat(underTest.shouldShowTooltip).isTrue()
        }

    @Test
    fun shouldShowDreamButtonTooltip_falseWhenNotDismissedAndHubOnboardingNotDismissed() =
        kosmos.runTest {
            runCurrent()
            assertThat(underTest.shouldShowTooltip).isFalse()
        }

    @Test
    fun shouldShowDreamButtonTooltip_falseWhenDismissed() =
        kosmos.runTest {
            setSelectedUser(MAIN_USER)
            fakeCommunalPrefsRepository.setDreamButtonTooltipDismissed(MAIN_USER)
            runCurrent()

            assertThat(underTest.shouldShowTooltip).isFalse()
        }

    @Test
    fun onShowDreamButtonTap_eventLogged() =
        with(kosmos) {
            runTest {
                underTest.onShowDreamButtonTap()
                runCurrent()

                assertThat(uiEventLoggerFake[0].eventId)
                    .isEqualTo(CommunalUiEvent.COMMUNAL_HUB_SHOW_DREAM_BUTTON_TAP.id)
            }
        }

    private suspend fun setSelectedUser(user: UserInfo) {
        with(kosmos.fakeUserRepository) {
            setUserInfos(listOf(user))
            setSelectedUserInfo(user)
        }
        kosmos.fakeUserTracker.set(userInfos = listOf(user), selectedUserIndex = 0)
    }
}
+0 −124
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.communal.ui.viewmodel

import android.annotation.SuppressLint
import android.app.DreamManager
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.getValue
import com.android.internal.logging.UiEventLogger
import com.android.systemui.communal.domain.interactor.CommunalPrefsInteractor
import com.android.systemui.communal.domain.interactor.CommunalSettingsInteractor
import com.android.systemui.communal.shared.log.CommunalUiEvent
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf
import com.android.systemui.util.kotlin.BooleanFlowOperators.not
import com.android.systemui.util.kotlin.isDevicePluggedIn
import com.android.systemui.util.kotlin.sample
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class CommunalToDreamButtonViewModel
@AssistedInject
constructor(
    @Background private val backgroundContext: CoroutineContext,
    batteryController: BatteryController,
    private val prefsInteractor: CommunalPrefsInteractor,
    private val settingsInteractor: CommunalSettingsInteractor,
    private val activityStarter: ActivityStarter,
    private val dreamManager: DreamManager,
    private val uiEventLogger: UiEventLogger,
) : ExclusiveActivatable() {

    private val hydrator = Hydrator("CommunalToDreamButtonViewModel.hydrator")
    private val _requests = Channel<Unit>(Channel.BUFFERED)

    /** Whether we should show a button on hub to switch to dream. */
    val shouldShowDreamButtonOnHub: Boolean by
        hydrator.hydratedStateOf(
            traceName = "shouldShowDreamButtonOnHub",
            initialValue = false,
            source = batteryController.isDevicePluggedIn().distinctUntilChanged(),
        )

    /** Return whether to show the dream button tooltip. */
    val shouldShowTooltip: Boolean by
        hydrator.hydratedStateOf(
            traceName = "shouldShowTooltip",
            initialValue = false,
            source =
                allOf(
                    not(prefsInteractor.isDreamButtonTooltipDismissed),
                    prefsInteractor.isHubOnboardingDismissed,
                ),
        )

    /** Set the dream button tooltip to be dismissed. */
    fun setDreamButtonTooltipDismissed() {
        prefsInteractor.setDreamButtonTooltipDismissed()
    }

    /** Handle a tap on the "show dream" button. */
    fun onShowDreamButtonTap() {
        uiEventLogger.log(CommunalUiEvent.COMMUNAL_HUB_SHOW_DREAM_BUTTON_TAP)
        _requests.trySend(Unit)
    }

    @SuppressLint("MissingPermission")
    override suspend fun onActivated(): Nothing = coroutineScope {
        launch {
            _requests
                .receiveAsFlow()
                .sample(settingsInteractor.isScreensaverEnabled)
                .collectLatest { enabled ->
                    withContext(backgroundContext) {
                        if (enabled) {
                            dreamManager.startDream()
                        } else {
                            activityStarter.postStartActivityDismissingKeyguard(
                                Intent(Settings.ACTION_DREAM_SETTINGS),
                                0,
                            )
                        }
                    }
                }
        }

        launch { hydrator.activate() }

        awaitCancellation()
    }

    @AssistedFactory
    interface Factory {
        fun create(): CommunalToDreamButtonViewModel
    }
}
+0 −39
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.communal.ui.viewmodel

import android.service.dream.dreamManager
import com.android.internal.logging.uiEventLogger
import com.android.systemui.communal.domain.interactor.communalPrefsInteractor
import com.android.systemui.communal.domain.interactor.communalSettingsInteractor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.plugins.activityStarter
import com.android.systemui.statusbar.policy.batteryController

val Kosmos.communalToDreamButtonViewModel by
    Kosmos.Fixture {
        CommunalToDreamButtonViewModel(
            backgroundContext = testDispatcher,
            batteryController = batteryController,
            prefsInteractor = communalPrefsInteractor,
            settingsInteractor = communalSettingsInteractor,
            activityStarter = activityStarter,
            dreamManager = dreamManager,
            uiEventLogger = uiEventLogger,
        )
    }