package solutions.tretter.mindmachine import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo import android.graphics.Canvas import android.graphics.Paint import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock import android.util.Log import android.view.View import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background import solutions.tretter.mindmachine.session.DurationSliderCard import solutions.tretter.mindmachine.session.FlashSequenceEditor import solutions.tretter.mindmachine.session.SetupTimelineEditor import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.ui.res.painterResource import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.layout.ContentScale import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.google.android.gms.ads.MobileAds import solutions.tretter.mindmachine.audio.BinauralAudioEngine import solutions.tretter.mindmachine.audio.HeadsetMonitor import solutions.tretter.mindmachine.ads.AdBanner import solutions.tretter.mindmachine.ads.rememberInterstitialController import solutions.tretter.mindmachine.data.SettingsRepository import solutions.tretter.mindmachine.data.UserProgramRepository import solutions.tretter.mindmachine.domain.RuntimeState import solutions.tretter.mindmachine.session.FlashColor import solutions.tretter.mindmachine.session.MainViewModel import solutions.tretter.mindmachine.session.SplitFlashFrame import kotlinx.coroutines.launch import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs import solutions.tretter.mindmachine.session.formatDuration import solutions.tretter.mindmachine.session.sessionProgressFraction import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen import kotlinx.coroutines.delay import kotlin.math.roundToInt import kotlin.math.roundToLong import java.util.Locale private const val SESSION_ENTRY_HOME = "home" private const val SESSION_ENTRY_SETUP = "setup" private val MindMachineColors = darkColorScheme( primary = Color(0xFF8AB4FF), onPrimary = Color(0xFF001B3D), primaryContainer = Color(0xFF004689), onPrimaryContainer = Color(0xFFD7E3FF), secondary = Color(0xFFFFD166), onSecondary = Color(0xFF2E2300), background = Color(0xFF05070D), onBackground = Color(0xFFF3F6FF), surface = Color(0xFF121621), onSurface = Color(0xFFF3F6FF), error = Color(0xFFFF6B6B), onError = Color(0xFF3A0002) ) class MainActivity : ComponentActivity() { private val settingsRepo by lazy { SettingsRepository(applicationContext) } private val billingManager by lazy { solutions.tretter.mindmachine.billing.BillingManager( applicationContext, onNoAdsEntitlementChanged = { hasNoAds -> lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) } }, ).also { it.start() } } private val vm: MainViewModel by viewModels { MainViewModel.Factory( settingsRepo, UserProgramRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine(), billingManager, ) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) MobileAds.initialize(this) {} setContent { MaterialTheme(colorScheme = MindMachineColors) { App(vm, billingManager) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindmachine.billing.BillingManager) { val nav = rememberNavController() val ui by vm.ui.collectAsStateWithLifecycle() val lifecycle = LocalLifecycleOwner.current.lifecycle val activity = LocalContext.current as? Activity DisposableEffect(lifecycle, activity, ui.runtimeState) { val observer = LifecycleEventObserver { _, event -> val appActuallyBackgrounded = activity?.isChangingConfigurations != true if ( event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING && appActuallyBackgrounded ) { vm.pause("Session paused because app moved to background.") } } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } if (!ui.settingsInitialized) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) return } val startRoute = if (ui.settings.safetyAcknowledged) "home" else "safety" NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) { composable("safety") { SafetyScreen( onAck = { vm.acknowledgeSafety() nav.navigate("home") { popUpTo(0) } }, onHolder = { nav.navigate("holder") } ) } composable("home") { var confirmDeleteProgramId by remember { mutableStateOf(null) } var confirmDeleteProgramName by remember { mutableStateOf("") } val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds) Column( Modifier .fillMaxSize() .safeDrawingPadding() ) { TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { Image( painter = painterResource(R.drawable.ic_app_logo), contentDescription = "MindMachine", modifier = Modifier.size(60.dp) ) Spacer(Modifier.width(8.dp)) Text("MindMachine") } }, actions = { IconButton( onClick = { vm.createNewProgramDraft() nav.navigate("setup") } ) { Icon(Icons.Filled.Add, contentDescription = "Add") } IconButton(onClick = { nav.navigate("settings") }) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } } ) LazyColumn( Modifier .weight(1f) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") } } items(ui.presets) { p -> Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface ), modifier = Modifier.fillMaxWidth().clickable { vm.choosePreset(p.id) nav.navigate("setup") }.padding(4.dp) ) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { Text(p.name, style = MaterialTheme.typography.titleMedium) Text(p.description) Text("${formatDuration(p.defaultDurationSec)} • ${p.visualPatternType} • binaural ${formatMaxTwoDecimals(p.binauralDifferenceHz)}") } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { IconButton( onClick = { val go = { val started = vm.startSessionFromProgramSelection(p.id) if (started) { nav.navigate("active/$SESSION_ENTRY_HOME") } } if (ui.settings.noAds) { go() } else { interstitial?.show(onDone = go) ?: go() } }, modifier = Modifier.size(48.dp) ) { Icon( imageVector = Icons.Filled.PlayArrow, contentDescription = "Play ${p.name}", tint = Color(0xFF4CAF50), modifier = Modifier.size(38.dp) ) } if (p.id in ui.deletableProgramIds) { Box( modifier = Modifier .size(24.dp) .clickable { confirmDeleteProgramId = p.id confirmDeleteProgramName = p.name }, contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Filled.Delete, contentDescription = "Delete ${p.name}", tint = Color(0xFFFF5252), modifier = Modifier.fillMaxSize() ) } } } } } } } AdBanner( enabled = !ui.settings.noAds, modifier = Modifier.fillMaxWidth() ) } if (confirmDeleteProgramId != null) { AlertDialog( onDismissRequest = { confirmDeleteProgramId = null confirmDeleteProgramName = "" }, title = { Text("Delete program?") }, text = { Text("Delete \"$confirmDeleteProgramName\"?") }, confirmButton = { TextButton(onClick = { confirmDeleteProgramId?.let(vm::deleteProgram) confirmDeleteProgramId = null confirmDeleteProgramName = "" }) { Text("Delete", color = Color(0xFFFF5252)) } }, dismissButton = { TextButton(onClick = { confirmDeleteProgramId = null confirmDeleteProgramName = "" }) { Text("Cancel") } } ) } } composable("setup") { val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds) SetupScreen( vm = vm, onStart = { val go = { val started = vm.startSession() if (started) { nav.navigate("active/$SESSION_ENTRY_SETUP") } } if (ui.settings.noAds) { go() } else { interstitial?.show(onDone = go) ?: go() } }, ) } composable( route = "active/{entryPoint}", arguments = listOf(navArgument("entryPoint") { type = NavType.StringType }) ) { backStackEntry -> val entryPoint = backStackEntry.arguments?.getString("entryPoint") ?: SESSION_ENTRY_SETUP ActiveSessionScreen( vm = vm, onFinish = { nav.navigate(entryPoint) { popUpTo("active/$entryPoint") { inclusive = true } launchSingleTop = true } }, onBackToEntry = { vm.stop() nav.navigate(entryPoint) { popUpTo("active/$entryPoint") { inclusive = true } launchSingleTop = true } } ) } composable("settings") { SettingsScreen(vm, onRemoveAds = { nav.navigate("remove_ads") }) } composable("remove_ads") { RemoveAdsScreen(billingManager = billingManager, vm = vm) } composable("holder") { PositioningScreen() } } } @Composable fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) { Column( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .safeDrawingPadding() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground) Text(subtitle, color = MaterialTheme.colorScheme.onBackground) actions() } } @Composable fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { var checked by remember { mutableStateOf(false) } Column( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .safeDrawingPadding() .padding(16.dp) ) { Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Spacer(Modifier.height(12.dp)) Text( "Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nFor immersive visual sessions, keep your eyes closed. Do not stare at the screen.\n\nDo not place the phone directly on your eyes. Avoid pressure on face/eyes and ensure comfortable breathing/airflow.\n\nUse at your own risk. This app is a prototype and not a medical device.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.", color = MaterialTheme.colorScheme.onBackground ) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = checked, onCheckedChange = { checked = it }) Text("I understand the risks and will stop immediately if I feel discomfort.", color = MaterialTheme.colorScheme.onBackground) } Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") } TextButton(onClick = onHolder) { Text("Positioning Guidance") } } } @Composable fun SetupScreen( vm: MainViewModel, onStart: () -> Unit, ) { val ui by vm.ui.collectAsStateWithLifecycle() val scrollState = rememberScrollState() val containerModifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.statusBars) .windowInsetsPadding(WindowInsets.navigationBars) .then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier) .padding(horizontal = 16.dp, vertical = 12.dp) var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) } val hasUnsavedChanges = setupScreenHasUnsavedChanges( hasUnsavedTimelineChanges = ui.hasUnsavedChanges, editedProgramName = programName, savedProgramName = ui.savedProgramName, ) Column( modifier = containerModifier, verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground, ) TextField( value = programName, onValueChange = { programName = it }, label = { Text("Program name") }, modifier = Modifier.fillMaxWidth(), ) DurationSliderCard( durationSec = ui.timeline.durationSec, onDurationSecChanged = { vm.setDurationSec(it) }, ) // Integrated timeline editor (no separate screen). SetupTimelineEditor( state = ui.timeline, onStateChanged = vm::updateTimeline, onCurveGranularityChanged = vm::setCurveGranularitySec, ) FlashSequenceEditor( sequence = ui.timeline.flashSequence, onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) }, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { if (hasUnsavedChanges) { OutlinedButton( onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp) ) { Text("Save") } } else { Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") } } } if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) } } internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String = "Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s" internal fun formatMaxTwoDecimals(value: Float): String = String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.') internal fun setupScreenHasUnsavedChanges( hasUnsavedTimelineChanges: Boolean, editedProgramName: String, savedProgramName: String, ): Boolean { val nameChanged = editedProgramName.trim() != savedProgramName.trim() return hasUnsavedTimelineChanges || nameChanged } internal fun setupScreenUsesScrollableContainer(): Boolean = true @Composable fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry: () -> Unit) { val ui by vm.ui.collectAsStateWithLifecycle() if (ui.runtimeState == RuntimeState.COMPLETED) onFinish() BackHandler { onBackToEntry() } val isVisualMode = true val context = LocalContext.current DisposableEffect(isVisualMode) { val activity = context as? Activity val previous = activity?.requestedOrientation if (isVisualMode) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } onDispose { if (isVisualMode) { activity?.requestedOrientation = previous ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } } val immersiveFullscreen = shouldUseImmersiveFullscreen(isVisualMode, ui.runtimeState) var revealSystemBarsSignal by remember { mutableIntStateOf(0) } ApplyImmersiveMode( enabled = immersiveFullscreen, revealSignal = revealSystemBarsSignal, ) var showOverlay by remember(ui.runtimeState) { mutableStateOf(shouldShowActiveControlsByDefault(ui.runtimeState)) } LaunchedEffect(ui.runtimeState) { showOverlay = shouldShowActiveControlsByDefault(ui.runtimeState) } var brightnessOverlayVisible by remember { mutableStateOf(false) } var brightnessOverlayToken by remember { mutableIntStateOf(0) } val timingLogTag = "MindMachineTiming" LaunchedEffect(brightnessOverlayToken) { if (!brightnessOverlayVisible) return@LaunchedEffect delay(2500) brightnessOverlayVisible = false } LaunchedEffect(ui.runtimeState) { when (ui.runtimeState) { RuntimeState.COUNTDOWN -> Log.d(timingLogTag, "COUNTDOWN_START") RuntimeState.RUNNING -> Log.d(timingLogTag, "IMMERSIVE_START") RuntimeState.STOPPED, RuntimeState.COMPLETED, RuntimeState.PAUSED, RuntimeState.INTERRUPTED, RuntimeState.ERROR -> { Log.d(timingLogTag, "IMMERSIVE_STOP state=${ui.runtimeState}") } else -> Unit } } ApplyKeepScreenOn(enabled = true) ApplyScreenBrightnessOverride( enabled = true, brightnessPercent = ui.settings.immersiveBrightnessPercent, ) ApplyPreferredRefreshRate( enabled = true, preferLowest = ui.settings.preferLowestRefreshRate, ) val progress = sessionProgressFraction( durationSec = ui.timeline.durationSec, remainingSec = ui.remainingSec, ) Box( modifier = Modifier .fillMaxSize() .background(Color.Black) .then(if (immersiveFullscreen) Modifier else Modifier.safeDrawingPadding()) .pointerInput(immersiveFullscreen) { detectTapGestures { brightnessOverlayVisible = true brightnessOverlayToken++ if (!immersiveFullscreen) { showOverlay = !showOverlay } } } ) { if (isVisualMode) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> SplitFlashRenderView(ctx).apply { setRunning(ui.runtimeState == RuntimeState.RUNNING) updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) setFlashSequence(ui.config.flashSequence) setMonochrome(ui.config.monochromeFlashMode) } }, update = { view -> view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) view.setFlashSequence(ui.config.flashSequence) view.setMonochrome(ui.config.monochromeFlashMode) view.setRunning(ui.runtimeState == RuntimeState.RUNNING) } ) } if (ui.runtimeState == RuntimeState.COUNTDOWN) { Text( "${ui.countdownSec}", modifier = Modifier.align(Alignment.Center), style = MaterialTheme.typography.displayLarge, color = Color.White ) } if (immersiveFullscreen && ui.settings.showImmersiveProgressBar) { LinearProgressIndicator( progress = { progress }, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .height(5.dp), trackColor = Color.White.copy(alpha = 0.2f), color = Color.White, ) } if (brightnessOverlayVisible) { Column( Modifier .align(Alignment.TopCenter) .padding(top = 24.dp) .fillMaxWidth(0.45f) .background(Color.Black.copy(alpha = 0.72f)) .padding(horizontal = 12.dp, vertical = 10.dp) ) { Text( text = "Brightness ${ui.settings.immersiveBrightnessPercent}%", color = Color.White, style = MaterialTheme.typography.bodyMedium, ) Slider( value = ui.settings.immersiveBrightnessPercent.toFloat(), onValueChange = { vm.updateImmersiveBrightnessPercent(it.roundToInt()) brightnessOverlayVisible = true brightnessOverlayToken++ }, valueRange = 5f..100f, ) } } if (showOverlay) { Column( Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .background(Color.Black.copy(alpha = 0.72f)) .padding(16.dp) ) { Text("${ui.selectedPreset.name} • ${ui.remainingSec}s", color = Color.White) if (ui.runtimeState == RuntimeState.RUNNING) { Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") } } else { Button(onClick = { vm.resume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume") } } OutlinedButton( onClick = { vm.stop() onFinish() }, modifier = Modifier.fillMaxWidth() ) { Text("Stop") } if (ui.interruptionReason != null) { Text(ui.interruptionReason!!, color = Color.White) if (ui.runtimeState == RuntimeState.INTERRUPTED) { OutlinedButton(onClick = { vm.switchToVisualOnlyAndResume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume Visual-only") } } } } } } } @Composable private fun ApplyKeepScreenOn(enabled: Boolean) { val activity = LocalContext.current as? Activity ?: return val window = activity.window DisposableEffect(window, enabled) { if (enabled) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } onDispose { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } } @Composable private fun ApplyScreenBrightnessOverride(enabled: Boolean, brightnessPercent: Int) { val activity = LocalContext.current as? Activity ?: return val window = activity.window DisposableEffect(window) { onDispose { val reset = window.attributes reset.screenBrightness = -1f window.attributes = reset } } LaunchedEffect(enabled, brightnessPercent) { val params = window.attributes params.screenBrightness = if (enabled) { (brightnessPercent.coerceIn(5, 100) / 100f) } else { -1f } window.attributes = params } } @Composable private fun ApplyPreferredRefreshRate(enabled: Boolean, preferLowest: Boolean) { val activity = LocalContext.current as? Activity ?: return val window = activity.window val timingLogTag = "MindMachineTiming" DisposableEffect(window) { onDispose { val params = window.attributes params.preferredRefreshRate = 0f params.preferredDisplayModeId = 0 window.attributes = params } } LaunchedEffect(enabled, preferLowest) { val params = window.attributes if (enabled && preferLowest) { val display = activity.display val chosenMode = display?.supportedModes?.minByOrNull { it.refreshRate } params.preferredDisplayModeId = chosenMode?.modeId ?: 0 params.preferredRefreshRate = chosenMode?.refreshRate ?: 0f Log.d( timingLogTag, "DISPLAY_MODE_REQUEST modeId=${chosenMode?.modeId ?: 0} refreshHz=${chosenMode?.refreshRate ?: 0f} supported=${display?.supportedModes?.joinToString { "${it.modeId}:${it.refreshRate}" }}" ) } else { params.preferredDisplayModeId = 0 params.preferredRefreshRate = 0f } window.attributes = params } } @Composable private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) { val context = LocalContext.current val activity = context as? Activity ?: return val window = activity.window val controller = remember(window) { WindowInsetsControllerCompat(window, window.decorView) } DisposableEffect(window) { onDispose { controller.show(WindowInsetsCompat.Type.systemBars()) WindowCompat.setDecorFitsSystemWindows(window, true) } } LaunchedEffect(enabled) { if (enabled) { WindowCompat.setDecorFitsSystemWindows(window, false) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE controller.hide(WindowInsetsCompat.Type.systemBars()) } else { controller.show(WindowInsetsCompat.Type.systemBars()) WindowCompat.setDecorFitsSystemWindows(window, true) } } LaunchedEffect(revealSignal, enabled) { if (!enabled || revealSignal == 0) return@LaunchedEffect controller.show(WindowInsetsCompat.Type.systemBars()) delay(2500) if (enabled) { controller.hide(WindowInsetsCompat.Type.systemBars()) } } } private class SplitFlashRenderView(context: Context) : View(context) { private val timingLogTag = "MindMachineTiming" private val handler = Handler(Looper.getMainLooper()) private val leftPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val rightPaint = Paint(Paint.ANTI_ALIAS_FLAG) private var isRunning = false private var phase = 1 // start black private var rawOnMs = 167L private var rawOffMs = 167L private var onMs = 167L private var offMs = 167L private var monochrome = false private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence() private var started = false private var startUptimeMs = 0L private var nextToggleUptimeMs = 0L private var toggleCount = 0L private var lateToggleCount = 0L private var maxLatenessMs = 0L private val toggleRunnable = object : Runnable { override fun run() { if (!isRunning) return val now = SystemClock.uptimeMillis() val latenessMs = (now - nextToggleUptimeMs).coerceAtLeast(0L) val previousPhase = phase if (latenessMs > 0L) { lateToggleCount += 1 maxLatenessMs = maxOf(maxLatenessMs, latenessMs) } phase = (phase + 1) % phaseCount() toggleCount += 1 invalidate() val nextDuration = if (isVisiblePhase(phase)) onMs else offMs nextToggleUptimeMs += nextDuration val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L if (shouldLogToggle) { Log.d( timingLogTag, "toggle#$toggleCount prev=${phaseName(previousPhase)} next=${phaseName(phase)} " + "scheduledMs=${nextToggleUptimeMs - startUptimeMs - nextDuration} " + "actualMs=${now - startUptimeMs} latenessMs=$latenessMs " + "lateCount=$lateToggleCount maxLateMs=$maxLatenessMs" ) } scheduleNext() } } fun updateIntervals(flashOnMs: Int, flashOffMs: Int) { val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong() val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong() val frameMs = framePeriodMs().coerceAtLeast(1L) val newOnMs = snapToFrameMs(newRawOnMs, frameMs) val newOffMs = snapToFrameMs(newRawOffMs, frameMs) val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs if (!changed) return rawOnMs = newRawOnMs rawOffMs = newRawOffMs onMs = newOnMs offMs = newOffMs if (isRunning) { Log.d( timingLogTag, "config onMs=$rawOnMs offMs=$rawOffMs refreshHz=${"%.2f".format(refreshRate())} frameMs=$frameMs snappedOnMs=$onMs snappedOffMs=$offMs" ) } if (isRunning && started) { val now = SystemClock.uptimeMillis() val nextDuration = if (isVisiblePhase(phase)) onMs else offMs nextToggleUptimeMs = maxOf(nextToggleUptimeMs, now + 1L) val remaining = (nextToggleUptimeMs - now).coerceAtLeast(1L) if (remaining > nextDuration) { nextToggleUptimeMs = now + nextDuration } scheduleNext() } } fun setRunning(running: Boolean) { if (isRunning == running) return isRunning = running handler.removeCallbacks(toggleRunnable) if (!running) { started = false phase = 1 invalidate() return } started = true phase = 0 startUptimeMs = SystemClock.uptimeMillis() nextToggleUptimeMs = startUptimeMs + onMs toggleCount = 0L lateToggleCount = 0L maxLatenessMs = 0L Log.d( timingLogTag, "session-start phase=${phaseName(phase)} modeId=${display?.mode?.modeId ?: 0} refreshHz=${"%.2f".format(refreshRate())} frameMs=${framePeriodMs()} nowMs=$startUptimeMs" ) invalidate() scheduleNext() } fun setMonochrome(enabled: Boolean) { if (monochrome == enabled) return monochrome = enabled invalidate() } fun setFlashSequence(sequence: List) { val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() } if (flashSequence == normalized) return flashSequence = normalized if (started) { phase %= phaseCount() } invalidate() } override fun onDetachedFromWindow() { handler.removeCallbacks(toggleRunnable) super.onDetachedFromWindow() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val mid = width / 2f val (leftColor, rightColor) = colorsForPhase(phase) leftPaint.color = leftColor rightPaint.color = rightColor canvas.drawRect(0f, 0f, mid, height.toFloat(), leftPaint) canvas.drawRect(mid, 0f, width.toFloat(), height.toFloat(), rightPaint) } private fun scheduleNext() { handler.removeCallbacks(toggleRunnable) handler.postAtTime(toggleRunnable, nextToggleUptimeMs) } private fun colorsForPhase(phase: Int): Pair { if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK) if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) val row = flashSequence[(phase / 2).mod(flashSequence.size)] val left = paletteColor(row.leftColor) val right = if (row.splitScreen) paletteColor(row.rightColor) else left return Pair(left, right) } private fun phaseName(phase: Int): String { if (!isVisiblePhase(phase)) return "BLACK" val row = flashSequence[(phase / 2).mod(flashSequence.size)] return if (row.splitScreen) { "${row.leftColor.name}_${row.rightColor.name}" } else { row.leftColor.name } } private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0 private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2 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 solutions.tretter.mindmachine.session.FlashPaletteColor.BLUE -> android.graphics.Color.BLUE } private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f private fun framePeriodMs(): Long = (1000f / refreshRate()).roundToLong().coerceAtLeast(1L) private fun snapToFrameMs(valueMs: Long, frameMs: Long): Long = ((valueMs + frameMs / 2) / frameMs).coerceAtLeast(1L) * frameMs } private fun FlashColor.toComposeColor(): Color = when (this) { FlashColor.RED -> Color.Red FlashColor.GREEN -> Color.Green FlashColor.BLACK -> Color.Black } @Composable private fun rememberSplitFlashFrame( isRunning: Boolean, flashOnMs: Int, flashOffMs: Int, ): androidx.compose.runtime.State { val latestFlashOnMs = rememberUpdatedState(flashOnMs) val latestFlashOffMs = rememberUpdatedState(flashOffMs) // The screen can only change on vsync boundaries. If the configured interval is not a // multiple of the refresh period, the *visual* interval will wobble by ~1 frame. // Snapping intervals to whole frames makes the blink look uniform. val refreshRateHz = LocalView.current.display?.refreshRate ?: 60f val framePeriodNanos = remember(refreshRateHz) { (1_000_000_000.0 / refreshRateHz.coerceAtLeast(1f)).roundToLong().coerceAtLeast(1L) } val timingLogTag = "MindMachineTiming" return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, framePeriodNanos) { if (!isRunning) { value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK) return@produceState } // Drive the blink schedule from the frame clock, but keep phase boundaries aligned // to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady // even if some frames are late. // // Key idea: instead of "advance by whatever elapsed since last frame", we keep an // explicit nextToggleTime and only advance the phase when now >= nextToggleTime. var started = false var phase = 0 // 0=RG, 1=BK, 2=GR, 3=BK var nextToggleNanos = 0L var onNanos = 0L var offNanos = 0L var lastLoggedFlashOnMs = -1 var lastLoggedFlashOffMs = -1 var toggleCount = 0L var lateToggleCount = 0L var maxLatenessNanos = 0L var lastToggleAtNanos = 0L var sessionStartNanos = 0L fun phaseName(p: Int): String = when (p) { 0 -> "RED_GREEN" 1 -> "BLACK" 2 -> "GREEN_RED" else -> "BLACK" } fun frameForPhase(p: Int): SplitFlashFrame = when (p) { 0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN) 1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) 2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED) else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK) } fun updateDurations() { val rawOn = latestFlashOnMs.value.coerceIn(50, 2000).toLong() * 1_000_000L val rawOff = latestFlashOffMs.value.coerceIn(50, 2000).toLong() * 1_000_000L val onFrames = ((rawOn + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L) val offFrames = ((rawOff + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L) onNanos = onFrames * framePeriodNanos offNanos = offFrames * framePeriodNanos val currentFlashOnMs = latestFlashOnMs.value.coerceIn(50, 2000) val currentFlashOffMs = latestFlashOffMs.value.coerceIn(50, 2000) if (currentFlashOnMs != lastLoggedFlashOnMs || currentFlashOffMs != lastLoggedFlashOffMs) { Log.d( timingLogTag, "config onMs=$currentFlashOnMs offMs=$currentFlashOffMs refreshHz=${"%.2f".format(refreshRateHz)} " + "frameMs=${"%.3f".format(framePeriodNanos / 1_000_000.0)} snappedOnMs=${"%.3f".format(onNanos / 1_000_000.0)} " + "snappedOffMs=${"%.3f".format(offNanos / 1_000_000.0)}" ) lastLoggedFlashOnMs = currentFlashOnMs lastLoggedFlashOffMs = currentFlashOffMs } } while (true) { withFrameNanos { now -> if (!started) { updateDurations() phase = 0 nextToggleNanos = now + onNanos value = frameForPhase(phase) started = true sessionStartNanos = now lastToggleAtNanos = now Log.d( timingLogTag, "session-start phase=${phaseName(phase)} refreshHz=${"%.2f".format(refreshRateHz)} " + "frameMs=${"%.3f".format(framePeriodNanos / 1_000_000.0)} nowNs=$now" ) return@withFrameNanos } updateDurations() // Catch up if we missed one or more phase boundaries due to frame delays. while (now >= nextToggleNanos) { val scheduledToggleNanos = nextToggleNanos val previousPhase = phase phase = (phase + 1) % 4 val latenessNanos = now - scheduledToggleNanos val actualDeltaNanos = now - lastToggleAtNanos val dur = if (phase == 0 || phase == 2) onNanos else offNanos nextToggleNanos += dur toggleCount += 1 lastToggleAtNanos = now if (latenessNanos > 0L) { lateToggleCount += 1 if (latenessNanos > maxLatenessNanos) { maxLatenessNanos = latenessNanos } } val shouldLogToggle = toggleCount <= 20 || latenessNanos > framePeriodNanos || toggleCount % 60L == 0L if (shouldLogToggle) { Log.d( timingLogTag, "toggle#$toggleCount prev=${phaseName(previousPhase)} next=${phaseName(phase)} " + "scheduledMs=${"%.3f".format((scheduledToggleNanos - sessionStartNanos) / 1_000_000.0)} " + "actualMs=${"%.3f".format((now - sessionStartNanos) / 1_000_000.0)} " + "actualDeltaMs=${"%.3f".format(actualDeltaNanos / 1_000_000.0)} " + "latenessMs=${"%.3f".format(latenessNanos / 1_000_000.0)} " + "lateCount=$lateToggleCount maxLateMs=${"%.3f".format(maxLatenessNanos / 1_000_000.0)}" ) } } value = frameForPhase(phase) } } } } @Composable fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) { val ui by vm.ui.collectAsStateWithLifecycle() val scrollState = rememberScrollState() Column( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .safeDrawingPadding() .verticalScroll(scrollState) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Text("Countdown: ${ui.settings.countdownSeconds}s", color = MaterialTheme.colorScheme.onBackground) Slider( value = ui.settings.countdownSeconds.toFloat(), onValueChange = { vm.updateCountdownSeconds(it.roundToInt()) }, valueRange = 0f..10f, steps = 9, ) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = ui.settings.forceAudioWithoutHeadphones, onCheckedChange = vm::updateForceAudioWithoutHeadphones, ) Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = ui.settings.showImmersiveProgressBar, onCheckedChange = vm::updateShowImmersiveProgressBar) Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground) } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate) Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground) } Button(onClick = onRemoveAds) { Text(if (ui.settings.noAds) "Ads removed" else "Remove ads") } HorizontalDivider() Text( "About/Disclaimer: MindMachine is a prototype and not a medical device. Use at your own risk. For immersive visual sessions, keep your eyes closed.", color = MaterialTheme.colorScheme.onBackground ) } } @Composable fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.BillingManager, vm: MainViewModel) { val ui by vm.ui.collectAsStateWithLifecycle() val activity = LocalContext.current as? Activity val details by billingManager.productDetails.collectAsStateWithLifecycle() val subDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY] val subPrice = subDetails ?.subscriptionOfferDetails ?.firstOrNull() ?.pricingPhases ?.pricingPhaseList ?.firstOrNull() ?.formattedPrice val inAppDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER] val inAppPrice = inAppDetails?.oneTimePurchaseOfferDetails?.formattedPrice Column( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .safeDrawingPadding() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text("Remove Ads", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Text( if (ui.settings.noAds) "You already have ads removed." else "MindMachine is free with ads. You can remove ads via subscription or one-time purchase.", color = MaterialTheme.colorScheme.onBackground ) Button( enabled = activity != null && !ui.settings.noAds, onClick = { activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) } } ) { Text("Subscribe: ${subPrice ?: "$0.99"}/month") } Button( enabled = activity != null && !ui.settings.noAds, onClick = { activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) } } ) { Text("Buy: ${inAppPrice ?: "$19.99"} one-time") } TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") } Text( "Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds won’t complete real billing flows.", color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) ) } } @Composable fun PositioningScreen() { val scrollState = rememberScrollState() Column( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .safeDrawingPadding() .verticalScroll(scrollState) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text("Positioning Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) Image( painter = painterResource(R.drawable.positioning_guide), contentDescription = "Positioning guide", contentScale = ContentScale.Fit, modifier = Modifier .fillMaxWidth() .height(260.dp) .padding(vertical = 6.dp) ) Text("• Lie on your back in a safe, comfortable place.", color = MaterialTheme.colorScheme.onBackground) Text("• Rest the phone across the upper nose/forehead area so it stays stable.", color = MaterialTheme.colorScheme.onBackground) Text("• Keep your eyes closed during immersive visual sessions.", color = MaterialTheme.colorScheme.onBackground) Text("Warnings", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground) Text("• Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.", color = MaterialTheme.colorScheme.onBackground) Text("• Do not stare at the screen, for immersive visual sessions, keep your eyes closed.", color = MaterialTheme.colorScheme.onBackground) Text("• Stop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.", color = MaterialTheme.colorScheme.onBackground) Spacer(Modifier.height(12.dp)) Text("Disclaimer", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground) Text( "MindMachine is provided \"as is\" for informational and experimental purposes only, without warranties of any kind. You assume all risks of use. MindMachine is not a medical device and does not provide medical advice. To the maximum extent permitted by law, the developers and distributors disclaim all liability for any claims, damages, losses, or injuries arising from or related to use of the app.", color = MaterialTheme.colorScheme.onBackground ) } }