diff options
8 files changed, 232 insertions, 175 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt index 02de78bc84ce..30623acbbd31 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeHeader.kt @@ -149,9 +149,6 @@ fun ContentScope.CollapsedShadeHeader( } } - val longerDateText by viewModel.longerDateText.collectAsStateWithLifecycle() - val shorterDateText by viewModel.shorterDateText.collectAsStateWithLifecycle() - val isShadeLayoutWide = viewModel.isShadeLayoutWide val isPrivacyChipVisible by viewModel.isPrivacyChipVisible.collectAsStateWithLifecycle() @@ -167,8 +164,8 @@ fun ContentScope.CollapsedShadeHeader( ) { Clock(scale = 1f, onClick = viewModel::onClockClicked) VariableDayDate( - longerDateText = longerDateText, - shorterDateText = shorterDateText, + longerDateText = viewModel.longerDateText, + shorterDateText = viewModel.shorterDateText, chipHighlight = viewModel.notificationsChipHighlight, modifier = Modifier.element(ShadeHeader.Elements.CollapsedContentStart), ) @@ -229,8 +226,6 @@ fun ContentScope.ExpandedShadeHeader( derivedStateOf { shouldUseExpandedFormat(layoutState.transitionState) } } - val longerDateText by viewModel.longerDateText.collectAsStateWithLifecycle() - val shorterDateText by viewModel.shorterDateText.collectAsStateWithLifecycle() val isPrivacyChipVisible by viewModel.isPrivacyChipVisible.collectAsStateWithLifecycle() Box(modifier = modifier.sysuiResTag(ShadeHeader.TestTags.Root)) { @@ -269,8 +264,8 @@ fun ContentScope.ExpandedShadeHeader( modifier = Modifier.element(ShadeHeader.Elements.ExpandedContent), ) { VariableDayDate( - longerDateText = longerDateText, - shorterDateText = shorterDateText, + longerDateText = viewModel.longerDateText, + shorterDateText = viewModel.shorterDateText, chipHighlight = viewModel.notificationsChipHighlight, modifier = Modifier.widthIn(max = 90.dp), ) @@ -337,12 +332,9 @@ fun ContentScope.OverlayShadeHeader( modifier = Modifier.width(IntrinsicSize.Min).height(20.dp), ) } else { - val longerDateText by viewModel.longerDateText.collectAsStateWithLifecycle() - val shorterDateText by - viewModel.shorterDateText.collectAsStateWithLifecycle() VariableDayDate( - longerDateText = longerDateText, - shorterDateText = shorterDateText, + longerDateText = viewModel.longerDateText, + shorterDateText = viewModel.shorterDateText, chipHighlight = viewModel.notificationsChipHighlight, ) } @@ -546,11 +538,8 @@ private fun BatteryIcon( @Composable private fun ShadeCarrierGroup(viewModel: ShadeHeaderViewModel, modifier: Modifier = Modifier) { - Row(modifier = modifier) { - val subIds by viewModel.mobileSubIds.collectAsStateWithLifecycle() - - for (subId in subIds) { - Spacer(modifier = Modifier.width(5.dp)) + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + for (subId in viewModel.mobileSubIds) { AndroidView( factory = { context -> ModernShadeCarrierGroupMobileView.constructAndBind( diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorTest.kt index 84fc93008f49..a3dd67f85150 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorTest.kt @@ -22,6 +22,9 @@ import android.provider.AlarmClock import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.broadcastDispatcher +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.coroutines.collectValues import com.android.systemui.kosmos.testScope import com.android.systemui.plugins.activityStarter import com.android.systemui.statusbar.policy.NextAlarmController.NextAlarmChangeCallback @@ -31,22 +34,32 @@ import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.argThat import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.withArgCaptor +import com.google.common.truth.Truth.assertThat +import java.util.Date +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatcher -import org.mockito.Mockito.times import org.mockito.Mockito.verify +@OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) class ShadeHeaderClockInteractorTest : SysuiTestCase() { + private val kosmos = testKosmos() private val testScope = kosmos.testScope private val activityStarter = kosmos.activityStarter private val nextAlarmController = kosmos.nextAlarmController - val underTest = kosmos.shadeHeaderClockInteractor + private val underTest = kosmos.shadeHeaderClockInteractor @Test fun launchClockActivity_default() = @@ -55,7 +68,7 @@ class ShadeHeaderClockInteractorTest : SysuiTestCase() { verify(activityStarter) .postStartActivityDismissingKeyguard( argThat(IntentMatcherAction(AlarmClock.ACTION_SHOW_ALARMS)), - any() + any(), ) } @@ -71,6 +84,75 @@ class ShadeHeaderClockInteractorTest : SysuiTestCase() { underTest.launchClockActivity() verify(activityStarter).postStartActivityDismissingKeyguard(any()) } + + @Test + fun onTimezoneOrLocaleChanged_localeAndTimezoneChanged_emitsForEach() = + testScope.runTest { + val timeZoneOrLocaleChanges by collectValues(underTest.onTimezoneOrLocaleChanged) + + sendIntentActionBroadcast(Intent.ACTION_TIMEZONE_CHANGED) + sendIntentActionBroadcast(Intent.ACTION_LOCALE_CHANGED) + sendIntentActionBroadcast(Intent.ACTION_LOCALE_CHANGED) + sendIntentActionBroadcast(Intent.ACTION_TIMEZONE_CHANGED) + + assertThat(timeZoneOrLocaleChanges).hasSize(4) + } + + @Test + fun onTimezoneOrLocaleChanged_timeChanged_doesNotEmit() = + testScope.runTest { + val timeZoneOrLocaleChanges by collectValues(underTest.onTimezoneOrLocaleChanged) + assertThat(timeZoneOrLocaleChanges).hasSize(1) + + sendIntentActionBroadcast(Intent.ACTION_TIME_CHANGED) + sendIntentActionBroadcast(Intent.ACTION_TIME_TICK) + + // Expect only 1 event to have been emitted onStart, but no more. + assertThat(timeZoneOrLocaleChanges).hasSize(1) + } + + @Test + fun currentTime_timeChanged() = + testScope.runTest { + val currentTime by collectLastValue(underTest.currentTime) + + sendIntentActionBroadcast(Intent.ACTION_TIME_CHANGED) + val earlierTime = checkNotNull(currentTime) + + advanceTimeBy(3.seconds) + runCurrent() + + sendIntentActionBroadcast(Intent.ACTION_TIME_CHANGED) + val laterTime = checkNotNull(currentTime) + + assertThat(differenceBetween(laterTime, earlierTime)).isEqualTo(3.seconds) + } + + @Test + fun currentTime_timeTicked() = + testScope.runTest { + val currentTime by collectLastValue(underTest.currentTime) + + sendIntentActionBroadcast(Intent.ACTION_TIME_TICK) + val earlierTime = checkNotNull(currentTime) + + advanceTimeBy(7.seconds) + runCurrent() + + sendIntentActionBroadcast(Intent.ACTION_TIME_TICK) + val laterTime = checkNotNull(currentTime) + + assertThat(differenceBetween(laterTime, earlierTime)).isEqualTo(7.seconds) + } + + private fun differenceBetween(date1: Date, date2: Date): Duration { + return (date1.time - date2.time).milliseconds + } + + private fun TestScope.sendIntentActionBroadcast(intentAction: String) { + kosmos.broadcastDispatcher.sendIntentToMatchingReceiversOnly(context, Intent(intentAction)) + runCurrent() + } } private class IntentMatcherAction(private val action: String) : ArgumentMatcher<Intent> { diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelTest.kt index 061e04ef29f7..37b4688f753d 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelTest.kt @@ -25,12 +25,15 @@ import com.android.systemui.shade.domain.interactor.disableDualShade import com.android.systemui.shade.domain.interactor.enableDualShade import com.android.systemui.shade.domain.interactor.enableSingleShade import com.android.systemui.shade.domain.interactor.enableSplitShade +import com.android.systemui.shade.domain.interactor.shadeMode +import com.android.systemui.shade.shared.model.ShadeMode import com.android.systemui.shade.ui.viewmodel.ShadeHeaderViewModel.HeaderChipHighlight import com.android.systemui.statusbar.pipeline.mobile.data.model.SubscriptionModel import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.fakeMobileIconsInteractor import com.android.systemui.testKosmos import com.android.systemui.util.mockito.argThat import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent @@ -43,6 +46,7 @@ import org.mockito.ArgumentMatchers.anyInt import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations +@OptIn(ExperimentalCoroutinesApi::class) @SmallTest @RunWith(AndroidJUnit4::class) @EnableSceneContainer @@ -64,14 +68,15 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun mobileSubIds_update() = testScope.runTest { - val mobileSubIds by collectLastValue(underTest.mobileSubIds) mobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1) + runCurrent() - assertThat(mobileSubIds).isEqualTo(listOf(1)) + assertThat(underTest.mobileSubIds).isEqualTo(listOf(1)) mobileIconsInteractor.filteredSubscriptions.value = listOf(SUB_1, SUB_2) + runCurrent() - assertThat(mobileSubIds).isEqualTo(listOf(1, 2)) + assertThat(underTest.mobileSubIds).isEqualTo(listOf(1, 2)) } @Test @@ -116,13 +121,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onSystemIconChipClicked_lockedOnQsShade_collapsesShadeToLockscreen() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.QuickSettingsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(false) - setScene(Scenes.Lockscreen) - setOverlay(Overlays.QuickSettingsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onSystemIconChipClicked() runCurrent() @@ -134,13 +135,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onSystemIconChipClicked_lockedOnNotifShade_expandsQsShade() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.NotificationsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(false) - setScene(Scenes.Lockscreen) - setOverlay(Overlays.NotificationsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onSystemIconChipClicked() runCurrent() @@ -166,13 +163,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onSystemIconChipClicked_unlockedOnQsShade_collapsesShadeToGone() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.QuickSettingsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.QuickSettingsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onSystemIconChipClicked() runCurrent() @@ -184,13 +177,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onSystemIconChipClicked_unlockedOnNotifShade_expandsQsShade() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.NotificationsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.NotificationsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onSystemIconChipClicked() runCurrent() @@ -203,13 +192,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onNotificationIconChipClicked_lockedOnNotifShade_collapsesShadeToLockscreen() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.NotificationsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(false) - setScene(Scenes.Lockscreen) - setOverlay(Overlays.NotificationsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onNotificationIconChipClicked() runCurrent() @@ -221,13 +206,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onNotificationIconChipClicked_lockedOnQsShade_expandsNotifShade() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.QuickSettingsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(false) - setScene(Scenes.Lockscreen) - setOverlay(Overlays.QuickSettingsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onNotificationIconChipClicked() runCurrent() @@ -240,13 +221,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onNotificationIconChipClicked_unlockedOnNotifShade_collapsesShadeToGone() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.NotificationsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.NotificationsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onNotificationIconChipClicked() runCurrent() @@ -258,13 +235,9 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun onNotificationIconChipClicked_unlockedOnQsShade_expandsNotifShade() = testScope.runTest { - kosmos.enableDualShade() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.QuickSettingsShade) val currentScene by collectLastValue(sceneInteractor.currentScene) val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.QuickSettingsShade) - assertThat(currentOverlays).isNotEmpty() underTest.onNotificationIconChipClicked() runCurrent() @@ -319,22 +292,13 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun highlightChips_notifsOpenInDualShade_notifsStrongQuickSettingsWeak() = testScope.runTest { - kosmos.enableDualShade() - val currentScene by collectLastValue(sceneInteractor.currentScene) - val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - // Test the lockscreen scenario. - setScene(Scenes.Lockscreen) - setOverlay(Overlays.NotificationsShade) + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.NotificationsShade) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.Strong) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.Weak) // Test the unlocked scenario. - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.NotificationsShade) - assertThat(currentScene).isEqualTo(Scenes.Gone) - assertThat(currentOverlays).isNotEmpty() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.NotificationsShade) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.Strong) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.Weak) } @@ -342,22 +306,13 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun highlightChips_quickSettingsOpenInDualShade_notifsWeakQuickSettingsStrong() = testScope.runTest { - kosmos.enableDualShade() - val currentScene by collectLastValue(sceneInteractor.currentScene) - val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - // Test the lockscreen scenario. - setScene(Scenes.Lockscreen) - setOverlay(Overlays.QuickSettingsShade) + setupDualShadeState(scene = Scenes.Lockscreen, overlay = Overlays.QuickSettingsShade) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.Weak) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.Strong) // Test the unlocked scenario. - setDeviceEntered(true) - setScene(Scenes.Gone) - setOverlay(Overlays.QuickSettingsShade) - assertThat(currentScene).isEqualTo(Scenes.Gone) - assertThat(currentOverlays).isNotEmpty() + setupDualShadeState(scene = Scenes.Gone, overlay = Overlays.QuickSettingsShade) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.Weak) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.Strong) } @@ -365,21 +320,13 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { @Test fun highlightChips_noOverlaysInDualShade_bothNone() = testScope.runTest { - kosmos.enableDualShade() - val currentScene by collectLastValue(sceneInteractor.currentScene) - val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) - // Test the lockscreen scenario. - setScene(Scenes.Lockscreen) - assertThat(currentOverlays).isEmpty() + setupDualShadeState(scene = Scenes.Lockscreen) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.None) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.None) // Test the unlocked scenario. - setDeviceEntered(true) - setScene(Scenes.Gone) - assertThat(currentScene).isEqualTo(Scenes.Gone) - assertThat(currentOverlays).isEmpty() + setupDualShadeState(scene = Scenes.Gone) assertThat(underTest.notificationsChipHighlight).isEqualTo(HeaderChipHighlight.None) assertThat(underTest.quickSettingsChipHighlight).isEqualTo(HeaderChipHighlight.None) } @@ -401,21 +348,43 @@ class ShadeHeaderViewModelTest : SysuiTestCase() { ) } - private fun setScene(key: SceneKey) { - sceneInteractor.changeScene(key, "test") + private fun TestScope.setupDualShadeState(scene: SceneKey, overlay: OverlayKey? = null) { + kosmos.enableDualShade() + val shadeMode by collectLastValue(kosmos.shadeMode) + val currentScene by collectLastValue(sceneInteractor.currentScene) + val currentOverlays by collectLastValue(sceneInteractor.currentOverlays) + if (scene == Scenes.Gone) { + // Unlock the device, marking the device has been entered. + kosmos.fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus( + SuccessFingerprintAuthenticationStatus(0, true) + ) + } + runCurrent() + assertThat(shadeMode).isEqualTo(ShadeMode.Dual) + + sceneInteractor.changeScene(scene, "test") + checkNotNull(currentOverlays).forEach { sceneInteractor.instantlyHideOverlay(it, "test") } + runCurrent() + overlay?.let { sceneInteractor.showOverlay(it, "test") } sceneInteractor.setTransitionState( - MutableStateFlow<ObservableTransitionState>(ObservableTransitionState.Idle(key)) + MutableStateFlow<ObservableTransitionState>( + ObservableTransitionState.Idle(scene, setOfNotNull(overlay)) + ) ) - testScope.runCurrent() + runCurrent() + + assertThat(currentScene).isEqualTo(scene) + if (overlay == null) { + assertThat(currentOverlays).isEmpty() + } else { + assertThat(currentOverlays).containsExactly(overlay) + } } - private fun setOverlay(key: OverlayKey) { - val currentOverlays = sceneInteractor.currentOverlays.value + key - sceneInteractor.showOverlay(key, "test") + private fun setScene(key: SceneKey) { + sceneInteractor.changeScene(key, "test") sceneInteractor.setTransitionState( - MutableStateFlow<ObservableTransitionState>( - ObservableTransitionState.Idle(sceneInteractor.currentScene.value, currentOverlays) - ) + MutableStateFlow<ObservableTransitionState>(ObservableTransitionState.Idle(key)) ) testScope.runCurrent() } diff --git a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractor.kt b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractor.kt index 186bfcbbc8e2..a4de1d675a15 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractor.kt @@ -17,11 +17,19 @@ package com.android.systemui.shade.domain.interactor import android.content.Intent +import android.content.IntentFilter +import android.os.UserHandle import android.provider.AlarmClock +import com.android.systemui.broadcast.BroadcastDispatcher import com.android.systemui.dagger.SysUISingleton import com.android.systemui.plugins.ActivityStarter import com.android.systemui.shade.data.repository.ShadeHeaderClockRepository +import com.android.systemui.util.kotlin.emitOnStart +import com.android.systemui.util.time.SystemClock +import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map @SysUISingleton class ShadeHeaderClockInteractor @@ -29,7 +37,20 @@ class ShadeHeaderClockInteractor constructor( private val repository: ShadeHeaderClockRepository, private val activityStarter: ActivityStarter, + private val broadcastDispatcher: BroadcastDispatcher, + private val systemClock: SystemClock, ) { + /** [Flow] that emits `Unit` whenever the timezone or locale has changed. */ + val onTimezoneOrLocaleChanged: Flow<Unit> = + broadcastFlowForActions(Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_LOCALE_CHANGED) + .emitOnStart() + + /** [Flow] that emits the current `Date` every minute, or when the system time has changed. */ + val currentTime: Flow<Date> = + broadcastFlowForActions(Intent.ACTION_TIME_TICK, Intent.ACTION_TIME_CHANGED) + .emitOnStart() + .map { Date(systemClock.currentTimeMillis()) } + /** Launch the clock activity. */ fun launchClockActivity() { val nextAlarmIntent = repository.nextAlarmIntent @@ -38,8 +59,22 @@ constructor( } else { activityStarter.postStartActivityDismissingKeyguard( Intent(AlarmClock.ACTION_SHOW_ALARMS), - 0 + 0, ) } } + + /** + * Returns a `Flow` that, when collected, emits `Unit` whenever a broadcast matching one of the + * given [actionsToFilter] is received. + */ + private fun broadcastFlowForActions( + vararg actionsToFilter: String, + user: UserHandle = UserHandle.SYSTEM, + ): Flow<Unit> { + return broadcastDispatcher.broadcastFlow( + filter = IntentFilter().apply { actionsToFilter.forEach(::addAction) }, + user = user, + ) + } } diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModel.kt index 8c38d2e7550c..63161db7902b 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModel.kt @@ -18,16 +18,13 @@ package com.android.systemui.shade.ui.viewmodel import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.icu.text.DateFormat import android.icu.text.DisplayContext -import android.os.UserHandle import android.provider.Settings import android.view.ViewGroup import androidx.compose.runtime.getValue import com.android.app.tracing.coroutines.launchTraced as launch import com.android.systemui.battery.BatteryMeterViewController -import com.android.systemui.broadcast.BroadcastDispatcher import com.android.systemui.lifecycle.ExclusiveActivatable import com.android.systemui.lifecycle.Hydrator import com.android.systemui.plugins.ActivityStarter @@ -50,18 +47,18 @@ import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIc import com.android.systemui.statusbar.pipeline.mobile.ui.viewmodel.MobileIconsViewModel import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import java.util.Date import java.util.Locale +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.mapLatest /** Models UI state for the shade header. */ +@OptIn(ExperimentalCoroutinesApi::class) class ShadeHeaderViewModel @AssistedInject constructor( @@ -70,7 +67,7 @@ constructor( private val sceneInteractor: SceneInteractor, private val shadeInteractor: ShadeInteractor, private val shadeModeInteractor: ShadeModeInteractor, - private val mobileIconsInteractor: MobileIconsInteractor, + mobileIconsInteractor: MobileIconsInteractor, val mobileIconsViewModel: MobileIconsViewModel, private val privacyChipInteractor: PrivacyChipInteractor, private val clockInteractor: ShadeHeaderClockInteractor, @@ -78,8 +75,8 @@ constructor( private val batteryMeterViewControllerFactory: BatteryMeterViewController.Factory, val statusBarIconController: StatusBarIconController, val notificationIconContainerStatusBarViewBinder: NotificationIconContainerStatusBarViewBinder, - private val broadcastDispatcher: BroadcastDispatcher, ) : ExclusiveActivatable() { + private val hydrator = Hydrator("ShadeHeaderViewModel.hydrator") val createTintedIconManager: (ViewGroup, StatusBarLocation) -> TintedIconManager = @@ -127,9 +124,16 @@ constructor( /** True if there is exactly one mobile connection. */ val isSingleCarrier: StateFlow<Boolean> = mobileIconsInteractor.isSingleCarrier - private val _mobileSubIds = MutableStateFlow(emptyList<Int>()) /** The list of subscription Ids for current mobile connections. */ - val mobileSubIds: StateFlow<List<Int>> = _mobileSubIds.asStateFlow() + val mobileSubIds: List<Int> by + hydrator.hydratedStateOf( + traceName = "mobileSubIds", + initialValue = emptyList(), + source = + mobileIconsInteractor.filteredSubscriptions.map { list -> + list.map { it.subscriptionId } + }, + ) /** The list of PrivacyItems to be displayed by the privacy chip. */ val privacyItems: StateFlow<List<PrivacyItem>> = privacyChipInteractor.privacyItems @@ -150,45 +154,34 @@ constructor( private val longerPattern = context.getString(R.string.abbrev_wday_month_day_no_year_alarm) private val shorterPattern = context.getString(R.string.abbrev_month_day_no_year) - private val longerDateFormat = MutableStateFlow(getFormatFromPattern(longerPattern)) - private val shorterDateFormat = MutableStateFlow(getFormatFromPattern(shorterPattern)) - private val _shorterDateText: MutableStateFlow<String> = MutableStateFlow("") - val shorterDateText: StateFlow<String> = _shorterDateText.asStateFlow() + private val longerDateFormat: Flow<DateFormat> = + clockInteractor.onTimezoneOrLocaleChanged.mapLatest { getFormatFromPattern(longerPattern) } + private val shorterDateFormat: Flow<DateFormat> = + clockInteractor.onTimezoneOrLocaleChanged.mapLatest { getFormatFromPattern(shorterPattern) } + + val longerDateText: String by + hydrator.hydratedStateOf( + traceName = "longerDateText", + initialValue = "", + source = + combine(longerDateFormat, clockInteractor.currentTime) { format, time -> + format.format(time) + }, + ) - private val _longerDateText: MutableStateFlow<String> = MutableStateFlow("") - val longerDateText: StateFlow<String> = _longerDateText.asStateFlow() + val shorterDateText: String by + hydrator.hydratedStateOf( + traceName = "shorterDateText", + initialValue = "", + source = + combine(shorterDateFormat, clockInteractor.currentTime) { format, time -> + format.format(time) + }, + ) override suspend fun onActivated(): Nothing { coroutineScope { - launch { - broadcastDispatcher - .broadcastFlow( - filter = - IntentFilter().apply { - addAction(Intent.ACTION_TIME_TICK) - addAction(Intent.ACTION_TIME_CHANGED) - addAction(Intent.ACTION_TIMEZONE_CHANGED) - addAction(Intent.ACTION_LOCALE_CHANGED) - }, - user = UserHandle.SYSTEM, - map = { intent, _ -> - intent.action == Intent.ACTION_TIMEZONE_CHANGED || - intent.action == Intent.ACTION_LOCALE_CHANGED - }, - ) - .onEach { invalidateFormats -> updateDateTexts(invalidateFormats) } - .launchIn(this) - } - - launch { updateDateTexts(false) } - - launch { - mobileIconsInteractor.filteredSubscriptions - .map { list -> list.map { it.subscriptionId } } - .collect { _mobileSubIds.value = it } - } - launch { hydrator.activate() } awaitCancellation() @@ -260,26 +253,9 @@ constructor( data object Strong : HeaderChipHighlight } - private fun updateDateTexts(invalidateFormats: Boolean) { - if (invalidateFormats) { - longerDateFormat.value = getFormatFromPattern(longerPattern) - shorterDateFormat.value = getFormatFromPattern(shorterPattern) - } - - val currentTime = Date() - - _longerDateText.value = longerDateFormat.value.format(currentTime) - _shorterDateText.value = shorterDateFormat.value.format(currentTime) - } - private fun getFormatFromPattern(pattern: String?): DateFormat { - val l = Locale.getDefault() - val format = DateFormat.getInstanceForSkeleton(pattern, l) - // The use of CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE instead of - // CAPITALIZATION_FOR_STANDALONE is to address - // https://unicode-org.atlassian.net/browse/ICU-21631 - // TODO(b/229287642): Switch back to CAPITALIZATION_FOR_STANDALONE - format.setContext(DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE) + val format = DateFormat.getInstanceForSkeleton(pattern, Locale.getDefault()) + format.setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE) return format } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorKosmos.kt index 6fd7cf6edbe4..2ea2119970ad 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/domain/interactor/ShadeHeaderClockInteractorKosmos.kt @@ -16,14 +16,18 @@ package com.android.systemui.shade.domain.interactor +import com.android.systemui.broadcast.broadcastDispatcher import com.android.systemui.kosmos.Kosmos import com.android.systemui.plugins.activityStarter import com.android.systemui.shade.data.repository.shadeHeaderClockRepository +import com.android.systemui.util.time.systemClock var Kosmos.shadeHeaderClockInteractor: ShadeHeaderClockInteractor by Kosmos.Fixture { ShadeHeaderClockInteractor( repository = shadeHeaderClockRepository, activityStarter = activityStarter, + broadcastDispatcher = broadcastDispatcher, + systemClock = systemClock, ) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelKosmos.kt index 08de73be1128..2f8387b2d0f9 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/shade/ui/viewmodel/ShadeHeaderViewModelKosmos.kt @@ -18,7 +18,6 @@ package com.android.systemui.shade.ui.viewmodel import android.content.applicationContext import com.android.systemui.battery.batteryMeterViewControllerFactory -import com.android.systemui.broadcast.broadcastDispatcher import com.android.systemui.kosmos.Kosmos import com.android.systemui.plugins.activityStarter import com.android.systemui.scene.domain.interactor.sceneInteractor @@ -50,7 +49,6 @@ val Kosmos.shadeHeaderViewModel: ShadeHeaderViewModel by statusBarIconController = mock<StatusBarIconController>(), notificationIconContainerStatusBarViewBinder = mock<NotificationIconContainerStatusBarViewBinder>(), - broadcastDispatcher = broadcastDispatcher, ) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/util/time/FakeSystemClockKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/util/time/FakeSystemClockKosmos.kt index 06af32e69b75..b23ccbfbd93f 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/util/time/FakeSystemClockKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/util/time/FakeSystemClockKosmos.kt @@ -14,12 +14,15 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package com.android.systemui.util.time import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.testScope import com.android.systemui.util.mockito.mock import com.android.systemui.util.mockito.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.currentTime var Kosmos.systemClock by @@ -27,6 +30,7 @@ var Kosmos.systemClock by mock { whenever(elapsedRealtime()).thenAnswer { testScope.currentTime } whenever(uptimeMillis()).thenAnswer { testScope.currentTime } + whenever(currentTimeMillis()).thenAnswer { testScope.currentTime } } } |