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

Commit c615cbcd authored by Andre Le's avatar Andre Le
Browse files

ComposeClock: Add basic structure for the compose clock

Create a generic compose clock with its view model and composable. Right
now, it only shows the current time and does not respond to anything.
Future CLs will add more functionalities for the clock on top of this
basic structure.

Also adds some small updates to the current clock interactor.

Bug: 390204943
Flag: com.android.systemui.clock_modernization
Test: ClockViewModelTest
Change-Id: I35d1e3be4e3e9a2503eedf3b354579a6643b447e
parent 5faf9e92
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ import com.android.systemui.plugins.activityStarter
import com.android.systemui.statusbar.policy.NextAlarmController.NextAlarmChangeCallback
import com.android.systemui.statusbar.policy.nextAlarmController
import com.android.systemui.testKosmos
import com.android.systemui.util.time.systemClock
import com.google.common.truth.Truth.assertThat
import java.util.Date
import kotlin.time.Duration
@@ -105,6 +106,13 @@ class ClockInteractorTest : SysuiTestCase() {
            assertThat(timeZoneOrLocaleChanges).hasSize(1)
        }

    @Test
    fun currentTime_initialTime() =
        kosmos.runTest {
            assertThat(underTest.currentTime.value)
                .isEqualTo(Date(kosmos.systemClock.currentTimeMillis()))
        }

    @Test
    fun currentTime_timeChanged() =
        kosmos.runTest {
+94 −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.clock.ui.viewmodel

import android.content.Intent
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.kosmos.advanceTimeBy
import com.android.systemui.kosmos.runCurrent
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.testKosmos
import com.android.systemui.util.time.systemClock
import com.google.common.truth.Truth.assertThat
import java.util.Date
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class ClockViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val underTest by lazy { kosmos.clockViewModel }

    @Before
    fun setUp() {
        underTest.activateIn(kosmos.testScope)
    }

    @Test
    fun clockText_equalsCurrentTime() =
        kosmos.runTest {
            assertThat(underTest.clockText)
                .isEqualTo(Date(systemClock.currentTimeMillis()).toString())
        }

    @Test
    fun clockText_updatesWhenTimeTick() =
        kosmos.runTest {
            val earlierTime = underTest.clockText
            assertThat(earlierTime).isEqualTo(Date(systemClock.currentTimeMillis()).toString())

            advanceTimeBy(7.seconds)
            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(Intent.ACTION_TIME_TICK),
            )
            runCurrent()

            assertThat(underTest.clockText)
                .isEqualTo(Date(systemClock.currentTimeMillis()).toString())
            assertThat(underTest.clockText).isNotEqualTo(earlierTime)
        }

    @Test
    fun clockText_updatesWhenTimeChanged() =
        kosmos.runTest {
            val earlierTime = underTest.clockText
            assertThat(earlierTime).isEqualTo(Date(systemClock.currentTimeMillis()).toString())

            advanceTimeBy(10.seconds)
            broadcastDispatcher.sendIntentToMatchingReceiversOnly(
                context,
                Intent(Intent.ACTION_TIME_CHANGED),
            )
            runCurrent()

            assertThat(underTest.clockText)
                .isEqualTo(Date(systemClock.currentTimeMillis()).toString())
            assertThat(underTest.clockText).isNotEqualTo(earlierTime)
        }
}
+18 −3
Original line number Diff line number Diff line
@@ -23,13 +23,18 @@ import android.provider.AlarmClock
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.clock.data.repository.ClockRepository
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.plugins.ActivityStarter
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.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

@SysUISingleton
class ClockInteractor
@@ -39,17 +44,27 @@ constructor(
    private val activityStarter: ActivityStarter,
    private val broadcastDispatcher: BroadcastDispatcher,
    private val systemClock: SystemClock,
    @Background private val coroutineScope: CoroutineScope,
) {
    /** [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> =
    /**
     * [StateFlow] that emits the current `Date` every minute, or when the system time has changed.
     *
     * TODO(b/390204943): Emits every second instead of every minute since the clock can show
     *   seconds.
     */
    val currentTime: StateFlow<Date> =
        broadcastFlowForActions(Intent.ACTION_TIME_TICK, Intent.ACTION_TIME_CHANGED)
            .emitOnStart()
            .map { Date(systemClock.currentTimeMillis()) }
            .stateIn(
                scope = coroutineScope,
                started = SharingStarted.Eagerly,
                initialValue = Date(systemClock.currentTimeMillis()),
            )

    /** Launch the clock activity. */
    fun launchClockActivity() {
+30 −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.clock.ui.composable

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.android.systemui.clock.ui.viewmodel.ClockViewModel
import com.android.systemui.lifecycle.rememberViewModel

/** Composable for the clock UI that is shown on the top left of the status bar and the shade. */
@Composable
fun Clock(viewModelFactory: ClockViewModel.Factory, modifier: Modifier = Modifier) {
    val clockViewModel = rememberViewModel("Clock-viewModel") { viewModelFactory.create() }
    Text(text = clockViewModel.clockText, modifier = modifier)
}
+56 −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.clock.ui.viewmodel

import androidx.compose.runtime.getValue
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.clock.domain.interactor.ClockInteractor
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.mapLatest

/** Models UI state for the clock. */
@OptIn(ExperimentalCoroutinesApi::class)
class ClockViewModel @AssistedInject constructor(clockInteractor: ClockInteractor) :
    ExclusiveActivatable() {
    private val hydrator = Hydrator("ClockViewModel.hydrator")

    val clockText: String by
        hydrator.hydratedStateOf(
            traceName = "clockText",
            initialValue = clockInteractor.currentTime.value.toString(),
            source = clockInteractor.currentTime.mapLatest { time -> time.toString() },
        )

    override suspend fun onActivated(): Nothing {
        coroutineScope {
            launch { hydrator.activate() }

            awaitCancellation()
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(): ClockViewModel
    }
}
Loading