From 4640491998482db320ec785608898b64ea6f8d1c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:59:35 +0200 Subject: [PATCH 1/8] Fix toggle race, IP retry, permission banner, heartbeat schema, CI Node 24 - toggleApp: pass new slugs directly to doRefresh to avoid StateFlow lag - Public IP: don't retry on failure, reset flag when config cleared - Permission banner: reset dismissal when permissions revoked - Heartbeat: send apps as top-level field instead of faking containers - CI: opt into Node 24 for GitHub Actions --- .github/workflows/ci.yml | 3 +++ .../com/cashpilot/android/model/Heartbeat.kt | 3 +++ .../android/service/HeartbeatService.kt | 16 +---------- .../com/cashpilot/android/ui/MainViewModel.kt | 27 ++++++++++++------- .../android/ui/screen/DashboardScreen.kt | 5 ++++ 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c0ee9..dd9c0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: runs-on: ubuntu-latest diff --git a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt index 508addf..12b2d08 100644 --- a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt +++ b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt @@ -11,7 +11,10 @@ import kotlinx.serialization.Serializable data class WorkerHeartbeat( val name: String, val url: String = "", + /** Docker containers (empty for Android workers). */ val containers: List = emptyList(), + /** Android app statuses (empty for Docker workers). */ + val apps: List = emptyList(), @SerialName("system_info") val systemInfo: SystemInfo = SystemInfo(), ) diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt index 6a425d6..574eae0 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -11,7 +11,6 @@ import android.provider.Settings.Secure import android.util.Log import androidx.core.app.NotificationCompat import com.cashpilot.android.R -import com.cashpilot.android.model.AppContainer import com.cashpilot.android.model.Settings import com.cashpilot.android.model.SystemInfo import com.cashpilot.android.model.WorkerHeartbeat @@ -77,27 +76,14 @@ class HeartbeatService : Service() { try { val apps = detector.detectAll(settings.enabledSlugs) - // Map app statuses to the server's container-like format - val containers = apps.map { app -> - AppContainer( - name = app.slug, - status = if (app.running) "running" else "stopped", - labels = mapOf( - "cashpilot.managed" to "true", - "cashpilot.service" to app.slug, - ), - ) - } - val heartbeat = WorkerHeartbeat( name = "${Build.MANUFACTURER} ${Build.MODEL} (${deviceId()})", - containers = containers, + apps = apps, systemInfo = SystemInfo( os = "Android", arch = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown", osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", deviceType = "android", - apps = apps, ), ) 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 8441e93..b54de9b 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -75,6 +75,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _publicIp = MutableStateFlow(null) val publicIp: StateFlow = _publicIp.asStateFlow() + private var publicIpFailed = false private var refreshJob: Job? = null @@ -90,19 +91,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun toggleApp(slug: String) { refreshJob?.cancel() refreshJob = viewModelScope.launch { + // Compute new slugs locally to avoid StateFlow lag after DataStore write + val currentSlugs = settings.value.enabledSlugs.toMutableSet() + if (slug in currentSlugs) currentSlugs.remove(slug) else currentSlugs.add(slug) + val newSlugs = currentSlugs.toSet() SettingsStore.update(getApplication()) { s -> - val new = s.enabledSlugs.toMutableSet() - if (slug in new) new.remove(slug) else new.add(slug) - s.copy(enabledSlugs = new) + s.copy(enabledSlugs = newSlugs) } - doRefresh() + doRefresh(enabledOverride = newSlugs) } } - private suspend fun doRefresh() { + private suspend fun doRefresh(enabledOverride: Set? = null) { _isRefreshing.value = true val result = withContext(Dispatchers.IO) { - val enabled = settings.value.enabledSlugs + val enabled = enabledOverride ?: settings.value.enabledSlugs val detected = detector.detectAll(enabled).associateBy { it.slug } val displayList = KnownApps.all.map { app -> @@ -134,12 +137,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { totalRx = result.mapNotNull { it.status?.netRx24h }.sum(), ) checkPermissions() - // Only fetch public IP when fully configured (both URL + key = setup complete) + // Only fetch public IP when fully configured, and don't retry on failure val serverReady = settings.value.serverUrl.isNotBlank() && settings.value.apiKey.isNotBlank() - if (serverReady && _publicIp.value == null) { + if (serverReady && _publicIp.value == null && !publicIpFailed) { fetchPublicIp() } else if (!serverReady) { _publicIp.value = null + publicIpFailed = false } _isRefreshing.value = false } @@ -171,13 +175,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private fun fetchPublicIp() { viewModelScope.launch { - _publicIp.value = withContext(Dispatchers.IO) { + val ip = withContext(Dispatchers.IO) { try { URL("https://api.ipify.org").readText().trim() } catch (_: Exception) { null } } + if (ip != null) { + _publicIp.value = ip + } else { + publicIpFailed = true + } } } diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt index f67f7ab..eb42ba1 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt @@ -332,6 +332,11 @@ private fun PermissionBanner(viewModel: MainViewModel) { var dismissed by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current + // Reset dismissal if permissions were revoked after user dismissed the banner + LaunchedEffect(hasNotif, hasUsage) { + if (!hasNotif || !hasUsage) dismissed = false + } + if (dismissed || (hasNotif && hasUsage)) return Card( From 31ea5587f2b196bdced4c6e22533869ea3d85f45 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:07:41 +0200 Subject: [PATCH 2/8] Address review round 4: CI, timeouts, back nav, cleartext warning, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI: build release APK on PRs too (validates minify/signing) - HeartbeatService: add 15s HTTP timeout and exponential backoff (30s → 60s → 120s → 5min max) on consecutive failures - HeartbeatService: send both containers (legacy) and apps (new) for backward-compatible server transition - MainActivity: add BackHandler so system Back returns from Settings to Dashboard instead of finishing the activity - Setup/Settings: show cleartext warning when server URL is http:// - README + fastlane: disclose api.ipify.org lookup in privacy section --- .github/workflows/ci.yml | 2 -- README.md | 4 +++ .../android/service/HeartbeatService.kt | 36 ++++++++++++++++++- .../com/cashpilot/android/ui/MainActivity.kt | 6 ++++ .../android/ui/screen/SettingsScreen.kt | 6 ++++ .../android/ui/screen/SetupScreen.kt | 4 +++ app/src/main/res/values/strings.xml | 3 ++ .../android/en-US/full_description.txt | 2 ++ 8 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd9c0d3..611e1be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,9 @@ jobs: run: ./gradlew lintDebug - name: Decode keystore - if: github.event_name == 'push' run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore - name: Build release APK - if: github.event_name == 'push' run: ./gradlew assembleRelease env: CASHPILOT_KEYSTORE_PATH: release.keystore diff --git a/README.md b/README.md index 5000992..f453ad2 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ CashPilot Android - WorkManager (future: periodic sync when app is backgrounded) - Material 3 + Dynamic Color +## Privacy + +All app status data is sent **only to your own CashPilot server**. The app makes one additional request to `api.ipify.org` to display your public IP on the dashboard — this only happens after both server URL and API key are configured. No other third-party services are contacted. + ## Requirements - Android 8.0+ (API 26) diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt index 574eae0..8be938a 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -11,12 +11,14 @@ import android.provider.Settings.Secure import android.util.Log import androidx.core.app.NotificationCompat import com.cashpilot.android.R +import com.cashpilot.android.model.AppContainer import com.cashpilot.android.model.Settings import com.cashpilot.android.model.SystemInfo import com.cashpilot.android.model.WorkerHeartbeat import com.cashpilot.android.util.SettingsStore import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.bearerAuth import io.ktor.client.request.post @@ -49,7 +51,13 @@ class HeartbeatService : Service() { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } } + private var consecutiveFailures = 0 override fun onCreate() { super.onCreate() @@ -66,7 +74,15 @@ class HeartbeatService : Service() { if (settings.serverUrl.isNotBlank() && settings.apiKey.isNotBlank()) { sendHeartbeat(settings) } - delay(settings.heartbeatIntervalSeconds * 1000L) + // Exponential backoff on consecutive failures (30s → 60s → 120s, max 5min) + val baseDelay = settings.heartbeatIntervalSeconds * 1000L + val backoff = if (consecutiveFailures > 0) { + (baseDelay * (1L shl consecutiveFailures.coerceAtMost(3))) + .coerceAtMost(300_000L) + } else { + baseDelay + } + delay(backoff) } } return START_STICKY @@ -76,8 +92,23 @@ class HeartbeatService : Service() { try { val apps = detector.detectAll(settings.enabledSlugs) + // Send apps in both formats for backward compatibility: + // - `apps` (new): rich app data for servers that understand Android workers + // - `containers` (legacy): simplified format so older servers still show the worker + val containers = apps.map { app -> + AppContainer( + name = app.slug, + status = if (app.running) "running" else "stopped", + labels = mapOf( + "cashpilot.managed" to "true", + "cashpilot.service" to app.slug, + ), + ) + } + val heartbeat = WorkerHeartbeat( name = "${Build.MANUFACTURER} ${Build.MODEL} (${deviceId()})", + containers = containers, apps = apps, systemInfo = SystemInfo( os = "Android", @@ -95,16 +126,19 @@ class HeartbeatService : Service() { } if (response.status.isSuccess()) { + consecutiveFailures = 0 _lastHeartbeat.value = System.currentTimeMillis() _lastHeartbeatFailed.value = false val runningCount = apps.count { it.running } updateNotification("$runningCount/${apps.size} apps running") } else { + consecutiveFailures++ _lastHeartbeatFailed.value = true Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}") updateNotification("Server rejected heartbeat (${response.status.value})") } } catch (e: Exception) { + consecutiveFailures++ _lastHeartbeatFailed.value = true Log.w(TAG, "Heartbeat failed: ${e.message}") updateNotification("Heartbeat failed — retrying...") 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 6c26db9..8d58777 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -74,6 +75,11 @@ class MainActivity : ComponentActivity() { val needsSetup = !setupDismissed && (settings.serverUrl.isBlank() || settings.apiKey.isBlank() || !hasNotif || !hasUsage) + // Handle system Back from Settings → return to Dashboard + BackHandler(enabled = showSettings) { + showSettings = false + } + when { needsSetup -> SetupScreen( viewModel = viewModel, diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt index 197e725..1ee32bb 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt @@ -36,7 +36,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.cashpilot.android.R import com.cashpilot.android.model.KnownApps import com.cashpilot.android.ui.MainViewModel @@ -99,6 +101,10 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = localUrl.startsWith("http://"), + supportingText = if (localUrl.startsWith("http://")) { + { Text(stringResource(R.string.cleartext_warning)) } + } else null, ) } item { 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 7248d1d..034ece9 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 @@ -114,6 +114,10 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = localUrl.startsWith("http://"), + supportingText = if (localUrl.startsWith("http://")) { + { Text(stringResource(R.string.cleartext_warning)) } + } else null, ) Spacer(Modifier.height(8.dp)) OutlinedTextField( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d907dc8..6c51c47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,4 +43,7 @@ Not installed — tap to install Last: %s Notification active + + + Insecure: API key and app data will be sent unencrypted. Use https:// if possible. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index bb7a897..e5ce289 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -8,4 +8,6 @@ Features: - Material Design 3 UI with Jetpack Compose - No accounts required — connects to your own CashPilot instance +Privacy: All data is sent only to your own server. A single request to api.ipify.org fetches your public IP for display — this only happens after setup is complete. + Requires a self-hosted CashPilot server to function. CashPilot is free and open-source software licensed under GPL-3.0. \ No newline at end of file From 17cee5a2f56d988d05cf3904db868e15bde769e6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:16:10 +0200 Subject: [PATCH 3/8] Add GitHub/donate links, referral URLs for app installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: add About section with GitHub repo links (Android + Server) and GitHub Sponsors donate button - MonitoredApp: add optional referralUrl field; populated for EarnApp, IPRoyal Pawns, Traffmonetizer, and Grass - DashboardScreen: use referral URL when tapping not-installed apps (opens browser with referral tracking → Play Store redirect), falls back to direct Play Store link for apps without referrals --- .../cashpilot/android/model/MonitoredApp.kt | 14 +++++-- .../android/ui/screen/DashboardScreen.kt | 32 ++++++++++------ .../android/ui/screen/SettingsScreen.kt | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt b/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt index ea8adf9..8171594 100644 --- a/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt +++ b/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt @@ -11,18 +11,24 @@ data class MonitoredApp( val slug: String, val packageName: String, val displayName: String, + /** Web referral URL (opens browser, tracks referral, redirects to Play Store). Null = direct Play Store link. */ + val referralUrl: String? = null, ) /** Built-in list of known passive income Android apps and their package names. */ object KnownApps { val all = listOf( - MonitoredApp("earnapp", "com.brd.earnapp.play", "EarnApp"), - MonitoredApp("iproyal", "com.iproyal.android", "IPRoyal Pawns"), + MonitoredApp("earnapp", "com.brd.earnapp.play", "EarnApp", + referralUrl = "https://earnapp.com/i/oLdIZYzl"), + MonitoredApp("iproyal", "com.iproyal.android", "IPRoyal Pawns", + referralUrl = "https://pawns.app?r=2335279"), MonitoredApp("mysterium", "network.mysterium.provider", "MystNodes"), - MonitoredApp("traffmonetizer", "com.traffmonetizer.client", "Traffmonetizer"), + MonitoredApp("traffmonetizer", "com.traffmonetizer.client", "Traffmonetizer", + referralUrl = "https://traffmonetizer.com/?aff=1604226"), MonitoredApp("bytelixir", "com.bytelixir.blapp", "Bytelixir"), MonitoredApp("bytebenefit", "io.bytebenefit.app", "ByteBenefit"), - MonitoredApp("grass", "io.getgrass.www", "Grass"), + MonitoredApp("grass", "io.getgrass.www", "Grass", + referralUrl = "https://app.getgrass.io/register/?referralCode=e0pz6dcOMGJO9Vu"), MonitoredApp("titan", "com.titan_network_vip.titan_app", "Titan Network"), MonitoredApp("nodle", "io.nodle.cash", "Nodle Cash"), MonitoredApp("uprock", "com.uprock.mining", "Uprock"), diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt index eb42ba1..d2b96c0 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt @@ -427,20 +427,28 @@ private fun AppCard(info: AppDisplayInfo) { .then( if (info.state == AppState.NOT_INSTALLED) { Modifier.clickable { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse("market://details?id=${info.app.packageName}"), - ) - try { - context.startActivity(intent) - } catch (_: Exception) { - // Fall back to browser if Play Store not available + // Use referral URL if available (opens browser with referral tracking) + val url = info.app.referralUrl + if (url != null) { context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=${info.app.packageName}"), - ), + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } else { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=${info.app.packageName}"), ) + try { + context.startActivity(intent) + } catch (_: Exception) { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=${info.app.packageName}"), + ), + ) + } } } } else { diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt index 1ee32bb..e12b301 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.cashpilot.android.ui.screen import android.content.Context import android.content.Intent +import android.net.Uri import android.provider.Settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -184,10 +185,47 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { ) } } + + // About section + item { + Spacer(modifier = Modifier.height(8.dp)) + Text("About", style = MaterialTheme.typography.titleMedium) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot-android") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("GitHub — CashPilot Android") + } + OutlinedButton( + onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("GitHub — CashPilot Server") + } + Button( + onClick = { openUrl(context, "https://github.com/sponsors/GeiserX") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Sponsor / Donate") + } + } + } + + item { Spacer(modifier = Modifier.height(16.dp)) } } } } +private fun openUrl(context: Context, url: String) { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) +} + private fun openNotificationListenerSettings(context: Context) { context.startActivity( Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) From b166b6c57e6032babcfbb29deaa8d4c0791b9bf7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:19:15 +0200 Subject: [PATCH 4/8] Fix container slug, CI fork safety, actual referral codes - AppContainer: add slug field so server discovers worker items correctly via containers[*].slug - CI: guard release build on KEYSTORE_BASE64 secret presence so forked/external PRs don't fail on missing secrets - Referral codes: use actual codes from CashPilot AGENTS.md for EarnApp, IPRoyal, Traffmonetizer, Bytelixir, Grass, Titan, Uprock (7 of 11 apps now have referral links) --- .github/workflows/ci.yml | 2 ++ .../com/cashpilot/android/model/Heartbeat.kt | 5 +++-- .../com/cashpilot/android/model/MonitoredApp.kt | 17 ++++++++++------- .../android/service/HeartbeatService.kt | 3 ++- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611e1be..2789f39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,11 @@ jobs: run: ./gradlew lintDebug - name: Decode keystore + if: ${{ secrets.KEYSTORE_BASE64 != '' }} run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore - name: Build release APK + if: ${{ secrets.KEYSTORE_BASE64 != '' }} run: ./gradlew assembleRelease env: CASHPILOT_KEYSTORE_PATH: release.keystore diff --git a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt index 12b2d08..c2be3a9 100644 --- a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt +++ b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt @@ -18,13 +18,14 @@ data class WorkerHeartbeat( @SerialName("system_info") val systemInfo: SystemInfo = SystemInfo(), ) -/** Maps an Android app to the server's container-like representation. */ +/** Maps an Android app to the server's container-like representation. + * Must include `slug` — the server discovers worker items via `containers[*].slug`. */ @Serializable data class AppContainer( + val slug: String, val name: String, val status: String, val image: String = "", - /** Extra Android-specific fields packed here for forward compatibility. */ val labels: Map = emptyMap(), ) diff --git a/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt b/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt index 8171594..c05f2d7 100644 --- a/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt +++ b/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt @@ -19,19 +19,22 @@ data class MonitoredApp( object KnownApps { val all = listOf( MonitoredApp("earnapp", "com.brd.earnapp.play", "EarnApp", - referralUrl = "https://earnapp.com/i/oLdIZYzl"), + referralUrl = "https://earnapp.com/i/TSMD9wSm"), MonitoredApp("iproyal", "com.iproyal.android", "IPRoyal Pawns", - referralUrl = "https://pawns.app?r=2335279"), + referralUrl = "https://pawns.app?r=19266874"), MonitoredApp("mysterium", "network.mysterium.provider", "MystNodes"), MonitoredApp("traffmonetizer", "com.traffmonetizer.client", "Traffmonetizer", - referralUrl = "https://traffmonetizer.com/?aff=1604226"), - MonitoredApp("bytelixir", "com.bytelixir.blapp", "Bytelixir"), + referralUrl = "https://traffmonetizer.com/?aff=2111758"), + MonitoredApp("bytelixir", "com.bytelixir.blapp", "Bytelixir", + referralUrl = "https://bytelixir.com/?ref=OYEIRE0VSZBZ"), MonitoredApp("bytebenefit", "io.bytebenefit.app", "ByteBenefit"), MonitoredApp("grass", "io.getgrass.www", "Grass", - referralUrl = "https://app.getgrass.io/register/?referralCode=e0pz6dcOMGJO9Vu"), - MonitoredApp("titan", "com.titan_network_vip.titan_app", "Titan Network"), + referralUrl = "https://app.getgrass.io/register/?referralCode=kn8FNEPnUr2tMqE"), + MonitoredApp("titan", "com.titan_network_vip.titan_app", "Titan Network", + referralUrl = "https://edge.titannet.info/signup?inviteCode=2GKKJ495"), MonitoredApp("nodle", "io.nodle.cash", "Nodle Cash"), - MonitoredApp("uprock", "com.uprock.mining", "Uprock"), + MonitoredApp("uprock", "com.uprock.mining", "Uprock", + referralUrl = "https://link.uprock.com/i/33e8492e"), MonitoredApp("wipter", "com.wipter.app", "Wipter"), ) diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt index 8be938a..f9b6841 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -97,7 +97,8 @@ class HeartbeatService : Service() { // - `containers` (legacy): simplified format so older servers still show the worker val containers = apps.map { app -> AppContainer( - name = app.slug, + slug = app.slug, + name = "cashpilot-${app.slug}", status = if (app.running) "running" else "stopped", labels = mapOf( "cashpilot.managed" to "true", From 96fe1224878ae54b0219097417312a7c802bd5c6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:24:29 +0200 Subject: [PATCH 5/8] Polish: referral fallback, BackHandler, cleartext check, strings - Referral install: try/catch with fallback to Play Store on failure - BackHandler: disable when setup screen is active - Cleartext warning: trim + case-insensitive check - About section: move hardcoded strings to strings.xml - Extract PUBLIC_IP_URL constant in MainViewModel --- .../com/cashpilot/android/ui/MainActivity.kt | 2 +- .../com/cashpilot/android/ui/MainViewModel.kt | 6 ++- .../android/ui/screen/DashboardScreen.kt | 48 ++++++++++--------- .../android/ui/screen/SettingsScreen.kt | 12 ++--- .../android/ui/screen/SetupScreen.kt | 4 +- app/src/main/res/values/strings.xml | 6 +++ 6 files changed, 45 insertions(+), 33 deletions(-) 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 8d58777..3574e63 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt @@ -76,7 +76,7 @@ class MainActivity : ComponentActivity() { (settings.serverUrl.isBlank() || settings.apiKey.isBlank() || !hasNotif || !hasUsage) // Handle system Back from Settings → return to Dashboard - BackHandler(enabled = showSettings) { + BackHandler(enabled = showSettings && !needsSetup) { showSettings = false } 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 b54de9b..6c13473 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -52,6 +52,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val detector = AppDetector(application) + companion object { + private const val PUBLIC_IP_URL = "https://api.ipify.org" + } + val settings: StateFlow = SettingsStore.settings(application) .stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) @@ -177,7 +181,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { val ip = withContext(Dispatchers.IO) { try { - URL("https://api.ipify.org").readText().trim() + URL(PUBLIC_IP_URL).readText().trim() } catch (_: Exception) { null } diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt index d2b96c0..5d882ca 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.cashpilot.android.R +import com.cashpilot.android.model.MonitoredApp import com.cashpilot.android.ui.AppDisplayInfo import com.cashpilot.android.ui.AppState import com.cashpilot.android.ui.MainViewModel @@ -427,29 +428,7 @@ private fun AppCard(info: AppDisplayInfo) { .then( if (info.state == AppState.NOT_INSTALLED) { Modifier.clickable { - // Use referral URL if available (opens browser with referral tracking) - val url = info.app.referralUrl - if (url != null) { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } else { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse("market://details?id=${info.app.packageName}"), - ) - try { - context.startActivity(intent) - } catch (_: Exception) { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=${info.app.packageName}"), - ), - ) - } - } + openAppInstall(context, info.app) } } else { Modifier @@ -576,3 +555,26 @@ private fun parseIso(iso: String): Long = } catch (_: Exception) { 0L } + +private fun openAppInstall(context: Context, app: MonitoredApp) { + // Try referral URL first, fall back to Play Store on any failure + val referral = app.referralUrl + if (referral != null) { + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(referral)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return + } catch (_: Exception) { /* fall through to Play Store */ } + } + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${app.packageName}")), + ) + } catch (_: Exception) { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=${app.packageName}")), + ) + } +} diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt index e12b301..9cfcad6 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt @@ -102,8 +102,8 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, - isError = localUrl.startsWith("http://"), - supportingText = if (localUrl.startsWith("http://")) { + isError = localUrl.trim().startsWith("http://", ignoreCase = true), + supportingText = if (localUrl.trim().startsWith("http://", ignoreCase = true)) { { Text(stringResource(R.string.cleartext_warning)) } } else null, ) @@ -189,7 +189,7 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { // About section item { Spacer(modifier = Modifier.height(8.dp)) - Text("About", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.about), style = MaterialTheme.typography.titleMedium) } item { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -197,19 +197,19 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot-android") }, modifier = Modifier.fillMaxWidth(), ) { - Text("GitHub — CashPilot Android") + Text(stringResource(R.string.about_github_android)) } OutlinedButton( onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot") }, modifier = Modifier.fillMaxWidth(), ) { - Text("GitHub — CashPilot Server") + Text(stringResource(R.string.about_github_server)) } Button( onClick = { openUrl(context, "https://github.com/sponsors/GeiserX") }, modifier = Modifier.fillMaxWidth(), ) { - Text("Sponsor / Donate") + Text(stringResource(R.string.about_donate)) } } } 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 034ece9..7d8d307 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 @@ -114,8 +114,8 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, - isError = localUrl.startsWith("http://"), - supportingText = if (localUrl.startsWith("http://")) { + isError = localUrl.trim().startsWith("http://", ignoreCase = true), + supportingText = if (localUrl.trim().startsWith("http://", ignoreCase = true)) { { Text(stringResource(R.string.cleartext_warning)) } } else null, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c51c47..34eab1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,4 +46,10 @@ Insecure: API key and app data will be sent unencrypted. Use https:// if possible. + + + About + GitHub — CashPilot Android + GitHub — CashPilot Server + Sponsor / Donate From 35e821a646ae14b5839fd9ed5a8417463b70c2f5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:27:36 +0200 Subject: [PATCH 6/8] Fix CI: use step output for secret availability check Secrets can't be referenced in if: expressions directly. Use an intermediate step that sets an output when the secret env var exists. --- .github/workflows/ci.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2789f39..7557d70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,19 @@ jobs: - name: Run lint run: ./gradlew lintDebug + - name: Check signing secrets + id: signing + run: echo "available=true" >> "$GITHUB_OUTPUT" + if: env.KEYSTORE_B64 != '' + env: + KEYSTORE_B64: ${{ secrets.KEYSTORE_BASE64 }} + - name: Decode keystore - if: ${{ secrets.KEYSTORE_BASE64 != '' }} + if: steps.signing.outputs.available == 'true' run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore - name: Build release APK - if: ${{ secrets.KEYSTORE_BASE64 != '' }} + if: steps.signing.outputs.available == 'true' run: ./gradlew assembleRelease env: CASHPILOT_KEYSTORE_PATH: release.keystore @@ -54,7 +61,7 @@ jobs: path: app/build/outputs/apk/debug/app-debug.apk - name: Upload release APK - if: github.event_name == 'push' + if: steps.signing.outputs.available == 'true' && github.event_name == 'push' uses: actions/upload-artifact@v4 with: name: app-release From 71f41db76b8aa4377bc9d1786f3387b505317665 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:31:17 +0200 Subject: [PATCH 7/8] Fix missing Context import in DashboardScreen Adds android.content.Context import needed by the openAppInstall function. --- .../main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt index 5d882ca..d229c5b 100644 --- a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt @@ -1,5 +1,6 @@ package com.cashpilot.android.ui.screen +import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings as AndroidSettings From 77b122ab87787d62fcffc47c19753bfaf3fb1b20 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:37:07 +0200 Subject: [PATCH 8/8] Fix TOCTOU race in toggleApp and add heartbeat delay floor - Move slug toggle logic inside DataStore transaction to prevent concurrent toggle races - Add 5s minimum floor to heartbeat delay to prevent tight loops if heartbeatIntervalSeconds is zero --- .../com/cashpilot/android/service/HeartbeatService.kt | 2 +- .../main/java/com/cashpilot/android/ui/MainViewModel.kt | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt index f9b6841..43b456e 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -75,7 +75,7 @@ class HeartbeatService : Service() { sendHeartbeat(settings) } // Exponential backoff on consecutive failures (30s → 60s → 120s, max 5min) - val baseDelay = settings.heartbeatIntervalSeconds * 1000L + val baseDelay = (settings.heartbeatIntervalSeconds * 1000L).coerceAtLeast(5_000L) val backoff = if (consecutiveFailures > 0) { (baseDelay * (1L shl consecutiveFailures.coerceAtMost(3))) .coerceAtMost(300_000L) 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 6c13473..52a13a2 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -95,11 +95,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun toggleApp(slug: String) { refreshJob?.cancel() refreshJob = viewModelScope.launch { - // Compute new slugs locally to avoid StateFlow lag after DataStore write - val currentSlugs = settings.value.enabledSlugs.toMutableSet() - if (slug in currentSlugs) currentSlugs.remove(slug) else currentSlugs.add(slug) - val newSlugs = currentSlugs.toSet() + // Toggle inside the DataStore transaction to avoid TOCTOU races + var newSlugs = emptySet() SettingsStore.update(getApplication()) { s -> + val updated = s.enabledSlugs.toMutableSet() + if (slug in updated) updated.remove(slug) else updated.add(slug) + newSlugs = updated.toSet() s.copy(enabledSlugs = newSlugs) } doRefresh(enabledOverride = newSlugs)