feat(core): Add Pro-state history and safe state collection helpers

Additive infrastructure for the canonical billing port, no coupling to the
billing core yet.

- CurriculumVitae: Pro-state slice only (ProState, ProHistory,
  updateProState, proHistory, transition classification, tolerant enum
  decode). Raw preference keys so a transition updates state, counter and
  timestamp in one DataStore transaction.
- ViewModel4.safeStateIn: render-state flows forward recoverable failures
  to errorEvents and emit an explicit fallback state instead of throwing
  into collectAsStateWithLifecycle().
- testhelpers: TestApplication, BaseComposeRobolectricTest and the
  mockDataStoreValue helper.
This commit is contained in:
darken
2026-07-29 14:05:26 +02:00
committed by Matthias Urhahn
parent 8c1b57a47c
commit 0192ae7081
8 changed files with 430 additions and 0 deletions
@@ -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 <T> Flow<T>.safeStateIn(
initialValue: T,
started: SharingStarted = SharingStarted.WhileSubscribed(5000),
onError: (Throwable) -> T,
): StateFlow<T> = 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,
)
}
@@ -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<Preferences>
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
}
}
}
@@ -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<ReadException>()
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<Int> {
throw CancellationException("cancelled")
}.safeStateIn(
initialValue = 0,
onError = { -1 },
)
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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 <T> mockDataStoreValue(value: T) = mockk<DataStoreValue<T>>().apply {
every { flow } returns flowOf(value)
}
@@ -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
}
@@ -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()
}
}
}