Compare commits

14 Commits

10 changed files with 162 additions and 75 deletions

View File

@@ -108,3 +108,29 @@ Important: **Billing only works reliably when the app is installed from Google P
6. **Verify in the app** 6. **Verify in the app**
- Trigger the subscribe/buy flow and confirm the purchase dialog appears. - Trigger the subscribe/buy flow and confirm the purchase dialog appears.
- Confirm acknowledgements/entitlements are applied as expected. - Confirm acknowledgements/entitlements are applied as expected.
## Billing logging (ADB)
Billing logs are written to Android's system log (Logcat), not displayed in the app UI.
To view billing logs:
```bash
# View only MindMachine billing logs
adb logcat -s MindMachineBilling:D
# Filter with silence for other tags
adb logcat -s MindMachineBilling:D *:S
```
Example log lines you'll see:
- `Billing setup finished: 0 OK`
- `Product details loaded: 2 items`
- `Query purchases returned X items`
- `Active entitlement: true/false`
- `Entitlement changed: false -> true`
Billing log tags:
- `MindMachineBilling` for debug/info messages
- Errors use `Log.e()` and appear as `E/MindMachineBilling` in logcat

View File

@@ -1,6 +1,8 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
} }
import java.util.Properties import java.util.Properties
@@ -13,8 +15,8 @@ android {
applicationId = "solutions.tretter.mindmachine" applicationId = "solutions.tretter.mindmachine"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 6 versionCode = 14
versionName = "1.0.1" versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -53,7 +55,7 @@ android {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.14" kotlinCompilerExtensionVersion = "2.1.0"
} }
packaging { packaging {
resources { resources {
@@ -83,7 +85,7 @@ dependencies {
// Monetization // Monetization
implementation("com.google.android.gms:play-services-ads:23.1.0") implementation("com.google.android.gms:play-services-ads:23.1.0")
implementation("com.android.billingclient:billing-ktx:7.1.1") implementation("com.android.billingclient:billing-ktx:8.0.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")

View File

@@ -158,9 +158,8 @@ class MainActivity : ComponentActivity() {
onNoAdsEntitlementChanged = { hasNoAds -> onNoAdsEntitlementChanged = { hasNoAds ->
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) } lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
}, },
) ).also { it.start() }
} }
private val vm: MainViewModel by viewModels { private val vm: MainViewModel by viewModels {
MainViewModel.Factory( MainViewModel.Factory(
settingsRepo, settingsRepo,
@@ -1037,6 +1036,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) { private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK
solutions.tretter.mindmachine.session.FlashPaletteColor.WHITE -> android.graphics.Color.WHITE
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
@@ -1255,7 +1255,6 @@ 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 subDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]
val subPrice = subDetails val subPrice = subDetails
@@ -1307,21 +1306,6 @@ 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

@@ -2,13 +2,15 @@ package solutions.tretter.mindmachine.billing
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResponseListener import com.android.billingclient.api.QueryProductDetailsResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.Purchase import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams
@@ -16,9 +18,6 @@ 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,
@@ -32,15 +31,12 @@ 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) { private fun log(message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) Log.d("MindMachineBilling", message)
_logs.value = _logs.value + "[$timestamp] $message"
if (_logs.value.size > 50) {
_logs.value = _logs.value.takeLast(50)
} }
private fun logError(message: String) {
Log.e("MindMachineBilling", message)
} }
private val billingClient: BillingClient = BillingClient.newBuilder(appContext) private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
@@ -49,27 +45,48 @@ class BillingManager(
handlePurchases(purchases) handlePurchases(purchases)
} }
} }
.enablePendingPurchases() .enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.build() .build()
@Volatile
private var isConnecting = false
fun start() { fun start() {
log("BillingManager.start()") log("BillingManager.start() - isReady=${billingClient.isReady}, isConnecting=$isConnecting")
if (billingClient.isReady) { if (billingClient.isReady) {
log("Billing client already ready") log("Billing client already ready")
refresh() refresh()
return return
} }
if (isConnecting) {
log("Billing client connection already in progress")
return
}
isConnecting = true
log("Calling startConnection()")
billingClient.startConnection(object : BillingClientStateListener { billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) { override fun onBillingSetupFinished(result: BillingResult) {
isConnecting = false
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}") log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) { if (result.responseCode == BillingClient.BillingResponseCode.OK) {
refresh() refresh()
} else {
logError("Billing setup failed: ${result.responseCode} ${result.debugMessage}")
} }
} }
override fun onBillingServiceDisconnected() { override fun onBillingServiceDisconnected() {
isConnecting = false
log("Billing service disconnected") log("Billing service disconnected")
// No-op, will retry on next call.
} }
}) })
} }
@@ -120,7 +137,11 @@ class BillingManager(
.build() .build()
val result = billingClient.launchBillingFlow(activity, params) val result = billingClient.launchBillingFlow(activity, params)
log("launchBillingFlow result: ${result.responseCode} ${result.debugMessage}") if (result.responseCode == BillingClient.BillingResponseCode.OK) {
log("launchBillingFlow succeeded")
} else {
logError("launchBillingFlow failed: ${result.responseCode} ${result.debugMessage}")
}
} }
private fun queryProductDetails() { private fun queryProductDetails() {
@@ -130,28 +151,60 @@ class BillingManager(
return return
} }
val products = listOf( val featureResult = billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS)
QueryProductDetailsParams.Product.newBuilder() log("PRODUCT_DETAILS support: ${featureResult.responseCode} ${featureResult.debugMessage}")
.setProductId(BillingProducts.SUB_REMOVE_ADS_MONTHLY) if (featureResult.responseCode != BillingClient.BillingResponseCode.OK) {
.setProductType(BillingClient.ProductType.SUBS) logError("PRODUCT_DETAILS feature not supported")
.build(), return
QueryProductDetailsParams.Product.newBuilder() }
.setProductId(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
.setProductType(BillingClient.ProductType.INAPP) queryProductDetailsForType(
.build(), productType = BillingClient.ProductType.SUBS,
productIds = listOf(BillingProducts.SUB_REMOVE_ADS_MONTHLY)
) )
queryProductDetailsForType(
productType = BillingClient.ProductType.INAPP,
productIds = listOf(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
)
}
private fun queryProductDetailsForType(
productType: String,
productIds: List<String>
) {
try {
log("Querying $productType product details for $productIds")
val products = productIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(productType)
.build()
}
val params = QueryProductDetailsParams.newBuilder() val params = QueryProductDetailsParams.newBuilder()
.setProductList(products) .setProductList(products)
.build() .build()
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList -> billingClient.queryProductDetailsAsync(params) { result, queryResult ->
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}") log("queryProductDetailsAsync($productType) result: ${result.responseCode} ${result.debugMessage}")
val detailsList = queryResult.productDetailsList
log("Fetched $productType products: ${detailsList.map { it.productId }}")
if (queryResult.unfetchedProductList.isNotEmpty()) {
logError("Unfetched $productType products: ${queryResult.unfetchedProductList}")
}
if (result.responseCode == BillingClient.BillingResponseCode.OK) { if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = detailsList.associateBy { it.productId } _productDetails.value = _productDetails.value + detailsList.associateBy { it.productId }
log("Product details loaded: ${detailsList.size} items") log("Product details cache now has: ${_productDetails.value.keys}")
}
}
} catch (t: Throwable) {
Log.e("MindMachineBilling", "Exception querying $productType product details", t)
} }
})
} }
private fun queryActivePurchases() { private fun queryActivePurchases() {
@@ -162,9 +215,11 @@ class BillingManager(
} }
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) {
log("Query purchases returned ${purchases.size} items")
handlePurchases(purchases) handlePurchases(purchases)
} else {
logError("Query purchases failed: ${result.responseCode} ${result.debugMessage}")
} }
} }
@@ -179,7 +234,7 @@ class BillingManager(
} }
private fun handlePurchases(purchases: List<Purchase>) { private fun handlePurchases(purchases: List<Purchase>) {
log("handlePurchases() count=${purchases.size}") 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 }
@@ -199,7 +254,13 @@ class BillingManager(
val params = AcknowledgePurchaseParams.newBuilder() val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken) .setPurchaseToken(purchase.purchaseToken)
.build() .build()
billingClient.acknowledgePurchase(params) { /* no-op */ } billingClient.acknowledgePurchase(params) { billingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
log("Purchase acknowledged successfully")
} else {
logError("Failed to acknowledge purchase: ${billingResult.responseCode} ${billingResult.debugMessage}")
}
}
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package solutions.tretter.mindmachine.session
enum class FlashPaletteColor { enum class FlashPaletteColor {
BLACK, BLACK,
WHITE,
RED, RED,
GREEN, GREEN,
YELLOW, YELLOW,

View File

@@ -164,6 +164,7 @@ private fun FlashPaletteColor.next(): FlashPaletteColor {
private fun FlashPaletteColor.previewColor(): Color = when (this) { private fun FlashPaletteColor.previewColor(): Color = when (this) {
FlashPaletteColor.BLACK -> Color.Black FlashPaletteColor.BLACK -> Color.Black
FlashPaletteColor.WHITE -> Color.White
FlashPaletteColor.RED -> Color(0xFFD32F2F) FlashPaletteColor.RED -> Color(0xFFD32F2F)
FlashPaletteColor.GREEN -> Color(0xFF2E7D32) FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
FlashPaletteColor.YELLOW -> Color(0xFFF9A825) FlashPaletteColor.YELLOW -> Color(0xFFF9A825)

View File

@@ -13,5 +13,5 @@ object ParameterRanges {
const val CARRIER_HZ_MAX = 1200f const val CARRIER_HZ_MAX = 1200f
const val BINAURAL_HZ_MIN = 0.5f const val BINAURAL_HZ_MIN = 0.5f
const val BINAURAL_HZ_MAX = 30f const val BINAURAL_HZ_MAX = 20f
} }

View File

@@ -266,18 +266,28 @@ internal fun TimelineGraph(
(context as? android.app.Activity)?.window?.decorView?.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) (context as? android.app.Activity)?.window?.decorView?.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
} }
val yLabels = if (isVisual) { // Y-axis labels - dynamic based on which curve is selected. For audio: carrier (left) or binaural (right). For visual: flash interval.
val yAxisLabels = if (isVisual) {
listOf( listOf(
formatMsLabel(ParameterMapping.logMap01(1f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)), formatMsLabel(ParameterMapping.logMap01(1f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
formatMsLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)), formatMsLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
formatMsLabel(ParameterMapping.logMap01(0f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)), formatMsLabel(ParameterMapping.logMap01(0f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
) )
} else { } else {
// Audio: switch labels based on selected side
if (selectedLeft) {
listOf( listOf(
formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)), formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)), formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)), formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)),
) )
} else {
listOf(
formatHzLabel(ParameterMapping.logMap01(1f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
formatHzLabel(ParameterMapping.logMap01(0.5f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
formatHzLabel(ParameterMapping.logMap01(0f, ParameterRanges.BINAURAL_HZ_MIN, ParameterRanges.BINAURAL_HZ_MAX)),
)
}
} }
val xStart = formatTimeLabel(viewport.startSec) val xStart = formatTimeLabel(viewport.startSec)
val xMid = formatTimeLabel(viewport.startSec + viewport.secondsPerScreen / 2f) val xMid = formatTimeLabel(viewport.startSec + viewport.secondsPerScreen / 2f)
@@ -288,12 +298,13 @@ internal fun TimelineGraph(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Left Y-axis labels
Column( Column(
modifier = Modifier.height(190.dp).width(48.dp), modifier = Modifier.height(190.dp).width(48.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
yLabels.forEach { label -> yAxisLabels.forEach { label ->
Text( Text(
text = label, text = label,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),

View File

@@ -35,7 +35,7 @@ object TimelineParameters {
ParameterId.BRIGHTNESS to Range(min = 0.0001f, max = 1.0f, logScale = true, units = "%"), ParameterId.BRIGHTNESS to Range(min = 0.0001f, max = 1.0f, logScale = true, units = "%"),
ParameterId.BLINK_RATE to Range(min = 0.1f, max = 30f, logScale = true, units = "Hz"), ParameterId.BLINK_RATE to Range(min = 0.1f, max = 30f, logScale = true, units = "Hz"),
ParameterId.CARRIER_FREQUENCY to Range(min = 200f, max = 1200f, logScale = true, units = "Hz"), ParameterId.CARRIER_FREQUENCY to Range(min = 200f, max = 1200f, logScale = true, units = "Hz"),
ParameterId.BINAURAL_BEAT to Range(min = 0.5f, max = 30f, logScale = true, units = "Hz"), ParameterId.BINAURAL_BEAT to Range(min = 0.5f, max = 20f, logScale = true, units = "Hz"),
) )
fun parameterForCurve(curve: TimelineEditorState.ActiveCurve): ParameterId { fun parameterForCurve(curve: TimelineEditorState.ActiveCurve): ParameterId {

View File

@@ -1,5 +1,6 @@
plugins { plugins {
id("com.android.application") version "8.5.2" apply false id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
} }