Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions app/src/main/java/com/cashpilot/android/service/AppDetector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ class AppDetector(private val context: Context) {
.filter { isInstalled(it.packageName) }
.map { detect(it) }

/** Check whether a package is installed on this device. */
fun isInstalled(packageName: String): Boolean =
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (_: PackageManager.NameNotFoundException) {
false
}

private fun detect(app: MonitoredApp): AppStatus {
val notificationActive = AppNotificationListener.isAppNotificationActive(app.packageName)
val lastActive = getLastActiveTime(app.packageName)
Expand All @@ -59,14 +68,6 @@ class AppDetector(private val context: Context) {
)
}

private fun isInstalled(packageName: String): Boolean =
try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (_: PackageManager.NameNotFoundException) {
false
}

private fun getLastActiveTime(packageName: String): Long? {
val usm = usageStatsManager ?: return null
val now = System.currentTimeMillis()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -106,13 +109,17 @@ class HeartbeatService : Service() {
}

if (response.status.isSuccess()) {
_lastHeartbeat.value = System.currentTimeMillis()
_lastHeartbeatFailed.value = false
val runningCount = apps.count { it.running }
updateNotification("$runningCount/${apps.size} apps running")
} else {
_lastHeartbeatFailed.value = true
Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}")
updateNotification("Server rejected heartbeat (${response.status.value})")
}
} catch (e: Exception) {
_lastHeartbeatFailed.value = true
Log.w(TAG, "Heartbeat failed: ${e.message}")
updateNotification("Heartbeat failed — retrying...")
}
Expand Down Expand Up @@ -162,5 +169,13 @@ class HeartbeatService : Service() {
private const val TAG = "HeartbeatService"
private const val CHANNEL_ID = "cashpilot_agent"
private const val NOTIFICATION_ID = 1

/** Timestamp of last successful heartbeat (0 = never). */
private val _lastHeartbeat = MutableStateFlow(0L)
val lastHeartbeat: StateFlow<Long> = _lastHeartbeat.asStateFlow()

/** Whether the last heartbeat attempt failed. */
private val _lastHeartbeatFailed = MutableStateFlow(false)
val lastHeartbeatFailed: StateFlow<Boolean> = _lastHeartbeatFailed.asStateFlow()
}
}
12 changes: 10 additions & 2 deletions app/src/main/java/com/cashpilot/android/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -35,8 +39,12 @@ class MainActivity : ComponentActivity() {
startService(serviceIntent)
}

// Initial scan
viewModel.refreshStatuses()
// Refresh statuses every time the activity resumes (e.g. returning from permission screens)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.refreshStatuses()
}
}

setContent {
CashPilotTheme {
Expand Down
132 changes: 123 additions & 9 deletions app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,46 @@
package com.cashpilot.android.ui

import android.app.Application
import android.app.usage.UsageStatsManager
import android.content.ComponentName
import android.content.Context
import android.provider.Settings as SystemSettings
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.cashpilot.android.model.AppStatus
import com.cashpilot.android.model.KnownApps
import com.cashpilot.android.model.MonitoredApp
import com.cashpilot.android.model.Settings
import com.cashpilot.android.service.AppDetector
import com.cashpilot.android.service.AppNotificationListener
import com.cashpilot.android.service.HeartbeatService
import com.cashpilot.android.util.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

enum class AppState { RUNNING, STOPPED, NOT_INSTALLED, DISABLED }

data class AppDisplayInfo(
val app: MonitoredApp,
val state: AppState,
val status: AppStatus? = null,
)

data class FleetSummary(
val running: Int = 0,
val stopped: Int = 0,
val notInstalled: Int = 0,
val disabled: Int = 0,
val totalTx: Long = 0,
val totalRx: Long = 0,
)

class MainViewModel(application: Application) : AndroidViewModel(application) {

Expand All @@ -21,12 +49,82 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val settings: StateFlow<Settings> = SettingsStore.settings(application)
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())

private val _appStatuses = MutableStateFlow<List<AppStatus>>(emptyList())
val appStatuses: StateFlow<List<AppStatus>> = _appStatuses.asStateFlow()
private val _apps = MutableStateFlow<List<AppDisplayInfo>>(emptyList())
val apps: StateFlow<List<AppDisplayInfo>> = _apps.asStateFlow()

private val _summary = MutableStateFlow(FleetSummary())
val summary: StateFlow<FleetSummary> = _summary.asStateFlow()

private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()

private val _hasNotificationAccess = MutableStateFlow(false)
val hasNotificationAccess: StateFlow<Boolean> = _hasNotificationAccess.asStateFlow()

private val _hasUsageAccess = MutableStateFlow(false)
val hasUsageAccess: StateFlow<Boolean> = _hasUsageAccess.asStateFlow()

val lastHeartbeat: StateFlow<Long> = HeartbeatService.lastHeartbeat
val lastHeartbeatFailed: StateFlow<Boolean> = HeartbeatService.lastHeartbeatFailed

private var refreshJob: Job? = null

init {
checkPermissions()
}

fun refreshStatuses() {
val enabled = settings.value.enabledSlugs
_appStatuses.value = detector.detectAll(enabled)
refreshJob?.cancel()
refreshJob = viewModelScope.launch { doRefresh() }
}

fun toggleApp(slug: String) {
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
SettingsStore.update(getApplication()) { s ->
val new = s.enabledSlugs.toMutableSet()
if (slug in new) new.remove(slug) else new.add(slug)
s.copy(enabledSlugs = new)
}
doRefresh()
}
Comment on lines +81 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine SettingsStore.kt
fd -t f 'SettingsStore.kt' -x cat -n {}

Repository: GeiserX/CashPilot-android

Length of output: 2580


🏁 Script executed:

# Find and examine MainViewModel.kt to verify the full context
fd -t f 'MainViewModel.kt' -x cat -n {}

Repository: GeiserX/CashPilot-android

Length of output: 6888


🏁 Script executed:

# Check DataStore implementation details and behavior
rg -t kotlin 'DataStore|StateFlow' -A 3 -B 1 | head -100

Repository: GeiserX/CashPilot-android

Length of output: 7209


🏁 Script executed:

# Check for other similar patterns where doRefresh or similar operations read settings.value after updates
rg -t kotlin 'SettingsStore\.update|settings\.value' -B 3 -A 3

Repository: GeiserX/CashPilot-android

Length of output: 2142


Pass the freshly computed enabled set into doRefresh() to avoid stale reads.

Line 89 calls doRefresh() immediately after SettingsStore.update(...) completes, but line 96 re-reads settings.value.enabledSlugs from a StateFlow backed by DataStore. Although the write is persisted when update() returns, the StateFlow may not have emitted the updated value yet. The toggled app can remain in its previous enabled/disabled state until the StateFlow catches up. Capture the computed slug set before storing and pass it to doRefresh(...) to ensure immediate consistency.

💡 Example fix
 fun toggleApp(slug: String) {
     viewModelScope.launch {
+        var enabledAfterToggle: Set<String> = emptySet()
         SettingsStore.update(getApplication()) { s ->
             val new = s.enabledSlugs.toMutableSet()
             if (slug in new) new.remove(slug) else new.add(slug)
-            s.copy(enabledSlugs = new)
+            enabledAfterToggle = new.toSet()
+            s.copy(enabledSlugs = enabledAfterToggle)
         }
-        // withContext(IO) yields to main, letting the settings StateFlow update
-        doRefresh()
+        doRefresh(enabledAfterToggle)
     }
 }
 
-private suspend fun doRefresh() {
+private suspend fun doRefresh(enabled: Set<String> = settings.value.enabledSlugs) {
     _isRefreshing.value = true
     withContext(Dispatchers.IO) {
-        val enabled = settings.value.enabledSlugs
         val detected = detector.detectAll(enabled).associateBy { it.slug }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt` around lines 73
- 82, In toggleApp, avoid reading the possibly-stale settings StateFlow after
SettingsStore.update by computing the new enabled set locally and passing it
into doRefresh; specifically, inside toggleApp compute val newSet =
s.enabledSlugs.toMutableSet(); apply the add/remove logic on newSet, call
SettingsStore.update(...) to persist the change using s.copy(enabledSlugs =
newSet), and then call doRefresh(newSet) (or add an overload/optional parameter
to doRefresh) so doRefresh uses the freshly computed set instead of
settings.value.enabledSlugs.

}

private suspend fun doRefresh() {
_isRefreshing.value = true
withContext(Dispatchers.IO) {
val enabled = settings.value.enabledSlugs
val detected = detector.detectAll(enabled).associateBy { it.slug }

val displayList = KnownApps.all.map { app ->
val installed = detector.isInstalled(app.packageName)
val isEnabled = app.slug in enabled
val status = detected[app.slug]

val state = when {
!installed -> AppState.NOT_INSTALLED
!isEnabled -> AppState.DISABLED
status?.running == true -> AppState.RUNNING
else -> AppState.STOPPED
}

AppDisplayInfo(app = app, state = state, status = status)
}

val sorted = displayList.sortedBy { it.state.ordinal }

_apps.value = sorted
_summary.value = FleetSummary(
running = sorted.count { it.state == AppState.RUNNING },
stopped = sorted.count { it.state == AppState.STOPPED },
notInstalled = sorted.count { it.state == AppState.NOT_INSTALLED },
disabled = sorted.count { it.state == AppState.DISABLED },
totalTx = sorted.mapNotNull { it.status?.netTx24h }.sum(),
totalRx = sorted.mapNotNull { it.status?.netRx24h }.sum(),
)
}
checkPermissions()
_isRefreshing.value = false
}

fun updateSettings(transform: (Settings) -> Settings) {
Expand All @@ -35,11 +133,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}

fun toggleApp(slug: String) {
updateSettings { s ->
val new = s.enabledSlugs.toMutableSet()
if (slug in new) new.remove(slug) else new.add(slug)
s.copy(enabledSlugs = new)
private fun checkPermissions() {
val ctx = getApplication<Application>()

val flat = SystemSettings.Secure.getString(
ctx.contentResolver,
"enabled_notification_listeners",
) ?: ""
val myComponent = ComponentName(ctx, AppNotificationListener::class.java).flattenToString()
_hasNotificationAccess.value = myComponent in flat

val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
_hasUsageAccess.value = if (usm != null) {
val now = System.currentTimeMillis()
val stats = usm.queryUsageStats(
UsageStatsManager.INTERVAL_DAILY,
now - 60_000,
now,
)
stats != null && stats.isNotEmpty()
} else {
false
}
}
}
Loading
Loading