Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<versionCode>.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. |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 15 additions & 23 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
android:protectionLevel="signature|privileged|development|appop|retailDemo" />
<uses-permission android:name="android.permission.READ_NETWORK_USAGE_HISTORY" />
Expand Down Expand Up @@ -56,24 +52,20 @@
</intent-filter>
</service>

<!-- Heartbeat foreground service — sends periodic status to CashPilot server -->
<service
android:name=".service.HeartbeatService"
<!-- Heartbeats run on WorkManager (service/HeartbeatWorker.kt), which
declares its own components and reschedules itself after a reboot.
CashPilotApp supplies its configuration, so WorkManager starts on
first use instead of from androidx.startup. -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Periodic heartbeat to CashPilot server reporting passive income app status" />
</service>

<!-- Boot receiver — restart heartbeat after reboot -->
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

</application>
</manifest>
24 changes: 23 additions & 1 deletion app/src/main/java/com/cashpilot/android/CashPilotApp.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
20 changes: 0 additions & 20 deletions app/src/main/java/com/cashpilot/android/service/BootReceiver.kt

This file was deleted.

Loading
Loading