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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
#
# verify, never record: a CI job that RE-RECORDS makes every change agree
# with itself, which is the one outcome a visual gate must never produce.
- name: Verify icon goldens
- name: Verify icon and screen goldens
if: github.event_name != 'pull_request' || steps.filter.outputs.code == 'true'
run: ./gradlew verifyRoborazziDebug

Expand Down
34 changes: 18 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ Android monitoring agent for CashPilot that tracks passive income apps (EarnApp,

**No working JVM on either Mac** — the dev machine has no Java, and the other Mac's Temurin 17 crashes at startup (`SIGBUS ... CodeHeap::allocate`, even on `java -version` and in `-Xint`). Use `scripts/remote-gradle.sh test|assembleDebug|lintDebug`, which runs Gradle in `eclipse-temurin:17-jdk` on a build host you name in `CASHPILOT_BUILD_HOST` and `CASHPILOT_BUILD_ROOT` (environment or the git-ignored `scripts/remote-gradle.env`; no defaults) (~26s warm). That is a **faster pre-check, not a replacement for CI** — CI additionally builds the signed release variant and runs lint against the baseline, so never report a green local run as "CI passed". Lint baseline at `app/lint-baseline.xml` — new lint errors are fatal.

### Icon goldens
### Goldens

`app/src/test/screenshots/` holds one golden PNG per icon the app draws.
Roborazzi renders Compose on the JVM through Robolectric, so there is no
emulator and no device involved and it runs anywhere Gradle does.
`app/src/test/screenshots/` holds one golden PNG per icon, and `screens/` one per
screen: every screen in four situations (healthy, problems, standalone, blind),
dark and light, plus three at font scale 1.3. Roborazzi renders Compose on the
JVM through Robolectric, so there is no emulator and it runs anywhere Gradle does.

```bash
./scripts/remote-gradle.sh verifyRoborazziDebug # fail on any visual change (what CI runs)
Expand All @@ -39,16 +40,18 @@ emulator and no device involved and it runs anywhere Gradle does.
`remote-gradle.sh` syncs the goldens and any failure diffs back from the build
host; without that the record task looks like it did nothing.

They exist for one job: `material-icons-extended` is frozen at 1.7.8 and the
migration to per-icon vector drawables is sixteen substitutions whose only
acceptable outcome is "nothing changed visually". A diff during that migration
means the glyph is not the same one.
Screen goldens are stable because Robolectric's NATIVE graphics draw text with
the fonts bundled in its android-all jar (not the machine's), the SDK is pinned
to 34, and every age and the version string on screen come from fixed test
values (the clock in `board/Situations.kt`, the version string in `board/Screens.kt`),
never the real clock or `BuildConfig`. Look at a
re-recorded image before committing it: a golden only proves "unchanged".
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

**Icons only, not whole screens — on purpose.** Screen goldens are dominated by
text, and text rendering depends on the fonts on the machine that rendered it,
so they fail for reasons unrelated to the change and then get muted. Icons are
pure vector geometry. Screen-level goldens belong with the UI-renewal work,
where the fonts can be pinned deliberately.
The screen list in `board/Screens.kt` also drives `AccessibilityTest` (48 dp
targets measured on the node's own size, since Compose widens touch bounds of
every clickable; a name on every tappable node; a heading per screen; no text
unreachable at font scale 1.3; TalkBack order on the home screen). Contrast is
`PaletteContrastTest`.

### Repository & Infrastructure

Expand All @@ -66,9 +69,8 @@ com.cashpilot.android/
├── model/ # Data classes (Heartbeat, AppStatus, MonitoredApp, Settings)
├── service/ # Background work (Heartbeat, HeartbeatWorker, AppNotificationListener, AppDetector)
├── ui/ # Compose UI (MainActivity, screens, theme, components)
│ ├── screen/ # Full-screen composables (Dashboard, Settings)
│ ├── theme/ # Material 3 theme
│ └── component/ # Reusable composable components
│ ├── board/ # The screens: Board.kt (every word and colour, pure, unit-tested), Kit, HomeScreens, Onboarding, SettingsScreen, BoardApp (navigation)
│ └── theme/ # Classic night and Light palettes
└── util/ # Utilities (SettingsStore, formatters)
```

Expand Down
13 changes: 8 additions & 5 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ It is easy to assume a store user installs this, has no CashPilot server, and
gets nothing. That is not what happens, and it changes what the listing should
promise.

- The **app grid** — which monitored apps are installed, which are running,
when each was last active — is gated on **permissions**, not on a server.
`AppDetector` uses three Android APIs and makes no network call.
- The **server** gates exactly one thing: the earnings card. Without one it
renders *"Pair a CashPilot server to see earnings"*.
- The **status board** — which supported apps are installed, which are
running, when each was last seen — is gated on **permissions**, not on a
server. `AppDetector` uses three Android APIs and makes no network call.
- The **server** gates exactly one thing: the earnings strip. Without one it
reads *"Add a server to see figures"*.
- A reviewer with none of the supported apps sees *"No supported app on this
phone yet"* and can open **Show a demo with sample data**, labelled as
sample data on every screen.

So a store user who grants the two access permissions gets a working
app-state monitor. Whether that is a listing worth shipping — and whether it
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ CashPilot Android is the Android companion app for [CashPilot](https://github.co
- Instant detection from each app's ongoing notification (NotificationListenerService)
- Confirms activity from per-app network traffic and recent foreground use (NetworkStatsManager, UsageStatsManager)
- Periodic heartbeats to your server (`POST /api/workers/heartbeat`), so phones appear next to Docker workers in the fleet view
- Sends data only to your own CashPilot server, plus one request to `api.ipify.org` to show your public IP
- Sends data only to your own CashPilot server, and to nothing else
- No root required; Android 8.0 or later

## Quick start
Expand Down
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ tasks.register<JacocoReport>("jacocoTestReport") {
val excludes = listOf(
"**/R.class", "**/R\$*.class", "**/BuildConfig.class",
// Android framework classes (require instrumented tests, not unit tests)
"**/ui/screen/*Screen*",
// Composables: covered by the screen goldens and accessibility tests,
// which JaCoCo does not count as line coverage.
"**/ui/board/*Screen*", "**/ui/board/*ScreensKt*", "**/ui/board/KitKt*",
"**/ui/board/OnboardingKt*", "**/ui/board/BoardAppKt*",
"**/ui/theme/*",
"**/ui/MainActivity*",
"**/ui/MainViewModel.class",
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/cashpilot/android/model/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,19 @@ data class Settings(
) {
/** The key to authenticate heartbeats with: our own once enrolled, else the shared bootstrap key. */
val activeKey: String get() = workerKey.ifBlank { apiKey }

/**
* The settings with a new server address. A different address drops this
* phone's own key: it was issued by the old server and must never be sent
* to the new one.
*/
fun withServerUrl(url: String): Settings =
if (url.trim() == serverUrl.trim()) copy(serverUrl = url) else copy(serverUrl = url, workerKey = "")

/**
* The settings with a new shared key typed in. A different key drops this
* phone's own key, so the next report enrolls with the key just typed and a
* connection test proves that key, not the one issued earlier.
*/
fun withApiKey(key: String): Settings = if (key.trim() == apiKey.trim()) copy(apiKey = key) else copy(apiKey = key, workerKey = "")
}
41 changes: 34 additions & 7 deletions app/src/main/java/com/cashpilot/android/service/Heartbeat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ import kotlinx.coroutines.sync.withLock
* heartbeat yet" and "nothing read yet" for up to 15 minutes.
*/
object Heartbeat {

/**
* What one send ended with, so a screen can say what the server answered.
* The pairing step needs to tell "the server refused the key" from "nothing
* answered": the first means copy the key again, the second means check the
* address or the network.
*/
enum class Outcome { SENT, NOT_CONFIGURED, URL_REFUSED, KEY_REFUSED, REJECTED, NOT_CASHPILOT, UNREACHABLE }

/**
* Whether a 2xx answer came from a CashPilot server that registered this
* phone. Both the web server and Desktop's fleet server answer
* `{"status": "ok", "worker_id": ...}`; a login page, a proxy or another
* endpoint answers 200 with something else, even a bare `{"status": "ok"}`.
*/
fun acceptedBy(body: WorkerHeartbeatResponse?): Boolean = body?.status == "ok" && body.workerId != null

/** The outcome for an HTTP status that was not a success. */
fun outcomeOf(status: Int): Outcome = if (status == 401 || status == 403) Outcome.KEY_REFUSED else Outcome.REJECTED

private const val TAG = "Heartbeat"
private const val PREFS = "heartbeat_state"
private const val K_LAST = "last_heartbeat"
Expand Down Expand Up @@ -198,10 +218,10 @@ object Heartbeat {
* Settings are read inside the lock, so each run uses what is stored when it
* starts, not what was stored when it was queued.
*/
suspend fun sendOnce(context: Context) = sendLock.withLock {
suspend fun sendOnce(context: Context): Outcome = sendLock.withLock {
val settings = SettingsStore.settings(context).first()
matchServer(context, settings.serverUrl)
if (settings.serverUrl.isBlank() || settings.activeKey.isBlank()) return@withLock
if (settings.serverUrl.isBlank() || settings.activeKey.isBlank()) return@withLock Outcome.NOT_CONFIGURED
try {
send(context, settings)
} finally {
Expand All @@ -214,16 +234,16 @@ object Heartbeat {
synchronized(this) { if (reportedServer == settings.serverUrl) block() }
}

private suspend fun send(context: Context, settings: Settings, retried: Boolean = false) {
private suspend fun send(context: Context, settings: Settings, retried: Boolean = false): Outcome {
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)) {
ifStillServer(settings) { _lastHeartbeatFailed.value = true }
Log.w(TAG, "Heartbeat not sent: server URL is not https and not a private address")
return
return Outcome.URL_REFUSED
}
val client = newClient()
try {
return try {
val apps = AppDetector(context).detectAll(settings.enabledSlugs)

// Send apps in both formats for backward compatibility:
Expand Down Expand Up @@ -263,8 +283,12 @@ object Heartbeat {
setBody(heartbeat)
}

if (response.status.isSuccess()) {
val body = runCatching { response.body<WorkerHeartbeatResponse>() }.getOrNull()
val body = if (response.status.isSuccess()) runCatching { response.body<WorkerHeartbeatResponse>() }.getOrNull() else null
if (response.status.isSuccess() && !acceptedBy(body)) {
ifStillServer(settings) { _lastHeartbeatFailed.value = true }
Log.w(TAG, "Heartbeat answered ${response.status.value}, but not by a CashPilot server")
Outcome.NOT_CASHPILOT
} else if (response.status.isSuccess()) {
ifStillServer(settings) {
_lastHeartbeat.value = System.currentTimeMillis()
_lastHeartbeatFailed.value = false
Expand All @@ -282,6 +306,7 @@ object Heartbeat {
SettingsStore.update(context) { current -> settingsAfterAccepted(current, settings, body) }
}.onFailure { Log.w(TAG, "Could not store this device's own fleet key: ${it.message}") }
}
Outcome.SENT
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} else {
ifStillServer(settings) { _lastHeartbeatFailed.value = true }
Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}")
Expand All @@ -295,11 +320,13 @@ object Heartbeat {
return send(context, settings.copy(workerKey = ""), retried = true)
}
}
outcomeOf(response.status.value)
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
ifStillServer(settings) { _lastHeartbeatFailed.value = true }
Log.w(TAG, "Heartbeat failed: ${e.message}")
Outcome.UNREACHABLE
} finally {
client.close()
}
Expand Down
40 changes: 3 additions & 37 deletions app/src/main/java/com/cashpilot/android/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,16 @@ package com.cashpilot.android.ui

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.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.HeartbeatWorker
import com.cashpilot.android.ui.screen.DashboardScreen
import com.cashpilot.android.ui.screen.SettingsScreen
import com.cashpilot.android.ui.screen.SetupScreen
import com.cashpilot.android.ui.board.BoardApp
import com.cashpilot.android.ui.theme.CashPilotTheme
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {

Expand All @@ -49,30 +38,7 @@ class MainActivity : ComponentActivity() {

setContent {
CashPilotTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val settings by viewModel.settings.collectAsState()
var showSettings by rememberSaveable { mutableStateOf(false) }

// Handle system Back from Settings → return to Dashboard
BackHandler(enabled = showSettings && settings.setupCompleted) {
showSettings = false
}

when {
!settings.setupCompleted -> SetupScreen(
viewModel = viewModel,
onComplete = {},
)
showSettings -> SettingsScreen(
viewModel = viewModel,
onBack = { showSettings = false },
)
else -> DashboardScreen(
viewModel = viewModel,
onNavigateToSettings = { showSettings = true },
)
}
}
BoardApp(viewModel)
}
}
}
Expand Down
Loading
Loading