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

Commit 0c714a66 authored by Ale Nijamkin's avatar Ale Nijamkin
Browse files

[flexiglass] Dual shade education tooltips.

Full stack (data, domain, and UI layers) implementation of dual shade
educational tooltips as per the latest spec.

When the user expands one of the two dual shade overlays, 5 seconds
after doing so, a tooltip will be shown to promote the existence of the
other shade.

Once a tooltip its overlay is shown for the first time for the user,
that tooltip will never be shown again.

The implementation also minimizes the number of running coroutines such
that once education is no longer needed (because an impression already
happened or because the user is not in dual shade mode), only one coroutine will remain running (namely, the one that monitors those conditions).

To turn on debug logging:
$ adb shell setprop log.tag.DualShadeEducation VERBOSE

To reset the state so the tooltips trigger again:
$ adb shell pm clear com.android.systemui

Bug: 406213664
Test: view-model level integration tests added
Test: manually verified that the correct tooltip appears on the correct
side when I expand one of the two overlays and never shown again
Flag: com.android.systemui.scene_container

Change-Id: I38e9114e4382fd04fb4d0beede4bc63a82fad244
parent d443cbd9
Loading
Loading
Loading
Loading
+107 −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(ExperimentalMaterial3Api::class)

package com.android.systemui.scene.ui.composable

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.height
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.scene.ui.viewmodel.DualShadeEducationalTooltipsViewModel

@Composable
fun DualShadeEducationalTooltips(viewModelFactory: DualShadeEducationalTooltipsViewModel.Factory) {
    val context = LocalContext.current
    val viewModel =
        rememberViewModel(traceName = "DualShadeEducationalTooltips") {
            viewModelFactory.create(context)
        }

    val visibleTooltip = viewModel.visibleTooltip ?: return

    val anchorBottomY = visibleTooltip.anchorBottomY
    // This Box represents the bounds of the top edge that the user can swipe down on to reveal
    // either of the dual shade overlays. It's used as a convenient way to position the anchor for
    // each of the tooltips that can be shown. As such, this Box is the same size as the status bar.
    Box(
        contentAlignment =
            if (visibleTooltip.isAlignedToStart) {
                Alignment.CenterStart
            } else {
                Alignment.CenterEnd
            },
        modifier = Modifier.fillMaxWidth().height { anchorBottomY }.padding(horizontal = 24.dp),
    ) {
        AnchoredTooltip(
            text = visibleTooltip.text,
            onShown = visibleTooltip.onShown,
            onDismissed = visibleTooltip.onDismissed,
        )
    }
}

@Composable
private fun AnchoredTooltip(
    text: String,
    onShown: () -> Unit,
    onDismissed: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val tooltipState = rememberTooltipState(initialIsVisible = true, isPersistent = true)

    LaunchedEffect(Unit) { onShown() }

    TooltipBox(
        positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
        tooltip = {
            RichTooltip(
                colors =
                    TooltipDefaults.richTooltipColors(
                        containerColor = LocalAndroidColorScheme.current.tertiaryFixed,
                        contentColor = LocalAndroidColorScheme.current.onTertiaryFixed,
                    ),
                caretSize = TooltipDefaults.caretSize,
                shadowElevation = 2.dp,
            ) {
                Text(text = text, modifier = Modifier.padding(8.dp))
            }
        },
        state = tooltipState,
        onDismissRequest = onDismissed,
        modifier = modifier,
    ) {
        Spacer(modifier = Modifier.width(48.dp).fillMaxHeight())
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -14,6 +14,8 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalMaterial3Api::class)

package com.android.systemui.scene.ui.composable

import android.os.Build
@@ -22,6 +24,7 @@ import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+212 −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.scene.ui.viewmodel

import android.content.applicationContext
import android.content.pm.UserInfo
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.OverlayKey
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.EnableSceneContainer
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.advanceTimeBy
import com.android.systemui.kosmos.currentValue
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.res.R
import com.android.systemui.scene.domain.interactor.DualShadeEducationInteractor
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.model.Overlays
import com.android.systemui.shade.domain.interactor.disableDualShade
import com.android.systemui.shade.domain.interactor.enableDualShade
import com.android.systemui.testKosmos
import com.android.systemui.user.data.repository.fakeUserRepository
import com.android.systemui.user.domain.interactor.selectedUserInteractor
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@SmallTest
@RunWith(ParameterizedAndroidJunit4::class)
@EnableSceneContainer
class DualShadeEducationalTooltipsViewModelTest(
    private val forOverlay: OverlayKey,
    private val tooltipText: String,
) : SysuiTestCase() {

    private val kosmos = testKosmos()

    private lateinit var underTest: DualShadeEducationalTooltipsViewModel

    @Before
    fun setUp() {
        kosmos.enableDualShade()
        kosmos.fakeUserRepository.setUserInfos(USER_INFOS)
        overrideResource(R.string.dual_shade_educational_tooltip_notifs, NOTIFS_TOOLTIP)
        overrideResource(R.string.dual_shade_educational_tooltip_qs, QS_TOOLTIP)

        underTest =
            kosmos.dualShadeEducationalTooltipsViewModelFactory.create(kosmos.applicationContext)
        underTest.activateIn(kosmos.testScope)
        kosmos.runCurrent()
    }

    @Test
    fun visibleTooltip_initiallyNull() =
        kosmos.runTest { assertThat(underTest.visibleTooltip).isNull() }

    @Test
    fun visibleTooltip_happyPath() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)

            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertNoTooltip()
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            // No tooltip because didn't wait quite long enough yet.
            assertNoTooltip()
            advanceTimeBy(1)
            // Expected tooltip after the full delay.
            val tooltip = assertVisibleTooltip(tooltipText)

            // UI reports impression and dismissal.
            tooltip.onShown()
            tooltip.onDismissed()
            assertNoTooltip()

            // Hide and reshow overlay to try and trigger it again it shouldn't show again because
            // it was already shown once.
            hideOverlay(otherOverlay)
            assertNoTooltip()
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // The tooltip should still be gone as it was already shown to the user.
            assertNoTooltip()
        }

    @Test
    fun visibleTooltip_otherOverlayHiddenBeforeDelay_noTooltip() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            // No tooltip before the delay.
            assertNoTooltip()

            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS - 1)
            // No tooltip because didn't wait quite long enough yet.
            assertNoTooltip()

            // Overlay hidden before the delay elapses.
            hideOverlay(otherOverlay)
            assertNoTooltip()

            advanceTimeBy(1)
            // Waited the entire delay, but the overlay was already hidden.
            assertNoTooltip()
        }

    @Test
    fun visibleTooltip_notDualShadeMode_noTooltip() =
        kosmos.runTest {
            disableDualShade()
            showOverlay(otherOverlay(forOverlay))
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            assertNoTooltip()
        }

    @Test
    fun visibleTooltip_reshowsTooltip_afterUserChanged() =
        kosmos.runTest {
            val otherOverlay = otherOverlay(forOverlay)
            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            val tooltip = assertVisibleTooltip(tooltipText)
            tooltip.onShown()
            tooltip.onDismissed()
            assertNoTooltip()
            hideOverlay(otherOverlay)

            selectUser(USER_INFOS[1])

            showOverlay(otherOverlay)
            advanceTimeBy(DualShadeEducationInteractor.TOOLTIP_APPEARANCE_DELAY_MS)
            // New user, tooltip shown again.
            assertVisibleTooltip(tooltipText)
        }

    /**
     * Returns the complementary overlay for [forOverlay]; the one that, when shown, the tooltip
     * will show for [forOverlay].
     */
    private fun otherOverlay(forOverlay: OverlayKey): OverlayKey {
        return when (forOverlay) {
            Overlays.NotificationsShade -> Overlays.QuickSettingsShade
            Overlays.QuickSettingsShade -> Overlays.NotificationsShade
            else -> error("Test isn't expecting forOverlay of ${forOverlay.debugName}")
        }
    }

    private fun Kosmos.showOverlay(overlay: OverlayKey) {
        sceneInteractor.showOverlay(overlay, "")
        assertThat(currentValue(sceneInteractor.currentOverlays)).contains(overlay)
    }

    private fun Kosmos.hideOverlay(overlay: OverlayKey) {
        sceneInteractor.hideOverlay(overlay, "")
        assertThat(currentValue(sceneInteractor.currentOverlays)).doesNotContain(overlay)
    }

    private fun Kosmos.assertVisibleTooltip(text: String): DualShadeEducationalTooltipViewModel {
        runCurrent()
        assertThat(underTest.visibleTooltip).isNotNull()
        assertThat(underTest.visibleTooltip?.text).isEqualTo(text)
        return underTest.visibleTooltip!!
    }

    private fun Kosmos.assertNoTooltip() {
        runCurrent()
        assertThat(underTest.visibleTooltip).isNull()
    }

    private suspend fun Kosmos.selectUser(userInfo: UserInfo) {
        fakeUserRepository.setSelectedUserInfo(userInfo)
        assertThat(selectedUserInteractor.getSelectedUserId()).isEqualTo(userInfo.id)
    }

    companion object {
        private const val NOTIFS_TOOLTIP = "notifs_tooltip"
        private const val QS_TOOLTIP = "qs_tooltip"

        private val USER_INFOS =
            listOf(UserInfo(10, "Initial user", 0), UserInfo(11, "Other user", 0))

        @JvmStatic
        @Parameters(name = "{0}, {1}")
        fun testParameters(): List<Array<Any>> {
            return listOf(
                arrayOf(Overlays.NotificationsShade, NOTIFS_TOOLTIP),
                arrayOf(Overlays.QuickSettingsShade, QS_TOOLTIP),
            )
        }
    }
}
+12 −0
Original line number Diff line number Diff line
@@ -19,4 +19,16 @@
<resources>
    <!-- Recents: Text that shows above the navigation bar after launching several apps. [CHAR LIMIT=NONE] -->
    <string name="recents_quick_scrub_onboarding">Drag left to quickly switch apps</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    right edge of the display to expand the notification shade panel.
    -->
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top right to open Notifications</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    left edge of the display to expand the Quick Settings shade panel.
    -->
    <string name="dual_shade_educational_tooltip_qs">Swipe from the top left to open Quick Settings</string>
</resources>
+12 −0
Original line number Diff line number Diff line
@@ -4233,4 +4233,16 @@

    <!-- Description for the Underlay close button. The Underlay view appears on the bottom of the screen and shows some AI hints. The button will dismiss the underlay view. [CHAR LIMIT=NONE] -->
    <string name="underlay_close_button_content_description">Close</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    left edge of the display to expand the notification shade panel.
    -->
    <string name="dual_shade_educational_tooltip_notifs">Swipe from the top left to open Notifications</string>

    <!--
    Content of user education tooltip shown to teach the user that they can swipe down from the top
    right edge of the display to expand the Quick Settings shade panel.
    -->
    <string name="dual_shade_educational_tooltip_qs">Swipe from the top right to open Quick Settings</string>
</resources>
Loading