From adbfa0754b603633a045ca57ffc32a5a174ab839 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Sat, 11 Apr 2026 13:01:03 -0500 Subject: [PATCH] Auto commit (MindMachine) Sat Apr 11 01:01:02 PM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 105 ++++++++++++++---- .../mindmachine/mvp/billing/BillingManager.kt | 58 +++++++++- .../mvp/data/SettingsRepository.kt | 16 ++- .../java/com/mindmachine/mvp/domain/Models.kt | 4 +- .../mindmachine/mvp/session/MainViewModel.kt | 6 + 5 files changed, 162 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 148abfa..581a15e 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics 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.SplitFlashFrame import kotlinx.coroutines.launch -import solutions.tretter.mindmachine.session.SplitFlashSequencer import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs import solutions.tretter.mindmachine.session.formatDuration @@ -118,6 +118,7 @@ import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen import kotlinx.coroutines.delay import kotlin.math.roundToInt +import kotlin.math.roundToLong import java.util.Locale @@ -806,33 +807,71 @@ private fun rememberSplitFlashFrame( val latestFlashOnMs = rememberUpdatedState(flashOnMs) 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) { value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) return@produceState } - // Lock the sequencer's time base to Compose's frame clock. - // Mixing System.nanoTime() (start) with withFrameNanos() (updates) can introduce - // an arbitrary offset that looks like irregular intervals. - val sequencer = SplitFlashSequencer( - startTimeNanos = 0L, - flashOnMs = latestFlashOnMs.value, - flashOffMs = latestFlashOffMs.value, - ) - + // Drive the blink schedule from the frame clock, but keep phase boundaries aligned + // to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady + // even if some frames are late. + // + // Key idea: instead of "advance by whatever elapsed since last frame", we keep an + // explicit nextToggleTime and only advance the phase when now >= nextToggleTime. 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) { - withFrameNanos { frameTimeNanos -> + withFrameNanos { now -> if (!started) { - // Initialize phaseStartNanos to the first real frame timestamp. - // This keeps phase transitions aligned to the same monotonic clock. - sequencer.frameAt(frameTimeNanos) + updateDurations() + phase = 0 + nextToggleNanos = now + onNanos + value = frameForPhase(phase) 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 activity = LocalContext.current as? Activity 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( 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) } } ) { - Text(details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]?.name ?: "Subscribe: $1/month") + Text("Subscribe: ${subPrice ?: "$0.99"}/month") } 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) } } ) { - 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") } @@ -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 won’t complete real billing flows.", 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) + } + } } } diff --git a/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt b/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt index 18420b1..2c789c0 100644 --- a/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt +++ b/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt @@ -16,6 +16,9 @@ import com.android.billingclient.api.QueryPurchasesParams import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class BillingManager( context: Context, @@ -29,6 +32,17 @@ class BillingManager( private val _productDetails = MutableStateFlow>(emptyMap()) val productDetails: StateFlow> = _productDetails.asStateFlow() + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _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) .setListener { _, purchases -> if (purchases != null) { @@ -39,37 +53,50 @@ class BillingManager( .build() fun start() { + log("BillingManager.start()") if (billingClient.isReady) { + log("Billing client already ready") refresh() return } billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) { + log("Billing setup finished: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { refresh() } } override fun onBillingServiceDisconnected() { + log("Billing service disconnected") // No-op, will retry on next call. } }) } fun refresh() { + log("refresh() called") queryProductDetails() queryActivePurchases() } 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) { BillingClient.ProductType.SUBS -> { val offerToken = details.subscriptionOfferDetails ?.firstOrNull() ?.offerToken - ?: return + if (offerToken == null) { + log("No subscription offer token for $productId") + return + } BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(details) .setOfferToken(offerToken) @@ -82,18 +109,26 @@ class BillingManager( .build() } - else -> return + else -> { + log("Unknown product type for $productId") + return + } } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) .build() - billingClient.launchBillingFlow(activity, params) + val result = billingClient.launchBillingFlow(activity, params) + log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}") } private fun queryProductDetails() { - if (!billingClient.isReady) return + log("queryProductDetails()") + if (!billingClient.isReady) { + log("Billing client not ready") + return + } val products = listOf( QueryProductDetailsParams.Product.newBuilder() @@ -111,16 +146,23 @@ class BillingManager( .build() billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList -> + log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { _productDetails.value = detailsList.associateBy { it.productId } + log("Product details loaded: ${detailsList.size} items") } }) } private fun queryActivePurchases() { - if (!billingClient.isReady) return + log("queryActivePurchases()") + if (!billingClient.isReady) { + log("Billing client not ready") + return + } val listener = PurchasesResponseListener { result, purchases -> + log("Query purchases result: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) { handlePurchases(purchases) } @@ -137,12 +179,15 @@ class BillingManager( } private fun handlePurchases(purchases: List) { + log("handlePurchases() count=${purchases.size}") val active = purchases.any { p -> p.purchaseState == Purchase.PurchaseState.PURCHASED && p.products.any { it == BillingProducts.SUB_REMOVE_ADS_MONTHLY || it == BillingProducts.INAPP_REMOVE_ADS_FOREVER } } + log("Active entitlement: $active") if (_hasNoAds.value != active) { + log("Entitlement changed: ${_hasNoAds.value} -> $active") _hasNoAds.value = active onNoAdsEntitlementChanged(active) } @@ -150,6 +195,7 @@ class BillingManager( // Acknowledge where needed purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged } .forEach { purchase -> + log("Acknowledging purchase: ${purchase.purchaseToken}") val params = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() diff --git a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt index a06ad38..d5b119b 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt @@ -34,12 +34,26 @@ class SettingsRepository(private val context: Context) { ?: 5, forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false, 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], 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) { context.dataStore.edit { it[Keys.safetyAcknowledged] = true diff --git a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt index be285da..0b0b161 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -36,7 +36,9 @@ data class AppSettings( val countdownSeconds: Int = 5, val forceAudioWithoutHeadphones: Boolean = false, 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 noAds: Boolean = false, ) diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index 1094841..60a27ae 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -91,6 +91,12 @@ class MainViewModel( init { billingManager.start() + + // One-time first-run defaults. + viewModelScope.launch { + settingsRepository.ensureFirstRunDefaults() + } + viewModelScope.launch { combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms -> settings to userPrograms