fix(debug): Bound the billing cache and fold pro history into diagnostics

BillingCache reads and writes are now bounded by a timeout seam: a wedged
DataStore file lock made the debug-log header hang, and a silent fallback to
the default snapshot would have reported "never bought" for an install whose
evidence merely could not be read. Reads now fail loudly, writes fail soft.

UpgradeDiagnosticsGplay absorbs the pro-state history that the recorder header
used to read directly, with a separate failure boundary per source so one
broken DataStore cannot suppress the other's evidence.

RecorderModule's start-failure guard now covers ordinary exceptions, not just
cancellation, stops the uncommitted recorder under NonCancellable and appears
once instead of per resume branch.
This commit is contained in:
darken
2026-07-30 12:55:31 +02:00
committed by Matthias Urhahn
parent e364a5b02c
commit 7fb1f7aabd
6 changed files with 396 additions and 239 deletions
@@ -12,24 +12,32 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.datastore.basicReader
import eu.darken.capod.common.datastore.basicWriter
import eu.darken.capod.common.datastore.createValue
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
// Retained legacy migration: installs that predate the DataStore move still carry their upgrade
// state in the "settings_gplay" SharedPreferences file.
private val Context.billingCacheDataStore by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) },
)
@Singleton
class BillingCache @Inject constructor(
@ApplicationContext private val context: Context,
class BillingCache internal constructor(
private val dataStore: DataStore<Preferences>,
) {
// Retained legacy migration: installs that predate the DataStore move still carry their upgrade
// state in the "settings_gplay" SharedPreferences file.
private val Context.dataStore by preferencesDataStore(
name = "settings_gplay",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) },
)
@Inject constructor(@ApplicationContext context: Context) : this(context.billingCacheDataStore)
private val dataStore: DataStore<Preferences>
get() = context.dataStore
// Test seam: the bounded reads/writes below run on real dispatchers, so a virtual-time test
// cannot advance the production bound. Same pattern as UpgradeRepoGplay.launchTimeoutMs.
internal var cacheTimeoutMs: Long = CACHE_TIMEOUT_MS
// Raw keys shared between the DataStoreValues and stampLastProState's transaction — one
// source of truth for key name and encoding.
@@ -66,8 +74,15 @@ class BillingCache @Inject constructor(
val proUnconfirmedSince: Long,
)
// Bounded on purpose: a wedged DataStore file lock would otherwise hang the caller forever.
// A timeout must NOT fall back to the default snapshot -- that would report "never bought"
// for an install whose evidence merely couldn't be read, which is the exact distinction the
// debug-log header exists to make.
suspend fun snapshot(): Snapshot {
val prefs = dataStore.data.first()
val prefs = withTimeoutOrNull(cacheTimeoutMs) { dataStore.data.first() } ?: run {
log(TAG, WARN) { "snapshot() timed out after ${cacheTimeoutMs}ms" }
throw IOException("BillingCache snapshot timed out after ${cacheTimeoutMs}ms")
}
return Snapshot(
lastProStateAt = prefs[lastProStateAtKey] ?: 0L,
lastProStateSku = prefs[lastProStateSkuKey] ?: "",
@@ -83,11 +98,19 @@ class BillingCache @Inject constructor(
// entitlement layer out of order) opened a still-valid episode that this older confirmation must
// not erase.
suspend fun stampLastProState(skuId: String, at: Long) {
dataStore.edit { prefs ->
prefs[lastProStateSkuKey] = skuId
prefs[lastProStateAtKey] = at
val episodeStart = prefs[proUnconfirmedSinceKey] ?: 0L
if (episodeStart in 1..at) prefs[proUnconfirmedSinceKey] = 0L
}
// Fail-soft: this decorates the entitlement path, it must never be the thing that blocks it.
withTimeoutOrNull(cacheTimeoutMs) {
dataStore.edit { prefs ->
prefs[lastProStateSkuKey] = skuId
prefs[lastProStateAtKey] = at
val episodeStart = prefs[proUnconfirmedSinceKey] ?: 0L
if (episodeStart in 1..at) prefs[proUnconfirmedSinceKey] = 0L
}
} ?: log(TAG, WARN) { "stampLastProState($skuId, $at) timed out after ${cacheTimeoutMs}ms, write skipped" }
}
companion object {
private const val CACHE_TIMEOUT_MS = 2_000L
private val TAG = logTag("Upgrade", "Gplay", "BillingCache")
}
}
@@ -1,31 +1,63 @@
package eu.darken.capod.common.upgrade.core
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.debug.logging.logTag
import eu.darken.capod.common.upgrade.UpgradeDiagnostics
import eu.darken.capod.main.core.CurriculumVitae
import kotlinx.coroutines.CancellationException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reports the local billing cache into the debug log header.
* Reports the local billing cache and the lifetime Pro-state history into the debug log header.
*
* `lastProStateAt > 0` is the "this install once confirmed a real Pro purchase" bit. It predates
* the CurriculumVitae pro-state counters by years and lives in a DataStore that was never migrated
* or renamed, so it survives update chains that the newer counters can't speak to. Without it in
* the header, a purchase complaint can't be told apart from a never-bought install.
*
* Depends on [BillingCache] alone -- see [UpgradeDiagnostics] for why this must not pull in
* UpgradeRepoGplay.
* Depends on [BillingCache] and [CurriculumVitae] alone -- see [UpgradeDiagnostics] for why this
* must not pull in UpgradeRepoGplay.
*/
@Singleton
class UpgradeDiagnosticsGplay @Inject constructor(
private val billingCache: BillingCache,
private val curriculumVitae: CurriculumVitae,
) : UpgradeDiagnostics {
override suspend fun debugInfo(): String {
val snapshot = billingCache.snapshot()
val lastProAt = snapshot.lastProStateAt.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "never"
val lastProSku = snapshot.lastProStateSku.takeIf { it.isNotEmpty() } ?: "unknown/legacy"
val unconfirmedSince = snapshot.proUnconfirmedSince.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "none"
return "BillingCache(lastProStateAt=$lastProAt, lastProStateSku=$lastProSku, proUnconfirmedSince=$unconfirmedSince)"
val cache = try {
val snapshot = billingCache.snapshot()
val lastProAt = snapshot.lastProStateAt.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "never"
val lastProSku = snapshot.lastProStateSku.takeIf { it.isNotEmpty() } ?: "unknown/legacy"
val unconfirmedSince =
snapshot.proUnconfirmedSince.takeIf { it > 0 }?.let { Instant.ofEpochMilli(it) } ?: "none"
"BillingCache(lastProStateAt=$lastProAt, lastProStateSku=$lastProSku, " +
"proUnconfirmedSince=$unconfirmedSince)"
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Billing cache unavailable: ${e.asLog()}" }
"BillingCache=unavailable"
}
// Separate boundary from the cache read above on purpose: these are different DataStores,
// and the counters only cover installs new enough to have them. A failure to read one must
// not suppress the other's independent evidence.
val history = try {
curriculumVitae.proHistory().toString()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log(TAG, WARN) { "Pro history unavailable: ${e.asLog()}" }
"unavailable"
}
return "$cache, ProHistory=$history"
}
companion object {
private val TAG = logTag("Upgrade", "Gplay", "Diagnostics")
}
}