diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index 03b57bb..afd6f72 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -5,11 +5,13 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider +import de.jeanlucmakiola.clockula.data.time.RealTicker import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.ZoneProvider @@ -34,4 +36,7 @@ abstract class TimeModule { @Binds abstract fun bindBootIdProvider(impl: AndroidBootIdProvider): BootIdProvider + + @Binds + abstract fun bindTicker(impl: RealTicker): Ticker } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index d35a5cf..ceb0206 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -92,4 +92,16 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setTimerDuration(duration: Duration) = store.set(ClockPrefs.timerDurationMillis, duration) suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId) + + // --- first-launch bookkeeping (M4) --- + + /** + * Whether the POST_NOTIFICATIONS request has already been shown once. + * Distinct-until-changed through [PrefStore.flow], so writing an unrelated + * preference does not re-emit it. + */ + val notificationPermissionAsked: Flow = store.flow(UiPrefs.notificationPermissionAsked) + + suspend fun markNotificationPermissionAsked() = + store.set(UiPrefs.notificationPermissionAsked, true) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/UiPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/UiPrefs.kt new file mode 100644 index 0000000..7b2f664 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/UiPrefs.kt @@ -0,0 +1,11 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.booleanPref + +/** The shell's own on-disk keys — first-launch bookkeeping, not a user setting. */ +object UiPrefs { + /** Whether the POST_NOTIFICATIONS request has been shown once. */ + val notificationPermissionAsked: Pref = + booleanPref("notification_permission_asked", false) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt new file mode 100644 index 0000000..4db5bdd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/RealTicker.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.clockula.data.time + +import de.jeanlucmakiola.clockula.domain.time.Ticker +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.Duration +import javax.inject.Inject +import javax.inject.Singleton + +/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */ +@Singleton +class RealTicker @Inject constructor() : Ticker { + + /** + * Cold, and per-collector: two readouts on different cadences get two + * independent streams, and a collector that walks away takes its own + * `delay` with it. The first emission is immediate, so a subscriber is + * never blank for a period. + */ + override fun ticks(period: Duration): Flow = flow { + while (true) { + emit(Unit) + delay(period) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index e2cd662..6b3cf96 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -11,7 +11,9 @@ import de.jeanlucmakiola.floret.prefs.PrefStore import de.jeanlucmakiola.floret.prefs.ThemeMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -26,12 +28,24 @@ import java.nio.file.Path */ class SettingsPrefsTest { - private fun TestScope.newDataStore(tempDir: Path): DataStore = + /** + * One store's worth of lifetime. DataStore keeps a registry of the files it + * has open and refuses a second store over a live one, so a test that wants + * to *reopen* a file has to end the first scope first — the way a process + * ending is what really releases the file. + */ + private fun TestScope.newScope(): CoroutineScope = + CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()) + + private fun newDataStore(tempDir: Path, scope: CoroutineScope): DataStore = PreferenceDataStoreFactory.create( - scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + scope = scope, produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, ) + private fun TestScope.newDataStore(tempDir: Path): DataStore = + newDataStore(tempDir, newScope()) + @Test fun `a fresh install follows the system theme with dynamic colour on`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) @@ -105,4 +119,41 @@ class SettingsPrefsTest { cancel() } } + + // --- M4: the one-shot notification permission request --- + + @Test + fun `a fresh install has not been asked for the notification permission`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + assertThat(prefs.notificationPermissionAsked.first()).isFalse() + } + + @Test + fun `having asked once survives process death`(@TempDir tempDir: Path) = runTest { + val firstProcess = newScope() + SettingsPrefs(PrefStore(newDataStore(tempDir, firstProcess))).markNotificationPermissionAsked() + + // The process death: the store's scope dies with the process, and that + // is also what lets the file be opened again. + firstProcess.coroutineContext.job.cancelAndJoin() + + val reopened = SettingsPrefs(PrefStore(newDataStore(tempDir, newScope()))) + + assertThat(reopened.notificationPermissionAsked.first()).isTrue() + } + + @Test + fun `the asked flag does not re-emit when an unrelated preference is written`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir))) + + prefs.notificationPermissionAsked.test { + assertThat(awaitItem()).isFalse() + + prefs.setSnoozeMinutes(7) + + expectNoEvents() + cancel() + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt new file mode 100644 index 0000000..1afa53e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/time/RealTickerTest.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.clockula.data.time + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * The ticker is nothing but `delay`, which is why it is injected: under + * `runTest`'s virtual clock five seconds of it cost nothing. It emits once + * immediately so a subscriber is never blank for a cadence. + */ +class RealTickerTest { + + private val ticker = RealTicker() + + @Test + fun `the first tick arrives immediately`() = runTest { + var ticks = 0 + backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } } + + runCurrent() + + assertThat(ticks).isEqualTo(1) + } + + @Test + fun `a second tick arrives one period later`() = runTest { + var ticks = 0 + backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } } + runCurrent() + + advanceTimeBy(1.seconds) + runCurrent() + + assertThat(ticks).isEqualTo(2) + } + + @Test + fun `five seconds is one immediate tick and five periodic ones`() = runTest { + var ticks = 0 + backgroundScope.launch { ticker.ticks(1.seconds).collect { ticks++ } } + runCurrent() + + advanceTimeBy(5.seconds) + runCurrent() + + assertThat(ticks).isEqualTo(6) + } + + @Test + fun `each collector gets its own cadence`() = runTest { + var slow = 0 + var fast = 0 + backgroundScope.launch { ticker.ticks(1.seconds).collect { slow++ } } + backgroundScope.launch { ticker.ticks(200.milliseconds).collect { fast++ } } + runCurrent() + + advanceTimeBy(1.seconds) + runCurrent() + + assertThat(slow to fast).isEqualTo(2 to 6) + } + + @Test + fun `a collector that has seen enough can walk away`() = runTest { + val ticks = ticker.ticks(1.seconds).take(3).toList() + + assertThat(ticks).hasSize(3) + } +}