Auto commit (MindMachine) Sat Apr 11 01:01:02 PM CDT 2026

This commit is contained in:
Tretzi
2026-04-11 13:01:03 -05:00
parent ddc497a91f
commit adbfa0754b
5 changed files with 162 additions and 27 deletions

View File

@@ -80,6 +80,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -109,7 +110,6 @@ import solutions.tretter.mindmachine.session.FlashColor
import solutions.tretter.mindmachine.session.MainViewModel import solutions.tretter.mindmachine.session.MainViewModel
import solutions.tretter.mindmachine.session.SplitFlashFrame import solutions.tretter.mindmachine.session.SplitFlashFrame
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import solutions.tretter.mindmachine.session.SplitFlashSequencer
import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds
import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs
import solutions.tretter.mindmachine.session.formatDuration import solutions.tretter.mindmachine.session.formatDuration
@@ -118,6 +118,7 @@ import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault
import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.roundToLong
import java.util.Locale import java.util.Locale
@@ -806,33 +807,71 @@ private fun rememberSplitFlashFrame(
val latestFlashOnMs = rememberUpdatedState(flashOnMs) val latestFlashOnMs = rememberUpdatedState(flashOnMs)
val latestFlashOffMs = rememberUpdatedState(flashOffMs) val latestFlashOffMs = rememberUpdatedState(flashOffMs)
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) { // The screen can only change on vsync boundaries. If the configured interval is not a
// multiple of the refresh period, the *visual* interval will wobble by ~1 frame.
// Snapping intervals to whole frames makes the blink look uniform.
val refreshRateHz = LocalView.current.display?.refreshRate ?: 60f
val framePeriodNanos = remember(refreshRateHz) {
(1_000_000_000.0 / refreshRateHz.coerceAtLeast(1f)).roundToLong().coerceAtLeast(1L)
}
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, framePeriodNanos) {
if (!isRunning) { if (!isRunning) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState return@produceState
} }
// Lock the sequencer's time base to Compose's frame clock. // Drive the blink schedule from the frame clock, but keep phase boundaries aligned
// Mixing System.nanoTime() (start) with withFrameNanos() (updates) can introduce // to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady
// an arbitrary offset that looks like irregular intervals. // even if some frames are late.
val sequencer = SplitFlashSequencer( //
startTimeNanos = 0L, // Key idea: instead of "advance by whatever elapsed since last frame", we keep an
flashOnMs = latestFlashOnMs.value, // explicit nextToggleTime and only advance the phase when now >= nextToggleTime.
flashOffMs = latestFlashOffMs.value,
)
var started = false var started = false
var phase = 0 // 0=RG, 1=BK, 2=GR, 3=BK
var nextToggleNanos = 0L
var onNanos = 0L
var offNanos = 0L
fun frameForPhase(p: Int): SplitFlashFrame = when (p) {
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
}
fun updateDurations() {
val rawOn = latestFlashOnMs.value.coerceIn(50, 2000).toLong() * 1_000_000L
val rawOff = latestFlashOffMs.value.coerceIn(50, 2000).toLong() * 1_000_000L
val onFrames = ((rawOn + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L)
val offFrames = ((rawOff + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L)
onNanos = onFrames * framePeriodNanos
offNanos = offFrames * framePeriodNanos
}
while (true) { while (true) {
withFrameNanos { frameTimeNanos -> withFrameNanos { now ->
if (!started) { if (!started) {
// Initialize phaseStartNanos to the first real frame timestamp. updateDurations()
// This keeps phase transitions aligned to the same monotonic clock. phase = 0
sequencer.frameAt(frameTimeNanos) nextToggleNanos = now + onNanos
value = frameForPhase(phase)
started = true started = true
return@withFrameNanos
} }
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
value = sequencer.frameAt(frameTimeNanos) updateDurations()
// Catch up if we missed one or more phase boundaries due to frame delays.
while (now >= nextToggleNanos) {
phase = (phase + 1) % 4
val dur = if (phase == 0 || phase == 2) onNanos else offNanos
nextToggleNanos += dur
}
value = frameForPhase(phase)
} }
} }
} }
@@ -891,6 +930,19 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
val ui by vm.ui.collectAsStateWithLifecycle() val ui by vm.ui.collectAsStateWithLifecycle()
val activity = LocalContext.current as? Activity val activity = LocalContext.current as? Activity
val details by billingManager.productDetails.collectAsStateWithLifecycle() val details by billingManager.productDetails.collectAsStateWithLifecycle()
val logs by billingManager.logs.collectAsStateWithLifecycle()
val subDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]
val subPrice = subDetails
?.subscriptionOfferDetails
?.firstOrNull()
?.pricingPhases
?.pricingPhaseList
?.firstOrNull()
?.formattedPrice
val inAppDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER]
val inAppPrice = inAppDetails?.oneTimePurchaseOfferDetails?.formattedPrice
Column( Column(
Modifier Modifier
@@ -912,7 +964,7 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) } activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) }
} }
) { ) {
Text(details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]?.name ?: "Subscribe: $1/month") Text("Subscribe: ${subPrice ?: "$0.99"}/month")
} }
Button( Button(
@@ -921,7 +973,7 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) } activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) }
} }
) { ) {
Text(details[solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER]?.name ?: "Buy: $20 one-time") Text("Buy: ${inAppPrice ?: "$19.99"} one-time")
} }
TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") } TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") }
@@ -930,6 +982,21 @@ fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.Billin
"Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds wont complete real billing flows.", "Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds wont complete real billing flows.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
) )
// Debug logs
Text("Billing logs:", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f))
.padding(8.dp)
) {
logs.forEach { log ->
Text(log, color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.bodySmall)
}
}
} }
} }

View File

@@ -16,6 +16,9 @@ import com.android.billingclient.api.QueryPurchasesParams
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class BillingManager( class BillingManager(
context: Context, context: Context,
@@ -29,6 +32,17 @@ class BillingManager(
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap()) private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow() val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow()
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs.asStateFlow()
private fun log(message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
_logs.value = _logs.value + "[$timestamp] $message"
if (_logs.value.size > 50) {
_logs.value = _logs.value.takeLast(50)
}
}
private val billingClient: BillingClient = BillingClient.newBuilder(appContext) private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
.setListener { _, purchases -> .setListener { _, purchases ->
if (purchases != null) { if (purchases != null) {
@@ -39,37 +53,50 @@ class BillingManager(
.build() .build()
fun start() { fun start() {
log("BillingManager.start()")
if (billingClient.isReady) { if (billingClient.isReady) {
log("Billing client already ready")
refresh() refresh()
return return
} }
billingClient.startConnection(object : BillingClientStateListener { billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) { override fun onBillingSetupFinished(result: BillingResult) {
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) { if (result.responseCode == BillingClient.BillingResponseCode.OK) {
refresh() refresh()
} }
} }
override fun onBillingServiceDisconnected() { override fun onBillingServiceDisconnected() {
log("Billing service disconnected")
// No-op, will retry on next call. // No-op, will retry on next call.
} }
}) })
} }
fun refresh() { fun refresh() {
log("refresh() called")
queryProductDetails() queryProductDetails()
queryActivePurchases() queryActivePurchases()
} }
fun launchPurchase(activity: Activity, productId: String) { fun launchPurchase(activity: Activity, productId: String) {
val details = _productDetails.value[productId] ?: return log("launchPurchase() productId=$productId")
val details = _productDetails.value[productId]
if (details == null) {
log("Product details not found for $productId")
return
}
val productDetailsParams = when (details.productType) { val productDetailsParams = when (details.productType) {
BillingClient.ProductType.SUBS -> { BillingClient.ProductType.SUBS -> {
val offerToken = details.subscriptionOfferDetails val offerToken = details.subscriptionOfferDetails
?.firstOrNull() ?.firstOrNull()
?.offerToken ?.offerToken
?: return if (offerToken == null) {
log("No subscription offer token for $productId")
return
}
BillingFlowParams.ProductDetailsParams.newBuilder() BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details) .setProductDetails(details)
.setOfferToken(offerToken) .setOfferToken(offerToken)
@@ -82,18 +109,26 @@ class BillingManager(
.build() .build()
} }
else -> return else -> {
log("Unknown product type for $productId")
return
}
} }
val params = BillingFlowParams.newBuilder() val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams)) .setProductDetailsParamsList(listOf(productDetailsParams))
.build() .build()
billingClient.launchBillingFlow(activity, params) val result = billingClient.launchBillingFlow(activity, params)
log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}")
} }
private fun queryProductDetails() { private fun queryProductDetails() {
if (!billingClient.isReady) return log("queryProductDetails()")
if (!billingClient.isReady) {
log("Billing client not ready")
return
}
val products = listOf( val products = listOf(
QueryProductDetailsParams.Product.newBuilder() QueryProductDetailsParams.Product.newBuilder()
@@ -111,16 +146,23 @@ class BillingManager(
.build() .build()
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList -> billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) { if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = detailsList.associateBy { it.productId } _productDetails.value = detailsList.associateBy { it.productId }
log("Product details loaded: ${detailsList.size} items")
} }
}) })
} }
private fun queryActivePurchases() { private fun queryActivePurchases() {
if (!billingClient.isReady) return log("queryActivePurchases()")
if (!billingClient.isReady) {
log("Billing client not ready")
return
}
val listener = PurchasesResponseListener { result, purchases -> val listener = PurchasesResponseListener { result, purchases ->
log("Query purchases result: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) { if (result.responseCode == BillingClient.BillingResponseCode.OK) {
handlePurchases(purchases) handlePurchases(purchases)
} }
@@ -137,12 +179,15 @@ class BillingManager(
} }
private fun handlePurchases(purchases: List<Purchase>) { private fun handlePurchases(purchases: List<Purchase>) {
log("handlePurchases() count=${purchases.size}")
val active = purchases.any { p -> val active = purchases.any { p ->
p.purchaseState == Purchase.PurchaseState.PURCHASED && p.purchaseState == Purchase.PurchaseState.PURCHASED &&
p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER } p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER }
} }
log("Active entitlement: $active")
if (_hasNoAds.value != active) { if (_hasNoAds.value != active) {
log("Entitlement changed: ${_hasNoAds.value} -> $active")
_hasNoAds.value = active _hasNoAds.value = active
onNoAdsEntitlementChanged(active) onNoAdsEntitlementChanged(active)
} }
@@ -150,6 +195,7 @@ class BillingManager(
// Acknowledge where needed // Acknowledge where needed
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged } purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { purchase -> .forEach { purchase ->
log("Acknowledging purchase: ${purchase.purchaseToken}")
val params = AcknowledgePurchaseParams.newBuilder() val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken) .setPurchaseToken(purchase.purchaseToken)
.build() .build()

View File

@@ -34,12 +34,26 @@ class SettingsRepository(private val context: Context) {
?: 5, ?: 5,
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false, forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 100).coerceIn(5, 100), immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
lastPresetId = p[Keys.lastPresetId], lastPresetId = p[Keys.lastPresetId],
noAds = p[Keys.noAds] ?: false, noAds = p[Keys.noAds] ?: false,
) )
} }
/**
* Called on app startup.
* Ensures a first-run default of 50% brightness for immersive sessions.
*
* Only applies when the user has never set a value.
*/
suspend fun ensureFirstRunDefaults() {
context.dataStore.edit { prefs ->
if (prefs[Keys.immersiveBrightnessPercent] == null) {
prefs[Keys.immersiveBrightnessPercent] = 50
}
}
}
suspend fun acknowledgeSafety(version: Int) { suspend fun acknowledgeSafety(version: Int) {
context.dataStore.edit { context.dataStore.edit {
it[Keys.safetyAcknowledged] = true it[Keys.safetyAcknowledged] = true

View File

@@ -36,7 +36,9 @@ data class AppSettings(
val countdownSeconds: Int = 5, val countdownSeconds: Int = 5,
val forceAudioWithoutHeadphones: Boolean = false, val forceAudioWithoutHeadphones: Boolean = false,
val showImmersiveProgressBar: Boolean = true, val showImmersiveProgressBar: Boolean = true,
val immersiveBrightnessPercent: Int = 100, // Default used only before DataStore emits (and as a fallback).
// We want the first-run experience to be less blinding.
val immersiveBrightnessPercent: Int = 50,
val lastPresetId: String? = null, val lastPresetId: String? = null,
val noAds: Boolean = false, val noAds: Boolean = false,
) )

View File

@@ -91,6 +91,12 @@ class MainViewModel(
init { init {
billingManager.start() billingManager.start()
// One-time first-run defaults.
viewModelScope.launch {
settingsRepository.ensureFirstRunDefaults()
}
viewModelScope.launch { viewModelScope.launch {
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms -> combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
settings to userPrograms settings to userPrograms