28 Commits

Author SHA1 Message Date
Hermes
f7722216a0 build: upgrade AGP for Android 16 support
All checks were successful
Android build / build (push) Successful in 16m36s
Android build / build (release) Successful in 13m37s
2026-08-31 13:31:01 -05:00
Hermes
8af42dbe48 build: target Android 16 / API 36
All checks were successful
Android build / build (push) Successful in 19m59s
2026-08-31 12:18:31 -05:00
Hermes
ebd7910b32 fix: resolve MindMachine release keystore path from repo root
All checks were successful
Android build / build (push) Successful in 11m29s
Android build / build (release) Successful in 11m56s
2026-08-29 14:03:23 -05:00
Hermes
c2a78e1a73 ci: align MindMachine Android workflow with e-Sun setup
Some checks failed
Android build / build (push) Successful in 15m28s
Android build / build (release) Failing after 8m10s
2026-08-29 13:30:22 -05:00
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
15 changed files with 400 additions and 96 deletions

View File

@@ -0,0 +1,138 @@
name: Android build
permissions:
contents: read
releases: write
on:
push:
branches:
- '**'
pull_request:
release:
types:
- published
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- name: Install Android SDK command-line tools and packages
shell: bash
run: |
set -euxo pipefail
export ANDROID_SDK_ROOT="$HOME/.android/sdk"
export ANDROID_HOME="$ANDROID_SDK_ROOT"
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
if [ ! -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then
tools_url="$(curl -fsSL https://developer.android.com/studio | grep -o 'https://dl.google.com/android/repository/commandlinetools-linux-[0-9][0-9]*_latest.zip' | head -n 1)"
test -n "$tools_url"
curl -fsSL -o /tmp/commandlinetools.zip "$tools_url"
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest" "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools"
unzip -q /tmp/commandlinetools.zip -d "$ANDROID_SDK_ROOT/cmdline-tools"
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
fi
export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH"
set +o pipefail
yes | sdkmanager --licenses >/dev/null
set -o pipefail
sdkmanager \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0"
- name: Prepare optional release signing
if: github.event_name == 'release'
continue-on-error: true
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
shell: bash
run: |
set -euo pipefail
missing=()
for key in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!key:-}" ]; then
missing+=("$key")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
printf -v missing_csv '%s, ' "${missing[@]}"
missing_csv="${missing_csv%, }"
echo "::error::Release signing secrets missing: ${missing_csv}. Continuing with unsigned/default-signed release AAB."
exit 1
fi
mkdir -p .ci-secrets
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > .ci-secrets/release-keystore.jks
cat > keystore.properties <<EOF
storeFile=.ci-secrets/release-keystore.jks
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Write Android SDK location
shell: bash
run: |
set -euxo pipefail
printf 'sdk.dir=%s/.android/sdk\n' "$HOME" > local.properties
- name: Build debug APK
if: github.event_name != 'release'
shell: bash
run: |
set -euxo pipefail
export ANDROID_SDK_ROOT="$HOME/.android/sdk"
export ANDROID_HOME="$ANDROID_SDK_ROOT"
./gradlew --no-daemon clean assembleDebug
- name: Build release AAB
if: github.event_name == 'release'
shell: bash
run: |
set -euxo pipefail
export ANDROID_SDK_ROOT="$HOME/.android/sdk"
export ANDROID_HOME="$ANDROID_SDK_ROOT"
./gradlew --no-daemon bundleRelease
- name: Upload debug APK
if: github.event_name != 'release'
uses: actions/upload-artifact@v3
with:
name: debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
- name: Attach release AAB to Gitea release
if: github.event_name == 'release'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
shell: bash
run: |
set -euxo pipefail
release_id="$(python3 -c 'import json, os; print(json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8"))["release"]["id"])')"
aab="$(find app/build/outputs/bundle/release -name '*.aab' | head -n 1)"
curl --fail-with-body \
--request POST \
--header "Authorization: token ${GITEA_TOKEN}" \
--form "attachment=@${aab}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=$(basename "$aab")"

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,45 +1,63 @@
import java.util.Properties
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
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
val hasReleaseSigning = listOf(
"storeFile",
"storePassword",
"keyAlias",
"keyPassword",
).all { !keystoreProperties.getProperty(it).isNullOrBlank() }
android {
namespace = "solutions.tretter.mindmachine"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "solutions.tretter.mindmachine"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "1.0.1"
targetSdk = 36
versionCode = 18
versionName = "1.0.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
signingConfigs {
signingConfigs {
if (hasReleaseSigning) {
create("release") {
val propsFile = rootProject.file("keystore/upload-keystore.properties")
if (propsFile.exists()) {
val props = Properties().apply { propsFile.inputStream().use { this.load(it) } }
storeFile = rootProject.file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
}
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
debug {
buildConfigField("boolean", "DEBUG_AD_FREE", "true")
}
release {
signingConfig = signingConfigs.getByName("release")
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "DEBUG_AD_FREE", "false")
}
}
compileOptions {
@@ -51,9 +69,10 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
kotlinCompilerExtensionVersion = "2.1.0"
}
packaging {
resources {
@@ -81,9 +100,8 @@ 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")
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

@@ -13,6 +13,7 @@ import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import solutions.tretter.mindmachine.BuildConfig
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@@ -48,6 +49,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
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.PlayArrow
import androidx.compose.material.icons.filled.Settings
@@ -158,9 +160,8 @@ class MainActivity : ComponentActivity() {
onNoAdsEntitlementChanged = { hasNoAds ->
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
},
)
).also { it.start() }
}
private val vm: MainViewModel by viewModels {
MainViewModel.Factory(
settingsRepo,
@@ -173,7 +174,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MobileAds.initialize(this) {}
if (!BuildConfig.DEBUG_AD_FREE) {
MobileAds.initialize(this) {}
}
setContent {
MaterialTheme(colorScheme = MindMachineColors) {
App(vm, billingManager)
@@ -228,7 +231,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
composable("home") {
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
var confirmDeleteProgramName by remember { mutableStateOf("") }
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
Column(
Modifier
@@ -343,10 +346,12 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
}
}
AdBanner(
enabled = !ui.settings.noAds,
modifier = Modifier.fillMaxWidth()
)
if (!BuildConfig.DEBUG_AD_FREE) {
AdBanner(
enabled = !ui.settings.noAds,
modifier = Modifier.fillMaxWidth()
)
}
}
if (confirmDeleteProgramId != null) {
@@ -376,7 +381,7 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
}
}
composable("setup") {
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds && !BuildConfig.DEBUG_AD_FREE)
SetupScreen(
vm = vm,
onStart = {
@@ -598,6 +603,8 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
}
var brightnessOverlayVisible by remember { mutableStateOf(false) }
var brightnessOverlayToken by remember { mutableIntStateOf(0) }
var stopButtonVisible by remember { mutableStateOf(false) }
var stopButtonToken by remember { mutableIntStateOf(0) }
val timingLogTag = "MindMachineTiming"
LaunchedEffect(brightnessOverlayToken) {
@@ -643,12 +650,24 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
detectTapGestures {
brightnessOverlayVisible = true
brightnessOverlayToken++
if (ui.settings.showImmersiveStopButton && immersiveFullscreen) {
stopButtonVisible = true
stopButtonToken++
}
if (!immersiveFullscreen) {
showOverlay = !showOverlay
}
}
}
) {
// Auto-hide stop button after 3 seconds of visibility
LaunchedEffect(stopButtonToken) {
if (stopButtonVisible) {
delay(3000)
stopButtonVisible = false
}
}
if (isVisualMode) {
AndroidView(
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) {
Column(
Modifier
@@ -1037,6 +1076,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
@@ -1233,6 +1273,18 @@ fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
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) {
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
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 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 +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.",
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()
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)
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(products)
.build()
queryProductDetailsForType(
productType = BillingClient.ProductType.INAPP,
productIds = listOf(BillingProducts.INAPP_REMOVE_ADS_FOREVER)
)
}
billingClient.queryProductDetailsAsync(params, ProductDetailsResponseListener { result, detailsList ->
log("queryProductDetails result: ${result.responseCode} ${result.debugMessage}")
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = detailsList.associateBy { it.productId }
log("Product details loaded: ${detailsList.size} items")
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) { billingResult, queryResult ->
log("queryProductDetailsAsync($productType) result: ${billingResult.responseCode} ${billingResult.debugMessage}")
val detailsList = queryResult.productDetailsList
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() {
@@ -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

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

View File

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

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

@@ -304,6 +304,7 @@ class MainViewModel(
settingsRepository.updateForceAudioWithoutHeadphones(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 updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) }
fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {

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

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("com.android.application") version "8.10.0" 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
}

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME