15 Commits

Author SHA1 Message Date
Hermes
56a7103503 ci: bump APK versionCode and timestamp AAB names
Some checks failed
Android build / build (push) Successful in 15m22s
Android build / build (release) Failing after 12m34s
2026-08-31 15:06:03 -05:00
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
10 changed files with 301 additions and 49 deletions

View File

@@ -0,0 +1,173 @@
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: Prepare CI build metadata
id: build_meta
shell: bash
run: |
set -euxo pipefail
timestamp_utc="$(date -u +%Y%m%d-%H%M%S)"
version_code="$(date -u +%s)"
project_name="$(basename "$GITHUB_REPOSITORY")"
python3 - "$version_code" <<'PY'
from pathlib import Path
import re, sys
path = Path('app/build.gradle.kts')
text = path.read_text(encoding='utf-8')
new_text, count = re.subn(r'(\bversionCode\s*=\s*)\d+', rf'\g<1>{sys.argv[1]}', text, count=1)
if count != 1:
raise SystemExit('versionCode not found in app/build.gradle.kts')
path.write_text(new_text, encoding='utf-8')
PY
echo "Using CI versionCode: $version_code"
echo "project_name=$project_name" >> "$GITHUB_OUTPUT"
echo "timestamp_utc=$timestamp_utc" >> "$GITHUB_OUTPUT"
echo "version_code=$version_code" >> "$GITHUB_OUTPUT"
- 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: Rename release AAB for upload
if: github.event_name == 'release'
id: release_aab
shell: bash
run: |
set -euxo pipefail
aab="$(find app/build/outputs/bundle/release -name '*.aab' | head -n 1)"
renamed="app/build/outputs/bundle/release/${{ steps.build_meta.outputs.project_name }}-${{ steps.build_meta.outputs.timestamp_utc }}-release.aab"
mv -f "$aab" "$renamed"
echo "Renamed release AAB to $(basename "$renamed")"
echo "path=$renamed" >> "$GITHUB_OUTPUT"
- 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="${{ steps.release_aab.outputs.path }}"
curl --fail-with-body \
--request POST \
--header "Authorization: token ***" \
--form "attachment=@${aab}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=$(basename "$aab")"

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -5,43 +7,57 @@ plugins {
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 = 14
versionName = "1.0.9"
targetSdk = 36
versionCode = 18
versionName = "1.0.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
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 {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "DEBUG_AD_FREE", "false")
}
}
compileOptions {
@@ -53,6 +69,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "2.1.0"
@@ -83,7 +100,6 @@ 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:8.0.0")

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
@@ -172,7 +174,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!BuildConfig.DEBUG_AD_FREE) {
MobileAds.initialize(this) {}
}
setContent {
MaterialTheme(colorScheme = MindMachineColors) {
App(vm, billingManager)
@@ -227,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
@@ -342,11 +346,13 @@ fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindm
}
}
if (!BuildConfig.DEBUG_AD_FREE) {
AdBanner(
enabled = !ui.settings.noAds,
modifier = Modifier.fillMaxWidth()
)
}
}
if (confirmDeleteProgramId != null) {
AlertDialog(
@@ -375,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 = {
@@ -597,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) {
@@ -642,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(),
@@ -715,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
@@ -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)

View File

@@ -187,18 +187,18 @@ class BillingManager(
.setProductList(products)
.build()
billingClient.queryProductDetailsAsync(params) { result, queryResult ->
log("queryProductDetailsAsync($productType) result: ${result.responseCode} ${result.debugMessage}")
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 }}")
log("Fetched $productType products: ${detailsList?.map { it.productId } ?: emptyList()}")
if (queryResult.unfetchedProductList.isNotEmpty()) {
logError("Unfetched $productType products: ${queryResult.unfetchedProductList}")
}
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = _productDetails.value + detailsList.associateBy { it.productId }
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
_productDetails.value = _productDetails.value + (detailsList?.associateBy { it.productId } ?: emptyMap())
log("Product details cache now has: ${_productDetails.value.keys}")
}
}

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

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

@@ -425,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

@@ -1,5 +1,5 @@
plugins {
id("com.android.application") version "8.5.2" 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