diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3406b..1de3735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Heartbeats run every 15 minutes on WorkManager instead of every 30 seconds from a foreground service.** The always-on special-use foreground service and its permanent notification are gone, and so is the restart-on-boot receiver: WorkManager keeps the schedule across restarts. The app also reports once when it is opened and once when the server URL or key changes. The last report and the last earnings figure are kept on disk, so the dashboard still shows them after Android stops the app between runs, and it now says "Last report 6 min ago · sent every 15 min" instead of implying live monitoring. +- **The battery button opens Android's battery-optimisation list instead of the direct exemption dialog**, and the app no longer requests `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. Google Play allows that permission only for chat, VoIP, safety, automation and peripheral apps. The setup screen says which entries to pick in the list. `POST_NOTIFICATIONS` is no longer requested either, because nothing posts a notification any more. + ## [0.4.0] - 2026-11-06 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index c89fdf5..b7f236b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ where the fonts can be pinned deliberately. ``` com.cashpilot.android/ ├── model/ # Data classes (Heartbeat, AppStatus, MonitoredApp, Settings) -├── service/ # Android services (HeartbeatService, AppNotificationListener, AppDetector) +├── service/ # Background work (Heartbeat, HeartbeatWorker, AppNotificationListener, AppDetector) ├── ui/ # Compose UI (MainActivity, screens, theme, components) │ ├── screen/ # Full-screen composables (Dashboard, Settings) │ ├── theme/ # Material 3 theme @@ -74,7 +74,7 @@ com.cashpilot.android/ ### Key Components -- **HeartbeatService**: Foreground service sending periodic HTTP POST to CashPilot server with app status. Uses Ktor client. Sticky service with boot receiver. +- **HeartbeatWorker / Heartbeat**: WorkManager job every 15 minutes (plus one on app open and on server-detail changes) that POSTs app status to the CashPilot server with Ktor. `Heartbeat` keeps the last result on disk so the UI survives process death. No foreground service and no `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: Google Play restricts both for this kind of app, and `BackgroundPolicyTest` guards it. - **AppNotificationListener**: NotificationListenerService that detects when monitored apps have active foreground notifications. Primary detection mechanism — instant callback. - **AppDetector**: Combines NotificationListener state with UsageStatsManager (last active time) and NetworkStatsManager (per-app bytes tx/rx) for complete app health picture. - **SettingsStore**: DataStore-backed persistence for server URL, fleet API key, heartbeat interval, and enabled app list. @@ -99,7 +99,6 @@ POST to `{serverUrl}/api/workers/heartbeat` with bearer auth (fleet API key via - Use `ensureActive()` after `withContext(Dispatchers.IO)` blocks to discard stale results - Use `AppOpsManager` to check usage access permission (not `UsageStatsManager.queryUsageStats` heuristic) - DataStore text field writes should be debounced (500ms per-field cancel-and-replace) -- POST_NOTIFICATIONS runtime permission must be requested on Android 13+ - License: GPL-3.0 ## Known Apps diff --git a/PUBLISHING.md b/PUBLISHING.md index b66d6b3..eef65b4 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -34,7 +34,7 @@ decision**, not a missing feature. Decide it deliberately before submitting. | Release signing | **Done.** `app/build.gradle.kts` reads `CASHPILOT_KEYSTORE_PATH`, `_PASSWORD`, `CASHPILOT_KEY_ALIAS`, `_KEY_PASSWORD` from Gradle properties or the environment, and only applies the config when all four are present. The keystore is never in the repository. | | Release artifacts | **Done.** `release.yml` builds **both** `app-release.aab` (what Play wants) and `app-release.apk` (what F-Droid and direct installs want). | | Version in one place | **Done.** `gradle.properties` holds `VERSION_NAME` and `VERSION_CODE` (major*10000 + minor*100 + patch). `release.yml` runs `scripts/check-release-version.sh` first and refuses a tag that disagrees with the file. F-Droid builds from that file without our `-P` flags, so the two must agree. | -| Special-use foreground service | **Done, and this one usually bites.** `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` is declared with a justification string. Google requires it and rejects builds that declare `FOREGROUND_SERVICE_SPECIAL_USE` without one. | +| Special-use foreground service | **Gone.** The heartbeat runs on WorkManager every 15 minutes, so there is no special-use foreground service to justify. WorkManager's merged manifest still lists `FOREGROUND_SERVICE` for its own optional service, which the app never starts. | | Store text and graphics | **Done.** `fastlane/metadata/android/en-US/` has `title.txt`, `short_description.txt`, `full_description.txt`, `changelogs/.txt`, `images/icon.png` (512x512, from `docs/images/icon.svg`) and `images/featureGraphic.png` (1024x500, from `docs/images/banner.svg`). | | F-Droid recipe | **Done.** `fdroid/com.cashpilot.android.yml` is the file to place in fdroiddata as `metadata/com.cashpilot.android.yml`. Fill in the tag's full commit sha first. | | Licence | **Done.** GPL-3.0, no proprietary dependencies — which is what makes F-Droid possible at all. | @@ -148,9 +148,8 @@ must say so precisely. |---|---| | `PACKAGE_USAGE_STATS` | Special access, granted by the user in system settings. Detects whether a monitored earning app is actually running rather than merely installed. | | `BIND_NOTIFICATION_LISTENER_SERVICE` | Special access. Several earning apps report their state only through a persistent notification; reading it is how the app knows they are alive. | -| `FOREGROUND_SERVICE_SPECIAL_USE` | The heartbeat that reports app status to the user's own server. Justification string already declared in the manifest. | -| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Play restricts this and it is a common rejection. Be ready to argue that a monitoring app whose service is dozed reports stale state, and to accept the answer if they disagree. | -| `RECEIVE_BOOT_COMPLETED` | Resume monitoring after a restart without the user reopening the app. | +| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | **Not requested.** Play allows it only for chat, VoIP, safety, automation and peripheral apps. The app opens the battery settings list instead, which needs no permission; `BackgroundPolicyTest` fails if the permission or the direct dialog comes back. | +| `RECEIVE_BOOT_COMPLETED` | Added by WorkManager, which uses it to restore the heartbeat schedule after a restart. | | `READ_NETWORK_USAGE_HISTORY` | **The app genuinely depends on this** — `NetworkStatsManager` is the third of `AppDetector`'s three detection signals (per-app bytes tx/rx, ~2h buckets), alongside usage stats and notifications. Do not drop it to dodge a question; the per-app byte counts are how a running-but-silent app is distinguished from an idle one. Note the effective grant comes from usage access, so expect the reviewer to treat the two together. | State plainly that notification and usage data are read **on device**, are used diff --git a/README.md b/README.md index 0bd3de1..315f988 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ CashPilot Android is the Android companion app for [CashPilot](https://github.co ## Quick start 1. Download `app-release.apk` from the [latest release](https://github.com/GeiserX/CashPilot-android/releases/latest) and install it. -2. Grant the three permissions it asks for: Notification Access, Usage Access, and the battery-optimisation exemption. +2. Grant Notification Access and Usage Access. Turning battery optimisation off for CashPilot is optional and keeps its 15-minute reports on time. 3. In Settings, enter your CashPilot server URL and the fleet key (`CASHPILOT_API_KEY` on the server). Use `https://`; the app accepts `http://` only for a server on your own network: `localhost`, a LAN, link-local or Tailscale address (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `169.254.x.x`, `100.64-127.x.x`), or a private name (`.local`, `.lan`, `.home.arpa`, `.internal`, Tailscale `.ts.net`). Needs Android 8.0 (API 26) or later and a reachable CashPilot 1.x server. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 00a43b6..ba567b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,6 +142,7 @@ dependencies { testImplementation(libs.junit4) testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.14.4") testImplementation(libs.robolectric) + testImplementation(libs.androidx.work.testing) testImplementation(libs.roborazzi) testImplementation(libs.roborazzi.compose) testImplementation(libs.roborazzi.junit.rule) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee3dd82..06fad4e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,12 +1,8 @@ - + - - - - - @@ -56,24 +52,20 @@ - - + - - - - - - - - - + tools:node="merge"> + + diff --git a/app/src/main/java/com/cashpilot/android/CashPilotApp.kt b/app/src/main/java/com/cashpilot/android/CashPilotApp.kt index f12f60b..1385124 100644 --- a/app/src/main/java/com/cashpilot/android/CashPilotApp.kt +++ b/app/src/main/java/com/cashpilot/android/CashPilotApp.kt @@ -1,5 +1,27 @@ package com.cashpilot.android import android.app.Application +import androidx.work.Configuration +import com.cashpilot.android.service.Heartbeat +import com.cashpilot.android.service.HeartbeatWorker -class CashPilotApp : Application() +/** + * Implements [Configuration.Provider], and the manifest removes WorkManager's + * startup initializer, so WorkManager initialises on first use: the + * [HeartbeatWorker.schedule] call below. Same behaviour on a device and under + * Robolectric, which does not run androidx.startup's content provider. + */ +class CashPilotApp : Application(), Configuration.Provider { + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().build() + + override fun onCreate() { + super.onCreate() + // Every process start, including the system binding the notification + // listener after a reboot: restore what the last heartbeat said, and make + // sure the periodic schedule exists (KEEP, so this never resets it). + Heartbeat.restore(this) + HeartbeatWorker.schedule(this) + } +} diff --git a/app/src/main/java/com/cashpilot/android/service/BootReceiver.kt b/app/src/main/java/com/cashpilot/android/service/BootReceiver.kt deleted file mode 100644 index 72bc580..0000000 --- a/app/src/main/java/com/cashpilot/android/service/BootReceiver.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.cashpilot.android.service - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Build - -/** Restarts the heartbeat service after device reboot. */ -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - val serviceIntent = Intent(context, HeartbeatService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } - } -} diff --git a/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt new file mode 100644 index 0000000..bbb647e --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt @@ -0,0 +1,235 @@ +package com.cashpilot.android.service + +import android.content.Context +import android.os.Build +import android.provider.Settings.Secure +import android.util.Log +import com.cashpilot.android.BuildConfig +import com.cashpilot.android.model.AppContainer +import com.cashpilot.android.model.Earnings +import com.cashpilot.android.model.HeartbeatJson +import com.cashpilot.android.model.Settings +import com.cashpilot.android.model.SystemInfo +import com.cashpilot.android.model.WorkerHeartbeat +import com.cashpilot.android.model.WorkerHeartbeatResponse +import com.cashpilot.android.util.ServerUrlPolicy +import com.cashpilot.android.util.SettingsStore +import io.ktor.client.HttpClient +import io.ktor.client.call.body +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 +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * One heartbeat to the CashPilot server, and what the last one said. + * + * This used to live in a foreground service that posted every 30 seconds for as + * long as the phone was on. [HeartbeatWorker] now runs it on WorkManager's + * schedule instead (every 15 minutes, plus once when the app is opened or the + * server details change), so there is no permanent notification and no + * special-use foreground service to justify to a store. + * + * The process is no longer kept alive between heartbeats, so what the UI shows + * about the last one ([lastHeartbeat], [lastHeartbeatFailed], [earnings], + * [earningsAsOf]) is written to disk after each run and read back by [restore] + * when the process starts. Without that, every cold start would say "no + * heartbeat yet" and "nothing read yet" for up to 15 minutes. + */ +object Heartbeat { + private const val TAG = "Heartbeat" + private const val PREFS = "heartbeat_state" + private const val K_LAST = "last_heartbeat" + private const val K_FAILED = "last_heartbeat_failed" + private const val K_EARNINGS = "earnings_json" + private const val K_EARNINGS_AS_OF = "earnings_as_of" + + /** Timestamp of last successful heartbeat (0 = never). */ + private val _lastHeartbeat = MutableStateFlow(0L) + val lastHeartbeat: StateFlow = _lastHeartbeat.asStateFlow() + + /** Whether the last heartbeat attempt failed. */ + private val _lastHeartbeatFailed = MutableStateFlow(false) + val lastHeartbeatFailed: StateFlow = _lastHeartbeatFailed.asStateFlow() + + /** + * Earnings from the most recent heartbeat that carried them, or null. + * + * Null is UNKNOWN, not zero: an older server, a server that could not + * produce the figures, or no heartbeat yet. The last known value is kept + * when a later heartbeat omits it, because a phone is offline often and + * blanking the figure on every blip would be worse than showing a stale + * one -- but see [earningsAsOf], which is what lets the UI say it is stale + * rather than pretending it is current. + */ + private val _earnings = MutableStateFlow(null) + val earnings: StateFlow = _earnings.asStateFlow() + + /** When [earnings] was received (0 = never). */ + private val _earningsAsOf = MutableStateFlow(0L) + val earningsAsOf: StateFlow = _earningsAsOf.asStateFlow() + + /** + * The earnings to keep after a heartbeat: the newly received ones, or the + * previous value when this response carried none. Pure, so it is + * unit-tested without Android. + */ + fun earningsToKeep(current: Earnings?, received: Earnings?): Earnings? = received ?: current + + /** + * The per-worker key to newly persist, given the currently stored key and the + * one the server returned on this heartbeat — or `null` if nothing should + * change (no key issued, blank, or unchanged). Pure, so it is unit-tested. + */ + fun keyToPersist(current: String, issued: String?): String? = + issued?.takeIf { it.isNotBlank() && it != current } + + /** + * The [Settings] to persist after a successful heartbeat, given the current + * settings and the parsed response body — or `null` if nothing should change. + * Pure, so the response→persist wiring is unit-tested without Robolectric. + */ + fun settingsAfterHeartbeat(settings: Settings, body: WorkerHeartbeatResponse): Settings? = + keyToPersist(settings.workerKey, body.workerKey)?.let { newKey -> settings.copy(workerKey = newKey) } + + /** Record the earnings a heartbeat carried, keeping the previous ones if it carried none. */ + fun recordEarnings(body: WorkerHeartbeatResponse, now: Long) { + val kept = earningsToKeep(_earnings.value, body.earnings) + _earnings.value = kept + if (body.earnings != null) _earningsAsOf.value = now + } + + /** Load what the last run in an earlier process saw. Called once at process start. */ + fun restore(context: Context) { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + _lastHeartbeat.value = p.getLong(K_LAST, 0L) + _lastHeartbeatFailed.value = p.getBoolean(K_FAILED, false) + _earnings.value = p.getString(K_EARNINGS, null)?.let { + // A shape this version cannot read is unknown, never a crash at startup. + runCatching { HeartbeatJson.decodeFromString(Earnings.serializer(), it) }.getOrNull() + } + _earningsAsOf.value = p.getLong(K_EARNINGS_AS_OF, 0L) + } + + internal fun persist(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putLong(K_LAST, _lastHeartbeat.value) + .putBoolean(K_FAILED, _lastHeartbeatFailed.value) + .putString(K_EARNINGS, _earnings.value?.let { HeartbeatJson.encodeToString(Earnings.serializer(), it) }) + .putLong(K_EARNINGS_AS_OF, _earningsAsOf.value) + .apply() + } + + private fun newClient() = HttpClient(OkHttp) { + install(ContentNegotiation) { json(HeartbeatJson) } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } + } + + /** Send one heartbeat if a server is configured, and record the outcome. */ + suspend fun sendOnce(context: Context, settings: Settings) { + if (settings.serverUrl.isBlank() || settings.activeKey.isBlank()) return + try { + send(context, settings) + } finally { + persist(context) + } + } + + private suspend fun send(context: Context, settings: Settings) { + val url = settings.serverUrl.trimEnd('/') + "/api/workers/heartbeat" + // http to a public host would hand the fleet key to every network on the way. + if (!ServerUrlPolicy.allowsToken(url)) { + _lastHeartbeatFailed.value = true + Log.w(TAG, "Heartbeat not sent: server URL is not https and not a private address") + return + } + val client = newClient() + try { + val apps = AppDetector(context).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( + slug = app.slug, + name = "cashpilot-${app.slug}", + // Three-valued on the wire too. `unknown` is NOT "stopped": + // the server maps a falsy running to "stopped", so sending + // false while blind would hand the fleet page a false claim. + status = Detection.wireStatus(app.running), + labels = mapOf( + "cashpilot.managed" to "true", + "cashpilot.service" to app.slug, + ), + ) + } + + val heartbeat = WorkerHeartbeat( + name = "${Build.MANUFACTURER} ${Build.MODEL} (${deviceId(context)})", + 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", + version = BuildConfig.VERSION_NAME, + ), + ) + + val response: HttpResponse = client.post(url) { + contentType(ContentType.Application.Json) + bearerAuth(settings.activeKey) + setBody(heartbeat) + } + + if (response.status.isSuccess()) { + _lastHeartbeat.value = System.currentTimeMillis() + _lastHeartbeatFailed.value = false + // Enrollment: on first contact (and re-delivered until we confirm it + // by using it) the server returns this device's own fleet key. Persist + // and adopt it, so subsequent heartbeats authenticate with our own key. + val body = runCatching { response.body() }.getOrNull() + if (body != null) { + settingsAfterHeartbeat(settings, body)?.let { updated -> + SettingsStore.update(context) { updated } + Log.i(TAG, "Enrolled: received and persisted this device's own fleet key") + } + recordEarnings(body, System.currentTimeMillis()) + } + } else { + _lastHeartbeatFailed.value = true + Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}") + if (response.status.value == 401 && settings.workerKey.isNotBlank()) { + SettingsStore.update(context) { it.copy(workerKey = "") } + Log.i(TAG, "Auth rejected (401) — clearing per-worker key to re-enroll") + } + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + _lastHeartbeatFailed.value = true + Log.w(TAG, "Heartbeat failed: ${e.message}") + } finally { + client.close() + } + } + + /** Short unique device ID from ANDROID_ID (per app+device, no permissions needed). */ + private fun deviceId(context: Context): String = + Secure.getString(context.contentResolver, Secure.ANDROID_ID)?.take(8) ?: "unknown" +} diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt deleted file mode 100644 index c1ab1dd..0000000 --- a/app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt +++ /dev/null @@ -1,298 +0,0 @@ -package com.cashpilot.android.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.provider.Settings.Secure -import android.util.Log -import androidx.core.app.NotificationCompat -import com.cashpilot.android.R -import com.cashpilot.android.BuildConfig -import com.cashpilot.android.model.HeartbeatJson -import com.cashpilot.android.model.Earnings -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.model.WorkerHeartbeatResponse -import com.cashpilot.android.util.ServerUrlPolicy -import com.cashpilot.android.util.SettingsStore -import io.ktor.client.HttpClient -import io.ktor.client.call.body -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 -import io.ktor.client.request.setBody -import io.ktor.client.statement.HttpResponse -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.http.isSuccess -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -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 - -class HeartbeatService : Service() { - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var heartbeatJob: Job? = null - private lateinit var detector: AppDetector - - private val httpClient = HttpClient(OkHttp) { - install(ContentNegotiation) { - json(HeartbeatJson) - } - install(HttpTimeout) { - requestTimeoutMillis = 15_000 - connectTimeoutMillis = 10_000 - socketTimeoutMillis = 10_000 - } - } - private var consecutiveFailures = 0 - - override fun onCreate() { - super.onCreate() - detector = AppDetector(applicationContext) - createNotificationChannel() - startForeground(NOTIFICATION_ID, buildNotification("Monitoring apps...")) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - heartbeatJob?.cancel() - heartbeatJob = scope.launch { - while (true) { - val settings = SettingsStore.settings(applicationContext).first() - if (settings.serverUrl.isNotBlank() && settings.activeKey.isNotBlank()) { - sendHeartbeat(settings) - } - // 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 - } - - private suspend fun sendHeartbeat(settings: Settings) { - val url = settings.serverUrl.trimEnd('/') + "/api/workers/heartbeat" - // http to a public host would hand the fleet key to every network on the way. - // No backoff: the next tick picks up a corrected URL straight away. - if (!ServerUrlPolicy.allowsToken(url)) { - _lastHeartbeatFailed.value = true - Log.w(TAG, "Heartbeat not sent: server URL is not https and not a private address") - updateNotification(getString(R.string.cleartext_refused_short)) - return - } - 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( - slug = app.slug, - name = "cashpilot-${app.slug}", - // Three-valued on the wire too. `unknown` is NOT "stopped": - // the server maps a falsy running to "stopped", so sending - // false while blind would hand the fleet page the same false - // claim this fix removes from the phone. - status = Detection.wireStatus(app.running), - 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", - version = BuildConfig.VERSION_NAME, - ), - ) - - val response: HttpResponse = httpClient.post(url) { - contentType(ContentType.Application.Json) - bearerAuth(settings.activeKey) - setBody(heartbeat) - } - - if (response.status.isSuccess()) { - consecutiveFailures = 0 - _lastHeartbeat.value = System.currentTimeMillis() - _lastHeartbeatFailed.value = false - // Enrollment: on first contact (and re-delivered until we confirm it - // by using it) the server returns this device's own fleet key. Persist - // and adopt it, so subsequent heartbeats authenticate with our own key. - val body = runCatching { response.body() }.getOrNull() - if (body != null) { - settingsAfterHeartbeat(settings, body)?.let { updated -> - SettingsStore.update(applicationContext) { updated } - Log.i(TAG, "Enrolled: received and persisted this device's own fleet key") - } - recordEarnings(body, System.currentTimeMillis()) - } - // Counts only what is KNOWN to be running. An app we cannot - // see is not counted as running, and equally not reported as a - // failure -- the notification says how many are unknown instead - // of quietly folding them into the stopped remainder. - val runningCount = apps.count { it.running == true } - val unknownCount = apps.count { it.running == null } - updateNotification( - if (unknownCount > 0) { - "$runningCount/${apps.size} apps running, $unknownCount unknown" - } else { - "$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})") - if (response.status.value == 401 && settings.workerKey.isNotBlank()) { - SettingsStore.update(applicationContext) { it.copy(workerKey = "") } - Log.i(TAG, "Auth rejected (401) — clearing per-worker key to re-enroll") - } - } - } catch (e: Exception) { - consecutiveFailures++ - _lastHeartbeatFailed.value = true - Log.w(TAG, "Heartbeat failed: ${e.message}") - updateNotification("Heartbeat failed — retrying...") - } - } - - private fun createNotificationChannel() { - val channel = NotificationChannel( - CHANNEL_ID, - "CashPilot Agent", - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = "Shows monitoring status" - setShowBadge(false) - } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) - } - - private fun buildNotification(text: String): Notification = - NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("CashPilot") - .setContentText(text) - .setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - .setSilent(true) - .build() - - private fun updateNotification(text: String) { - val manager = getSystemService(NotificationManager::class.java) - manager.notify(NOTIFICATION_ID, buildNotification(text)) - } - - /** Short unique device ID from ANDROID_ID (per app+device, no permissions needed). */ - private fun deviceId(): String = - Secure.getString(contentResolver, Secure.ANDROID_ID)?.take(8) ?: "unknown" - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onDestroy() { - heartbeatJob?.cancel() - scope.cancel() - httpClient.close() - super.onDestroy() - } - - companion object { - 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 = _lastHeartbeat.asStateFlow() - - /** Whether the last heartbeat attempt failed. */ - private val _lastHeartbeatFailed = MutableStateFlow(false) - val lastHeartbeatFailed: StateFlow = _lastHeartbeatFailed.asStateFlow() - - /** - * Earnings from the most recent heartbeat that carried them, or null. - * - * Null is UNKNOWN, not zero: an older server, a server that could not - * produce the figures, or no heartbeat yet. The last known value is kept - * when a later heartbeat omits it, because a phone is offline often and - * blanking the figure on every blip would be worse than showing a stale - * one -- but see [earningsAsOf], which is what lets the UI say it is stale - * rather than pretending it is current. - */ - private val _earnings = MutableStateFlow(null) - val earnings: StateFlow = _earnings.asStateFlow() - - /** When [earnings] was received (0 = never). */ - private val _earningsAsOf = MutableStateFlow(0L) - val earningsAsOf: StateFlow = _earningsAsOf.asStateFlow() - - /** - * The earnings to keep after a heartbeat: the newly received ones, or the - * previous value when this response carried none. Pure, so it is - * unit-tested without a service. - */ - fun earningsToKeep(current: Earnings?, received: Earnings?): Earnings? = received ?: current - - /** - * The per-worker key to newly persist, given the currently stored key and the - * one the server returned on this heartbeat — or `null` if nothing should - * change (no key issued, blank, or unchanged). Pure, so it is unit-tested. - */ - fun keyToPersist(current: String, issued: String?): String? = - issued?.takeIf { it.isNotBlank() && it != current } - - /** - * The [Settings] to persist after a successful heartbeat, given the current - * settings and the parsed response body — or `null` if nothing should change. - * Pure, so the response→persist wiring is unit-tested without Robolectric. - */ - fun settingsAfterHeartbeat(settings: Settings, body: WorkerHeartbeatResponse): Settings? = - keyToPersist(settings.workerKey, body.workerKey)?.let { newKey -> settings.copy(workerKey = newKey) } - - /** - * Record the earnings a heartbeat carried. Separate from - * [settingsAfterHeartbeat] because earnings are ephemeral display state, - * not settings -- persisting them to DataStore would mean writing on every - * heartbeat for a value that is meaningless once stale. - */ - fun recordEarnings(body: WorkerHeartbeatResponse, now: Long) { - val kept = earningsToKeep(_earnings.value, body.earnings) - _earnings.value = kept - if (body.earnings != null) _earningsAsOf.value = now - } - } -} diff --git a/app/src/main/java/com/cashpilot/android/service/HeartbeatWorker.kt b/app/src/main/java/com/cashpilot/android/service/HeartbeatWorker.kt new file mode 100644 index 0000000..f191e00 --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/service/HeartbeatWorker.kt @@ -0,0 +1,64 @@ +package com.cashpilot.android.service + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.cashpilot.android.util.SettingsStore +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * Runs one [Heartbeat]. Scheduled every [PERIOD_MINUTES] minutes, the shortest + * period WorkManager allows, and once more whenever [runNow] is called. + * + * Always returns success. A failed heartbeat is recorded for the UI, and the next + * period is the retry; asking WorkManager to retry as well would stack a second + * schedule on top of the periodic one. + */ +class HeartbeatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val settings = SettingsStore.settings(applicationContext).first() + Heartbeat.sendOnce(applicationContext, settings) + return Result.success() + } + + companion object { + const val PERIOD_MINUTES = 15L + const val PERIODIC = "heartbeat" + const val NOW = "heartbeat-now" + + private val online = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + + /** Idempotent: an existing schedule is kept, so calling this on every start does not reset it. */ + fun schedule(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(PERIOD_MINUTES, TimeUnit.MINUTES) + .setConstraints(online) + .build(), + ) + } + + /** + * One heartbeat as soon as there is a network: when the app is opened and + * when the server details change, so a new URL or key is tried at once + * instead of on the next period. A pending one is replaced, not queued twice. + */ + fun runNow(context: Context) { + WorkManager.getInstance(context).enqueueUniqueWork( + NOW, + ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().setConstraints(online).build(), + ) + } + } +} 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 8389a00..80fccd7 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt @@ -1,15 +1,10 @@ package com.cashpilot.android.ui -import android.Manifest -import android.content.Intent -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 import androidx.activity.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -23,7 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.runtime.collectAsState -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.HeartbeatWorker import com.cashpilot.android.ui.screen.DashboardScreen import com.cashpilot.android.ui.screen.SettingsScreen import com.cashpilot.android.ui.screen.SetupScreen @@ -33,28 +28,12 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() - private val notificationPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { /* HeartbeatService notification becomes visible once granted */ } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - // Request POST_NOTIFICATIONS on Android 13+ so the heartbeat notification is visible - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - // Start heartbeat service - val serviceIntent = Intent(this, HeartbeatService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } + // Opening the app reports once now; the 15-minute schedule does the rest. + HeartbeatWorker.runNow(this) // Refresh statuses every time the activity resumes (e.g. returning from permission screens) lifecycleScope.launch { 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 3565576..e77c3ad 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -18,7 +18,8 @@ import com.cashpilot.android.model.Settings import com.cashpilot.android.service.AppDetector import com.cashpilot.android.service.Detection import com.cashpilot.android.service.AppNotificationListener -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.Heartbeat +import com.cashpilot.android.service.HeartbeatWorker import com.cashpilot.android.util.SettingsStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -116,8 +117,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { Detection.isBlind(canSeeNotifications = notif, canSeeUsage = usage) }.stateIn(viewModelScope, SharingStarted.Eagerly, false) - val lastHeartbeat: StateFlow = HeartbeatService.lastHeartbeat - val lastHeartbeatFailed: StateFlow = HeartbeatService.lastHeartbeatFailed + val lastHeartbeat: StateFlow = Heartbeat.lastHeartbeat + val lastHeartbeatFailed: StateFlow = Heartbeat.lastHeartbeatFailed /** * What the server says these platforms earned. @@ -131,8 +132,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { * dashboard can say a figure is stale instead of presenting it as current; * a phone is offline often, so the last known value is kept deliberately. */ - val earnings: StateFlow = HeartbeatService.earnings - val earningsAsOf: StateFlow = HeartbeatService.earningsAsOf + val earnings: StateFlow = Heartbeat.earnings + val earningsAsOf: StateFlow = Heartbeat.earningsAsOf private val _publicIp = MutableStateFlow(null) val publicIp: StateFlow = _publicIp.asStateFlow() @@ -220,6 +221,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { serverUrlJob = viewModelScope.launch { delay(500) SettingsStore.update(getApplication()) { it.copy(serverUrl = url) } + // Try the new address now, not on the next 15-minute period. + HeartbeatWorker.runNow(getApplication()) } } @@ -228,6 +231,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { apiKeyJob = viewModelScope.launch { delay(500) SettingsStore.update(getApplication()) { it.copy(apiKey = key) } + HeartbeatWorker.runNow(getApplication()) } } 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 3630edb..1f2e832 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 @@ -244,18 +244,13 @@ private fun openUrl(context: Context, url: String) { ) } +// Opens the system list of battery-optimised apps, where the user picks +// CashPilot. Never ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: that direct +// dialog needs the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission, which Google +// Play allows only for chat, VoIP, safety, automation and peripheral apps. private fun openBatteryOptimizationSettings(context: Context) { - try { - context.startActivity( - Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - android.net.Uri.parse("package:${context.packageName}"), - ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } catch (_: Exception) { - context.startActivity( - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } + context.startActivity( + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) } 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 df8d93b..e1e7a03 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 @@ -296,20 +296,13 @@ private fun openUsageAccessSettings(context: Context) { ) } +// Opens the system list of battery-optimised apps, where the user picks +// CashPilot. Never ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: that direct +// dialog needs the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission, which Google +// Play allows only for chat, VoIP, safety, automation and peripheral apps. private fun openBatteryOptimizationSettings(context: Context) { - try { - // Direct prompt for this app specifically - context.startActivity( - Intent( - AndroidSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - android.net.Uri.parse("package:${context.packageName}"), - ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } catch (_: Exception) { - // Fallback to global list - context.startActivity( - Intent(AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } + context.startActivity( + Intent(AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) } diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index 860124d..0000000 --- a/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d900690..4e196c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,8 +16,10 @@ Optional — skip this if you don\'t have a server Without a server CashPilot still detects which apps are running on this device and warns you when one stops. Pairing adds fleet-wide totals and earnings history across all your machines. You can pair later from Settings. Continue without a server - No heartbeat yet - Last heartbeat %s + No report sent yet + + Last report %s · sent every 15 min Permissions needed @@ -37,7 +39,7 @@ Usage Access Allows CashPilot to check when apps were last active and measure per-app network usage. This data stays on your device and is only sent to your own server. Battery Optimization - Disable battery optimization so Android doesn\'t kill CashPilot\'s background service. Without this, heartbeats may stop when the screen is off. + Lets Android send CashPilot\'s reports on time while the screen is off; without it they can arrive late. In the list that opens, choose All apps, then CashPilot, then Don\'t optimize. Open Settings Continue to Dashboard Skip for now diff --git a/app/src/test/java/com/cashpilot/android/BackgroundPolicyTest.kt b/app/src/test/java/com/cashpilot/android/BackgroundPolicyTest.kt new file mode 100644 index 0000000..602f3c9 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/BackgroundPolicyTest.kt @@ -0,0 +1,150 @@ +package com.cashpilot.android + +import android.app.Application +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import com.cashpilot.android.model.Earnings +import com.cashpilot.android.model.WorkerHeartbeatResponse +import com.cashpilot.android.service.Heartbeat +import com.cashpilot.android.service.HeartbeatWorker +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * What the app may run in the background, checked against the MERGED manifest + * and the real WorkManager schedule rather than against a copy of either. + * + * Google Play allows REQUEST_IGNORE_BATTERY_OPTIMIZATIONS only for chat, VoIP, + * safety, automation and peripheral apps, and asks every special-use foreground + * service for a justification. CashPilot is none of those, so it reports on a + * WorkManager schedule and opens the battery settings LIST instead of the direct + * exemption dialog. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BackgroundPolicyTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Before + fun initWorkManager() { + WorkManagerTestInitHelper.initializeTestWorkManager( + context, + Configuration.Builder().setExecutor(SynchronousExecutor()).build(), + ) + } + + private fun requested(): Set = + context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions.orEmpty().toSet() + + @Test + fun `the manifest no longer asks for the battery exemption or a special-use service`() { + val perms = requested() + // Positive control: the check reads the merged manifest. If it saw + // nothing, every "absent" assertion below would pass for free. + assertTrue("INTERNET should be requested; is the manifest being read?", "android.permission.INTERNET" in perms) + assertFalse("android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" in perms) + assertFalse("android.permission.FOREGROUND_SERVICE_SPECIAL_USE" in perms) + } + + @Test + fun `no service declares the special-use foreground type`() { + val services = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_SERVICES).services.orEmpty() + // Positive control: the notification listener is declared, so the list is real. + assertTrue(services.any { it.name.endsWith("AppNotificationListener") }) + if (Build.VERSION.SDK_INT >= 34) { + val special = services.filter { + it.foregroundServiceType and ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE != 0 + } + assertTrue("special-use services: ${special.map { it.name }}", special.isEmpty()) + } + } + + @Test + fun `no code opens the direct battery exemption dialog`() { + val main = File("src/main/java") + assertTrue("expected sources at ${main.absolutePath}", main.isDirectory) + val offenders = main.walkTopDown().filter { it.extension == "kt" } + .filter { f -> + // Only code, not the comment that explains why it is not used. + f.readLines().any { line -> + "ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" in line && !line.trimStart().startsWith("//") + } + }.map { it.name }.toList() + assertTrue("direct exemption dialog used in: $offenders", offenders.isEmpty()) + } + + @Test + fun `the heartbeat is one periodic job every 15 minutes that needs a network`() { + HeartbeatWorker.schedule(context) + HeartbeatWorker.schedule(context) // a second start must not add or reset anything + val infos = WorkManager.getInstance(context).getWorkInfosForUniqueWork(HeartbeatWorker.PERIODIC).get() + assertEquals(1, infos.size) + val info = infos.single() + assertEquals(WorkInfo.State.ENQUEUED, info.state) + assertEquals(TimeUnit.MINUTES.toMillis(15), info.periodicityInfo?.repeatIntervalMillis) + assertEquals(androidx.work.NetworkType.CONNECTED, info.constraints.requiredNetworkType) + } + + @Test + fun `asking for a report now twice leaves one pending run`() { + HeartbeatWorker.runNow(context) + HeartbeatWorker.runNow(context) + val pending = WorkManager.getInstance(context).getWorkInfosForUniqueWork(HeartbeatWorker.NOW).get() + .filter { !it.state.isFinished } + assertEquals(1, pending.size) + } + + @Test + fun `with no server the worker succeeds and sends nothing`() = runBlocking { + val before = Heartbeat.lastHeartbeat.value + val result = TestListenableWorkerBuilder(context).build().doWork() + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(before, Heartbeat.lastHeartbeat.value) + assertFalse(Heartbeat.lastHeartbeatFailed.value) + } + + @Test + fun `what the last report said survives a process restart`() { + val kept = Earnings(windowDays = 30, totalUsd = 12.5) + Heartbeat.recordEarnings(WorkerHeartbeatResponse(status = "ok", earnings = kept), now = 1_000L) + Heartbeat.persist(context) + + // Change the in-memory state, as a new process would start empty... + Heartbeat.recordEarnings(WorkerHeartbeatResponse(status = "ok", earnings = Earnings(totalUsd = 99.0)), now = 2_000L) + // ...then restore must bring back what was on disk, not what is in memory. + Heartbeat.restore(context) + assertEquals(kept, Heartbeat.earnings.value) + assertEquals(1_000L, Heartbeat.earningsAsOf.value) + } + + @Test + fun `an unreadable stored figure restores as unknown, not a crash`() { + context.getSharedPreferences("heartbeat_state", Context.MODE_PRIVATE).edit() + .putString("earnings_json", "{not json").commit() + Heartbeat.restore(context) + assertNull(Heartbeat.earnings.value) + } +} diff --git a/app/src/test/java/com/cashpilot/android/EarningsDisplayTest.kt b/app/src/test/java/com/cashpilot/android/EarningsDisplayTest.kt index f814535..40d04df 100644 --- a/app/src/test/java/com/cashpilot/android/EarningsDisplayTest.kt +++ b/app/src/test/java/com/cashpilot/android/EarningsDisplayTest.kt @@ -3,7 +3,7 @@ package com.cashpilot.android import com.cashpilot.android.model.Earnings import com.cashpilot.android.model.PlatformEarnings import com.cashpilot.android.model.WorkerHeartbeatResponse -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.Heartbeat import com.cashpilot.android.util.FormatUtils import kotlinx.serialization.json.Json import org.junit.jupiter.api.Assertions.assertEquals @@ -82,18 +82,18 @@ class EarningsDisplayTest { @Test fun `a response without earnings keeps the previous figures`() { val previous = Earnings(totalUsd = 5.0) - assertEquals(previous, HeartbeatService.earningsToKeep(previous, null)) + assertEquals(previous, Heartbeat.earningsToKeep(previous, null)) } @Test fun `a response with earnings replaces them`() { val fresh = Earnings(totalUsd = 9.0) - assertEquals(fresh, HeartbeatService.earningsToKeep(Earnings(totalUsd = 5.0), fresh)) + assertEquals(fresh, Heartbeat.earningsToKeep(Earnings(totalUsd = 5.0), fresh)) } @Test fun `nothing known stays nothing known`() { - assertNull(HeartbeatService.earningsToKeep(null, null)) + assertNull(Heartbeat.earningsToKeep(null, null)) } // --- the wire format -------------------------------------------------- diff --git a/app/src/test/java/com/cashpilot/android/HeartbeatServiceCompanionTest.kt b/app/src/test/java/com/cashpilot/android/HeartbeatCompanionTest.kt similarity index 63% rename from app/src/test/java/com/cashpilot/android/HeartbeatServiceCompanionTest.kt rename to app/src/test/java/com/cashpilot/android/HeartbeatCompanionTest.kt index fb0902f..d929d54 100644 --- a/app/src/test/java/com/cashpilot/android/HeartbeatServiceCompanionTest.kt +++ b/app/src/test/java/com/cashpilot/android/HeartbeatCompanionTest.kt @@ -1,32 +1,32 @@ package com.cashpilot.android -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.Heartbeat import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test -class HeartbeatServiceCompanionTest { +class HeartbeatCompanionTest { @Test fun `lastHeartbeat initial value is zero`() { - assertEquals(0L, HeartbeatService.lastHeartbeat.value) + assertEquals(0L, Heartbeat.lastHeartbeat.value) } @Test fun `lastHeartbeatFailed initial value is false`() { - assertFalse(HeartbeatService.lastHeartbeatFailed.value) + assertFalse(Heartbeat.lastHeartbeatFailed.value) } @Test fun `lastHeartbeat is a StateFlow`() { // Verify it's accessible as a non-null StateFlow - val flow = HeartbeatService.lastHeartbeat + val flow = Heartbeat.lastHeartbeat assertNotNull(flow) assertNotNull(flow.value) } @Test fun `lastHeartbeatFailed is a StateFlow`() { - val flow = HeartbeatService.lastHeartbeatFailed + val flow = Heartbeat.lastHeartbeatFailed assertNotNull(flow) assertNotNull(flow.value) } diff --git a/app/src/test/java/com/cashpilot/android/HeartbeatLogicTest.kt b/app/src/test/java/com/cashpilot/android/HeartbeatLogicTest.kt index 5801954..18bc8aa 100644 --- a/app/src/test/java/com/cashpilot/android/HeartbeatLogicTest.kt +++ b/app/src/test/java/com/cashpilot/android/HeartbeatLogicTest.kt @@ -5,77 +5,13 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test /** - * Tests the pure logic patterns extracted from HeartbeatService: - * - Exponential backoff calculation + * Tests the pure logic patterns used by Heartbeat.send: * - URL building from settings * - Server readiness checks */ class HeartbeatLogicTest { - // --- Backoff calculation (matches HeartbeatService.onStartCommand) --- - - private fun computeBackoff(baseIntervalSeconds: Int, consecutiveFailures: Int): Long { - val baseDelay = (baseIntervalSeconds * 1000L).coerceAtLeast(5_000L) - return if (consecutiveFailures > 0) { - (baseDelay * (1L shl consecutiveFailures.coerceAtMost(3))) - .coerceAtMost(300_000L) - } else { - baseDelay - } - } - - @Test - fun `backoff with zero failures returns base delay`() { - assertEquals(30_000L, computeBackoff(30, 0)) - } - - @Test - fun `backoff with 1 failure doubles delay`() { - assertEquals(60_000L, computeBackoff(30, 1)) - } - - @Test - fun `backoff with 2 failures quadruples delay`() { - assertEquals(120_000L, computeBackoff(30, 2)) - } - - @Test - fun `backoff with 3 failures 8x delay`() { - assertEquals(240_000L, computeBackoff(30, 3)) - } - - @Test - fun `backoff with 4+ failures capped at 3 shifts`() { - // consecutiveFailures.coerceAtMost(3) means 4 failures = same as 3 - assertEquals(240_000L, computeBackoff(30, 4)) - assertEquals(240_000L, computeBackoff(30, 10)) - assertEquals(240_000L, computeBackoff(30, 100)) - } - - @Test - fun `backoff capped at 5 minutes`() { - // With a 60s base interval and 3 failures: 60000 * 8 = 480000, capped at 300000 - assertEquals(300_000L, computeBackoff(60, 3)) - } - - @Test - fun `backoff minimum base delay is 5 seconds`() { - // If interval is 0, coerceAtLeast(5000) kicks in - assertEquals(5_000L, computeBackoff(0, 0)) - assertEquals(10_000L, computeBackoff(0, 1)) - } - - @Test - fun `backoff with 1 second interval uses minimum 5 seconds`() { - assertEquals(5_000L, computeBackoff(1, 0)) - } - - @Test - fun `backoff with negative interval uses minimum 5 seconds`() { - assertEquals(5_000L, computeBackoff(-10, 0)) - } - - // --- URL building (matches HeartbeatService.sendHeartbeat) --- + // --- URL building (matches Heartbeat.send) --- private fun buildHeartbeatUrl(serverUrl: String): String = serverUrl.trimEnd('/') + "/api/workers/heartbeat" diff --git a/app/src/test/java/com/cashpilot/android/HeartbeatPayloadBuildTest.kt b/app/src/test/java/com/cashpilot/android/HeartbeatPayloadBuildTest.kt index 9814e9a..3e06e5b 100644 --- a/app/src/test/java/com/cashpilot/android/HeartbeatPayloadBuildTest.kt +++ b/app/src/test/java/com/cashpilot/android/HeartbeatPayloadBuildTest.kt @@ -13,14 +13,14 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test /** - * Tests the full heartbeat payload construction pattern as used by HeartbeatService.sendHeartbeat. + * Tests the full heartbeat payload construction pattern as used by Heartbeat.send. * This exercises the model classes with realistic data matching production usage. */ class HeartbeatPayloadBuildTest { private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } - /** Mirrors the container-building logic from HeartbeatService.sendHeartbeat */ + /** Mirrors the container-building logic from Heartbeat.send */ private fun buildContainers(apps: List): List = apps.map { app -> AppContainer( @@ -34,7 +34,7 @@ class HeartbeatPayloadBuildTest { ) } - /** Mirrors the heartbeat building logic from HeartbeatService.sendHeartbeat */ + /** Mirrors the heartbeat building logic from Heartbeat.send */ private fun buildHeartbeat( deviceName: String, apps: List, @@ -223,18 +223,4 @@ class HeartbeatPayloadBuildTest { val filtered = KnownApps.all.filter { it.slug in allSlugs } assertEquals(KnownApps.all.size, filtered.size) } - - @Test - fun `running count matches HeartbeatService notification format`() { - val apps = listOf( - AppStatus(slug = "earnapp", running = true), - AppStatus(slug = "iproyal", running = true), - AppStatus(slug = "grass", running = false), - AppStatus(slug = "titan", running = true), - AppStatus(slug = "nodle", running = false), - ) - val runningCount = apps.count { it.running == true } - val notificationText = "$runningCount/${apps.size} apps running" - assertEquals("3/5 apps running", notificationText) - } } diff --git a/app/src/test/java/com/cashpilot/android/HeartbeatServiceStateFlowTest.kt b/app/src/test/java/com/cashpilot/android/HeartbeatStateFlowTest.kt similarity index 57% rename from app/src/test/java/com/cashpilot/android/HeartbeatServiceStateFlowTest.kt rename to app/src/test/java/com/cashpilot/android/HeartbeatStateFlowTest.kt index b70dec5..a1e8a68 100644 --- a/app/src/test/java/com/cashpilot/android/HeartbeatServiceStateFlowTest.kt +++ b/app/src/test/java/com/cashpilot/android/HeartbeatStateFlowTest.kt @@ -1,56 +1,56 @@ package com.cashpilot.android -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.Heartbeat import kotlinx.coroutines.flow.StateFlow import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test /** - * Extended tests for HeartbeatService companion StateFlows. + * Extended tests for Heartbeat StateFlows. * Covers the StateFlow contract: initial values, type checking, and immutability. */ -class HeartbeatServiceStateFlowTest { +class HeartbeatStateFlowTest { @Test fun `lastHeartbeat is StateFlow of Long`() { - val flow: StateFlow = HeartbeatService.lastHeartbeat + val flow: StateFlow = Heartbeat.lastHeartbeat assertNotNull(flow) assertTrue(flow.value is Long) } @Test fun `lastHeartbeatFailed is StateFlow of Boolean`() { - val flow: StateFlow = HeartbeatService.lastHeartbeatFailed + val flow: StateFlow = Heartbeat.lastHeartbeatFailed assertNotNull(flow) assertTrue(flow.value is Boolean) } @Test fun `lastHeartbeat initial value is exactly zero`() { - assertEquals(0L, HeartbeatService.lastHeartbeat.value) + assertEquals(0L, Heartbeat.lastHeartbeat.value) } @Test fun `lastHeartbeatFailed initial value is exactly false`() { - assertEquals(false, HeartbeatService.lastHeartbeatFailed.value) + assertEquals(false, Heartbeat.lastHeartbeatFailed.value) } @Test fun `lastHeartbeat and lastHeartbeatFailed are distinct flows`() { - assertNotSame(HeartbeatService.lastHeartbeat, HeartbeatService.lastHeartbeatFailed) + assertNotSame(Heartbeat.lastHeartbeat, Heartbeat.lastHeartbeatFailed) } @Test fun `lastHeartbeat returns same instance on multiple accesses`() { - val flow1 = HeartbeatService.lastHeartbeat - val flow2 = HeartbeatService.lastHeartbeat + val flow1 = Heartbeat.lastHeartbeat + val flow2 = Heartbeat.lastHeartbeat assertSame(flow1, flow2) } @Test fun `lastHeartbeatFailed returns same instance on multiple accesses`() { - val flow1 = HeartbeatService.lastHeartbeatFailed - val flow2 = HeartbeatService.lastHeartbeatFailed + val flow1 = Heartbeat.lastHeartbeatFailed + val flow2 = Heartbeat.lastHeartbeatFailed assertSame(flow1, flow2) } } diff --git a/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt b/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt index 593e174..bd9b948 100644 --- a/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt +++ b/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt @@ -71,7 +71,7 @@ import java.io.File class IconGoldenTest { /** Drawables that are not part of the migrated icon set and need no golden. */ - private val NOT_AN_ICON = setOf("ic_github", "ic_notification", "ic_launcher_background") + private val NOT_AN_ICON = setOf("ic_github", "ic_launcher_background") /** diff --git a/app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt b/app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt index 6a6bb51..aa481e3 100644 --- a/app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt +++ b/app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt @@ -436,7 +436,7 @@ class ModelEdgeCaseTest { assertTrue("titan" in slugs) } - // --- Container building pattern (from HeartbeatService) --- + // --- Container building pattern (from Heartbeat.send) --- @Test fun `container building from app status running`() { diff --git a/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt b/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt index c8377cd..601d819 100644 --- a/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt +++ b/app/src/test/java/com/cashpilot/android/PerWorkerKeyTest.kt @@ -2,7 +2,7 @@ package com.cashpilot.android import com.cashpilot.android.model.Settings import com.cashpilot.android.model.WorkerHeartbeatResponse -import com.cashpilot.android.service.HeartbeatService +import com.cashpilot.android.service.Heartbeat import kotlinx.serialization.json.Json import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -24,47 +24,47 @@ class PerWorkerKeyTest { assertEquals("own", Settings(apiKey = "shared", workerKey = "own").activeKey) } - // --- HeartbeatService.keyToPersist ------------------------------------- + // --- Heartbeat.keyToPersist ------------------------------------- @Test fun `keyToPersist adopts a newly issued key`() { - assertEquals("issued", HeartbeatService.keyToPersist(current = "", issued = "issued")) + assertEquals("issued", Heartbeat.keyToPersist(current = "", issued = "issued")) } @Test fun `keyToPersist ignores an unchanged key`() { - assertNull(HeartbeatService.keyToPersist(current = "own", issued = "own")) + assertNull(Heartbeat.keyToPersist(current = "own", issued = "own")) } @Test fun `keyToPersist ignores absent or blank keys`() { - assertNull(HeartbeatService.keyToPersist(current = "own", issued = null)) - assertNull(HeartbeatService.keyToPersist(current = "own", issued = "")) - assertNull(HeartbeatService.keyToPersist(current = "own", issued = " ")) + assertNull(Heartbeat.keyToPersist(current = "own", issued = null)) + assertNull(Heartbeat.keyToPersist(current = "own", issued = "")) + assertNull(Heartbeat.keyToPersist(current = "own", issued = " ")) } - // --- HeartbeatService.settingsAfterHeartbeat ---------------------------- + // --- Heartbeat.settingsAfterHeartbeat ---------------------------- @Test fun `settingsAfterHeartbeat adopts a newly issued worker key`() { val settings = Settings(apiKey = "shared", workerKey = "") val body = WorkerHeartbeatResponse(status = "ok", workerKey = "issued") - assertEquals("issued", HeartbeatService.settingsAfterHeartbeat(settings, body)?.workerKey) + assertEquals("issued", Heartbeat.settingsAfterHeartbeat(settings, body)?.workerKey) } @Test fun `settingsAfterHeartbeat returns null when the worker key is unchanged, blank, or absent`() { val settings = Settings(apiKey = "shared", workerKey = "own") - assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = "own"))) - assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = ""))) - assertNull(HeartbeatService.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = null))) + assertNull(Heartbeat.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = "own"))) + assertNull(Heartbeat.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = ""))) + assertNull(Heartbeat.settingsAfterHeartbeat(settings, WorkerHeartbeatResponse(status = "ok", workerKey = null))) } @Test fun `settingsAfterHeartbeat compares against the current worker key, not the api key`() { val settings = Settings(apiKey = "different", workerKey = "own") val body = WorkerHeartbeatResponse(status = "ok", workerKey = "own") - assertNull(HeartbeatService.settingsAfterHeartbeat(settings, body)) + assertNull(Heartbeat.settingsAfterHeartbeat(settings, body)) } // --- WorkerHeartbeatResponse deserialization --------------------------- diff --git a/docs/configuration.md b/docs/configuration.md index 76d7063..03a1e87 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,10 +10,10 @@ Everything the app stores, where you set it, and what it does. There are no conf | Fleet API Key | Setup step 1, or Settings, Server Connection | `api_key` | empty | The server's `CASHPILOT_API_KEY`. Used only until the phone has its own key (next section). | | Per-worker key | Nothing to set: the server issues it | `worker_key` | empty | This phone's own key, received on its first heartbeat and used for every heartbeat after that. | | Monitored apps | Settings, Monitored Apps | `enabled_slugs` | all 11 on | Which apps the dashboard and the heartbeat include. A switched-off app shows as **Disabled** and is left out of the heartbeat. | -| Heartbeat interval | No control in the app | `heartbeat_interval` | 30 seconds | Time between heartbeats. The service never goes below 5 seconds. | +| Heartbeat interval | No control in the app | `heartbeat_interval` | 30 seconds | Stored but no longer used. Heartbeats run every 15 minutes, the shortest period Android's WorkManager allows. | | Setup completed | Finishing or skipping the setup screen | `setup_completed` | false | Whether the setup screen is shown at launch. An install that already had a URL and a key before this setting existed counts as set up. | -Changes to the URL and the key are saved half a second after you stop typing. The heartbeat service reads the settings again before every heartbeat, so a change takes effect on the next one; no restart is needed. +Changes to the URL and the key are saved half a second after you stop typing. The heartbeat reads the settings again every time it runs, so a change takes effect on the next one; no restart is needed. ## The server URL: use https @@ -26,7 +26,7 @@ The app accepts an `http://` URL only for a server it can tell is private, becau A public host over `http://` is refused (`http://cashpilot.example.com`, `http://8.8.8.8`). Android itself lets an app allow plain http per host name or domain suffix, never per address range, so the app keeps cleartext on at the platform level and does the refusing itself, before any request is made. -On the hosts above, the fleet key, the per-worker key and the app data travel unencrypted, and the URL field turns red with **Insecure: API key and app data will be sent unencrypted. Use https:// if possible.** Any other `http://` URL is refused: the field shows **CashPilot sends your API key over http:// only to a server on your own network: localhost, a LAN, link-local or Tailscale address, a name ending in .local, .lan, .home.arpa, .internal or .ts.net, or a bare name with no dot. Use https:// for anything else.**, the dashboard and the notification read **Not sending heartbeats: this server URL needs https://**, and no request leaves the phone. An `http://` URL saved by an older version is refused the same way. +On the hosts above, the fleet key, the per-worker key and the app data travel unencrypted, and the URL field turns red with **Insecure: API key and app data will be sent unencrypted. Use https:// if possible.** Any other `http://` URL is refused: the field shows **CashPilot sends your API key over http:// only to a server on your own network: localhost, a LAN, link-local or Tailscale address, a name ending in .local, .lan, .home.arpa, .internal or .ts.net, or a bare name with no dot. Use https:// for anything else.**, the dashboard reads **Not sending heartbeats: this server URL needs https://**, and no request leaves the phone. An `http://` URL saved by an older version is refused the same way. ## The fleet key and enrollment @@ -41,10 +41,10 @@ The server side of this is on CashPilot's [fleet page](https://geiserx.github.io ## Heartbeat timing -- One heartbeat every 30 seconds while a server URL and a key are set. -- After a failed heartbeat the wait doubles, up to eight times the interval and never more than 5 minutes: 60, 120, 240, then 240 seconds again with the default interval. The first successful heartbeat resets it. +- One heartbeat every 15 minutes while a server URL and a key are set, plus one when you open the app and one when you change the server URL or key. Android may delay a scheduled one while the phone is idle. +- A failed heartbeat is not retried on its own: the next one is the next 15-minute run, or the next time you open the app or change the server URL or key. - Each request gives up after 15 seconds (10 seconds to connect). -- The service starts when the app opens and again after the phone restarts. +- Android's WorkManager keeps the schedule, including across a phone restart. The app runs no service and shows no notification. ## Monitored apps diff --git a/docs/getting-started.md b/docs/getting-started.md index 6dc4aba..ccec76e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,7 +13,6 @@ The app is not in Google Play or F-Droid. The APK on GitHub is the only build, s 1. Open the [latest release](https://github.com/GeiserX/CashPilot-android/releases/latest) on the phone and download `app-release.apk`. The `.aab` file next to it is for app stores; ignore it. 2. Open the downloaded file. Android asks you to allow installs from the browser or file manager you used ("Install unknown apps"); allow it for that app, then install. -3. On Android 13 and later, the first launch asks to show notifications. Allow it: the app keeps one quiet notification while it monitors, and without the permission you cannot see it. To update, install the newer APK over the old one. Settings are kept. @@ -24,15 +23,14 @@ The first launch opens **Welcome to CashPilot**, four steps on one screen. 1. **Server Connection** (optional). Enter the **CashPilot Server URL**, for example `https://cashpilot.example.com`, and the **Fleet API Key**: the server's `CASHPILOT_API_KEY`, which the owner can copy from the fleet page with **Reveal API Key** ([how the server's keys work](https://geiserx.github.io/CashPilot/fleet/#authentication)). Leave both empty to run without a server; you can pair later from Settings. 2. **Notification Access**. **Open Settings** takes you to Android's list; switch CashPilot on. The app only checks whether a monitored app has an ongoing notification; it does not read notification content. 3. **Usage Access**. **Open Settings**, then switch CashPilot on. This gives the app each monitored app's last foreground time and its network traffic. -4. **Battery Optimization**. **Open Settings** and allow CashPilot to run unrestricted, or Android may stop the background service when the screen is off. +4. **Battery Optimization**. Optional. **Open Settings** opens Android's list of battery-optimised apps; choose All apps, then CashPilot, then Don't optimize. Without it, reports can arrive late while the screen is off. The main button unlocks once both accesses are granted. It reads **Continue to Dashboard** when a server URL and key are filled in, and **Continue without a server** when they are not. **Skip for now** leaves the setup screen without the permissions; the dashboard then asks for them. ## What a working start looks like - The dashboard lists the 11 monitored apps. Installed ones show **Running**, **Stopped** or **Can't tell**; the others show **Not installed — tap to install**. The summary at the top counts them. -- A notification titled **CashPilot** stays in the shade. Without a server it reads **Monitoring apps...**; with one it reads, for example, **3/4 apps running** after each heartbeat. -- With a server, the dashboard's summary shows a green dot and **Last heartbeat** with a time, and the phone appears on the server's fleet page as its maker and model plus a short device id, for example `Google Pixel 8 (a1b2c3d4)`. The first heartbeat also enrolls the phone: the server issues it its own key ([Configuration](configuration.md#the-fleet-key-and-enrollment)). +- With a server, the dashboard's summary shows a green dot and **Last report** with its age, for example **Last report 6 min ago · sent every 15 min**, and the phone appears on the server's fleet page as its maker and model plus a short device id, for example `Google Pixel 8 (a1b2c3d4)`. The first heartbeat also enrolls the phone: the server issues it its own key ([Configuration](configuration.md#the-fleet-key-and-enrollment)). - Without a server, the summary shows **Not paired — running standalone**. That is a supported mode, not an error. If a step does not look like this, see [Troubleshooting](troubleshooting.md). diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 8059c32..ebe37ab 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -16,7 +16,7 @@ CashPilot Server (fleet dashboard) | POST /api/workers/heartbeat (Bearer auth; HTTPS if the server URL uses it) | CashPilot Android -├── HeartbeatService (foreground, periodic POST) +├── HeartbeatWorker (WorkManager, POST every 15 min) ├── AppNotificationListener (instant app detection) ├── AppDetector (UsageStats + NetworkStats) └── Jetpack Compose UI (dashboard + settings) diff --git a/docs/index.md b/docs/index.md index 88d7a7b..7f32ea5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,7 +32,7 @@ hide: --- - Grant two accesses and the battery exemption, and pair a server if you have one. + Grant two accesses, and pair a server if you have one. - :material-view-dashboard-outline: **[Usage](usage.md)** @@ -53,7 +53,7 @@ hide: - Shows every monitored app as **Running**, **Stopped**, **Can't tell**, **Disabled** or **Not installed**, the ones that need attention first. - Sees a running app the moment its ongoing notification appears, and confirms quiet ones from their recent foreground use and network traffic. - Says **Can't tell** when a missing permission hides an app, instead of calling it stopped, so you fix the permission rather than restart an app that was fine. -- Sends a heartbeat to your CashPilot server every 30 seconds, so the fleet page lists the phone and its apps next to your servers. +- Sends a heartbeat to your CashPilot server every 15 minutes, and when you open the app, so the fleet page lists the phone and its apps next to your servers. - Shows the earnings the server recorded for the platforms on the phone, marks figures older than an hour, and never shows a missing figure as zero. - Works without a server as a monitor for the phone alone. @@ -61,8 +61,8 @@ hide: ## How it runs -- One foreground service with a quiet notification, restarted when the phone boots. No root, no ADB, no accessibility service. -- Two special accesses, Notification Access and Usage Access, plus an exemption from battery optimization so Android does not stop the service with the screen off. +- No always-running service and no permanent notification: Android's WorkManager runs the heartbeat, and keeps the schedule across restarts. No root, no ADB, no accessibility service. +- Two special accesses, Notification Access and Usage Access. Turning battery optimization off for CashPilot is optional and keeps reports on time with the screen off. - One `POST /api/workers/heartbeat` to your server per interval, authenticated with this phone's own key after the first one. The settings are on [Configuration](configuration.md). - Installed from the APK on [GitHub releases](https://github.com/GeiserX/CashPilot-android/releases/latest); it is not in Google Play or F-Droid. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2c4591d..67d44ad 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,11 +14,11 @@ Symptom, cause, fix. The texts in bold are what the app shows. **Fix:** pull the dashboard down to check again after a few minutes. If it stays Stopped while the app is earning, open an issue with the app's name and version. -## Heartbeats stop when the screen is off +## Heartbeats arrive late when the screen is off -**Cause:** battery optimization. Android pauses or kills background services of optimized apps, and some makers add their own limits on top. +**Cause:** battery optimization. Android holds back scheduled work of optimized apps while the phone is idle, and some makers add their own limits on top. Heartbeats are due every 15 minutes, so a gap of up to about an hour on an idle phone is Android's doing, not a fault. -**Fix:** Settings, **Disable Battery Optimization**, until it reads **Battery optimization disabled**. On phones with an extra "auto-start" or "background activity" setting, allow CashPilot there too. The service starts again on its own after a restart of the phone. +**Fix:** Settings, **Disable Battery Optimization**. It opens Android's list; choose All apps, then CashPilot, then Don't optimize, until the app reads **Battery optimization disabled**. On phones with an extra "auto-start" or "background activity" setting, allow CashPilot there too. The schedule survives a restart of the phone. ## Server rejected heartbeat (401) @@ -32,9 +32,9 @@ Symptom, cause, fix. The texts in bold are what the app shows. **Fix:** enter the server's base URL only, for example `https://cashpilot.example.com`, without `/api/...`. The app adds `/api/workers/heartbeat` itself. Check the server's log for the 5xx. -## Heartbeat failed — retrying... +## Red dot next to Last report -**Cause:** the phone cannot reach the server: wrong host or port, the phone is off your network, or a certificate the phone does not trust (the app trusts only the system certificate authorities, so a self-signed certificate, or one from an authority you installed on the phone yourself, fails). The app retries with a growing wait, up to 4 minutes apart with the default interval. +**Cause:** the phone cannot reach the server: wrong host or port, the phone is off your network, or a certificate the phone does not trust (the app trusts only the system certificate authorities, so a self-signed certificate, or one from an authority you installed on the phone yourself, fails). The app tries again at the next 15-minute report, or when you open it or change the server URL or key. **Fix:** open the server URL in the phone's browser. If the browser cannot load it, neither can the app. A server that is only on your home network needs the phone on that network or on a VPN to it. diff --git a/docs/usage.md b/docs/usage.md index f33ea84..0a44a51 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -7,7 +7,7 @@ Day to day you open the dashboard, look at what needs attention, and leave the a From top to bottom: - **The summary.** How many monitored apps are **Running**, **Stopped**, **unknown** and **N/A** (not installed), the upload and download traffic of all monitored apps over the last 24 hours, and, once a server is set, the phone's public IP. -- **The server line.** **Not paired — running standalone** without a server (tap it to open Settings). With a server: a green dot and **Last heartbeat** with the time, a red dot when the last heartbeat failed, a grey one and **No heartbeat yet** before the first. +- **The server line.** **Not paired — running standalone** without a server (tap it to open Settings). With a server: a green dot and **Last report** with its age and **sent every 15 min**, a red dot when the last report failed, a grey one and **No report sent yet** before the first. - **The earnings card** (below). - **One card per app**, the apps that need attention first: Stopped, then Can't tell, then Running, then Disabled, then Not installed. @@ -41,18 +41,6 @@ With a server paired, each heartbeat's answer can carry what the server has reco - **Shared across your devices** marks a platform whose provider reports one balance for your whole account, so the figure is not what this phone earned alone. - Without a server the card reads **Pair a CashPilot server to see earnings**. -## The notification - -The service keeps one silent, low-priority notification titled **CashPilot**: - -| Text | When | -|---|---| -| Monitoring apps... | The service has started and has sent no heartbeat yet; without a server it stays this way. | -| 3/4 apps running | After a heartbeat: running apps out of installed, switched-on apps. | -| 3/4 apps running, 1 unknown | Same, with apps in Can't tell. | -| Server rejected heartbeat (401) | The server answered with an error code. | -| Heartbeat failed — retrying... | The server could not be reached. | - ## Settings The gear icon on the dashboard opens Settings: diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 370fc24..b727e29 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -9,7 +9,7 @@ How it knows: All of this is read on the phone. Without a server, the app list is the whole app. -With a self-hosted CashPilot server (https://github.com/GeiserX/CashPilot), the phone also joins your fleet. A small foreground service sends the apps' status to your server on a schedule, and the dashboard shows your earnings from the server. The phone sends app status, never notification content. +With a self-hosted CashPilot server (https://github.com/GeiserX/CashPilot), the phone also joins your fleet. The app sends the apps' status to your server every 15 minutes, and the dashboard shows your earnings from the server. The phone sends app status, never notification content. Privacy: no account, no analytics, no ads. Data goes only to the server you set. Once a server is set, the app makes one request to api.ipify.org to show your public IP. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02ac7f3..68677fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }