diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5c0ee9..7557d70 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 @@ -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 @@ -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 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/model/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt index 508addf..c2be3a9 100644 --- a/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt +++ b/app/src/main/java/com/cashpilot/android/model/Heartbeat.kt @@ -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 = emptyList(), + /** Android app statuses (empty for Docker workers). */ + val apps: List = emptyList(), @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 ea8adf9..c05f2d7 100644 --- a/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt +++ b/app/src/main/java/com/cashpilot/android/model/MonitoredApp.kt @@ -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"), ) 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..43b456e 100644 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt @@ -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 @@ -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() @@ -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 @@ -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", @@ -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, ), ) @@ -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...") 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..3574e63 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 && !needsSetup) { + showSettings = false + } + when { needsSetup -> SetupScreen( viewModel = viewModel, 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..52a13a2 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()) @@ -75,6 +79,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 +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() 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? = 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 +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 } @@ -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 + } } } 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..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 @@ -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 @@ -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( @@ -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 @@ -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}")), + ) + } +} 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..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 @@ -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 @@ -36,7 +37,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 +102,10 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = localUrl.trim().startsWith("http://", ignoreCase = true), + supportingText = if (localUrl.trim().startsWith("http://", ignoreCase = true)) { + { Text(stringResource(R.string.cleartext_warning)) } + } else null, ) } item { @@ -178,10 +185,47 @@ fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { ) } } + + // About section + item { + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.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(stringResource(R.string.about_github_android)) + } + OutlinedButton( + onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.about_github_server)) + } + Button( + onClick = { openUrl(context, "https://github.com/sponsors/GeiserX") }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.about_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) 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..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,6 +114,10 @@ fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { placeholder = { Text("https://cashpilot.example.com") }, modifier = Modifier.fillMaxWidth(), singleLine = true, + isError = localUrl.trim().startsWith("http://", ignoreCase = true), + supportingText = if (localUrl.trim().startsWith("http://", ignoreCase = true)) { + { 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..34eab1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,4 +43,13 @@ Not installed — tap to install Last: %s Notification active + + + Insecure: API key and app data will be sent unencrypted. Use https:// if possible. + + + About + GitHub — CashPilot Android + GitHub — CashPilot Server + Sponsor / Donate 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