diff --git a/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt b/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt index e43071d3..a236ec0f 100644 --- a/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt +++ b/app/src/main/java/eu/darken/capod/common/uix/ViewModel4.kt @@ -1,6 +1,7 @@ package eu.darken.capod.common.uix import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.error.ErrorEventSource2 @@ -9,10 +10,23 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.navigation.NavEvent import eu.darken.capod.common.navigation.NavigationDestination import eu.darken.capod.common.navigation.NavigationEventSource +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.stateIn +/** + * Base ViewModel for Compose screens. + * Provides error events via [SingleEventFlow] and navigation via [NavigationEventSource]. + * + * Compose render state should be exposed as VM-owned [StateFlow]s. Those render-state flows must + * stay collector-safe and never throw into `collectAsStateWithLifecycle()`. Use [safeStateIn] to + * forward recoverable failures to [errorEvents] and emit an explicit fallback UI state instead. + */ abstract class ViewModel4( dispatcherProvider: DispatcherProvider, ) : ViewModel2(dispatcherProvider), NavigationEventSource, ErrorEventSource2 { @@ -44,4 +58,26 @@ abstract class ViewModel4( log(TAG) { "navUp()" } navEvents.tryEmit(NavEvent.Up) } + + /** + * Collect a render-state flow in [vmScope] and convert upstream failures into explicit fallback + * UI state plus an [errorEvents] emission. Cancellation is never converted into UI state. + */ + protected fun Flow.safeStateIn( + initialValue: T, + started: SharingStarted = SharingStarted.WhileSubscribed(5000), + onError: (Throwable) -> T, + ): StateFlow = this + .catch { ex -> + if (ex is CancellationException) throw ex + + log(TAG, WARN) { "Error during state collection: ${ex.asLog()}" } + errorEvents.emit(ex) + emit(onError(ex)) + } + .stateIn( + scope = vmScope, + started = started, + initialValue = initialValue, + ) } diff --git a/app/src/main/java/eu/darken/capod/main/core/CurriculumVitae.kt b/app/src/main/java/eu/darken/capod/main/core/CurriculumVitae.kt new file mode 100644 index 00000000..71723dce --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/core/CurriculumVitae.kt @@ -0,0 +1,109 @@ +package eu.darken.capod.main.core + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import kotlinx.coroutines.flow.first +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CurriculumVitae @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val Context.dataStore by preferencesDataStore(name = "curriculum_vitae") + + private val dataStore: DataStore + get() = context.dataStore + + // Lifetime Pro-state history: how often the billing grace period had to save this install, and + // whether/when Pro was actually lost. Written by the gplay UpgradeRepo from FRESH Play data + // only; surfaced in every debug log recording so billing complaints arrive with context. + // Raw preference keys (not DataStoreValues): a transition must update state, counter, and + // timestamp in ONE transaction. + private val proStateLastKey = stringPreferencesKey("stats.pro.state.last") + private val proGraceCountKey = intPreferencesKey("stats.pro.grace.count") + private val proGraceLastKey = longPreferencesKey("stats.pro.grace.last") + private val proLostCountKey = intPreferencesKey("stats.pro.lost.count") + private val proLostLastKey = longPreferencesKey("stats.pro.lost.last") + + enum class ProState { PURCHASED, GRACE, FREE } + + data class ProHistory( + val lastState: ProState?, + val graceEngagedCount: Int, + val graceEngagedLast: Instant?, + val proLostCount: Int, + val proLostLast: Instant?, + ) + + // Suspend on purpose: the caller's collector is ordered (billing commit order) and a + // fire-and-forget launch per update could apply rapid transitions out of order. + suspend fun updateProState(state: ProState) { + dataStore.edit { prefs -> + val previous = parseProState(prefs[proStateLastKey]) + if (previous == state) return@edit + log(TAG, INFO) { "updateProState(): $previous -> $state" } + val now = Instant.now().toEpochMilli() + when (proTransitionOf(previous, state)) { + ProTransition.GRACE_ENGAGED -> { + prefs[proGraceCountKey] = (prefs[proGraceCountKey] ?: 0) + 1 + prefs[proGraceLastKey] = now + } + + ProTransition.PRO_LOST -> { + prefs[proLostCountKey] = (prefs[proLostCountKey] ?: 0) + 1 + prefs[proLostLastKey] = now + } + + // First observation (or an unknown/corrupt stored value): baseline only. + null -> {} + } + prefs[proStateLastKey] = state.name + } + } + + suspend fun proHistory(): ProHistory { + val prefs = dataStore.data.first() + return ProHistory( + lastState = parseProState(prefs[proStateLastKey]), + graceEngagedCount = prefs[proGraceCountKey] ?: 0, + graceEngagedLast = prefs[proGraceLastKey]?.let { Instant.ofEpochMilli(it) }, + proLostCount = prefs[proLostCountKey] ?: 0, + proLostLast = prefs[proLostLastKey]?.let { Instant.ofEpochMilli(it) }, + ) + } + + internal enum class ProTransition { GRACE_ENGAGED, PRO_LOST } + + companion object { + internal val TAG = logTag("Debug", "CurriculumVitae") + + // Tolerant of blank/corrupt/future enum names: an unknown stored value must behave like a + // fresh baseline, not kill the update job or the recorder's history read. + internal fun parseProState(raw: String?): ProState? = + raw?.let { r -> ProState.entries.firstOrNull { it.name == r } } + + // Which transitions count: grace only "engages" coming FROM a confirmed purchase, and Pro + // is only "lost" when a previously Pro-ish state drops to FREE. Everything else (baseline, + // recovery, unknown previous value) just moves the stored state. Pure and unit-tested. + internal fun proTransitionOf(previous: ProState?, current: ProState): ProTransition? = when { + previous == ProState.PURCHASED && current == ProState.GRACE -> ProTransition.GRACE_ENGAGED + (previous == ProState.PURCHASED || previous == ProState.GRACE) && current == ProState.FREE -> + ProTransition.PRO_LOST + + else -> null + } + } +} diff --git a/app/src/test/java/eu/darken/capod/common/uix/ViewModel4StateFlowTest.kt b/app/src/test/java/eu/darken/capod/common/uix/ViewModel4StateFlowTest.kt new file mode 100644 index 00000000..850c0b56 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/uix/ViewModel4StateFlowTest.kt @@ -0,0 +1,103 @@ +package eu.darken.capod.common.uix + +import eu.darken.capod.common.coroutine.DispatcherProvider +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutine.TestDispatcherProvider +import testhelpers.coroutine.runTest2 + +class ViewModel4StateFlowTest : BaseTest() { + + private class ReadException(message: String) : Exception(message) + + private val testDispatcher = StandardTestDispatcher() + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @AfterEach + fun teardown() { + Dispatchers.resetMain() + } + + @Test + fun `safeStateIn forwards failure and keeps fallback state collectable`() = runTest2( + context = testDispatcher, + ) { + val vm = FailingStateViewModel(TestDispatcherProvider(testDispatcher)) + + val fallbackState = async { vm.state.first { it == -1 } } + val forwardedError = async { vm.errorEvents.first() } + + advanceUntilIdle() + + fallbackState.await() shouldBe -1 + forwardedError.await().shouldBeInstanceOf() + vm.state.value shouldBe -1 + vm.state.first() shouldBe -1 + } + + @Test + fun `safeStateIn does not convert cancellation into fallback state or error event`() = runTest2( + context = testDispatcher, + ) { + val vm = CancelledStateViewModel(TestDispatcherProvider(testDispatcher)) + var forwardedError: Throwable? = null + + val errorJob = launch { + vm.errorEvents.collect { forwardedError = it } + } + val stateJob = launch { + vm.state.collect() + } + + advanceUntilIdle() + + vm.state.value shouldBe 0 + forwardedError.shouldBeNull() + + stateJob.cancel() + errorJob.cancel() + } + + private class FailingStateViewModel( + dispatcherProvider: DispatcherProvider, + ) : ViewModel4(dispatcherProvider = dispatcherProvider) { + val state = flow { + emit(1) + throw ReadException(message = "No matching mode available.") + }.safeStateIn( + initialValue = 0, + onError = { -1 }, + ) + } + + private class CancelledStateViewModel( + dispatcherProvider: DispatcherProvider, + ) : ViewModel4(dispatcherProvider = dispatcherProvider) { + val state = flow { + throw CancellationException("cancelled") + }.safeStateIn( + initialValue = 0, + onError = { -1 }, + ) + } +} diff --git a/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProHistoryTest.kt b/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProHistoryTest.kt new file mode 100644 index 00000000..78d510d4 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProHistoryTest.kt @@ -0,0 +1,69 @@ +package eu.darken.capod.main.core + +import androidx.test.core.app.ApplicationProvider +import eu.darken.capod.main.core.CurriculumVitae.ProState +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import testhelpers.BaseTest +import testhelpers.TestApplication + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = TestApplication::class) +class CurriculumVitaeProHistoryTest : BaseTest() { + + // One test method on purpose: DataStore forbids two active instances on the same file, and + // CurriculumVitae is a @Singleton in production. + @Test + fun `pro state transitions persist counters atomically and in order`() = runTest { + val cv = CurriculumVitae( + context = ApplicationProvider.getApplicationContext(), + ) + + cv.proHistory() shouldBe CurriculumVitae.ProHistory( + lastState = null, + graceEngagedCount = 0, + graceEngagedLast = null, + proLostCount = 0, + proLostLast = null, + ) + + // First observation is a baseline, not a transition. + cv.updateProState(ProState.PURCHASED) + cv.proHistory().apply { + lastState shouldBe ProState.PURCHASED + graceEngagedCount shouldBe 0 + proLostCount shouldBe 0 + } + + // Repeats are no-ops. + cv.updateProState(ProState.PURCHASED) + cv.proHistory().graceEngagedCount shouldBe 0 + + // A rapid PURCHASED -> GRACE -> FREE episode: exactly one increment each, final state FREE. + cv.updateProState(ProState.GRACE) + cv.updateProState(ProState.FREE) + cv.proHistory().apply { + lastState shouldBe ProState.FREE + graceEngagedCount shouldBe 1 + graceEngagedLast shouldNotBe null + proLostCount shouldBe 1 + proLostLast shouldNotBe null + } + + // Recovery doesn't count; a second full episode counts again. + cv.updateProState(ProState.PURCHASED) + cv.updateProState(ProState.GRACE) + cv.updateProState(ProState.PURCHASED) + cv.updateProState(ProState.FREE) + cv.proHistory().apply { + lastState shouldBe ProState.FREE + graceEngagedCount shouldBe 2 + proLostCount shouldBe 2 + } + } +} diff --git a/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProStateTest.kt b/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProStateTest.kt new file mode 100644 index 00000000..086b6d35 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/core/CurriculumVitaeProStateTest.kt @@ -0,0 +1,44 @@ +package eu.darken.capod.main.core + +import eu.darken.capod.main.core.CurriculumVitae.ProState +import eu.darken.capod.main.core.CurriculumVitae.ProTransition +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CurriculumVitaeProStateTest : BaseTest() { + + @Test fun `grace only engages coming from a confirmed purchase`() { + CurriculumVitae.proTransitionOf(ProState.PURCHASED, ProState.GRACE) shouldBe ProTransition.GRACE_ENGAGED + + // A launch that settles straight into grace (or a baseline) is not a new engagement. + CurriculumVitae.proTransitionOf(null, ProState.GRACE) shouldBe null + CurriculumVitae.proTransitionOf(ProState.FREE, ProState.GRACE) shouldBe null + CurriculumVitae.proTransitionOf(ProState.GRACE, ProState.GRACE) shouldBe null + } + + @Test fun `pro is lost when a pro-ish state drops to free`() { + CurriculumVitae.proTransitionOf(ProState.PURCHASED, ProState.FREE) shouldBe ProTransition.PRO_LOST + CurriculumVitae.proTransitionOf(ProState.GRACE, ProState.FREE) shouldBe ProTransition.PRO_LOST + + CurriculumVitae.proTransitionOf(null, ProState.FREE) shouldBe null + CurriculumVitae.proTransitionOf(ProState.FREE, ProState.FREE) shouldBe null + } + + @Test fun `recovering pro is never a counted transition`() { + CurriculumVitae.proTransitionOf(null, ProState.PURCHASED) shouldBe null + CurriculumVitae.proTransitionOf(ProState.GRACE, ProState.PURCHASED) shouldBe null + CurriculumVitae.proTransitionOf(ProState.FREE, ProState.PURCHASED) shouldBe null + CurriculumVitae.proTransitionOf(ProState.PURCHASED, ProState.PURCHASED) shouldBe null + } + + @Test fun `stored state parsing tolerates blank, corrupt and future values`() { + CurriculumVitae.parseProState(null) shouldBe null + CurriculumVitae.parseProState("") shouldBe null + CurriculumVitae.parseProState("garbage") shouldBe null + CurriculumVitae.parseProState("PURCHASED_V2") shouldBe null + CurriculumVitae.parseProState("PURCHASED") shouldBe ProState.PURCHASED + CurriculumVitae.parseProState("GRACE") shouldBe ProState.GRACE + CurriculumVitae.parseProState("FREE") shouldBe ProState.FREE + } +} diff --git a/app/src/test/java/testhelpers/DataStoreHelper.kt b/app/src/test/java/testhelpers/DataStoreHelper.kt new file mode 100644 index 00000000..c56927f0 --- /dev/null +++ b/app/src/test/java/testhelpers/DataStoreHelper.kt @@ -0,0 +1,10 @@ +package testhelpers + +import eu.darken.capod.common.datastore.DataStoreValue +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf + +fun mockDataStoreValue(value: T) = mockk>().apply { + every { flow } returns flowOf(value) +} diff --git a/app/src/test/java/testhelpers/TestApplication.kt b/app/src/test/java/testhelpers/TestApplication.kt new file mode 100644 index 00000000..5d4073d4 --- /dev/null +++ b/app/src/test/java/testhelpers/TestApplication.kt @@ -0,0 +1,11 @@ +package testhelpers + +import android.app.Application + +/** + * Minimal test application for Robolectric tests. + * Prevents the real app class from being instantiated during unit tests. + */ +class TestApplication : Application() { + // No initialization - keep tests fast and isolated +} diff --git a/app/src/test/java/testhelpers/compose/BaseComposeRobolectricTest.kt b/app/src/test/java/testhelpers/compose/BaseComposeRobolectricTest.kt new file mode 100644 index 00000000..db86e216 --- /dev/null +++ b/app/src/test/java/testhelpers/compose/BaseComposeRobolectricTest.kt @@ -0,0 +1,48 @@ +package testhelpers.compose + +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import eu.darken.capod.common.debug.logging.Logging +import io.mockk.unmockkAll +import org.junit.AfterClass +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import testhelpers.TestApplication +import testhelpers.logging.JUnitLogger + +/** + * Base class for JVM Compose UI tests. + * + * Wires up Robolectric + JUnit 4 + createComposeRule() in one place so individual + * tests don't repeat the @RunWith / @Config / @get:Rule preamble. Subclasses just + * declare @Test methods and use `composeRule`. + * + * Use this for tests that render Composables via `composeRule.setContent { ... }`. + * For non-Compose Robolectric tests, extend [testhelpers.BaseTest] with the same + * @RunWith / @Config annotations on the subclass. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = TestApplication::class) +abstract class BaseComposeRobolectricTest { + + @get:Rule + val composeRule: ComposeContentTestRule = createComposeRule() + + init { + Logging.clearAll() + Logging.install(JUnitLogger()) + } + + companion object { + // Class-level cleanup (not @After) so it cannot race the Compose rule's + // per-test teardown. Mirrors BaseTest's @AfterAll behavior for JUnit 4. + @JvmStatic + @AfterClass + fun afterClass() { + unmockkAll() + Logging.clearAll() + } + } +}