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
16 changes: 13 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build:
runs-on: ubuntu-latest
Expand All @@ -27,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: github.event_name == 'push'
if: steps.signing.outputs.available == 'true'
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore

- name: Build release APK
if: github.event_name == 'push'
if: steps.signing.outputs.available == 'true'
run: ./gradlew assembleRelease
env:
CASHPILOT_KEYSTORE_PATH: release.keystore
Expand All @@ -51,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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/com/cashpilot/android/model/Heartbeat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@ import kotlinx.serialization.Serializable
data class WorkerHeartbeat(
val name: String,
val url: String = "",
/** Docker containers (empty for Android workers). */
val containers: List<AppContainer> = emptyList(),
/** Android app statuses (empty for Docker workers). */
val apps: List<AppStatus> = emptyList(),
@SerialName("system_info") val systemInfo: SystemInfo = SystemInfo(),
Comment on lines +14 to 18
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

Schema contract drift: apps should not be top-level in WorkerHeartbeat.

This change introduces a payload shape that conflicts with the documented WorkerHeartbeat contract used by this repo. Keep Android app status under system_info.apps (optionally dual-write only during migration windows).

Suggested fix
 data class WorkerHeartbeat(
     val name: String,
     val url: String = "",
     /** Docker containers (empty for Android workers). */
     val containers: List<AppContainer> = emptyList(),
-    /** Android app statuses (empty for Docker workers). */
-    val apps: List<AppStatus> = emptyList(),
     `@SerialName`("system_info") val systemInfo: SystemInfo = SystemInfo(),
 )

As per coding guidelines: "Heartbeat payload in CashPilot-android must match the server's WorkerHeartbeat schema with name, url, containers, and system_info fields, with Android-specific app status packed into system_info.apps".

🤖 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/model/Heartbeat.kt` around lines 14 -
18, The Heartbeat data class introduced a top-level val apps: List<AppStatus>
which violates the WorkerHeartbeat schema; remove the top-level apps field and
move Android app status into SystemInfo (use systemInfo.apps) so the serialized
payload contains name, url, containers, and system_info only; if you need a
migration window, optionally dual-write by populating systemInfo.apps and, for a
short time only, mirror it into the removed top-level field via a
transient/ignored property or explicit serializer, but ensure production
serialization matches WorkerHeartbeat (check the Heartbeat class, the apps
property, and the SystemInfo class).

)

/** 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<String, String> = emptyMap(),
)

Expand Down
23 changes: 16 additions & 7 deletions app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,30 @@ 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/TSMD9wSm"),
MonitoredApp("iproyal", "com.iproyal.android", "IPRoyal Pawns",
referralUrl = "https://pawns.app?r=19266874"),
MonitoredApp("mysterium", "network.mysterium.provider", "MystNodes"),
MonitoredApp("traffmonetizer", "com.traffmonetizer.client", "Traffmonetizer"),
MonitoredApp("bytelixir", "com.bytelixir.blapp", "Bytelixir"),
MonitoredApp("traffmonetizer", "com.traffmonetizer.client", "Traffmonetizer",
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"),
MonitoredApp("titan", "com.titan_network_vip.titan_app", "Titan Network"),
MonitoredApp("grass", "io.getgrass.www", "Grass",
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"),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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
Expand Down Expand Up @@ -50,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()
Expand All @@ -67,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).coerceAtLeast(5_000L)
val backoff = if (consecutiveFailures > 0) {
(baseDelay * (1L shl consecutiveFailures.coerceAtMost(3)))
.coerceAtMost(300_000L)
} else {
baseDelay
}
delay(backoff)
}
}
return START_STICKY
Expand All @@ -77,10 +92,13 @@ class HeartbeatService : Service() {
try {
val apps = detector.detectAll(settings.enabledSlugs)

// Map app statuses to the server's container-like format
// 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,
slug = app.slug,
name = "cashpilot-${app.slug}",
status = if (app.running) "running" else "stopped",
labels = mapOf(
"cashpilot.managed" to "true",
Expand All @@ -92,12 +110,12 @@ class HeartbeatService : Service() {
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,
),
Comment on lines 110 to 119
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

Heartbeat payload is emitted with empty system_info.apps.

At send time, app statuses are placed at top-level and not in system_info.apps, which breaks the repo’s declared WorkerHeartbeat contract. Move (or at least also copy) apps into SystemInfo.

Suggested fix
             val heartbeat = WorkerHeartbeat(
                 name = "${Build.MANUFACTURER} ${Build.MODEL} (${deviceId()})",
-                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,
                 ),
             )

Based on learnings: "Applies to **/{Heartbeat,HeartbeatService}.kt : Heartbeat payload in CashPilot-android must match the server's WorkerHeartbeat schema with name, url, containers, and system_info fields, with Android-specific app status packed into system_info.apps".

🤖 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/service/HeartbeatService.kt` around
lines 79 - 87, The Heartbeat payload created in HeartbeatService
(WorkerHeartbeat instantiation) currently sets apps at top-level instead of
placing Android app statuses into SystemInfo, violating the WorkerHeartbeat
contract; update the WorkerHeartbeat construction in HeartbeatService (and
related Heartbeat/HeartbeatService functions) so that the apps list is assigned
to SystemInfo.apps (or duplicated there) — i.e., add an apps field inside the
SystemInfo instance (populated with the existing apps variable) and remove or
keep the top-level apps only if duplication is desired, ensuring the emitted
payload includes system_info.apps as per the server schema.

)

Expand All @@ -109,16 +127,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...")
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/cashpilot/android/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 && !needsSetup) {
showSettings = false
}

when {
needsSetup -> SetupScreen(
viewModel = viewModel,
Expand Down
34 changes: 24 additions & 10 deletions app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Settings> = SettingsStore.settings(application)
.stateIn(viewModelScope, SharingStarted.Eagerly, Settings())

Expand All @@ -75,6 +79,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {

private val _publicIp = MutableStateFlow<String?>(null)
val publicIp: StateFlow<String?> = _publicIp.asStateFlow()
private var publicIpFailed = false

private var refreshJob: Job? = null

Expand All @@ -90,19 +95,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun toggleApp(slug: String) {
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
// Toggle inside the DataStore transaction to avoid TOCTOU races
var newSlugs = emptySet<String>()
SettingsStore.update(getApplication()) { s ->
val new = s.enabledSlugs.toMutableSet()
if (slug in new) new.remove(slug) else new.add(slug)
s.copy(enabledSlugs = new)
val updated = s.enabledSlugs.toMutableSet()
if (slug in updated) updated.remove(slug) else updated.add(slug)
newSlugs = updated.toSet()
s.copy(enabledSlugs = newSlugs)
}
doRefresh()
doRefresh(enabledOverride = newSlugs)
}
}

private suspend fun doRefresh() {
private suspend fun doRefresh(enabledOverride: Set<String>? = 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 ->
Expand Down Expand Up @@ -134,12 +142,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
}
Expand Down Expand Up @@ -171,13 +180,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()
URL(PUBLIC_IP_URL).readText().trim()
} catch (_: Exception) {
null
}
}
if (ip != null) {
_publicIp.value = ip
} else {
publicIpFailed = true
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -61,6 +62,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
Expand Down Expand Up @@ -332,6 +334,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(
Expand Down Expand Up @@ -422,21 +429,7 @@ 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
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
Expand Down Expand Up @@ -563,3 +556,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}")),
)
}
}
Loading
Loading