Auto commit (MindMachine) Fri Apr 10 12:01:02 PM CDT 2026
This commit is contained in:
@@ -65,6 +65,10 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
// Monetization
|
||||
implementation("com.google.android.gms:play-services-ads:23.1.0")
|
||||
implementation("com.android.billingclient:billing-ktx:7.1.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
@@ -10,6 +12,12 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MindMachine">
|
||||
|
||||
<!-- AdMob test app id (replace with your real id before release) -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="ca-app-pub-3940256099942544~3347511713" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
|
||||
@@ -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)
|
||||
|
||||
7
app/src/main/java/com/mindmachine/mvp/ads/AdUnits.kt
Normal file
7
app/src/main/java/com/mindmachine/mvp/ads/AdUnits.kt
Normal 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"
|
||||
}
|
||||
107
app/src/main/java/com/mindmachine/mvp/ads/AdsComposable.kt
Normal file
107
app/src/main/java/com/mindmachine/mvp/ads/AdsComposable.kt
Normal 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
|
||||
}
|
||||
159
app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt
Normal file
159
app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user