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**
- Trigger the subscribe/buy flow and confirm the purchase dialog appears.
- 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 {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose")
}
import java.util.Properties
@@ -13,8 +15,8 @@ android {
applicationId = "solutions.tretter.mindmachine"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "1.0.1"
versionCode = 14
versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -53,7 +55,7 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
kotlinCompilerExtensionVersion = "2.1.0"
}
packaging {
resources {
@@ -83,7 +85,7 @@ dependencies {
// Monetization
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("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")

View File

@@ -158,9 +158,8 @@ class MainActivity : ComponentActivity() {
onNoAdsEntitlementChanged = { hasNoAds ->
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
},
)
).also { it.start() }
}
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(
settingsRepo,
@@ -1037,6 +1036,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
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.WHITE -> android.graphics.Color.WHITE
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.YELLOW -> android.graphics.Color.YELLOW
@@ -1255,7 +1255,6 @@ 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
@@ -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.",
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.content.Context
import android.util.Log
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.QueryProductDetailsResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class BillingManager(
context: Context,
@@ -32,15 +31,12 @@ class BillingManager(
private val _productDetails = MutableStateFlow<Map<String, ProductDetails>>(emptyMap())
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) {
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)
Log.d("MindMachineBilling", message)
}
private fun logError(message: String) {
Log.e("MindMachineBilling", message)
}
private val billingClient: BillingClient = BillingClient.newBuilder(appContext)
@@ -49,27 +45,48 @@ class BillingManager(
handlePurchases(purchases)
}
}
.enablePendingPurchases()
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.build()
@Volatile
private var isConnecting = false
fun start() {
log("BillingManager.start()")
log("BillingManager.start() - isReady=${billingClient.isReady}, isConnecting=$isConnecting")
if (billingClient.isReady) {
log("Billing client already ready")
refresh()
return
}
if (isConnecting) {
log("Billing client connection already in progress")
return
}
isConnecting = true
log("Calling startConnection()")
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
isConnecting = false
log("Billing setup finished: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
refresh()
} else {
logError("Billing setup failed: ${result.responseCode} ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
isConnecting = false
log("Billing service disconnected")
// No-op, will retry on next call.
}
})
}
@@ -120,7 +137,11 @@ class BillingManager(
.build()
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() {
@@ -130,28 +151,60 @@ class BillingManager(
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 featureResult = billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS)
log("PRODUCT_DETAILS support: ${featureResult.responseCode} ${featureResult.debugMessage}")
if (featureResult.responseCode != BillingClient.BillingResponseCode.OK) {
logError("PRODUCT_DETAILS feature not supported")
return
}
queryProductDetailsForType(
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()
.setProductList(products)
.build()
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
billingClient.queryProductDetailsAsync(params) { result, queryResult ->
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) {
_productDetails.value = detailsList.associateBy { it.productId }
log("Product details loaded: ${detailsList.size} items")
_productDetails.value = _productDetails.value + detailsList.associateBy { it.productId }
log("Product details cache now has: ${_productDetails.value.keys}")
}
}
} catch (t: Throwable) {
Log.e("MindMachineBilling", "Exception querying $productType product details", t)
}
})
}
private fun queryActivePurchases() {
@@ -162,9 +215,11 @@ class BillingManager(
}
val listener = PurchasesResponseListener { result, purchases ->
log("Query purchases result: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
log("Query purchases returned ${purchases.size} items")
handlePurchases(purchases)
} else {
logError("Query purchases failed: ${result.responseCode} ${result.debugMessage}")
}
}
@@ -179,7 +234,7 @@ class BillingManager(
}
private fun handlePurchases(purchases: List<Purchase>) {
log("handlePurchases() count=${purchases.size}")
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 }
@@ -199,7 +254,13 @@ class BillingManager(
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.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 {
BLACK,
WHITE,
RED,
GREEN,
YELLOW,

View File

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

View File

@@ -13,5 +13,5 @@ object ParameterRanges {
const val CARRIER_HZ_MAX = 1200f
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)
}
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(
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(0f, ParameterRanges.FLASH_INTERVAL_MS_MIN, ParameterRanges.FLASH_INTERVAL_MS_MAX)),
)
} else {
// Audio: switch labels based on selected side
if (selectedLeft) {
listOf(
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(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 xMid = formatTimeLabel(viewport.startSec + viewport.secondsPerScreen / 2f)
@@ -288,12 +298,13 @@ internal fun TimelineGraph(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Left Y-axis labels
Column(
modifier = Modifier.height(190.dp).width(48.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End,
) {
yLabels.forEach { label ->
yAxisLabels.forEach { label ->
Text(
text = label,
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.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.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 {

View File

@@ -1,5 +1,6 @@
plugins {
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.plugin.serialization") version "1.9.24" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" 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
}