From a24a5d26c525a1e2c408d54009fdbc03e0b9fa55 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:52:00 +0200 Subject: [PATCH 1/2] Add battery optimization as Step 4 in setup wizard Detects whether battery optimization is disabled via PowerManager and shows it as a required step before continuing to dashboard. --- .../com/cashpilot/android/ui/MainViewModel.kt | 7 +++++ .../android/ui/screen/SetupScreen.kt | 28 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt index 52a13a2..59cb37e 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -2,6 +2,7 @@ package com.cashpilot.android.ui import android.app.AppOpsManager import android.app.Application +import android.os.PowerManager import android.content.ComponentName import android.content.Context import android.os.Build @@ -74,6 +75,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _hasUsageAccess = MutableStateFlow(false) val hasUsageAccess: StateFlow = _hasUsageAccess.asStateFlow() + private val _hasBatteryOptOut = MutableStateFlow(false) + val hasBatteryOptOut: StateFlow = _hasBatteryOptOut.asStateFlow() + val lastHeartbeat: StateFlow = HeartbeatService.lastHeartbeat val lastHeartbeatFailed: StateFlow = HeartbeatService.lastHeartbeatFailed @@ -225,5 +229,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } else { false } + + val pm = ctx.getSystemService(Context.POWER_SERVICE) as? PowerManager + _hasBatteryOptOut.value = pm?.isIgnoringBatteryOptimizations(ctx.packageName) ?: false } } diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt index 7d8d307..5ac0b70 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.BatteryAlert import androidx.compose.material.icons.filled.QueryStats import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -52,6 +53,7 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { val settings by viewModel.settings.collectAsState() val hasNotif by viewModel.hasNotificationAccess.collectAsState() val hasUsage by viewModel.hasUsageAccess.collectAsState() + val hasBattery by viewModel.hasBatteryOptOut.collectAsState() val context = LocalContext.current var localUrl by rememberSaveable { mutableStateOf("") } @@ -164,6 +166,23 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { } } + // Step 4: Battery optimization + SetupCard( + step = 4, + icon = Icons.Default.BatteryAlert, + title = stringResource(R.string.setup_battery_title), + description = stringResource(R.string.setup_battery_desc), + done = hasBattery, + ) { + TextButton( + onClick = { openBatteryOptimizationSettings(context) }, + ) { + Text(stringResource(R.string.setup_grant_access)) + Spacer(Modifier.width(4.dp)) + Icon(Icons.Default.ChevronRight, null, Modifier.size(18.dp)) + } + } + Spacer(Modifier.height(8.dp)) Button( @@ -173,7 +192,7 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { onComplete() }, modifier = Modifier.fillMaxWidth(), - enabled = serverDone && hasNotif && hasUsage, + enabled = serverDone && hasNotif && hasUsage && hasBattery, ) { Text(stringResource(R.string.setup_continue)) } @@ -263,3 +282,10 @@ private fun openUsageAccessSettings(context: Context) { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ) } + +private fun openBatteryOptimizationSettings(context: Context) { + context.startActivity( + Intent(AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34eab1f..1e0d5e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,8 @@ CashPilot reads foreground notifications to detect when passive income apps are running. No notification content is collected — only presence is checked. Usage Access Allows CashPilot to check when apps were last active and measure per-app network usage. This data stays on your device and is only sent to your own server. + Battery Optimization + Disable battery optimization so Android doesn\'t kill CashPilot\'s background service. Without this, heartbeats may stop when the screen is off. Open Settings Continue to Dashboard Skip for now From bc139ccbcc81433de16b63763d09e2d13fc28c52 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:56:12 +0200 Subject: [PATCH 2/2] Enforce battery optimization in setup gating MainActivity needsSetup now includes hasBatteryOptOut check, matching SetupScreen's Continue button requirement. --- app/src/main/java/com/cashpilot/android/ui/MainActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt index 3574e63..7c47878 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt @@ -69,11 +69,12 @@ class MainActivity : ComponentActivity() { val settings by viewModel.settings.collectAsState() val hasNotif by viewModel.hasNotificationAccess.collectAsState() val hasUsage by viewModel.hasUsageAccess.collectAsState() + val hasBattery by viewModel.hasBatteryOptOut.collectAsState() var showSettings by rememberSaveable { mutableStateOf(false) } var setupDismissed by rememberSaveable { mutableStateOf(false) } val needsSetup = !setupDismissed && - (settings.serverUrl.isBlank() || settings.apiKey.isBlank() || !hasNotif || !hasUsage) + (settings.serverUrl.isBlank() || settings.apiKey.isBlank() || !hasNotif || !hasUsage || !hasBattery) // Handle system Back from Settings → return to Dashboard BackHandler(enabled = showSettings && !needsSetup) {