Skip to content

Commit 3971a71

Browse files
committed
Fix paste race, privacy, and detection false positives
- Sync DataStore fields independently to prevent partial writes from clobbering the other field during paste - Only fetch public IP after server is configured (no third-party requests before setup completes) - Narrow network activity window from 24h to 2h for running detection to avoid stale false positives
1 parent 2a30a3a commit 3971a71

4 files changed

Lines changed: 37 additions & 21 deletions

File tree

app/src/main/java/com/cashpilot/android/service/AppDetector.kt

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,25 @@ class AppDetector(private val context: Context) {
4646
private fun detect(app: MonitoredApp): AppStatus {
4747
val notificationActive = AppNotificationListener.isAppNotificationActive(app.packageName)
4848
val lastActive = getLastActiveTime(app.packageName)
49-
val (tx, rx) = getNetworkStats(app.packageName)
49+
val (tx24h, rx24h) = getNetworkStats(app.packageName, hours = 24)
50+
// Use a 2h window for the "running" heuristic to avoid stale 24h false positives
51+
val (tx2h, rx2h) = getNetworkStats(app.packageName, hours = 2)
5052

5153
// App is "running" if:
5254
// 1. It has an active foreground notification, OR
5355
// 2. It was in foreground within the last 15 minutes, OR
54-
// 3. It has network activity in the last 24h (bandwidth apps run as background services)
56+
// 3. It has network activity in the last 2h (bandwidth apps run as background services)
5557
val recentlyActive = lastActive?.let {
5658
(System.currentTimeMillis() - it) < 15 * 60 * 1000
5759
} ?: false
58-
val hasNetworkActivity = (tx + rx) > 1024 // >1KB to filter noise
60+
val hasRecentNetworkActivity = (tx2h + rx2h) > 1024 // >1KB in last 2h
5961

6062
return AppStatus(
6163
slug = app.slug,
62-
running = notificationActive || recentlyActive || hasNetworkActivity,
64+
running = notificationActive || recentlyActive || hasRecentNetworkActivity,
6365
notificationActive = notificationActive,
64-
netTx24h = tx,
65-
netRx24h = rx,
66+
netTx24h = tx24h,
67+
netRx24h = rx24h,
6668
lastActive = lastActive?.let {
6769
Instant.ofEpochMilli(it)
6870
.atOffset(ZoneOffset.UTC)
@@ -87,7 +89,7 @@ class AppDetector(private val context: Context) {
8789
}
8890

8991
@Suppress("DEPRECATION")
90-
private fun getNetworkStats(packageName: String): Pair<Long, Long> {
92+
private fun getNetworkStats(packageName: String, hours: Int = 24): Pair<Long, Long> {
9193
val nsm = networkStatsManager ?: return 0L to 0L
9294
val uid = try {
9395
packageManager.getApplicationInfo(packageName, 0).uid
@@ -96,7 +98,7 @@ class AppDetector(private val context: Context) {
9698
}
9799

98100
val now = System.currentTimeMillis()
99-
val dayAgo = now - 24 * 60 * 60 * 1000
101+
val start = now - hours.toLong() * 60 * 60 * 1000
100102
var tx = 0L
101103
var rx = 0L
102104

@@ -106,9 +108,8 @@ class AppDetector(private val context: Context) {
106108
ConnectivityManager.TYPE_MOBILE,
107109
)) {
108110
try {
109-
val bucket = nsm.querySummaryForDevice(networkType, null, dayAgo, now)
110111
// querySummary gives per-app breakdowns
111-
val summary = nsm.querySummary(networkType, null, dayAgo, now)
112+
val summary = nsm.querySummary(networkType, null, start, now)
112113
val b = android.app.usage.NetworkStats.Bucket()
113114
while (summary.hasNextBucket()) {
114115
summary.getNextBucket(b)

app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
8080

8181
init {
8282
checkPermissions()
83-
fetchPublicIp()
8483
}
8584

8685
fun refreshStatuses() {
@@ -135,6 +134,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
135134
totalRx = result.mapNotNull { it.status?.netRx24h }.sum(),
136135
)
137136
checkPermissions()
137+
// Only fetch public IP once the server is configured (post-setup)
138+
if (_publicIp.value == null && settings.value.serverUrl.isNotBlank()) {
139+
fetchPublicIp()
140+
}
138141
_isRefreshing.value = false
139142
}
140143

app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,19 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) {
5050
var localUrl by rememberSaveable { mutableStateOf("") }
5151
var localKey by rememberSaveable { mutableStateOf("") }
5252

53-
// Sync once when DataStore loads real values (won't re-trigger after)
54-
var synced by rememberSaveable { mutableStateOf(false) }
55-
LaunchedEffect(settings.serverUrl, settings.apiKey) {
56-
if (!synced && (settings.serverUrl.isNotEmpty() || settings.apiKey.isNotEmpty())) {
53+
// Sync each field independently so a partial DataStore write doesn't clobber the other
54+
var urlSynced by rememberSaveable { mutableStateOf(false) }
55+
var keySynced by rememberSaveable { mutableStateOf(false) }
56+
LaunchedEffect(settings.serverUrl) {
57+
if (!urlSynced && settings.serverUrl.isNotEmpty()) {
5758
localUrl = settings.serverUrl
59+
urlSynced = true
60+
}
61+
}
62+
LaunchedEffect(settings.apiKey) {
63+
if (!keySynced && settings.apiKey.isNotEmpty()) {
5864
localKey = settings.apiKey
59-
synced = true
65+
keySynced = true
6066
}
6167
}
6268

app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,19 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) {
5757
var localUrl by rememberSaveable { mutableStateOf("") }
5858
var localKey by rememberSaveable { mutableStateOf("") }
5959

60-
// Sync once when DataStore loads real values (won't re-trigger after)
61-
var synced by rememberSaveable { mutableStateOf(false) }
62-
LaunchedEffect(settings.serverUrl, settings.apiKey) {
63-
if (!synced && (settings.serverUrl.isNotEmpty() || settings.apiKey.isNotEmpty())) {
60+
// Sync each field independently so a partial DataStore write doesn't clobber the other
61+
var urlSynced by rememberSaveable { mutableStateOf(false) }
62+
var keySynced by rememberSaveable { mutableStateOf(false) }
63+
LaunchedEffect(settings.serverUrl) {
64+
if (!urlSynced && settings.serverUrl.isNotEmpty()) {
6465
localUrl = settings.serverUrl
66+
urlSynced = true
67+
}
68+
}
69+
LaunchedEffect(settings.apiKey) {
70+
if (!keySynced && settings.apiKey.isNotEmpty()) {
6571
localKey = settings.apiKey
66-
synced = true
72+
keySynced = true
6773
}
6874
}
6975

0 commit comments

Comments
 (0)