mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user