summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author William Leshner <wleshner@google.com> 2025-01-22 12:18:16 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-01-22 12:18:16 -0800
commitdfab2fa8b0d0dda14dd1dbb78374fc8b2bd1ec95 (patch)
tree1042bdd699ff28d66536cfbc65bd6b6caf11e9dd
parenta8891401ad9a4bbf375cb0f386be78bba1c6e65e (diff)
parent24625587cd9626c3afedd91296a491d1da27b381 (diff)
Merge "Implement an onboarding bottom sheet for hub." into main
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt3
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt164
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt28
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt34
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt130
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt79
-rw-r--r--packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.pngbin0 -> 14127 bytes
-rw-r--r--packages/SystemUI/res/values/strings.xml6
-rw-r--r--packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt31
-rw-r--r--packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt19
-rw-r--r--packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt46
-rw-r--r--packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt44
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt9
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt29
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt23
15 files changed, 637 insertions, 8 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt
index fea34921b853..30dfa5bb826a 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/CommunalContent.kt
@@ -37,6 +37,7 @@ import com.android.systemui.communal.smartspace.SmartspaceInteractionHandler
import com.android.systemui.communal.ui.compose.section.AmbientStatusBarSection
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
import com.android.systemui.keyguard.ui.composable.blueprint.BlueprintAlignmentLines
@@ -62,6 +63,7 @@ constructor(
private val communalPopupSection: CommunalPopupSection,
private val widgetSection: CommunalAppWidgetSection,
private val communalToDreamButtonSection: CommunalToDreamButtonSection,
+ private val hubOnboardingSection: HubOnboardingSection,
) {
@Composable
@@ -83,6 +85,7 @@ constructor(
modifier = Modifier.element(Communal.Elements.Grid),
contentScope = this@Content,
)
+ with(hubOnboardingSection) { BottomSheet() }
}
if (communalSettingsInteractor.isV2FlagEnabled()) {
Icon(
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt
new file mode 100644
index 000000000000..6943e9b00ed8
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/section/HubOnboardingSection.kt
@@ -0,0 +1,164 @@
+/*
+ * 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.communal.ui.compose.section
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ChargingStation
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+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.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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.android.systemui.communal.ui.viewmodel.HubOnboardingViewModel
+import com.android.systemui.lifecycle.rememberViewModel
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.phone.ComponentSystemUIDialog
+import com.android.systemui.statusbar.phone.SystemUIDialogFactory
+import com.android.systemui.statusbar.phone.createBottomSheet
+import javax.inject.Inject
+
+class HubOnboardingSection
+@Inject
+constructor(
+ private val viewModelFactory: HubOnboardingViewModel.Factory,
+ private val dialogFactory: SystemUIDialogFactory,
+) {
+ @Composable
+ fun BottomSheet() {
+ val viewModel = rememberViewModel("HubOnboardingSection") { viewModelFactory.create() }
+ val shouldShowHubOnboarding by
+ viewModel.shouldShowHubOnboarding.collectAsStateWithLifecycle(false)
+
+ if (!shouldShowHubOnboarding) {
+ return
+ }
+
+ HubOnboardingBottomSheet(shouldShowBottomSheet = true, dialogFactory = dialogFactory) {
+ viewModel.onDismissed()
+ }
+ }
+}
+
+@Composable
+private fun HubOnboardingBottomSheet(
+ shouldShowBottomSheet: Boolean,
+ dialogFactory: SystemUIDialogFactory,
+ onDismiss: () -> Unit,
+) {
+ var dialog: ComponentSystemUIDialog? by remember { mutableStateOf(null) }
+ var dismissingDueToCancel by remember { mutableStateOf(false) }
+
+ DisposableEffect(shouldShowBottomSheet) {
+ if (shouldShowBottomSheet) {
+ dialog =
+ dialogFactory
+ .createBottomSheet(
+ content = { HubOnboardingBottomSheetContent { dialog?.dismiss() } },
+ isDraggable = true,
+ maxWidth = 627.dp,
+ )
+ .apply {
+ setOnDismissListener {
+ // Don't set the onboarding dismissed flag if the dismiss was due to a
+ // cancel. Note that a "dismiss" is something initiated by the user
+ // (e.g. swipe down or tapping outside), while a "cancel" is a dismiss
+ // not initiated by the user (e.g. timing out to dream). We only want
+ // to mark the bottom sheet as dismissed if the user explicitly
+ // dismissed it.
+ if (!dismissingDueToCancel) {
+ onDismiss()
+ }
+ }
+ setOnCancelListener { dismissingDueToCancel = true }
+ show()
+ }
+ }
+
+ onDispose {
+ dialog?.cancel()
+ dialog = null
+ }
+ }
+}
+
+@Composable
+private fun HubOnboardingBottomSheetContent(onButtonClicked: () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(48.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.ChargingStation,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.hub_onboarding_bottom_sheet_title),
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ // TODO(b/388283881): Replace with correct animations and possibly add a content description
+ // if necessary.
+ Image(painter = painterResource(R.drawable.hub_onboarding_bg), contentDescription = null)
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ modifier = Modifier.width(300.dp),
+ text = stringResource(R.string.hub_onboarding_bottom_sheet_text),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = colors.primary,
+ contentColor = colors.onPrimary,
+ ),
+ onClick = onButtonClicked,
+ ) {
+ Text(
+ stringResource(R.string.hub_onboarding_bottom_sheet_action_button),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt
index 1f5e30ca4a0d..0d410cff5ff6 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/data/repository/CommunalPrefsRepositoryImplTest.kt
@@ -88,6 +88,34 @@ class CommunalPrefsRepositoryImplTest : SysuiTestCase() {
}
@Test
+ fun isHubOnboardingDismissedValue_byDefault_isFalse() =
+ testScope.runTest {
+ val isHubOnboardingDismissed by
+ collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))
+ assertThat(isHubOnboardingDismissed).isFalse()
+ }
+
+ @Test
+ fun isHubOnboardingDismissedValue_onSet_isTrue() =
+ testScope.runTest {
+ val isHubOnboardingDismissed by
+ collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))
+
+ underTest.setHubOnboardingDismissed(MAIN_USER)
+ assertThat(isHubOnboardingDismissed).isTrue()
+ }
+
+ @Test
+ fun isHubOnboardingDismissedValue_onSetForDifferentUser_isStillFalse() =
+ testScope.runTest {
+ val isHubOnboardingDismissed by
+ collectLastValue(underTest.isHubOnboardingDismissed(MAIN_USER))
+
+ underTest.setHubOnboardingDismissed(SECONDARY_USER)
+ assertThat(isHubOnboardingDismissed).isFalse()
+ }
+
+ @Test
fun getSharedPreferences_whenFileRestored() =
testScope.runTest {
val isCtaDismissed by collectLastValue(underTest.isCtaDismissed(MAIN_USER))
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt
index 9a92f76f90c6..1fef6932ecca 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractorTest.kt
@@ -74,6 +74,40 @@ class CommunalPrefsInteractorTest : SysuiTestCase() {
assertThat(isCtaDismissed).isFalse()
}
+ @Test
+ fun setHubOnboardingDismissed_currentUser() =
+ testScope.runTest {
+ setSelectedUser(MAIN_USER)
+ val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)
+
+ assertThat(isHubOnboardingDismissed).isFalse()
+ underTest.setHubOnboardingDismissed(MAIN_USER)
+ assertThat(isHubOnboardingDismissed).isTrue()
+ }
+
+ @Test
+ fun setHubOnboardingDismissed_anotherUser() =
+ testScope.runTest {
+ setSelectedUser(MAIN_USER)
+ val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)
+
+ assertThat(isHubOnboardingDismissed).isFalse()
+ underTest.setHubOnboardingDismissed(SECONDARY_USER)
+ assertThat(isHubOnboardingDismissed).isFalse()
+ }
+
+ @Test
+ fun isHubOnboardingDismissed_userSwitch() =
+ testScope.runTest {
+ setSelectedUser(MAIN_USER)
+ underTest.setHubOnboardingDismissed(MAIN_USER)
+ val isHubOnboardingDismissed by collectLastValue(underTest.isHubOnboardingDismissed)
+
+ assertThat(isHubOnboardingDismissed).isTrue()
+ setSelectedUser(SECONDARY_USER)
+ assertThat(isHubOnboardingDismissed).isFalse()
+ }
+
private suspend fun setSelectedUser(user: UserInfo) {
with(kosmos.fakeUserRepository) {
setUserInfos(listOf(user))
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt
new file mode 100644
index 000000000000..ef25dabb4c7f
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorTest.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.communal.domain.interactor
+
+import android.content.pm.UserInfo
+import android.content.pm.UserInfo.FLAG_MAIN
+import android.platform.test.annotations.DisableFlags
+import android.platform.test.annotations.EnableFlags
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.compose.animation.scene.ObservableTransitionState
+import com.android.compose.animation.scene.SceneKey
+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.kosmos.collectLastValue
+import com.android.systemui.kosmos.runTest
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.scene.domain.interactor.sceneInteractor
+import com.android.systemui.scene.shared.model.Scenes
+import com.android.systemui.settings.fakeUserTracker
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class HubOnboardingInteractorTest : SysuiTestCase() {
+ private val kosmos = testKosmos().useUnconfinedTestDispatcher()
+ private val sceneInteractor = kosmos.sceneInteractor
+
+ private val underTest: HubOnboardingInteractor by lazy { kosmos.hubOnboardingInteractor }
+
+ @Test
+ @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
+ fun setHubOnboardingDismissed() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+ val isHubOnboardingDismissed by
+ collectLastValue(fakeCommunalPrefsRepository.isHubOnboardingDismissed(MAIN_USER))
+
+ underTest.setHubOnboardingDismissed()
+
+ assertThat(isHubOnboardingDismissed).isTrue()
+ }
+
+ @Test
+ @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
+ fun shouldShowHubOnboarding_falseWhenDismissed() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+ val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)
+
+ fakeCommunalPrefsRepository.setHubOnboardingDismissed(MAIN_USER)
+
+ assertThat(shouldShowHubOnboarding).isFalse()
+ }
+
+ @Test
+ @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
+ fun shouldShowHubOnboarding_falseWhenNotIdleOnCommunal() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+ val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)
+
+ assertThat(shouldShowHubOnboarding).isFalse()
+ }
+
+ @Test
+ @EnableFlags(FLAG_GLANCEABLE_HUB_V2)
+ fun shouldShowHubOnboarding_trueWhenIdleOnCommunal() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+ val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)
+
+ // Change to Communal scene.
+ setIdleScene(Scenes.Communal)
+
+ assertThat(shouldShowHubOnboarding).isFalse()
+ }
+
+ @Test
+ @DisableFlags(FLAG_GLANCEABLE_HUB_V2)
+ fun shouldShowHubOnboarding_falseWhenFlagDisabled() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+ val shouldShowHubOnboarding by collectLastValue(underTest.shouldShowHubOnboarding)
+
+ // Change to Communal scene.
+ setIdleScene(Scenes.Communal)
+
+ assertThat(shouldShowHubOnboarding).isFalse()
+ }
+
+ private fun setIdleScene(scene: SceneKey) {
+ sceneInteractor.changeScene(scene, "test")
+ val transitionState =
+ MutableStateFlow<ObservableTransitionState>(ObservableTransitionState.Idle(scene))
+ sceneInteractor.setTransitionState(transitionState)
+ }
+
+ private suspend fun setSelectedUser(user: UserInfo) {
+ with(kosmos.fakeUserRepository) {
+ setUserInfos(listOf(user))
+ setSelectedUserInfo(user)
+ }
+ kosmos.fakeUserTracker.set(userInfos = listOf(user), selectedUserIndex = 0)
+ }
+
+ companion object {
+ val MAIN_USER = UserInfo(0, "main", FLAG_MAIN)
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt
new file mode 100644
index 000000000000..712d26275000
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.communal.ui.viewmodel
+
+import android.content.pm.UserInfo
+import android.content.pm.UserInfo.FLAG_MAIN
+import android.platform.test.annotations.EnableFlags
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+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.flags.Flags.COMMUNAL_SERVICE_ENABLED
+import com.android.systemui.flags.fakeFeatureFlagsClassic
+import com.android.systemui.kosmos.collectLastValue
+import com.android.systemui.kosmos.runTest
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.kosmos.useUnconfinedTestDispatcher
+import com.android.systemui.lifecycle.activateIn
+import com.android.systemui.settings.fakeUserTracker
+import com.android.systemui.testKosmos
+import com.android.systemui.user.data.repository.fakeUserRepository
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@EnableFlags(FLAG_GLANCEABLE_HUB_V2)
+@RunWith(AndroidJUnit4::class)
+class HubOnboardingViewModelTest : SysuiTestCase() {
+ private val kosmos = testKosmos().useUnconfinedTestDispatcher()
+ private val underTest: HubOnboardingViewModel by lazy { kosmos.hubOnboardingViewModel }
+
+ @Before
+ fun setUp() {
+ kosmos.fakeFeatureFlagsClassic.set(COMMUNAL_SERVICE_ENABLED, true)
+ underTest.activateIn(kosmos.testScope)
+ }
+
+ @Test
+ fun onDismissed_setsDismissedTrue() =
+ kosmos.runTest {
+ setSelectedUser(MAIN_USER)
+
+ val isHubOnboardingDismissed by
+ collectLastValue(fakeCommunalPrefsRepository.isHubOnboardingDismissed(MAIN_USER))
+
+ underTest.onDismissed()
+
+ assertThat(isHubOnboardingDismissed).isTrue()
+ }
+
+ private suspend fun setSelectedUser(user: UserInfo) {
+ with(kosmos.fakeUserRepository) {
+ setUserInfos(listOf(user))
+ setSelectedUserInfo(user)
+ }
+ kosmos.fakeUserTracker.set(userInfos = listOf(user), selectedUserIndex = 0)
+ }
+
+ companion object {
+ val MAIN_USER = UserInfo(0, "main", FLAG_MAIN)
+ }
+}
diff --git a/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png b/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png
new file mode 100644
index 000000000000..bd55e83b377b
--- /dev/null
+++ b/packages/SystemUI/res/drawable-nodpi/hub_onboarding_bg.png
Binary files differ
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 79fa41949b57..866dfe4d8972 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -1359,6 +1359,12 @@
<string name="glanceable_hub_lockscreen_affordance_action_button_label">Settings</string>
<!-- Content description for a "show screensaver" button on glanceable hub. [CHAR LIMIT=NONE] -->
<string name="accessibility_glanceable_hub_to_dream_button">Show screensaver button</string>
+ <!-- Title shown in hub onboarding bottom sheet. [CHAR LIMIT=50] -->
+ <string name="hub_onboarding_bottom_sheet_title">Explore hub mode</string>
+ <!-- Information about communal hub shown in the onboarding bottom sheet. [CHAR LIMIT=NONE] -->
+ <string name="hub_onboarding_bottom_sheet_text">Access your favorite widgets and screen savers while charging.</string>
+ <!-- Hub onboarding bottom sheet action button title. [CHAR LIMIT=NONE] -->
+ <string name="hub_onboarding_bottom_sheet_action_button">Let\u2019s go</string>
<!-- Related to user switcher --><skip/>
diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt
index 4de39c457f3b..a02bc8b89910 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalPrefsRepository.kt
@@ -51,6 +51,12 @@ interface CommunalPrefsRepository {
/** Save the CTA tile dismissed state for the current user. */
suspend fun setCtaDismissed(user: UserInfo)
+
+ /** Whether hub onboarding has been dismissed. */
+ fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean>
+
+ /** Save the hub onboarding dismissed state for the current user. */
+ suspend fun setHubOnboardingDismissed(user: UserInfo)
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -65,9 +71,6 @@ constructor(
) : CommunalPrefsRepository {
private val logger by lazy { Logger(logBuffer, TAG) }
- override fun isCtaDismissed(user: UserInfo): Flow<Boolean> =
- readKeyForUser(user, CTA_DISMISSED_STATE)
-
/**
* Emits an event each time a Backup & Restore restoration job is completed, and once at the
* start of collection.
@@ -82,18 +85,29 @@ constructor(
.onEach { logger.i("Restored state for communal preferences.") }
.emitOnStart()
+ override fun isCtaDismissed(user: UserInfo): Flow<Boolean> =
+ readKeyForUser(user, CTA_DISMISSED_STATE)
+
override suspend fun setCtaDismissed(user: UserInfo) =
withContext(bgDispatcher) {
getSharedPrefsForUser(user).edit().putBoolean(CTA_DISMISSED_STATE, true).apply()
logger.i("Dismissed CTA tile")
}
+ override fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean> =
+ readKeyForUser(user, HUB_ONBOARDING_DISMISSED_STATE)
+
+ override suspend fun setHubOnboardingDismissed(user: UserInfo) =
+ withContext(bgDispatcher) {
+ getSharedPrefsForUser(user)
+ .edit()
+ .putBoolean(HUB_ONBOARDING_DISMISSED_STATE, true)
+ .apply()
+ logger.i("Dismissed hub onboarding")
+ }
+
private fun getSharedPrefsForUser(user: UserInfo): SharedPreferences {
- return userFileManager.getSharedPreferences(
- FILE_NAME,
- Context.MODE_PRIVATE,
- user.id,
- )
+ return userFileManager.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE, user.id)
}
private fun readKeyForUser(user: UserInfo, key: String): Flow<Boolean> {
@@ -109,5 +123,6 @@ constructor(
const val TAG = "CommunalPrefsRepository"
const val FILE_NAME = "communal_hub_prefs"
const val CTA_DISMISSED_STATE = "cta_dismissed"
+ const val HUB_ONBOARDING_DISMISSED_STATE = "hub_onboarding_dismissed"
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt
index 0b5f40d8041e..76e6cde4ad81 100644
--- a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalPrefsInteractor.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@SysUISingleton
@@ -63,6 +64,24 @@ constructor(
suspend fun setCtaDismissed(user: UserInfo = userTracker.userInfo) =
repository.setCtaDismissed(user)
+ val isHubOnboardingDismissed: Flow<Boolean> =
+ userInteractor.selectedUserInfo
+ .flatMapLatest { user -> repository.isHubOnboardingDismissed(user) }
+ .logDiffsForTable(
+ tableLogBuffer = tableLogBuffer,
+ columnPrefix = "",
+ columnName = "isHubOnboardingDismissed",
+ initialValue = false,
+ )
+ .stateIn(
+ scope = bgScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = false,
+ )
+
+ fun setHubOnboardingDismissed(user: UserInfo = userTracker.userInfo) =
+ bgScope.launch { repository.setHubOnboardingDismissed(user) }
+
private companion object {
const val TAG = "CommunalPrefsInteractor"
}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt
new file mode 100644
index 000000000000..26a0a7930b4a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractor.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.communal.domain.interactor
+
+import com.android.systemui.communal.data.repository.CommunalSettingsRepository
+import com.android.systemui.util.kotlin.BooleanFlowOperators.allOf
+import com.android.systemui.util.kotlin.BooleanFlowOperators.not
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
+class HubOnboardingInteractor
+@Inject
+constructor(
+ communalSceneInteractor: CommunalSceneInteractor,
+ communalSettingsRepository: CommunalSettingsRepository,
+ private val communalPrefsInteractor: CommunalPrefsInteractor,
+) {
+ /** Dismiss hub onboarding education. */
+ fun setHubOnboardingDismissed() = communalPrefsInteractor.setHubOnboardingDismissed()
+
+ /** Should hub onboarding be shown to the user. */
+ val shouldShowHubOnboarding: Flow<Boolean> =
+ if (communalSettingsRepository.getV2FlagEnabled()) {
+ allOf(
+ not(communalPrefsInteractor.isHubOnboardingDismissed),
+ communalSceneInteractor.isIdleOnCommunal,
+ )
+ } else {
+ flowOf(false)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt
new file mode 100644
index 000000000000..5245800ba3d1
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModel.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.communal.ui.viewmodel
+
+import android.annotation.SuppressLint
+import com.android.systemui.communal.domain.interactor.HubOnboardingInteractor
+import com.android.systemui.lifecycle.ExclusiveActivatable
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+
+class HubOnboardingViewModel
+@AssistedInject
+constructor(private val hubOnboardingInteractor: HubOnboardingInteractor) : ExclusiveActivatable() {
+
+ val shouldShowHubOnboarding = hubOnboardingInteractor.shouldShowHubOnboarding
+
+ fun onDismissed() {
+ hubOnboardingInteractor.setHubOnboardingDismissed()
+ }
+
+ @SuppressLint("MissingPermission")
+ override suspend fun onActivated(): Nothing = coroutineScope { awaitCancellation() }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(): HubOnboardingViewModel
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt
index 5e870b19681b..163625747d85 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalPrefsRepository.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.map
/** Fake implementation of [CommunalPrefsRepository] */
class FakeCommunalPrefsRepository : CommunalPrefsRepository {
private val _isCtaDismissed = MutableStateFlow<Set<UserInfo>>(emptySet())
+ private val _isHubOnboardingismissed = MutableStateFlow<Set<UserInfo>>(emptySet())
override fun isCtaDismissed(user: UserInfo): Flow<Boolean> =
_isCtaDismissed.map { it.contains(user) }
@@ -32,4 +33,12 @@ class FakeCommunalPrefsRepository : CommunalPrefsRepository {
override suspend fun setCtaDismissed(user: UserInfo) {
_isCtaDismissed.value = _isCtaDismissed.value.toMutableSet().apply { add(user) }
}
+
+ override fun isHubOnboardingDismissed(user: UserInfo): Flow<Boolean> =
+ _isHubOnboardingismissed.map { it.contains(user) }
+
+ override suspend fun setHubOnboardingDismissed(user: UserInfo) {
+ _isHubOnboardingismissed.value =
+ _isHubOnboardingismissed.value.toMutableSet().apply { add(user) }
+ }
}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt
new file mode 100644
index 000000000000..9db4e4f4b8f2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/domain/interactor/HubOnboardingInteractorKosmos.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.communal.domain.interactor
+
+import com.android.systemui.communal.data.repository.communalSettingsRepository
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.hubOnboardingInteractor: HubOnboardingInteractor by
+ Kosmos.Fixture {
+ HubOnboardingInteractor(
+ communalSceneInteractor = communalSceneInteractor,
+ communalSettingsRepository = communalSettingsRepository,
+ communalPrefsInteractor = communalPrefsInteractor,
+ )
+ }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt
new file mode 100644
index 000000000000..9cdaaf4b88f8
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/HubOnboardingViewModelKosmos.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.communal.ui.viewmodel
+
+import com.android.systemui.communal.domain.interactor.hubOnboardingInteractor
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.hubOnboardingViewModel by
+ Kosmos.Fixture { HubOnboardingViewModel(hubOnboardingInteractor = hubOnboardingInteractor) }