Auto commit (MindMachine) Fri Apr 10 12:01:02 PM CDT 2026

This commit is contained in:
Tretzi
2026-04-10 12:01:02 -05:00
parent 7b1c4ec474
commit 3cbd8138ee
10 changed files with 318 additions and 2 deletions

View File

@@ -96,8 +96,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.android.gms.ads.MobileAds
import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.ads.AdBanner
import com.mindmachine.mvp.ads.rememberInterstitialController
import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.RuntimeState
@@ -136,16 +139,27 @@ private val MindMachineColors = darkColorScheme(
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels {
val settingsRepo = SettingsRepository(applicationContext)
val billing = com.mindmachine.mvp.billing.BillingManager(
applicationContext,
onNoAdsEntitlementChanged = { hasNoAds ->
// Cache the entitlement so UI can gate ads.
// Billing will refresh on each app start.
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
},
)
MainViewModel.Factory(
SettingsRepository(applicationContext),
settingsRepo,
UserProgramRepository(applicationContext),
HeadsetMonitor(applicationContext),
BinauralAudioEngine(),
billing,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MobileAds.initialize(this) {}
setContent {
MaterialTheme(colorScheme = MindMachineColors) {
App(vm)

View File

@@ -0,0 +1,7 @@
package com.mindmachine.mvp.ads
object AdUnits {
// Google test ad unit ids. Replace with your real unit ids before release.
const val BANNER = "ca-app-pub-3940256099942544/6300978111"
const val INTERSTITIAL = "ca-app-pub-3940256099942544/1033173712"
}

View File

@@ -0,0 +1,107 @@
package com.mindmachine.mvp.ads
import android.app.Activity
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
import com.google.android.gms.ads.FullScreenContentCallback
import com.google.android.gms.ads.LoadAdError
import com.google.android.gms.ads.interstitial.InterstitialAd
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback
@Composable
fun AdBanner(modifier: Modifier = Modifier, enabled: Boolean) {
if (!enabled) return
val context = LocalContext.current
AndroidView(
modifier = modifier,
factory = {
AdView(context).apply {
setAdSize(AdSize.BANNER)
adUnitId = AdUnits.BANNER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
loadAd(AdRequest.Builder().build())
}
},
update = { /* no-op */ }
)
}
class InterstitialController(private val activity: Activity) {
private var interstitial: InterstitialAd? = null
private var loading: Boolean = false
fun preload() {
if (interstitial != null || loading) return
loading = true
InterstitialAd.load(
activity,
AdUnits.INTERSTITIAL,
AdRequest.Builder().build(),
object : InterstitialAdLoadCallback() {
override fun onAdLoaded(ad: InterstitialAd) {
loading = false
interstitial = ad
}
override fun onAdFailedToLoad(error: LoadAdError) {
loading = false
interstitial = null
}
}
)
}
fun show(onDone: () -> Unit) {
val ad = interstitial
if (ad == null) {
onDone()
return
}
ad.fullScreenContentCallback = object : FullScreenContentCallback() {
override fun onAdDismissedFullScreenContent() {
interstitial = null
preload()
onDone()
}
override fun onAdFailedToShowFullScreenContent(p0: com.google.android.gms.ads.AdError) {
interstitial = null
preload()
onDone()
}
}
ad.show(activity)
}
}
@Composable
fun rememberInterstitialController(enabled: Boolean): InterstitialController? {
if (!enabled) return null
val activity = LocalContext.current as? Activity ?: return null
val controller = remember(activity) { InterstitialController(activity) }
LaunchedEffect(controller) {
controller.preload()
}
return controller
}

View File

@@ -0,0 +1,159 @@
package com.mindmachine.mvp.billing
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResponseListener
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class BillingManager(
context: Context,
private val onNoAdsEntitlementChanged: (Boolean) -> Unit,
) {
private val appContext = context.applicationContext
private val _hasNoAds = MutableStateFlow(false)
val hasNoAds: StateFlow<Boolean> = _hasNoAds.asStateFlow()
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
val productDetails: StateFlow<Map<String, ProductDetails>> = _productDetails.asStateFlow()
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
.setListener { _, purchases ->
if (purchases != null) {
handlePurchases(purchases)
}
}
.enablePendingPurchases()
.build()
fun start() {
if (billingClient.isReady) {
refresh()
return
}
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
refresh()
}
}
override fun onBillingServiceDisconnected() {
// No-op, will retry on next call.
}
})
}
fun refresh() {
queryProductDetails()
queryActivePurchases()
}
fun launchPurchase(activity: Activity, productId: String) {
val details = _productDetails.value[productId] ?: return
val productDetailsParams = when (details.productType) {
BillingClient.ProductType.SUBS -> {
val offerToken = details.subscriptionOfferDetails
?.firstOrNull()
?.offerToken
?: return
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.setOfferToken(offerToken)
.build()
}
BillingClient.ProductType.INAPP -> {
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
}
else -> return
}
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient.launchBillingFlow(activity, params)
}
private fun queryProductDetails() {
if (!billingClient.isReady) return
val products = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(BillingProducts.SUB_REMOVE_ADS_MONTHLY)
.setProductType(BillingClient.ProductType.SUBS)
.build(),
QueryProductDetailsParams.Product.newBuilder()
.setProductId(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
.setProductType(BillingClient.ProductType.INAPP)
.build(),
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(products)
.build()
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = detailsList.associateBy { it.productId }
}
})
}
private fun queryActivePurchases() {
if (!billingClient.isReady) return
val listener = PurchasesResponseListener { result, purchases ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
handlePurchases(purchases)
}
}
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build(),
listener
)
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(),
listener
)
}
private fun handlePurchases(purchases: List<Purchase>) {
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 }
}
if (_hasNoAds.value != active) {
_hasNoAds.value = active
onNoAdsEntitlementChanged(active)
}
// Acknowledge where needed
purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { purchase ->
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { /* no-op */ }
}
}
}

View File

@@ -0,0 +1,9 @@
package com.mindmachine.mvp.billing
object BillingProducts {
// Configure these in Google Play Console
const val SUB_REMOVE_ADS_MONTHLY = "remove_ads_monthly"
const val INAPP_REMOVE_ADS_FOREVER = "remove_ads_forever"
val allProductIds = listOf(SUB_REMOVE_ADS_MONTHLY, INAPP_REMOVE_ADS_FOREVER)
}

View File

@@ -22,6 +22,7 @@ class SettingsRepository(private val context: Context) {
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent")
val lastPresetId = stringPreferencesKey("last_preset")
val noAds = booleanPreferencesKey("no_ads")
}
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
@@ -35,6 +36,7 @@ class SettingsRepository(private val context: Context) {
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 100).coerceIn(5, 100),
lastPresetId = p[Keys.lastPresetId],
noAds = p[Keys.noAds] ?: false,
)
}
@@ -63,6 +65,8 @@ class SettingsRepository(private val context: Context) {
suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
suspend fun updateNoAds(value: Boolean) = context.dataStore.edit { it[Keys.noAds] = value }
private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) {
"OFF" -> 0
"FIVE" -> 5

View File

@@ -38,6 +38,7 @@ data class AppSettings(
val showImmersiveProgressBar: Boolean = true,
val immersiveBrightnessPercent: Int = 100,
val lastPresetId: String? = null,
val noAds: Boolean = false,
)
object Presets {

View File

@@ -80,6 +80,7 @@ class MainViewModel(
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine,
private val billingManager: com.mindmachine.mvp.billing.BillingManager,
) : ViewModel() {
private val _ui = MutableStateFlow(UiState())
val ui: StateFlow<UiState> = _ui.asStateFlow()
@@ -89,6 +90,7 @@ class MainViewModel(
private var cachedPrograms: List<UserProgramEntity> = emptyList()
init {
billingManager.start()
viewModelScope.launch {
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
settings to userPrograms
@@ -486,8 +488,9 @@ class MainViewModel(
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine,
private val billingManager: com.mindmachine.mvp.billing.BillingManager,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine) as T
MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine, billingManager) as T
}
}