diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51c6a36..e75772f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ff2fb7c..7ff8aab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + + + + + diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 5f87520..a7989ce 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/mindmachine/mvp/ads/AdUnits.kt b/app/src/main/java/com/mindmachine/mvp/ads/AdUnits.kt new file mode 100644 index 0000000..8e60873 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/ads/AdUnits.kt @@ -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" +} diff --git a/app/src/main/java/com/mindmachine/mvp/ads/AdsComposable.kt b/app/src/main/java/com/mindmachine/mvp/ads/AdsComposable.kt new file mode 100644 index 0000000..74569b4 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/ads/AdsComposable.kt @@ -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 +} diff --git a/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt b/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt new file mode 100644 index 0000000..cecdd61 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/billing/BillingManager.kt @@ -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 = _hasNoAds.asStateFlow() + + private val _productDetails = MutableStateFlow>(emptyMap()) + val productDetails: StateFlow> = _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) { + 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 */ } + } + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/billing/BillingProducts.kt b/app/src/main/java/com/mindmachine/mvp/billing/BillingProducts.kt new file mode 100644 index 0000000..49f799b --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/billing/BillingProducts.kt @@ -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) +} 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 ef4dbce..435d65d 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/SettingsRepository.kt @@ -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 = 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 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 29d1d2b..6ac5dae 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -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 { 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 26f1431..efa1d17 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -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 = _ui.asStateFlow() @@ -89,6 +90,7 @@ class MainViewModel( private var cachedPrograms: List = 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 create(modelClass: Class): T = - MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine) as T + MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine, billingManager) as T } }