Compare commits

24 Commits

Author SHA1 Message Date
Tretzi
9a6f28d39f Auto commit (MindMachine) Sun Apr 19 10:01:02 AM CDT 2026 2026-04-19 10:01:02 -05:00
Tretzi
0357da5158 Bump versionCode to 18, versionName to 1.0.13 for AAB release 2026-04-19 10:00:10 -05:00
Tretzi
c468840359 Auto commit (MindMachine) Thu Apr 16 09:01:01 PM CDT 2026 2026-04-16 21:01:01 -05:00
Tretzi
260d0a1e84 Auto commit (MindMachine) Thu Apr 16 08:01:01 PM CDT 2026 2026-04-16 20:01:01 -05:00
Tretzi
7d7057f640 Auto commit (MindMachine) Thu Apr 16 07:01:02 PM CDT 2026 2026-04-16 19:01:02 -05:00
Tretzi
a1a3f2ab8c Bump versionCode to 17, versionName to 1.0.12 for ad-free debug builds 2026-04-16 18:58:58 -05:00
Tretzi
d3f21094ff Add DEBUG_AD_FREE build config flag to disable ads in debug builds 2026-04-16 18:55:50 -05:00
Tretzi
3876d68183 Bump versionCode to 16, versionName to 1.0.11 2026-04-16 18:48:39 -05:00
Tretzi
6ccf19707b Add showImmersiveStopButton setting and top-right X button overlay 2026-04-16 18:48:30 -05:00
Tretzi
95cb3de029 Bump versionCode to 15, versionName to 1.1.0 for AAB release 2026-04-16 18:12:00 -05:00
Tretzi
bc014951e5 Make Y-axis dynamic: show carrier or binaural range based on selected audio curve 2026-04-16 18:11:49 -05:00
Tretzi
18f5aa213b Add right y-axis for binaural frequency in audio timeline 2026-04-16 18:01:17 -05:00
Tretzi
06c3dbb079 Auto commit (MindMachine) Thu Apr 16 06:01:02 PM CDT 2026 2026-04-16 18:01:02 -05:00
Tretzi
3230803c2c Upgrade Kotlin to 2.1.0 and Compose compiler to 2.1.0 for Billing 8 compatibility 2026-04-16 17:49:09 -05:00
Tretzi
3a40ee5a0c Restrict binaural difference UI range to 0.5-20 Hz to match validator 2026-04-16 17:46:08 -05:00
Tretzi
f2a9e3daa7 Fix the purchase and subscribe functionality 2026-04-15 22:36:13 -05:00
Tretzi
937689cbfa Auto commit (MindMachine) Wed Apr 15 10:01:01 PM CDT 2026 2026-04-15 22:01:01 -05:00
Tretzi
011e4ba349 Auto commit (MindMachine) Wed Apr 15 09:01:01 PM CDT 2026 2026-04-15 21:01:01 -05:00
Tretzi
0fff8c141b Auto commit (MindMachine) Wed Apr 15 08:01:02 PM CDT 2026 2026-04-15 20:01:02 -05:00
Tretzi
3cbefd67b5 Bump versionCode to 7, versionName to 1.0.2 2026-04-15 19:00:31 -05:00
Tretzi
2ec47df39d Billing: add PRODUCT_DETAILS feature support check 2026-04-15 18:52:35 -05:00
Tretzi
703cbcfb5b Add ADB logging documentation to DEVELOPMENT.md 2026-04-15 18:27:13 -05:00
Tretzi
6d9f9b4632 Billing: replace in-app logs with ADB logging, add auto-reconnection 2026-04-15 18:26:34 -05:00
Tretzi
138da56024 Add white to flash color palette 2026-04-15 18:16:40 -05:00
13 changed files with 234 additions and 79 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 = 18
versionName = "1.0.1" versionName = "1.0.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -33,6 +35,9 @@ android {
} }
} }
debug {
buildConfigField("boolean", "DEBUG_AD_FREE", "true")
}
release { release {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
@@ -40,6 +45,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
buildConfigField("boolean", "DEBUG_AD_FREE", "false")
} }
} }
compileOptions { compileOptions {
@@ -51,9 +57,10 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.14" kotlinCompilerExtensionVersion = "2.1.0"
} }
packaging { packaging {
resources { resources {
@@ -83,7 +90,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

@@ -13,6 +13,7 @@ import android.util.Log
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import solutions.tretter.mindmachine.BuildConfig
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
@@ -48,6 +49,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -158,9 +160,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,
@@ -173,7 +174,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!BuildConfig.DEBUG_AD_FREE) {
MobileAds.initialize(this) {} MobileAds.initialize(this) {}
}
setContent { setContent {
MaterialTheme(colorScheme = MindMachineColors) { MaterialTheme(colorScheme = MindMachineColors) {
App(vm, billingManager) App(vm, billingManager)
@@ -228,7 +231,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
composable("home") { composable("home") {
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) } var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") } var confirmDeleteProgramName by remember { mutableStateOf("") }
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds) val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
Column( Column(
Modifier Modifier
@@ -343,11 +346,13 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
} }
} }
if (!BuildConfig.DEBUG_AD_FREE) {
AdBanner( AdBanner(
enabled = !ui.settings.noAds, enabled = !ui.settings.noAds,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
}
if (confirmDeleteProgramId != null) { if (confirmDeleteProgramId != null) {
AlertDialog( AlertDialog(
@@ -376,7 +381,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
} }
} }
composable("setup") { composable("setup") {
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds) val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
SetupScreen( SetupScreen(
vm = vm, vm = vm,
onStart = { onStart = {
@@ -598,6 +603,8 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
} }
var brightnessOverlayVisible by remember { mutableStateOf(false) } var brightnessOverlayVisible by remember { mutableStateOf(false) }
var brightnessOverlayToken by remember { mutableIntStateOf(0) } var brightnessOverlayToken by remember { mutableIntStateOf(0) }
var stopButtonVisible by remember { mutableStateOf(false) }
var stopButtonToken by remember { mutableIntStateOf(0) }
val timingLogTag = "MindMachineTiming" val timingLogTag = "MindMachineTiming"
LaunchedEffect(brightnessOverlayToken) { LaunchedEffect(brightnessOverlayToken) {
@@ -643,12 +650,24 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
detectTapGestures { detectTapGestures {
brightnessOverlayVisible = true brightnessOverlayVisible = true
brightnessOverlayToken++ brightnessOverlayToken++
if (ui.settings.showImmersiveStopButton && immersiveFullscreen) {
stopButtonVisible = true
stopButtonToken++
}
if (!immersiveFullscreen) { if (!immersiveFullscreen) {
showOverlay = !showOverlay showOverlay = !showOverlay
} }
} }
} }
) { ) {
// Auto-hide stop button after 3 seconds of visibility
LaunchedEffect(stopButtonToken) {
if (stopButtonVisible) {
delay(3000)
stopButtonVisible = false
}
}
if (isVisualMode) { if (isVisualMode) {
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -716,6 +735,26 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
} }
} }
// Stop button in immersive mode (top-right)
if (stopButtonVisible && ui.settings.showImmersiveStopButton) {
IconButton(
onClick = {
vm.stop()
onFinish()
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 16.dp, end = 16.dp)
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Stop",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
if (showOverlay) { if (showOverlay) {
Column( Column(
Modifier Modifier
@@ -1037,6 +1076,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
@@ -1233,6 +1273,18 @@ fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground) Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.showImmersiveStopButton, onCheckedChange = vm::updateShowImmersiveStopButton)
Column {
Text("Show stop button in immersive mode", color = MaterialTheme.colorScheme.onBackground)
Text(
"If off, swipe from edge to display back button",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate) Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground) Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground)
@@ -1255,7 +1307,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 +1358,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) { billingResult, queryResult ->
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}") log("queryProductDetailsAsync($productType) result: ${billingResult.responseCode} ${billingResult.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = detailsList.associateBy { it.productId } val detailsList = queryResult.productDetailsList
log("Product details loaded: ${detailsList.size} items") log("Fetched $productType products: ${detailsList?.map { it.productId } ?: emptyList()}")
if (queryResult.unfetchedProductList.isNotEmpty()) {
logError("Unfetched $productType products: ${queryResult.unfetchedProductList}")
}
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = _productDetails.value + (detailsList?.associateBy { it.productId } ?: emptyMap())
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

@@ -20,6 +20,7 @@ class SettingsRepository(private val context: Context) {
val countdownSeconds = intPreferencesKey("countdown_seconds") val countdownSeconds = intPreferencesKey("countdown_seconds")
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones") val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar") val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
val showImmersiveStopButton = booleanPreferencesKey("show_immersive_stop_button")
val preferLowestRefreshRate = booleanPreferencesKey("prefer_lowest_refresh_rate") val preferLowestRefreshRate = booleanPreferencesKey("prefer_lowest_refresh_rate")
val monochromeFlashMode = booleanPreferencesKey("monochrome_flash_mode") val monochromeFlashMode = booleanPreferencesKey("monochrome_flash_mode")
val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent") val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent")
@@ -36,6 +37,7 @@ class SettingsRepository(private val context: Context) {
?: 5, ?: 5,
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false, forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
showImmersiveStopButton = p[Keys.showImmersiveStopButton] ?: true,
preferLowestRefreshRate = p[Keys.preferLowestRefreshRate] ?: true, preferLowestRefreshRate = p[Keys.preferLowestRefreshRate] ?: true,
monochromeFlashMode = p[Keys.monochromeFlashMode] ?: false, monochromeFlashMode = p[Keys.monochromeFlashMode] ?: false,
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100), immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
@@ -58,6 +60,9 @@ class SettingsRepository(private val context: Context) {
if (prefs[Keys.preferLowestRefreshRate] == null) { if (prefs[Keys.preferLowestRefreshRate] == null) {
prefs[Keys.preferLowestRefreshRate] = true prefs[Keys.preferLowestRefreshRate] = true
} }
if (prefs[Keys.showImmersiveStopButton] == null) {
prefs[Keys.showImmersiveStopButton] = true
}
} }
} }
@@ -80,6 +85,10 @@ class SettingsRepository(private val context: Context) {
it[Keys.showImmersiveProgressBar] = value it[Keys.showImmersiveProgressBar] = value
} }
suspend fun updateShowImmersiveStopButton(value: Boolean) = context.dataStore.edit {
it[Keys.showImmersiveStopButton] = value
}
suspend fun updatePreferLowestRefreshRate(value: Boolean) = context.dataStore.edit { suspend fun updatePreferLowestRefreshRate(value: Boolean) = context.dataStore.edit {
it[Keys.preferLowestRefreshRate] = value it[Keys.preferLowestRefreshRate] = value
} }

View File

@@ -42,6 +42,7 @@ data class AppSettings(
val countdownSeconds: Int = 5, val countdownSeconds: Int = 5,
val forceAudioWithoutHeadphones: Boolean = false, val forceAudioWithoutHeadphones: Boolean = false,
val showImmersiveProgressBar: Boolean = true, val showImmersiveProgressBar: Boolean = true,
val showImmersiveStopButton: Boolean = true,
val preferLowestRefreshRate: Boolean = true, val preferLowestRefreshRate: Boolean = true,
val monochromeFlashMode: Boolean = false, val monochromeFlashMode: Boolean = false,
// Default used only before DataStore emits (and as a fallback). // Default used only before DataStore emits (and as a fallback).

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

@@ -304,6 +304,7 @@ class MainViewModel(
settingsRepository.updateForceAudioWithoutHeadphones(value) settingsRepository.updateForceAudioWithoutHeadphones(value)
} }
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) } fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun updateShowImmersiveStopButton(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveStopButton(value) }
fun updatePreferLowestRefreshRate(value: Boolean) = viewModelScope.launch { settingsRepository.updatePreferLowestRefreshRate(value) } fun updatePreferLowestRefreshRate(value: Boolean) = viewModelScope.launch { settingsRepository.updatePreferLowestRefreshRate(value) }
fun updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) } fun updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) }
fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch { fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {

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),
@@ -414,7 +425,7 @@ internal fun TimelineGraph(
height = h, height = h,
curveId = curveId, curveId = curveId,
) )
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, constrainedY, h) val updated = targetCurve.movePointVertical(pointIndex, constrainedY, h)
if (curveId == leftId) curveLeft = updated else curveRight = updated if (curveId == leftId) curveLeft = updated else curveRight = updated
currentOnUpdateCurve(curveId, updated) currentOnUpdateCurve(curveId, updated)
onCurveEditedHaptic() onCurveEditedHaptic()

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
} }