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