diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc7985..62b5949 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7080a46..68528b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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". -**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 @@ -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) ``` diff --git a/PUBLISHING.md b/PUBLISHING.md index b5f4885..542a331 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -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 diff --git a/README.md b/README.md index 9698e47..fcf8b3b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0482bea..a9d53e4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,7 +182,10 @@ tasks.register("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", diff --git a/app/src/main/java/com/cashpilot/android/model/Settings.kt b/app/src/main/java/com/cashpilot/android/model/Settings.kt index 4646ec8..1ee476d 100644 --- a/app/src/main/java/com/cashpilot/android/model/Settings.kt +++ b/app/src/main/java/com/cashpilot/android/model/Settings.kt @@ -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 = "") } diff --git a/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt b/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt index 519c063..8c12793 100644 --- a/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt +++ b/app/src/main/java/com/cashpilot/android/service/Heartbeat.kt @@ -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" @@ -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 { @@ -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: @@ -263,8 +283,12 @@ object Heartbeat { setBody(heartbeat) } - if (response.status.isSuccess()) { - val body = runCatching { response.body() }.getOrNull() + val body = if (response.status.isSuccess()) runCatching { response.body() }.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 @@ -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 } else { ifStillServer(settings) { _lastHeartbeatFailed.value = true } Log.w(TAG, "Heartbeat rejected: HTTP ${response.status.value}") @@ -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() } 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 eb36b7e..99e8d87 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainActivity.kt @@ -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() { @@ -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) } } } 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 732b28e..e8cc093 100644 --- a/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt +++ b/app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt @@ -1,5 +1,6 @@ package com.cashpilot.android.ui +import android.util.Log import android.app.AppOpsManager import android.app.Application import android.os.PowerManager @@ -20,7 +21,10 @@ import com.cashpilot.android.service.Detection import com.cashpilot.android.service.AppNotificationListener import com.cashpilot.android.service.Heartbeat import com.cashpilot.android.service.HeartbeatWorker +import com.cashpilot.android.ui.board.PairResult +import com.cashpilot.android.ui.board.pairResultOf import com.cashpilot.android.util.SettingsStore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive @@ -33,7 +37,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.net.URL import kotlin.coroutines.coroutineContext /** @@ -81,10 +84,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val detector = AppDetector(application) - companion object { - private const val PUBLIC_IP_URL = "https://api.ipify.org" - } - val settings: StateFlow = SettingsStore.settings(application) .stateIn(viewModelScope, SharingStarted.Eagerly, Settings()) @@ -135,10 +134,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val earnings: StateFlow = Heartbeat.earnings val earningsAsOf: StateFlow = Heartbeat.earningsAsOf - private val _publicIp = MutableStateFlow(null) - val publicIp: StateFlow = _publicIp.asStateFlow() - private var publicIpFailed = false - private var refreshJob: Job? = null init { @@ -196,14 +191,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _apps.value = result _summary.value = AppPresentation.summarise(result) checkPermissions() - // Only fetch public IP when fully configured, and don't retry on failure - val serverReady = settings.value.serverUrl.isNotBlank() && settings.value.apiKey.isNotBlank() - if (serverReady && _publicIp.value == null && !publicIpFailed) { - fetchPublicIp() - } else if (!serverReady) { - _publicIp.value = null - publicIpFailed = false - } _isRefreshing.value = false } @@ -220,7 +207,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { serverUrlJob?.cancel() serverUrlJob = viewModelScope.launch { delay(500) - SettingsStore.update(getApplication()) { it.copy(serverUrl = url) } + SettingsStore.update(getApplication()) { it.withServerUrl(url) } Heartbeat.matchServer(getApplication(), url) // Try the new address now, not on the next 15-minute period. HeartbeatWorker.runNow(getApplication()) @@ -231,25 +218,78 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { apiKeyJob?.cancel() apiKeyJob = viewModelScope.launch { delay(500) - SettingsStore.update(getApplication()) { it.copy(apiKey = key) } + SettingsStore.update(getApplication()) { it.withApiKey(key) } HeartbeatWorker.runNow(getApplication()) } } - private fun fetchPublicIp() { - viewModelScope.launch { - val ip = withContext(Dispatchers.IO) { - try { - URL(PUBLIC_IP_URL).readText().trim() - } catch (_: Exception) { - null - } + private val _pairResult = MutableStateFlow(PairResult.NONE) + + /** What the last connection test said; NONE until one runs or after the fields change. */ + val pairResult: StateFlow = _pairResult.asStateFlow() + + private var testJob: Job? = null + + /** The fields changed: any result, even one still on its way, is about other details. */ + fun clearPairResult() { + testJob?.cancel() + _pairResult.value = PairResult.NONE + } + + /** + * Save the typed server details now and send one real report with them. + * + * A real report rather than a ping, so "Connected" means exactly what the + * 15-minute schedule will do: same URL policy, same key, same enrollment. + */ + fun testConnection(url: String, key: String) { + serverUrlJob?.cancel() + apiKeyJob?.cancel() + testJob?.cancel() + testJob = viewModelScope.launch { + _pairResult.value = PairResult.TESTING + val app = getApplication() + try { + saveServer(url, key) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("MainViewModel", "Could not save the server details: ${e.javaClass.simpleName}") + _pairResult.value = PairResult.SAVE_FAILED + return@launch } - if (ip != null) { - _publicIp.value = ip + val outcome = withContext(Dispatchers.IO) { Heartbeat.sendOnce(app) } + // An edit while the report was out cancelled this job: its answer is + // about details that are no longer in the fields, so it is not shown. + coroutineContext.ensureActive() + _pairResult.value = pairResultOf(outcome) + } + } + + private suspend fun saveServer(url: String, key: String) { + val app = getApplication() + SettingsStore.update(app) { it.withServerUrl(url.trim()).withApiKey(key.trim()) } + Heartbeat.matchServer(app, url.trim()) + } + + /** + * Leave onboarding. The pending debounced writes are cancelled, so the + * details in the fields are saved here: as typed with a server, cleared + * without one, where the cancelled writes cannot put them back. + */ + fun finishSetup(keepServer: Boolean, url: String = "", key: String = "") { + serverUrlJob?.cancel() + apiKeyJob?.cancel() + testJob?.cancel() + viewModelScope.launch { + if (keepServer) { + saveServer(url, key) + SettingsStore.update(getApplication()) { it.copy(setupCompleted = true) } } else { - publicIpFailed = true + SettingsStore.update(getApplication()) { it.copy(serverUrl = "", apiKey = "", workerKey = "", setupCompleted = true) } + Heartbeat.matchServer(getApplication(), "") } + _pairResult.value = PairResult.NONE } } diff --git a/app/src/main/java/com/cashpilot/android/ui/board/Board.kt b/app/src/main/java/com/cashpilot/android/ui/board/Board.kt new file mode 100644 index 0000000..b9b928b --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/Board.kt @@ -0,0 +1,435 @@ +package com.cashpilot.android.ui.board + +import com.cashpilot.android.model.Earnings +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.Settings +import com.cashpilot.android.service.Heartbeat +import com.cashpilot.android.ui.AppDisplayInfo +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.EarningsPresentation +import com.cashpilot.android.util.FormatUtils +import com.cashpilot.android.util.ServerUrlPolicy +import java.net.URI + +/** + * Everything the screens say, decided outside Compose. + * + * The screens only lay out what this file returns: which sentence leads the + * home screen, which colour it gets, what goes in the task list and in which + * order, how old a report reads. Each of those is a claim to the user, and as + * plain functions they are unit tests (BoardTest) instead of something only a + * screenshot could show. + * + * Time is passed in as [Board.now], never read from the clock here, so the same + * input always gives the same words. That is also what keeps the screen goldens + * stable from one day to the next. + */ + +/** How a line should be coloured. The theme turns this into a colour. */ +enum class Tone { OK, WARN, BAD, MUTED } + +enum class ServerState { + /** No server, or no key. A supported way to use the app, not an error. */ + NONE, + + /** An http:// address on the public internet. The key is never sent there. */ + NEEDS_HTTPS, + OK, + + /** The last report went through, but none has gone out for over [LATE_AFTER_MS]. */ + LATE, + + /** The last report did not get through: no answer, or the server refused it. */ + FAILING, +} + +/** Four missed 15-minute periods: past what Android's normal deferral explains. */ +const val LATE_AFTER_MS = 60 * 60 * 1000L + +data class AppRow( + val slug: String, + val name: String, + val packageName: String, + val state: AppState, + val lastActive: Long?, + val bytes24h: Long, + /** Null means the server has never read this provider. Not zero. */ + val usd: Double?, + /** The provider reports one balance for the whole account, not this phone. */ + val shared: Boolean, + val referralUrl: String?, + /** False when detection has no reading for this app (switched off, or no access): its data use and last-opened time are unknown, not zero. */ + val measured: Boolean = true, +) + +data class Board( + val apps: List, + val server: ServerState, + val host: String, + val lastReport: Long, + val total: Double?, + val windowDays: Int, + val earningsAsOf: Long, + /** Decided by [EarningsPresentation]: no server, nothing read, or a figure. */ + val earningsMode: EarningsPresentation.Mode, + /** A figure that may be out of date. Never true without a figure. */ + val stale: Boolean, + val notif: Boolean, + val usage: Boolean, + val battery: Boolean, + val now: Long, +) { + /** Installed and switched on: the apps the board is about. */ + val watched get() = apps.filter { it.state != AppState.NOT_INSTALLED && it.state != AppState.DISABLED } + val running get() = watched.filter { it.state == AppState.RUNNING } + val stopped get() = watched.filter { it.state == AppState.STOPPED } + val unknown get() = watched.filter { it.state == AppState.UNKNOWN } + val notInstalled get() = apps.filter { it.state == AppState.NOT_INSTALLED } + val installed get() = apps.filter { it.state != AppState.NOT_INSTALLED } + + /** Neither permission: every app reads unknown, so the home screen has nothing true to show. */ + val blind get() = !notif && !usage + val paired get() = server != ServerState.NONE + val anyShared get() = watched.any { it.shared && it.usd != null } + val withoutReading get() = watched.filter { it.usd == null } + + companion object { + fun from( + apps: List, + settings: Settings, + lastHeartbeat: Long, + heartbeatFailed: Boolean, + earnings: Earnings?, + earningsAsOf: Long, + notif: Boolean, + usage: Boolean, + battery: Boolean, + now: Long, + ): Board { + val url = settings.serverUrl.trim() + val configured = url.isNotBlank() && settings.activeKey.isNotBlank() + return Board( + apps = apps.map { info -> + val p = earnings?.platforms?.firstOrNull { it.slug == info.app.slug } + AppRow( + slug = info.app.slug, + name = info.app.displayName, + packageName = info.app.packageName, + state = info.state, + lastActive = info.status?.lastActive?.let(FormatUtils::parseIso)?.takeIf { it > 0 }, + bytes24h = (info.status?.netTx24h ?: 0) + (info.status?.netRx24h ?: 0), + usd = p?.usd, + shared = p?.sharedWithOtherWorkers == true, + referralUrl = info.app.referralUrl, + measured = info.status != null, + ) + }, + server = when { + !configured -> ServerState.NONE + !ServerUrlPolicy.allowsToken(url) -> ServerState.NEEDS_HTTPS + heartbeatFailed -> ServerState.FAILING + lastHeartbeat > 0L && now - lastHeartbeat > LATE_AFTER_MS -> ServerState.LATE + else -> ServerState.OK + }, + host = hostOf(url), + lastReport = lastHeartbeat, + total = earnings?.totalUsd, + windowDays = earnings?.windowDays ?: 30, + earningsAsOf = earningsAsOf, + earningsMode = EarningsPresentation.mode(earnings, configured), + stale = EarningsPresentation.isStale(earnings, configured, earningsAsOf, now), + notif = notif, + usage = usage, + battery = battery, + now = now, + ) + } + + fun hostOf(url: String): String = + runCatching { URI(url.trim()).host }.getOrNull()?.removeSurrounding("[", "]") ?: url.trim() + } +} + +// --------------------------------------------------------------------------- +// Words +// --------------------------------------------------------------------------- + +object Words { + fun money(usd: Double?): String = FormatUtils.formatPlatformEarnings(usd) + + fun apps(n: Int): String = if (n == 1) "1 app" else "$n apps" + + fun ago(millis: Long?, now: Long): String { + if (millis == null || millis <= 0L) return "never" + val d = ((now - millis) / 1000).coerceAtLeast(0) + return when { + d < 60 -> "just now" + d < 3600 -> "${d / 60} min ago" + d < 86_400 -> "${d / 3600} h ago" + d < 2 * 86_400 -> "yesterday" + else -> "${d / 86_400} days ago" + } + } + + /** "for 25 min", "for 3 h": how long something has been wrong. */ + fun forHowLong(millis: Long?, now: Long): String = when (val a = ago(millis, now)) { + "yesterday" -> "since yesterday" + "just now" -> "for a moment" + "never" -> "yet" + else -> "for " + a.removeSuffix(" ago") + } + + /** The last four characters of the key, the rest as dots. Never more. */ + fun maskKey(key: String): String = when { + key.isBlank() -> "Not set" + key.length <= 8 -> "••••" + else -> "•••• •••• " + key.takeLast(4) + } + + /** The well-known apps the welcome screen names, and how many more there are. */ + fun supportedSample(): String { + val names = listOf("earnapp", "iproyal", "grass", "mysterium").mapNotNull { KnownApps.bySlug[it]?.displayName } + return names.joinToString(", ") + " and ${KnownApps.all.size - names.size} more" + } +} + +fun Board.ago(millis: Long?) = Words.ago(millis, now) + +fun AppRow.stateWord(now: Long): String = when (state) { + AppState.RUNNING -> "Running" + AppState.STOPPED -> "Stopped" + (lastActive?.let { " · last opened " + Words.ago(it, now) } ?: "") + AppState.UNKNOWN -> "Can't see it" + AppState.DISABLED -> "Not watched" + AppState.NOT_INSTALLED -> "Not installed" +} + +/** The second line of a board row. Data use is the last 24 hours, so it says so. */ +fun AppRow.rowLine(now: Long): String = + if (state == AppState.RUNNING && bytes24h > 0) "Running · ${FormatUtils.formatBytes(bytes24h)} in 24 h" else stateWord(now) + +fun AppState.tone(): Tone = when (this) { + AppState.RUNNING -> Tone.OK + AppState.STOPPED -> Tone.BAD + AppState.UNKNOWN -> Tone.WARN + AppState.DISABLED, AppState.NOT_INSTALLED -> Tone.MUTED +} + +data class Line(val tone: Tone, val text: String) + +/** The one line about the server, the same words on every screen that shows it. */ +fun Board.serverLine(): Line = when (server) { + ServerState.NONE -> Line(Tone.MUTED, "No server · everything stays on this phone") + ServerState.NEEDS_HTTPS -> Line(Tone.WARN, "Not sending to $host · it needs https://") + ServerState.OK -> if (lastReport <= 0L) { + Line(Tone.MUTED, "Paired with $host · waiting for the first report") + } else { + Line(Tone.OK, "Last report ${ago(lastReport)} · sent every 15 min") + } + ServerState.LATE -> Line(Tone.WARN, "Last report ${ago(lastReport)} · reports are late") + ServerState.FAILING -> if (lastReport <= 0L) { + Line(Tone.WARN, "No report has reached $host yet") + } else { + Line(Tone.WARN, "No report has reached $host ${Words.forHowLong(lastReport, now)}") + } +} + +/** The headline of the status board: the answer to "is everything running?". */ +data class Verdict(val tone: Tone, val count: Int?, val title: String, val sub: String) + +fun Board.verdict(): Verdict { + val n = watched.size + val all = if (n == 1) "Your app is running" else "All $n apps running" + return when { + n == 0 -> Verdict( + Tone.MUTED, null, "No apps watched", + "Every installed app is switched off. Turn them back on in Settings, Apps to watch.", + ) + stopped.isNotEmpty() -> Verdict( + Tone.BAD, stopped.size, "${stopped.size} of ${Words.apps(n)} stopped", + "Opening a stopped app usually starts it again.", + ) + unknown.isNotEmpty() -> Verdict( + Tone.WARN, null, "${running.size} of ${Words.apps(n)} running", + "CashPilot can't see the other ${unknown.size} until its access is back on.", + ) + server == ServerState.FAILING || server == ServerState.NEEDS_HTTPS -> Verdict( + Tone.WARN, null, all, "But your server isn't getting this phone's reports.", + ) + server == ServerState.LATE -> Verdict( + Tone.WARN, null, all, "But no report has gone out ${Words.forHowLong(lastReport, now)}.", + ) + else -> Verdict(Tone.OK, null, all, "Nothing needs you right now.") + } +} + +enum class TaskKind { STOPPED_APP, SERVER, NOTIFICATIONS, USAGE, BATTERY } + +data class Task(val kind: TaskKind, val tone: Tone, val title: String, val body: String, val action: String, val slug: String? = null) + +/** + * Everything that needs the user, most urgent first: stopped apps (by name: + * Android tells us when an app was last opened, never when it stopped), then + * the server, then missing access, then battery. + * + * Both accesses missing is the blind screen, not a pair of tasks. + */ +fun Board.tasks(): List { + val out = mutableListOf() + stopped.sortedBy { it.name }.forEach { + out += Task( + TaskKind.STOPPED_APP, Tone.BAD, + "${it.name} stopped", + "Opening it usually starts it again.", "Open ${it.name}", it.slug, + ) + } + when (server) { + ServerState.FAILING -> out += Task( + TaskKind.SERVER, Tone.WARN, "Reports aren't getting through", + (if (lastReport <= 0L) "No report has reached $host yet." else "Nothing has reached $host ${Words.forHowLong(lastReport, now)}.") + + " Check connection says why.", + "Check connection", + ) + ServerState.LATE -> out += Task( + TaskKind.SERVER, Tone.WARN, "Reports are late", + "No report has gone out ${Words.forHowLong(lastReport, now)}. Android holds background work back, more so with battery optimisation on or no network.", + "Check connection", + ) + ServerState.NEEDS_HTTPS -> out += Task( + TaskKind.SERVER, Tone.WARN, "Reports are not being sent", + "$host is on the public internet, so CashPilot sends the key only over https://.", + "Fix the address", + ) + else -> Unit + } + if (!blind && !notif) { + out += Task(TaskKind.NOTIFICATIONS, Tone.BAD, "Notification access is off", "CashPilot can't tell which apps are running until it's back on.", "Turn on") + } + if (!blind && !usage) { + out += Task(TaskKind.USAGE, Tone.WARN, "Usage access is off", "No last-seen times or data use until it's back on.", "Turn on") + } + if (!battery) { + out += Task( + TaskKind.BATTERY, Tone.WARN, "Reports may arrive late", + "Battery optimisation is on for CashPilot, so Android can hold reports back while the screen is off.", + "Open battery settings", + ) + } + return out +} + +/** + * What the home screen shows above the app list. + * + * Two or more problems: the ordered list, so the user works top to bottom. + * One stopped app: nothing extra, its row already carries the Open button. + * One other problem: that one card on its own. + */ +fun Board.tasksToShow(): List { + val all = tasks() + return when { + all.size >= 2 -> all + all.size == 1 && all[0].kind != TaskKind.STOPPED_APP -> all + else -> emptyList() + } +} + +/** The earnings strip: label, value and whether the value is a real figure. */ +data class EarningsLine(val label: String, val value: String, val isFigure: Boolean, val staleNote: String?) + +fun Board.earningsLine(): EarningsLine { + val label = "Your server reports, last $windowDays days" + return when (earningsMode) { + EarningsPresentation.Mode.NEEDS_SERVER -> EarningsLine(label, "Add a server to see figures", false, null) + EarningsPresentation.Mode.NOTHING_READ -> EarningsLine(label, "Nothing read yet", false, null) + EarningsPresentation.Mode.FIGURE -> + EarningsLine(label, Words.money(total), true, if (stale) "From ${ago(earningsAsOf)}" else null) + } +} + +/** The single footnote for figures that are a whole account's balance, not this phone's. */ +const val SHARED_FOOTNOTE = + "* Runs on more than one of your devices. The provider reports one balance for the account, so this is not this phone alone." + +/** A detail value with no reading behind it, so it is never shown as 0 B or "never". */ +const val NOT_MEASURED = "Not measured" + +/** Data use and last-opened time: only real readings, which need usage access and a detected app. */ +fun Board.measuredUse(a: AppRow): Pair = + if (a.measured && usage) FormatUtils.formatBytes(a.bytes24h) to ago(a.lastActive) else NOT_MEASURED to NOT_MEASURED + +const val NO_READING_NOTE = "No reading means your server has sent no figure for that provider. It is not \$0." + +fun Board.footnotes(): List = buildList { + if (earningsMode == EarningsPresentation.Mode.FIGURE && anyShared) add(SHARED_FOOTNOTE) + if (paired && withoutReading.isNotEmpty()) add(NO_READING_NOTE) +} + +/** The value in the app detail's earnings row, starred when it is a whole account. */ +fun Board.appEarnings(a: AppRow): String = when { + !paired -> "Needs a server" + !EarningsPresentation.showsPerAppEarnings(a.state) -> "\u2014" + a.usd == null -> "Nothing read yet" + a.shared -> Words.money(a.usd) + " *" + else -> Words.money(a.usd) +} + +/** The Server row at the top of Settings. */ +fun Board.serverSetting(): Line = when (server) { + ServerState.NONE -> Line(Tone.MUTED, "Everything stays on this phone · add a server for figures") + ServerState.NEEDS_HTTPS -> Line(Tone.WARN, "Not sending · this address needs https://") + ServerState.FAILING -> Line(Tone.WARN, if (lastReport <= 0L) "Not getting through · no report yet" else "Not getting through · last report ${ago(lastReport)}") + ServerState.LATE -> Line(Tone.WARN, "Reports are late · last report ${ago(lastReport)}") + ServerState.OK -> if (lastReport <= 0L) { + Line(Tone.MUTED, "Paired · waiting for the first report") + } else { + Line(Tone.OK, "Reports every 15 min · last report ${ago(lastReport)}") + } +} + +// --------------------------------------------------------------------------- +// Pairing: what a connection test says. +// --------------------------------------------------------------------------- + +enum class PairResult { NONE, TESTING, OK, KEY_REFUSED, UNREACHABLE, URL_REFUSED, REJECTED, NOT_CASHPILOT, SAVE_FAILED } + +fun pairResultOf(outcome: Heartbeat.Outcome): PairResult = when (outcome) { + Heartbeat.Outcome.SENT -> PairResult.OK + Heartbeat.Outcome.KEY_REFUSED -> PairResult.KEY_REFUSED + Heartbeat.Outcome.UNREACHABLE -> PairResult.UNREACHABLE + Heartbeat.Outcome.URL_REFUSED -> PairResult.URL_REFUSED + Heartbeat.Outcome.REJECTED -> PairResult.REJECTED + Heartbeat.Outcome.NOT_CASHPILOT -> PairResult.NOT_CASHPILOT + Heartbeat.Outcome.NOT_CONFIGURED -> PairResult.NONE +} + +data class PairMessage(val tone: Tone, val title: String, val body: String) + +fun pairMessage(result: PairResult, host: String, deviceName: String): PairMessage? = when (result) { + PairResult.NONE -> null + PairResult.TESTING -> PairMessage(Tone.MUTED, "Testing…", "Sending one report to $host.") + PairResult.OK -> PairMessage(Tone.OK, "Connected", "Your server answered and accepted the key. This phone appears in Fleet as $deviceName.") + PairResult.KEY_REFUSED -> PairMessage( + Tone.BAD, "The server refused this API key", + "The address is right: the server answered. Copy the key again from Fleet → Reveal API Key.", + ) + PairResult.UNREACHABLE -> PairMessage( + Tone.BAD, "No answer from $host", + "Check the address, and that this phone can reach it: on the same network, or over your VPN.", + ) + PairResult.URL_REFUSED -> PairMessage( + Tone.BAD, "This address needs https://", + "CashPilot sends the key over http:// only to your own network: 192.168.x.x, 10.x.x.x, a .local or .lan name, or Tailscale.", + ) + PairResult.REJECTED -> PairMessage( + Tone.BAD, "The server answered with an error", + "It is reachable but did not accept the report. Check that it runs a recent CashPilot release.", + ) + PairResult.NOT_CASHPILOT -> PairMessage( + Tone.BAD, "$host answered, but not as CashPilot", + "Something else answers at this address, such as a login page or another app. Use the address you open the CashPilot dashboard at.", + ) + PairResult.SAVE_FAILED -> PairMessage( + Tone.BAD, "Couldn't save the details", + "Android didn't store the address and key, so nothing was sent. Try again.", + ) +} diff --git a/app/src/main/java/com/cashpilot/android/ui/board/BoardApp.kt b/app/src/main/java/com/cashpilot/android/ui/board/BoardApp.kt new file mode 100644 index 0000000..c2383cd --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/BoardApp.kt @@ -0,0 +1,211 @@ +package com.cashpilot.android.ui.board + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings as AndroidSettings +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.cashpilot.android.BuildConfig +import com.cashpilot.android.ui.MainViewModel +import kotlinx.coroutines.delay + +/** The device name the server lists this phone under, minus the short id it appends. */ +val deviceName: String get() = "${Build.MANUFACTURER.replaceFirstChar { it.uppercase() }} ${Build.MODEL}" + +/** + * The app: onboarding until setup is done, then the board and the screens it + * leads to. Navigation is a small saved stack of route strings; four screens + * and one level of depth need nothing more. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BoardApp(vm: MainViewModel) { + val context = LocalContext.current + val settings by vm.settings.collectAsState() + val apps by vm.apps.collectAsState() + val notif by vm.hasNotificationAccess.collectAsState() + val usage by vm.hasUsageAccess.collectAsState() + val battery by vm.hasBatteryOptOut.collectAsState() + val lastHeartbeat by vm.lastHeartbeat.collectAsState() + val failed by vm.lastHeartbeatFailed.collectAsState() + val earnings by vm.earnings.collectAsState() + val earningsAsOf by vm.earningsAsOf.collectAsState() + val pair by vm.pairResult.collectAsState() + val refreshing by vm.isRefreshing.collectAsState() + + val stack = rememberSaveable(saver = listSaver, String>({ it.toList() }, { it.toMutableStateList() })) { + mutableStateListOf(HOME) + } + var demo by rememberSaveable { mutableStateOf(false) } + var step by rememberSaveable { mutableStateOf(0) } + + // The server fields as typed. Written through the ViewModel's debounced + // setters; filled from storage once, and never after the user has typed in + // that field, so a slow DataStore read cannot overwrite the newer input. + var url by rememberSaveable { mutableStateOf("") } + var key by rememberSaveable { mutableStateOf("") } + var urlSynced by rememberSaveable { mutableStateOf(false) } + var keySynced by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(settings.serverUrl) { + if (!urlSynced && settings.serverUrl.isNotEmpty()) { url = settings.serverUrl; urlSynced = true } + } + LaunchedEffect(settings.apiKey) { + if (!keySynced && settings.apiKey.isNotEmpty()) { key = settings.apiKey; keySynced = true } + } + + // Ages and the stale-earnings label move while the screen stays open. + val now by produceState(System.currentTimeMillis()) { + while (true) { + delay(30_000) + value = System.currentTimeMillis() + } + } + val real = Board.from(apps, settings, lastHeartbeat, failed, earnings, earningsAsOf, notif, usage, battery, now) + val board = if (demo) Demo.board(now) else real + + fun push(route: String) { stack.add(route) } + fun pop() { if (stack.size > 1) stack.removeAt(stack.lastIndex) } + + val actions = BoardActions( + openApp = { if (!demo) openApp(context, it) }, + showApp = { push("$APP$it") }, + openSettings = { if (!demo) push(SETTINGS) }, + showSupported = { push(SUPPORTED) }, + showAllSupported = { push(SUPPORTED_ALL) }, + install = { if (!demo) install(context, it) }, + allowNotifications = { push(DISCLOSE + Access.NOTIFICATIONS.name) }, + allowUsage = { push(DISCLOSE + Access.USAGE.name) }, + openBattery = { openSystem(context, AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) }, + // The fields as typed: an edit still inside its debounce is what Settings + // will show, so it is what gets tested. + checkServer = { push(SETTINGS); vm.testConnection(url.ifEmpty { settings.serverUrl }, key.ifEmpty { settings.apiKey }) }, + startDemo = { demo = true; stack.clear(); stack.add(HOME) }, + toggleApp = { if (!demo) vm.toggleApp(it) }, + ) + val settingsActions = SettingsActions( + onUrl = { url = it; urlSynced = true; vm.clearPairResult(); vm.updateServerUrl(it) }, + onKey = { key = it; keySynced = true; vm.clearPairResult(); vm.updateApiKey(it) }, + testConnection = { vm.testConnection(url, key) }, + openUrl = { openUri(context, it) }, + ) + + val route = stack.last() + BackHandler(enabled = stack.size > 1 || demo || (!settings.setupCompleted && step > 0)) { + when { + stack.size > 1 -> pop() + demo -> demo = false + else -> step -= 1 + } + } + + Box(Modifier.fillMaxSize().safeDrawingPadding()) { + if (route.startsWith(DISCLOSE)) { + val kind = Access.valueOf(route.removePrefix(DISCLOSE)) + DisclosureScreen( + kind, + onContinue = { + pop() + openSystem( + context, + if (kind == Access.NOTIFICATIONS) AndroidSettings.ACTION_NOTIFICATION_LISTENER_SETTINGS else AndroidSettings.ACTION_USAGE_ACCESS_SETTINGS, + ) + }, + onNotNow = { pop() }, + ) + return@Box + } + if (!settings.setupCompleted && !demo) { + when (step) { + 0 -> OnboardWelcome { step = 1 } + 1 -> OnboardPermissions( + notif, usage, battery, + actions.allowNotifications, actions.allowUsage, actions.openBattery, + ) { step = 2 } + else -> OnboardServer( + url, key, settingsActions.onUrl, settingsActions.onKey, pair, deviceName, + onTest = settingsActions.testConnection, + onFinish = { vm.finishSetup(keepServer = true, url = url, key = key) }, + onSkip = { url = ""; key = ""; vm.finishSetup(keepServer = false) }, + ) + } + return@Box + } + val screen: @Composable () -> Unit = { + when { + route == SETTINGS -> SettingsScreen( + board, ServerForm(url, key, settings.apiKey, pair), BuildConfig.VERSION_NAME, deviceName, + actions, settingsActions, ::pop, + ) + route == SUPPORTED -> SupportedAppsScreen(board, actions, onBack = ::pop) + route == SUPPORTED_ALL -> SupportedAppsScreen(board, actions, all = true, onBack = ::pop) + route.startsWith(APP) -> { + val row = board.apps.firstOrNull { it.slug == route.removePrefix(APP) } + when { + row != null -> AppDetailScreen(row, board, actions, ::pop) + // After process death the route is restored before the first + // detection lists the apps; only a finished list can say it is gone. + apps.isNotEmpty() -> LaunchedEffect(route) { pop() } + } + } + // Pull down to check every app again; it also checks on every return to the app. + demo -> HomeScreen(board, actions) + else -> PullToRefreshBox(isRefreshing = refreshing, onRefresh = vm::refreshStatuses) { HomeScreen(board, actions) } + } + } + if (demo) DemoFrame(onExit = { demo = false; stack.clear(); stack.add(HOME) }) { screen() } else screen() + } +} + +private const val HOME = "home" +private const val SETTINGS = "settings" +private const val SUPPORTED = "supported" +private const val SUPPORTED_ALL = "supported/all" +private const val APP = "app/" +private const val DISCLOSE = "disclose/" + +private fun openSystem(context: Context, action: String) { + runCatching { context.startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } +} + +private fun openUri(context: Context, uri: String): Boolean = runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) +}.isSuccess + +/** Start the app itself. If it has no launcher entry, its system settings page, where Force stop and Open live. */ +private fun openApp(context: Context, a: AppRow) { + val launch = context.packageManager.getLaunchIntentForPackage(a.packageName) + if (launch != null && runCatching { context.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }.isSuccess) return + runCatching { + context.startActivity( + Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${a.packageName}")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } +} + +/** The provider's sign-up page when there is a referral link, otherwise the store page. */ +private fun install(context: Context, a: AppRow) { + if (a.referralUrl != null && openUri(context, a.referralUrl)) return + if (openUri(context, "market://details?id=${a.packageName}")) return + openUri(context, "https://play.google.com/store/apps/details?id=${a.packageName}") +} diff --git a/app/src/main/java/com/cashpilot/android/ui/board/Demo.kt b/app/src/main/java/com/cashpilot/android/ui/board/Demo.kt new file mode 100644 index 0000000..925a9b0 --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/Demo.kt @@ -0,0 +1,60 @@ +package com.cashpilot.android.ui.board + +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.ui.AppPresentation +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.EarningsPresentation + +/** + * Sample data for demo mode, so someone without any supported app (a store + * reviewer, or someone deciding whether to install one) can see what the board + * does. Every demo screen carries the "sample data" banner. + * + * Built relative to [now] so the ages read the same whenever it is opened. The + * figures are not anybody's earnings and are kept small and round on purpose. + */ +object Demo { + private const val MIN = 60_000L + private const val HOUR = 60 * MIN + private const val MB = 1024L * 1024L + + fun board(now: Long): Board { + data class S(val state: AppState, val agoMin: Long, val mb: Long, val usd: Double?, val shared: Boolean = false) + val sample = mapOf( + "earnapp" to S(AppState.RUNNING, 4, 450, 1.20), + "iproyal" to S(AppState.RUNNING, 4, 690, 0.80), + "mysterium" to S(AppState.RUNNING, 4, 1500, 2.00, shared = true), + "grass" to S(AppState.STOPPED, 3 * 60, 4, null), + "traffmonetizer" to S(AppState.RUNNING, 4, 250, 0.50), + ) + val rows = KnownApps.all.map { app -> + val s = sample[app.slug] + AppRow( + slug = app.slug, + name = app.displayName, + packageName = app.packageName, + state = s?.state ?: AppState.NOT_INSTALLED, + lastActive = s?.let { now - it.agoMin * MIN }, + bytes24h = (s?.mb ?: 0) * MB, + usd = s?.usd, + shared = s?.shared == true, + referralUrl = app.referralUrl, + ) + }.sortedWith(compareBy({ AppPresentation.attentionRank(it.state) }, { it.name.lowercase() })) + return Board( + apps = rows, + server = ServerState.OK, + host = "cashpilot.example.com", + lastReport = now - 6 * MIN, + total = sample.values.mapNotNull { it.usd }.sum(), + windowDays = 30, + earningsAsOf = now - HOUR / 4, + earningsMode = EarningsPresentation.Mode.FIGURE, + stale = false, + notif = true, + usage = true, + battery = true, + now = now, + ) + } +} diff --git a/app/src/main/java/com/cashpilot/android/ui/board/HomeScreens.kt b/app/src/main/java/com/cashpilot/android/ui/board/HomeScreens.kt new file mode 100644 index 0000000..a97aa9f --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/HomeScreens.kt @@ -0,0 +1,506 @@ +package com.cashpilot.android.ui.board + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cashpilot.android.R +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.theme.LocalPalette +import com.cashpilot.android.ui.theme.Tnum +import com.cashpilot.android.util.FormatUtils + +/** What the home screens can ask for. The activity wires these to real intents. */ +data class BoardActions( + val openApp: (AppRow) -> Unit = {}, + val showApp: (String) -> Unit = {}, + val openSettings: () -> Unit = {}, + val showSupported: () -> Unit = {}, + val showAllSupported: () -> Unit = {}, + val install: (AppRow) -> Unit = {}, + val allowNotifications: () -> Unit = {}, + val allowUsage: () -> Unit = {}, + val openBattery: () -> Unit = {}, + val checkServer: () -> Unit = {}, + val startDemo: () -> Unit = {}, + val toggleApp: (String) -> Unit = {}, +) + +/** + * The home screen: the status board. + * + * One sentence answers the question the app is opened for ("is everything + * running?"), then one quiet row per app. Colour appears only where something + * is wrong, so a healthy phone is a calm screen and a broken one is not. + */ +@Composable +fun HomeScreen(b: Board, actions: BoardActions) { + when { + // Every known app gets a row once the first detection has run; before + // that, an empty list is "not looked yet", never "nothing installed". + b.apps.isEmpty() -> return Box(Modifier.fillMaxSize()) + b.installed.isEmpty() -> return NoAppsHome(b, actions) + b.blind -> return BlindHome(b, actions) + } + val c = LocalPalette.current + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopBar("CashPilot") { + IconButton(onClick = actions.openSettings, modifier = Modifier.size(48.dp)) { + Ic(R.drawable.ic_settings, size = 24, description = "Settings") + } + } + Gap(12) + VerdictBlock(b) + Gap(24) + Column(Modifier.padding(horizontal = 16.dp)) { + val tasks = b.tasksToShow() + if (tasks.size >= 2) { + Heading( + "${tasks.size} things need you", + Modifier.padding(start = 4.dp, bottom = 10.dp), + MaterialTheme.typography.titleMedium, + ) + } + tasks.forEachIndexed { i, t -> TaskCard(t, first = i == 0, numbered = tasks.size >= 2, index = i + 1, b = b, actions = actions) } + if (tasks.isNotEmpty()) Gap(8) + if (b.watched.isNotEmpty()) { + Panel(pad = PaddingValues(vertical = 4.dp)) { + b.watched.forEachIndexed { i, a -> + if (i > 0) Divider(Modifier.padding(start = 70.dp)) + BoardRow(a, b.now, actions) + } + } + } else { + Panel { + Text("Every installed app is switched off in Settings.", color = c.text2) + } + } + Gap(12) + EarningsStrip(b, actions) + b.footnotes().forEach { + Gap(8) + Text(it, style = MaterialTheme.typography.bodySmall, color = c.muted, modifier = Modifier.padding(horizontal = 4.dp)) + } + if (b.notInstalled.isNotEmpty()) { + Gap(8) + LinkRow("${b.notInstalled.size} other supported apps", actions.showSupported) + } + Gap(24) + } + } +} + +@Composable +private fun VerdictBlock(b: Board) { + val c = LocalPalette.current + val v = b.verdict() + val ring = toneColor(v.tone) + Column(Modifier.padding(horizontal = 20.dp)) { + Box( + Modifier.size(64.dp).clip(CircleShape).border(5.dp, ring, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (v.count != null) { + Text( + "${v.count}", + Modifier.clearAndSetSemantics { }, + style = MaterialTheme.typography.headlineSmall.merge(Tnum), + fontWeight = FontWeight.Bold, + color = ring, + ) + } else { + Ic(if (v.tone == Tone.OK) R.drawable.ic_check_circle else R.drawable.ic_warning, tint = ring, size = 32) + } + } + Gap(16) + Heading(v.title, style = MaterialTheme.typography.headlineMedium.copy(fontSize = 30.sp, lineHeight = 36.sp)) + Gap(6) + Text(v.sub, style = MaterialTheme.typography.bodyLarge, color = c.text2) + Gap(14) + ServerLine(b) + } +} + +@Composable +private fun TaskCard(t: Task, first: Boolean, numbered: Boolean, index: Int, b: Board, actions: BoardActions) { + val c = LocalPalette.current + val tint = toneColor(t.tone) + val icon = when (t.kind) { + TaskKind.STOPPED_APP -> R.drawable.ic_warning + TaskKind.SERVER -> R.drawable.ic_cloud_off + TaskKind.NOTIFICATIONS -> R.drawable.ic_notifications + TaskKind.USAGE -> R.drawable.ic_query_stats + TaskKind.BATTERY -> R.drawable.ic_battery_alert + } + val onClick: () -> Unit = when (t.kind) { + TaskKind.STOPPED_APP -> { { b.watched.firstOrNull { it.slug == t.slug }?.let(actions.openApp) } } + TaskKind.SERVER -> if (b.server == ServerState.NEEDS_HTTPS) actions.openSettings else actions.checkServer + TaskKind.NOTIFICATIONS -> actions.allowNotifications + TaskKind.USAGE -> actions.allowUsage + TaskKind.BATTERY -> actions.openBattery + } + Panel(Modifier.padding(bottom = 10.dp), border = if (first) tint else c.line) { + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier.size(36.dp).clip(CircleShape).background(tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { Ic(icon, tint = tint, size = 18) } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + (if (numbered) "$index. " else "") + t.title, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium, + ) + Gap(2) + Text(t.body, style = MaterialTheme.typography.bodyMedium, color = c.text2) + Gap(10) + SmallAction(t.action, onClick, color = if (first) c.accent else c.text, filled = first) + } + } + } +} + +@Composable +private fun BoardRow(a: AppRow, now: Long, actions: BoardActions) { + val c = LocalPalette.current + Row( + Modifier.fillMaxWidth() + .clickable(onClickLabel = "Details", role = Role.Button) { actions.showApp(a.slug) } + .heightIn(min = 56.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppMark(a.name) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(toneColor(a.state.tone()), 7) + Spacer(Modifier.width(6.dp)) + Text( + a.rowLine(now), + style = MaterialTheme.typography.bodySmall, + color = if (a.state == AppState.STOPPED) c.bad else c.text2, + ) + } + } + if (a.state == AppState.STOPPED) { + SmallAction("Open", { actions.openApp(a) }, filled = true, modifier = Modifier.semantics { contentDescription = "Open ${a.name}" }) + } else { + Ic(R.drawable.ic_chevron_right, tint = c.muted, size = 18) + } + } +} + +@Composable +private fun EarningsStrip(b: Board, actions: BoardActions) { + val c = LocalPalette.current + val e = b.earningsLine() + val tap = if (!b.paired) Modifier.clickable(role = Role.Button, onClick = actions.openSettings) else Modifier + Panel(Modifier.clip(RoundedCornerShape(16.dp)).then(tap).semantics(mergeDescendants = true) {}, pad = PaddingValues(horizontal = 16.dp, vertical = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(e.label, style = MaterialTheme.typography.bodySmall, color = c.text2) + when { + e.isFigure -> Text(e.value, style = MaterialTheme.typography.titleLarge.merge(Tnum), fontWeight = FontWeight.Bold) + !b.paired -> Text(e.value, fontWeight = FontWeight.SemiBold, color = c.accent) + else -> Text(e.value, fontWeight = FontWeight.SemiBold) + } + } + if (e.staleNote != null) Pill(e.staleNote, Tone.WARN) + } + } +} + +@Composable +private fun LinkRow(text: String, onClick: () -> Unit) { + val c = LocalPalette.current + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button, onClick = onClick) + .heightIn(min = 48.dp).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = c.accent, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) + Ic(R.drawable.ic_chevron_right, tint = c.accent, size = 18) + } +} + +/** Tapping a row: everything known about one app, and the one thing to do. */ +@Composable +fun AppDetailScreen(a: AppRow, b: Board, actions: BoardActions, onBack: () -> Unit) { + val c = LocalPalette.current + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + BackBar(null, onBack) + Column(Modifier.padding(horizontal = 20.dp)) { + Gap(8) + AppMark(a.name, 64) + Gap(16) + Heading(a.name) + Gap(6) + Row(verticalAlignment = Alignment.CenterVertically) { + Dot(toneColor(a.state.tone())) + Spacer(Modifier.width(8.dp)) + Text( + when (a.state) { + AppState.STOPPED -> "Stopped. Last opened ${b.ago(a.lastActive)}." + AppState.UNKNOWN -> "CashPilot can't see whether it runs." + else -> a.stateWord(b.now) + }, + color = if (a.state == AppState.STOPPED) c.bad else c.text2, + ) + } + Gap(20) + if (a.state != AppState.NOT_INSTALLED) { + Primary("Open ${a.name}", { actions.openApp(a) }, Modifier.fillMaxWidth()) + Gap(8) + Text( + "These apps often stop when Android clears them from memory. Opening the app usually starts it again.", + style = MaterialTheme.typography.bodySmall, + color = c.muted, + ) + } + Gap(24) + Panel(pad = PaddingValues(0.dp)) { + Fact("Server reports, last ${b.windowDays} days", b.appEarnings(a)) + Divider() + val (moved, opened) = b.measuredUse(a) + Fact("Data moved, last 24 h", moved) + Divider() + Fact("Last opened", opened) + Divider() + // The whole row is the switch: one 56 dp target, one TalkBack stop. + Row( + Modifier.fillMaxWidth() + .toggleable(a.state != AppState.DISABLED, role = Role.Switch) { actions.toggleApp(a.slug) } + .heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Watched by CashPilot", color = c.text2, modifier = Modifier.weight(1f)) + Switch(checked = a.state != AppState.DISABLED, onCheckedChange = null) + } + } + if (b.paired && a.shared && a.usd != null) { + Gap(10) + Text(SHARED_FOOTNOTE, style = MaterialTheme.typography.bodySmall, color = c.muted) + } + Gap(24) + } + } +} + +@Composable +private fun Fact(k: String, v: String) { + Row( + Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}.padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(k, color = LocalPalette.current.text2, modifier = Modifier.weight(1f)) + Text(v, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyLarge.merge(Tnum)) + } +} + +/** + * The one state where the home screen can say nothing true: no access at all, + * so every app reads unknown. The fix is the only thing on screen, and it is + * calm, because nothing is known to be wrong. + */ +@Composable +fun BlindHome(b: Board, actions: BoardActions) { + val c = LocalPalette.current + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp)) { + Gap(48) + Box(Modifier.size(56.dp).clip(CircleShape).background(c.raised), contentAlignment = Alignment.Center) { + Ic(R.drawable.ic_visibility_off, tint = c.text2, size = 28) + } + Gap(20) + Heading("CashPilot can't see your apps yet", style = MaterialTheme.typography.headlineSmall) + Gap(10) + Text( + "${Words.apps(b.watched.size).replaceFirstChar { it.uppercase() }} you use " + + (if (b.watched.size == 1) "is" else "are") + + " installed, but Android won't show CashPilot whether they run until you allow two things. Each one explains what it reads first.", + style = MaterialTheme.typography.bodyLarge, + color = c.text2, + ) + Gap(24) + Primary("Allow notification access", actions.allowNotifications, Modifier.fillMaxWidth()) + Gap(10) + Secondary("Allow usage access", actions.allowUsage, Modifier.fillMaxWidth()) + Gap(12) + if (b.paired) { + Gap(20) + EarningsStrip(b, actions) + } + Gap(12) + LinkRow("Settings", actions.openSettings) + Gap(12) + ServerLine(b) + } +} + +/** + * Nothing supported is installed. Leads with what the app does, not with + * install buttons: the supported list is one link down, and says which links + * carry the referral code. + */ +@Composable +fun NoAppsHome(b: Board, actions: BoardActions) { + val c = LocalPalette.current + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.weight(1f)) + IconButton(onClick = actions.openSettings, modifier = Modifier.size(48.dp)) { + Ic(R.drawable.ic_settings, size = 24, description = "Settings") + } + } + Gap(8) + Box(Modifier.size(56.dp).clip(CircleShape).background(c.raised), contentAlignment = Alignment.Center) { + Ic(R.drawable.ic_query_stats, tint = c.text2, size = 28) + } + Gap(20) + Heading("No supported app on this phone yet", style = MaterialTheme.typography.headlineSmall) + Gap(10) + Text( + "CashPilot watches passive-income apps you already use, such as EarnApp or Grass, and tells you when one stops running. Once one is installed it appears here by itself.", + style = MaterialTheme.typography.bodyLarge, + color = c.text2, + ) + Gap(24) + Panel(Modifier.semantics(mergeDescendants = true) {}) { + Text("Works without a server", fontWeight = FontWeight.SemiBold) + Gap(6) + Text( + "On its own, CashPilot shows which apps run, which have stopped, and opens them for you. A CashPilot server adds earnings figures and lists this phone with your other machines.", + style = MaterialTheme.typography.bodyMedium, + color = c.text2, + ) + } + Gap(20) + Secondary("Show a demo with sample data", actions.startDemo, Modifier.fillMaxWidth()) + Gap(8) + LinkRow("See the ${b.apps.size} supported apps", actions.showSupported) + Gap(24) + ServerLine(b) + } +} + +/** + * The supported apps, with the referral disclosure at the top. From the home + * screen, the ones this phone does not have; from Settings ([all]), every one, + * the installed ones with Open instead of Get. + */ +@Composable +fun SupportedAppsScreen(b: Board, actions: BoardActions, all: Boolean = false, onBack: () -> Unit) { + val c = LocalPalette.current + val rows = if (all) b.apps else b.notInstalled.ifEmpty { b.apps } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + BackBar("Supported apps", onBack) + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + (if (all) "The apps CashPilot can watch." else "CashPilot can watch these apps if you install them.") + + " What any of them pays depends on the provider, your connection and your region.", + color = c.text2, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + ) + Panel(fill = c.raised, pad = PaddingValues(horizontal = 14.dp, vertical = 10.dp)) { + Text( + "Links marked Referral include CashPilot's referral code, which supports CashPilot's development. The others go to the app store.", + style = MaterialTheme.typography.bodySmall, + color = c.text2, + ) + } + Gap(12) + Panel(pad = PaddingValues(vertical = 4.dp)) { + rows.forEachIndexed { i, a -> + val have = a.state != AppState.NOT_INSTALLED + if (i > 0) Divider(Modifier.padding(start = 70.dp)) + Row( + Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppMark(a.name) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text( + when { + have -> "Installed" + a.referralUrl != null -> "Referral · provider's sign-up page" + else -> "App store page" + }, + style = MaterialTheme.typography.bodySmall, + color = c.muted, + ) + } + SmallAction( + if (have) "Open" else "Get", + { if (have) actions.openApp(a) else actions.install(a) }, + color = c.text, + modifier = Modifier.semantics { contentDescription = (if (have) "Open " else "Get ") + a.name }, + ) + } + } + } + Gap(24) + } + } +} + +/** Demo mode: the real screens with sample data, labelled on every screen. */ +@Composable +fun DemoFrame(onExit: () -> Unit, content: @Composable () -> Unit) { + val c = LocalPalette.current + Column(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().background(c.accentSoft).padding(start = 20.dp, end = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Demo · sample data, nothing here is real", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + SmallAction("Exit", onExit, color = c.accent) + } + Box(Modifier.weight(1f)) { content() } + } +} + +/** The supported apps, by name, for the disclosures. */ +fun supportedNames(): String = KnownApps.all.joinToString(", ") { it.displayName } + "." diff --git a/app/src/main/java/com/cashpilot/android/ui/board/Kit.kt b/app/src/main/java/com/cashpilot/android/ui/board/Kit.kt new file mode 100644 index 0000000..220bce7 --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/Kit.kt @@ -0,0 +1,247 @@ +package com.cashpilot.android.ui.board + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.cashpilot.android.R +import com.cashpilot.android.ui.theme.LocalPalette + +@Composable +fun toneColor(tone: Tone): Color { + val c = LocalPalette.current + return when (tone) { + Tone.OK -> c.ok + Tone.WARN -> c.warn + Tone.BAD -> c.bad + Tone.MUTED -> c.muted + } +} + +@Composable +fun toneSoft(tone: Tone): Color { + val c = LocalPalette.current + return when (tone) { + Tone.OK -> c.okSoft + Tone.WARN -> c.warnSoft + Tone.BAD -> c.badSoft + Tone.MUTED -> c.raised + } +} + +/** + * A two-letter tile per app. Decorative: the app's name is always written next + * to it, so TalkBack skips it. + */ +@Composable +fun AppMark(name: String, size: Int = 40, dim: Boolean = false) { + val c = LocalPalette.current + val hues = listOf(0xFF8B5CF6, 0xFF22D3EE, 0xFFF472B6, 0xFF46B37F, 0xFFE0A53C, 0xFF3987E5, 0xFFD95926) + val base = Color(hues[(name.hashCode() and 0x7fffffff) % hues.size]) + val words = name.split(" ").filter { it.isNotEmpty() } + val letters = if (words.size > 1) "${words[0][0]}${words[1][0]}" else name.take(2) + Box( + Modifier.size(size.dp).clip(RoundedCornerShape((size / 4).dp)) + .background(if (dim) c.raised else base.copy(alpha = if (c.dark) 0.22f else 0.16f)) + .clearAndSetSemantics { }, + contentAlignment = Alignment.Center, + ) { + Text( + letters, + fontWeight = FontWeight.Bold, + fontSize = (size * 0.36).sp, + color = when { + dim -> c.muted + c.dark -> base + else -> base.copy(red = base.red * 0.6f, green = base.green * 0.6f, blue = base.blue * 0.6f) + }, + ) + } +} + +@Composable +fun Dot(color: Color, size: Int = 8) = Box(Modifier.size(size.dp).clip(CircleShape).background(color)) + +@Composable +fun Ic(@DrawableRes id: Int, tint: Color = LocalPalette.current.text2, size: Int = 20, description: String? = null) = + Icon(painterResource(id), contentDescription = description, tint = tint, modifier = Modifier.size(size.dp)) + +@Composable +fun Heading(text: String, modifier: Modifier = Modifier, style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.headlineMedium) = + Text(text, modifier.semantics { heading() }, style = style, fontWeight = FontWeight.Bold) + +@Composable +fun SectionLabel(text: String) = Text( + text.uppercase(), + Modifier.padding(start = 4.dp, bottom = 8.dp, top = 4.dp).semantics { heading() }, + style = MaterialTheme.typography.labelMedium, + letterSpacing = 1.2.sp, + color = LocalPalette.current.muted, + fontWeight = FontWeight.SemiBold, +) + +/** A flat card with a hairline, the web dashboard's card. */ +@Composable +fun Panel( + modifier: Modifier = Modifier, + border: Color = LocalPalette.current.line, + fill: Color = LocalPalette.current.card, + pad: PaddingValues = PaddingValues(16.dp), + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(fill) + .border(BorderStroke(1.dp, border), RoundedCornerShape(16.dp)).padding(pad), + content = content, + ) +} + +@Composable +fun Primary(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) = Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 48.dp), + shape = RoundedCornerShape(12.dp), +) { Text(text, fontWeight = FontWeight.SemiBold) } + +@Composable +fun Secondary(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) = OutlinedButton( + onClick = onClick, + modifier = modifier.heightIn(min = 48.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, LocalPalette.current.lineStrong), + colors = ButtonDefaults.outlinedButtonColors(contentColor = LocalPalette.current.text), +) { Text(text, fontWeight = FontWeight.SemiBold) } + +/** A compact action inside a row or card. Still a 48 dp touch target. */ +@Composable +fun SmallAction( + text: String, + onClick: () -> Unit, + color: Color = LocalPalette.current.accent, + filled: Boolean = false, + modifier: Modifier = Modifier, +) { + val c = LocalPalette.current + val pad = PaddingValues(horizontal = 14.dp, vertical = 6.dp) + if (filled) { + Button( + onClick = onClick, + modifier = modifier.heightIn(min = 48.dp), + shape = RoundedCornerShape(10.dp), + contentPadding = pad, + colors = ButtonDefaults.buttonColors(containerColor = color, contentColor = c.onAccent), + ) { Text(text, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) } + } else { + OutlinedButton( + onClick = onClick, + modifier = modifier.heightIn(min = 48.dp), + shape = RoundedCornerShape(10.dp), + contentPadding = pad, + border = BorderStroke(1.dp, c.lineStrong), + colors = ButtonDefaults.outlinedButtonColors(contentColor = color), + ) { Text(text, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) } + } +} + +@Composable +fun Pill(text: String, tone: Tone) = Text( + text, + Modifier.clip(RoundedCornerShape(50)).background(toneSoft(tone)).padding(horizontal = 10.dp, vertical = 4.dp), + color = toneColor(tone), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, +) + +/** One line about the server, the same words on every screen. */ +@Composable +fun ServerLine(b: Board, modifier: Modifier = Modifier) { + val line = b.serverLine() + Row(modifier.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically) { + Dot(toneColor(line.tone)) + Spacer(Modifier.width(8.dp)) + Text(line.text, style = MaterialTheme.typography.bodySmall, color = LocalPalette.current.text2) + } +} + +/** The title row of a top-level screen, with room for icon actions. */ +@Composable +fun TopBar(title: String, actions: @Composable RowScope.() -> Unit = {}) { + Row( + Modifier.fillMaxWidth().padding(start = 20.dp, end = 8.dp, top = 12.dp, bottom = 4.dp).heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Heading(title, Modifier.weight(1f), MaterialTheme.typography.titleLarge) + actions() + } +} + +/** The title row of a screen one level down: back arrow, then the title. */ +@Composable +fun BackBar(title: String?, onBack: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(start = 4.dp, top = 12.dp, end = 8.dp).heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { + Ic(R.drawable.ic_arrow_back, tint = LocalPalette.current.text, size = 24, description = "Back") + } + if (title != null) Heading(title, style = MaterialTheme.typography.titleLarge) + } +} + +@Composable +fun Divider(modifier: Modifier = Modifier) = HorizontalDivider(modifier, color = LocalPalette.current.line) + +@Composable +fun Gap(h: Int) = Spacer(Modifier.height(h.dp)) + +/** A note in its tone's colours: what a task, a test result or a warning looks like. */ +@Composable +fun Notice(tone: Tone, title: String, body: String, @DrawableRes icon: Int, modifier: Modifier = Modifier) { + Panel(modifier.semantics(mergeDescendants = true) {}, border = toneColor(tone), fill = toneSoft(tone)) { + Row(verticalAlignment = Alignment.Top) { + Ic(icon, tint = toneColor(tone)) + Spacer(Modifier.width(10.dp)) + Column { + Text(title, fontWeight = FontWeight.SemiBold) + Text(body, style = MaterialTheme.typography.bodySmall, color = LocalPalette.current.text2) + } + } + } +} diff --git a/app/src/main/java/com/cashpilot/android/ui/board/Onboarding.kt b/app/src/main/java/com/cashpilot/android/ui/board/Onboarding.kt new file mode 100644 index 0000000..613699c --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/Onboarding.kt @@ -0,0 +1,329 @@ +package com.cashpilot.android.ui.board + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.cashpilot.android.R +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.ui.theme.LocalPalette +import com.cashpilot.android.util.ServerUrlPolicy + +// --------------------------------------------------------------------------- +// Onboarding: three short steps. The two permissions come first; the optional +// server comes last, so skipping it is the natural end of the flow. +// --------------------------------------------------------------------------- + +@Composable +private fun StepDots(at: Int) { + val c = LocalPalette.current + Row( + Modifier.semantics(mergeDescendants = true) { contentDescription = "Step ${at + 1} of 3" }, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(3) { i -> + Box( + Modifier.size(width = if (i == at) 22.dp else 8.dp, height = 8.dp).clip(RoundedCornerShape(50)) + .background(if (i <= at) c.accent else c.line), + ) + } + } +} + +/** Title and lead on top, the body scrolls, the buttons stay at the bottom at any font size. */ +@Composable +private fun OnboardFrame(step: Int, title: String, lead: String, bottom: @Composable ColumnScope.() -> Unit, body: @Composable ColumnScope.() -> Unit) { + Column(Modifier.fillMaxSize().padding(horizontal = 24.dp).padding(top = 32.dp, bottom = 16.dp)) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + if (step >= 0) StepDots(step) + Gap(24) + Heading(title) + Gap(10) + Text(lead, style = MaterialTheme.typography.bodyLarge, color = LocalPalette.current.text2) + Gap(24) + body() + } + Gap(12) + bottom() + } +} + +@Composable +private fun Fact(@DrawableRes icon: Int, title: String, body: String) { + val c = LocalPalette.current + Row(Modifier.padding(bottom = 20.dp).semantics(mergeDescendants = true) {}) { + Box(Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(c.accentSoft), contentAlignment = Alignment.Center) { + Ic(icon, tint = c.accent) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall) + Gap(2) + Text(body, style = MaterialTheme.typography.bodyMedium, color = c.text2) + } + } +} + +@Composable +fun OnboardWelcome(onNext: () -> Unit) = OnboardFrame( + 0, + "Know when an earning app stops", + "CashPilot checks the passive-income apps on this phone and shows you which ones stopped, and how to start them again.", + bottom = { Primary("Get started", onNext, Modifier.fillMaxWidth()) }, +) { + Fact(R.drawable.ic_check_circle, "Which apps are running", "${Words.supportedSample()}, checked whenever you open CashPilot.") + Fact(R.drawable.ic_warning, "What to do when one stops", "One tap opens the app so you can start it again.") + Fact(R.drawable.ic_cloud, "Figures from your server, if you have one", "A CashPilot server reports what each app's account shows. Optional; reports go every 15 minutes.") + Fact(R.drawable.ic_visibility_off, "Nothing leaves this phone", "Except to your own server, if you add one. No ads, no tracking.") +} + +@Composable +private fun PermissionCard( + @DrawableRes icon: Int, + title: String, + why: String, + notRead: String, + granted: Boolean, + required: Boolean, + onAllow: () -> Unit, +) { + val c = LocalPalette.current + Panel(Modifier.padding(bottom = 12.dp), border = if (!granted && required) c.lineStrong else c.line) { + Row(verticalAlignment = Alignment.CenterVertically) { + Ic(icon, tint = if (granted) c.ok else c.text) + Spacer(Modifier.width(12.dp)) + Text(title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + when { + granted -> Pill(if (required) "Allowed" else "Done", Tone.OK) + required -> SmallAction("Allow", onAllow, filled = true, modifier = Modifier.semantics { contentDescription = "Allow ${title.lowercase()}" }) + // Optional, and not a permission: it only opens Android's + // battery list. No exemption dialog, so it never says "Allow". + else -> SmallAction("Open settings", onAllow, color = c.text, modifier = Modifier.semantics { contentDescription = "Open battery settings" }) + } + } + Gap(8) + Text(why, style = MaterialTheme.typography.bodyMedium, color = c.text2) + Gap(4) + Text(notRead, style = MaterialTheme.typography.bodySmall, color = c.muted) + } +} + +@Composable +fun OnboardPermissions( + notif: Boolean, + usage: Boolean, + battery: Boolean, + onAllowNotifications: () -> Unit, + onAllowUsage: () -> Unit, + onOpenBattery: () -> Unit, + onNext: () -> Unit, +) = OnboardFrame( + 1, + "Let CashPilot see your apps", + "Android hides other apps' state. Two switches in system settings fix that; you come straight back here.", + bottom = { + val c = LocalPalette.current + if (!(notif && usage)) { + Text( + "Without both, CashPilot can't tell running from stopped. You can allow them later from Settings.", + style = MaterialTheme.typography.bodySmall, + color = c.muted, + modifier = Modifier.padding(bottom = 10.dp), + ) + } + Primary(if (notif && usage) "Continue" else "Continue without them", onNext, Modifier.fillMaxWidth()) + }, +) { + val n = KnownApps.all.size + PermissionCard( + R.drawable.ic_notifications, "Notification access", + "These apps keep a notification up while they run. CashPilot checks whether it is there, for the $n supported apps only.", + "Never reads notification text. You'll see exactly what it reads before Android asks.", + notif, required = true, onAllow = onAllowNotifications, + ) + PermissionCard( + R.drawable.ic_query_stats, "Usage access", + "Shows when each supported app last ran and how much data it moved in the last 24 hours, which shows it is working.", + "Never looks at other apps or websites. You'll see exactly what it reads first.", + usage, required = true, onAllow = onAllowUsage, + ) + PermissionCard( + R.drawable.ic_battery_alert, "Battery optimisation", + "CashPilot reports every 15 minutes. With battery optimisation on, Android can hold those reports back while the screen is off.", + "Optional. Opens Android's battery list: choose All apps, then CashPilot, then Don't optimize.", + battery, required = false, onAllow = onOpenBattery, + ) +} + +/** The address and key fields, shared by onboarding and Settings so both enforce the same rules. */ +@Composable +fun ServerFields(url: String, key: String, onUrl: (String) -> Unit, onKey: (String) -> Unit) { + val c = LocalPalette.current + val refused = url.isNotBlank() && "://" in url && !ServerUrlPolicy.allowsToken(url) + val cleartext = !refused && url.trim().startsWith("http://", ignoreCase = true) + OutlinedTextField( + value = url, + onValueChange = onUrl, + label = { Text("Server address") }, + placeholder = { Text("https://cashpilot.example.com") }, + singleLine = true, + isError = refused, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + supportingText = when { + refused -> { { Text("This address is on the public internet, so it needs https://. CashPilot never sends the key over plain http:// there.") } } + cleartext -> { { Text("Plain http:// on your own network: the key and app list travel unencrypted on it.") } } + else -> null + }, + modifier = Modifier.fillMaxWidth(), + ) + Gap(12) + OutlinedTextField( + value = key, + onValueChange = onKey, + label = { Text("API key") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + supportingText = { Text("On your server: Fleet → Reveal API Key. Stored only on this phone.") }, + modifier = Modifier.fillMaxWidth(), + ) + Gap(4) + Text( + "Use https://. Plain http:// works only for an address on your own network, such as 192.168.x.x or a .local or Tailscale name.", + style = MaterialTheme.typography.bodySmall, + color = c.muted, + ) +} + +@Composable +fun PairNotice(result: PairResult, host: String, deviceName: String) { + val m = pairMessage(result, host, deviceName) ?: return + Notice( + m.tone, m.title, m.body, + when (m.tone) { + Tone.OK -> R.drawable.ic_check_circle + Tone.MUTED -> R.drawable.ic_cloud + else -> R.drawable.ic_warning + }, + ) +} + +@Composable +fun OnboardServer( + url: String, + key: String, + onUrl: (String) -> Unit, + onKey: (String) -> Unit, + result: PairResult, + deviceName: String, + onTest: () -> Unit, + onFinish: () -> Unit, + onSkip: () -> Unit, +) = OnboardFrame( + 2, + "Connect your server", + "Optional. Without one you still see which apps run and which stopped. A server adds the figures its provider accounts show, and lists this phone in your fleet.", + bottom = { + if (result == PairResult.OK) { + Primary("Finish", onFinish, Modifier.fillMaxWidth()) + } else { + Primary( + if (result == PairResult.TESTING) "Testing…" else "Test connection", + onTest, + Modifier.fillMaxWidth(), + enabled = url.isNotBlank() && key.isNotBlank() && result != PairResult.TESTING, + ) + } + Gap(8) + Secondary("Use without a server", onSkip, Modifier.fillMaxWidth()) + }, +) { + ServerFields(url, key, onUrl, onKey) + Gap(16) + PairNotice(result, Board.hostOf(url), deviceName) +} + +enum class Access { NOTIFICATIONS, USAGE } + +/** + * Shown when the user taps Allow, BEFORE Android's own settings screen opens. + * Google Play requires a prominent in-app disclosure for both special accesses, + * stating exactly what is read. + */ +@Composable +fun DisclosureScreen(kind: Access, onContinue: () -> Unit, onNotNow: () -> Unit) { + val c = LocalPalette.current + val n = KnownApps.all.size + val icon: Int + val title: String + val reads: String + val never: String + when (kind) { + Access.NOTIFICATIONS -> { + icon = R.drawable.ic_notifications + title = "notification access" + reads = "Only whether one of the $n apps below is showing an ongoing notification right now. That notification is how those apps show they are running." + never = "The text of any notification, and anything from any other app. Only two things go out, and only to your own server if you add one: whether each of these apps has that notification up, and whether it is running." + } + Access.USAGE -> { + icon = R.drawable.ic_query_stats + title = "usage access" + reads = "For the $n apps below only: when each one was last in use, and how much data it sent and received in the last 24 hours." + never = "Which other apps you use, which websites you visit, or what any app does. The results stay on this phone, unless you add your own server." + } + } + OnboardFrame( + -1, + "Before you turn on $title", + "Android will open its settings next. This is what CashPilot does with it.", + bottom = { + Primary("Continue to Android settings", onContinue, Modifier.fillMaxWidth()) + Gap(8) + Secondary("Not now", onNotNow, Modifier.fillMaxWidth()) + }, + ) { + Box( + Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(c.accentSoft).clearAndSetSemantics { }, + contentAlignment = Alignment.Center, + ) { Ic(icon, tint = c.accent, size = 26) } + Gap(16) + Panel { + Text("CASHPILOT READS", style = MaterialTheme.typography.labelMedium, color = c.ok, fontWeight = FontWeight.SemiBold) + Gap(4) + Text(reads, style = MaterialTheme.typography.bodyMedium) + Gap(14) + Text("IT NEVER READS", style = MaterialTheme.typography.labelMedium, color = c.bad, fontWeight = FontWeight.SemiBold) + Gap(4) + Text(never, style = MaterialTheme.typography.bodyMedium) + } + Gap(14) + Text(supportedNames(), style = MaterialTheme.typography.bodySmall, color = c.muted) + } +} diff --git a/app/src/main/java/com/cashpilot/android/ui/board/SettingsScreen.kt b/app/src/main/java/com/cashpilot/android/ui/board/SettingsScreen.kt new file mode 100644 index 0000000..91ac523 --- /dev/null +++ b/app/src/main/java/com/cashpilot/android/ui/board/SettingsScreen.kt @@ -0,0 +1,167 @@ +package com.cashpilot.android.ui.board + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.cashpilot.android.R +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.theme.LocalPalette + +/** What Settings can ask for, on top of the board actions. */ +data class SettingsActions( + val onUrl: (String) -> Unit = {}, + val onKey: (String) -> Unit = {}, + val testConnection: () -> Unit = {}, + val openUrl: (String) -> Unit = {}, +) + +/** The server fields as typed, and what the last test said. */ +data class ServerForm(val url: String, val key: String, val savedKey: String, val result: PairResult) + +const val SOURCE_URL = "https://github.com/GeiserX/CashPilot-android" +const val SERVER_PROJECT_URL = "https://github.com/GeiserX/CashPilot" +const val SPONSOR_URL = "https://github.com/sponsors/GeiserX" + +@Composable +private fun SettingRow( + title: String, + value: String, + valueColor: Color = LocalPalette.current.text2, + onClick: (() -> Unit)? = null, +) { + val tap = if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier.semantics(mergeDescendants = true) {} + Row( + Modifier.fillMaxWidth().then(tap).heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text(value, style = MaterialTheme.typography.bodySmall, color = valueColor) + } + if (onClick != null) Ic(R.drawable.ic_chevron_right, size = 18) + } +} + +/** Grouped by what the user is checking; every row says its current state. */ +@Composable +fun SettingsScreen( + b: Board, + form: ServerForm, + version: String, + deviceName: String, + actions: BoardActions, + settings: SettingsActions, + onBack: () -> Unit, +) { + val c = LocalPalette.current + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + BackBar("Settings", onBack) + Column(Modifier.padding(horizontal = 16.dp)) { + Gap(8) + SectionLabel("Server") + Panel(pad = PaddingValues(0.dp)) { + val s = b.serverSetting() + SettingRow(if (b.paired) b.host else "No server", s.text, toneColor(s.tone)) + Divider() + SettingRow("API key", Words.maskKey(form.savedKey) + if (form.savedKey.isNotBlank()) " · stored on this phone only" else "") + Divider() + Column(Modifier.padding(16.dp)) { + ServerFields(form.url, form.key, settings.onUrl, settings.onKey) + Gap(12) + Secondary( + if (form.result == PairResult.TESTING) "Testing…" else "Test connection", + settings.testConnection, + Modifier.fillMaxWidth(), + ) + Gap(4) + Text("Sends a report now and shows what the server said.", style = MaterialTheme.typography.bodySmall, color = c.muted) + if (form.result != PairResult.NONE) { + Gap(12) + PairNotice(form.result, Board.hostOf(form.url), deviceName) + } + } + } + Gap(20) + SectionLabel("This phone") + Panel(pad = PaddingValues(0.dp)) { + SettingRow( + "Notification access", + if (b.notif) "Allowed" else "Off · can't see running apps", + if (b.notif) c.ok else c.bad, + actions.allowNotifications, + ) + Divider() + SettingRow( + "Usage access", + if (b.usage) "Allowed" else "Off · no last-seen times", + if (b.usage) c.ok else c.bad, + actions.allowUsage, + ) + Divider() + SettingRow( + "Battery optimisation", + if (b.battery) "Off for CashPilot" else "On · reports may arrive late", + if (b.battery) c.ok else c.warn, + actions.openBattery, + ) + } + Gap(20) + SectionLabel("Apps to watch") + Panel(pad = PaddingValues(0.dp)) { + b.installed.forEachIndexed { i, a -> + if (i > 0) Divider() + // The whole row is the switch: one 56 dp target, one TalkBack stop. + Row( + Modifier.fillMaxWidth() + .toggleable(a.state != AppState.DISABLED, role = Role.Switch) { actions.toggleApp(a.slug) } + .heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppMark(a.name, 32) + Spacer(Modifier.width(12.dp)) + Text(a.name, Modifier.weight(1f)) + Switch(checked = a.state != AppState.DISABLED, onCheckedChange = null) + } + } + if (b.installed.isNotEmpty()) Divider() + SettingRow( + "All ${b.apps.size} supported apps", + "${b.installed.size} installed · ${b.notInstalled.size} not installed", + onClick = actions.showAllSupported, + ) + } + Gap(20) + SectionLabel("About") + Panel(pad = PaddingValues(0.dp)) { + SettingRow("CashPilot for Android $version", "Source code and licence (GPL-3.0)", onClick = { settings.openUrl(SOURCE_URL) }) + Divider() + SettingRow("CashPilot server", "The self-hosted dashboard this app reports to", onClick = { settings.openUrl(SERVER_PROJECT_URL) }) + Divider() + SettingRow("Sponsor the project", "GitHub Sponsors", onClick = { settings.openUrl(SPONSOR_URL) }) + Divider() + SettingRow("Demo mode", "Shows the screens with sample data", onClick = actions.startDemo) + } + Gap(24) + } + } +} diff --git a/app/src/main/java/com/cashpilot/android/ui/component/EarningsCard.kt b/app/src/main/java/com/cashpilot/android/ui/component/EarningsCard.kt deleted file mode 100644 index 2a92826..0000000 --- a/app/src/main/java/com/cashpilot/android/ui/component/EarningsCard.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.cashpilot.android.ui.component - -import android.text.format.DateUtils -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.cashpilot.android.R -import com.cashpilot.android.model.Earnings -import com.cashpilot.android.ui.EarningsPresentation -import com.cashpilot.android.util.FormatUtils - -/** - * What the server says these apps earned. - * - * This card is the reason the earnings work existed. Every piece of it shipped - * — the model, the parsing, the "keep the last value across an offline blip" - * rule, the timestamp that makes staleness expressible — and no screen read any - * of it, so the app the user opened still showed no earnings at all. - * - * The three rules below are the ones a display can quietly throw away, and each - * of them is a lie if it goes: - * - * 1. **A missing figure is not zero.** `null` renders as "nothing read yet", - * never `$0.00`. A confident zero beside a service the user is running says - * the provider paid nothing, which is a claim nobody measured. - * 2. **A kept figure is not a current one.** The last value survives an offline - * phone on purpose, so it must be labelled when it is old, or the user is - * told a stale number is live. - * 3. **An account balance is not a device's earnings.** Providers report one - * balance per account, so a platform running on more than one machine - * cannot be attributed here — the card says so rather than implying it. - */ -@Composable -fun EarningsCard( - earnings: Earnings?, - asOfMillis: Long, - nowMillis: Long, - serverConfigured: Boolean, - modifier: Modifier = Modifier, -) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringResource(R.string.earnings_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - earnings?.let { - Text( - stringResource(R.string.earnings_window, it.windowDays), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - - Spacer(Modifier.height(8.dp)) - - when (EarningsPresentation.mode(earnings, serverConfigured)) { - // Standalone is a supported mode, so this is a normal state and - // not a failure: there is no server to have asked. - EarningsPresentation.Mode.NEEDS_SERVER -> Text( - stringResource(R.string.earnings_needs_server), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - - // No heartbeat has ever carried figures, or every platform is - // unread. Both are "not measured", which is not "earned zero". - EarningsPresentation.Mode.NOTHING_READ -> { - Text( - stringResource(R.string.earnings_none_yet), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.earnings_none_yet_detail), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - - EarningsPresentation.Mode.FIGURE -> { - Text( - FormatUtils.formatPlatformEarnings(earnings?.totalUsd), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - if (!earnings?.platformsWithoutReadings.isNullOrEmpty()) { - Spacer(Modifier.height(4.dp)) - Text( - pluralStringResource( - R.plurals.earnings_platforms_without_readings, - earnings?.platformsWithoutReadings?.size ?: 0, - earnings?.platformsWithoutReadings?.size ?: 0, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } - - // Staleness is reported whenever there is a figure on screen, which - // includes a kept one from before the phone went offline. - if (EarningsPresentation.isStale(earnings, serverConfigured, asOfMillis, nowMillis)) { - Spacer(Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.earnings_stale_badge), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.error, - ) - if (asOfMillis > 0L) { - Spacer(Modifier.width(6.dp)) - Text( - stringResource( - R.string.earnings_stale, - DateUtils.getRelativeTimeSpanString( - asOfMillis, - nowMillis, - DateUtils.MINUTE_IN_MILLIS, - ).toString(), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt deleted file mode 100644 index b2acd5e..0000000 --- a/app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt +++ /dev/null @@ -1,725 +0,0 @@ -package com.cashpilot.android.ui.screen - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.Settings as AndroidSettings -import android.text.format.DateUtils -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.cashpilot.android.R -import com.cashpilot.android.model.MonitoredApp -import com.cashpilot.android.ui.AppDisplayInfo -import com.cashpilot.android.ui.AppState -import com.cashpilot.android.ui.EarningsPresentation -import com.cashpilot.android.model.PlatformEarnings -import com.cashpilot.android.ui.MainViewModel -import com.cashpilot.android.ui.component.EarningsCard -import com.cashpilot.android.util.FormatUtils -import com.cashpilot.android.util.ServerUrlPolicy -import kotlinx.coroutines.delay - -private val RunningGreen = Color(0xFF22C55E) -// Amber, deliberately NOT the stopped red: "we cannot see this" is a different -// claim from "this is dead", and colouring them the same is what made a -// permission problem look like a fleet of dead apps. -private val UnknownAmber = Color(0xFFF59E0B) -private val StoppedRed = Color(0xFFEF4444) -private val DisabledGray = Color(0xFF9CA3AF) -private val NotInstalledGray = Color(0xFF6B7280) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DashboardScreen(viewModel: MainViewModel, onNavigateToSettings: () -> Unit) { - val apps by viewModel.apps.collectAsState() - val summary by viewModel.summary.collectAsState() - val settings by viewModel.settings.collectAsState() - val lastHeartbeat by viewModel.lastHeartbeat.collectAsState() - val lastHeartbeatFailed by viewModel.lastHeartbeatFailed.collectAsState() - val isRefreshing by viewModel.isRefreshing.collectAsState() - val publicIp by viewModel.publicIp.collectAsState() - val earnings by viewModel.earnings.collectAsState() - val earningsAsOf by viewModel.earningsAsOf.collectAsState() - val isBlind by viewModel.isBlind.collectAsState() - // Recomputed on each refresh tick rather than read at composition time, so - // the "may be out of date" badge appears while the screen is open instead of - // only after the user navigates away and back. - var nowMillis by rememberSaveable { mutableStateOf(System.currentTimeMillis()) } - - // Auto-refresh every 30s while visible - LaunchedEffect(Unit) { - while (true) { - delay(30_000) - nowMillis = System.currentTimeMillis() - viewModel.refreshStatuses() - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.app_name)) }, - actions = { - IconButton(onClick = onNavigateToSettings) { - Icon(painterResource(R.drawable.ic_settings), contentDescription = stringResource(R.string.settings)) - } - }, - ) - }, - ) { padding -> - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = { viewModel.refreshStatuses() }, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // -- Summary header (full width) -- - item(span = { GridItemSpan(2) }) { - SummaryHeader( - summary = summary, - serverConfigured = settings.serverUrl.isNotBlank() && settings.apiKey.isNotBlank(), - lastHeartbeat = lastHeartbeat, - heartbeatFailed = lastHeartbeatFailed, - urlRefused = !ServerUrlPolicy.allowsToken(settings.serverUrl), - publicIp = publicIp, - onNavigateToSettings = onNavigateToSettings, - ) - } - - // -- Permissions -- - // While blind the banner is the wrong shape: it is dismissible, - // and dismissing it leaves a screen full of cards that cannot - // say anything true. The blocking card takes over instead. - item(span = { GridItemSpan(2) }) { - if (isBlind) PermissionBlocker() else PermissionBanner(viewModel) - } - - // -- Earnings (full width) -- - item(span = { GridItemSpan(2) }) { - EarningsCard( - earnings = earnings, - asOfMillis = earningsAsOf, - nowMillis = nowMillis, - serverConfigured = settings.serverUrl.isNotBlank() && settings.apiKey.isNotBlank(), - ) - } - - // -- App grid -- - // Every card would read "Can't tell", which is eleven copies of - // one message. The blocking card above says it once, with the fix. - // Earnings stay visible either way -- they come from the server - // and are unaffected by what this device can see. - if (!isBlind) { - items(apps, key = { it.app.slug }) { info -> - AppCard(info, earnings?.platforms?.firstOrNull { it.slug == info.app.slug }) - } - } - } - } - } -} - -@Composable -private fun SummaryHeader( - summary: com.cashpilot.android.ui.FleetSummary, - serverConfigured: Boolean, - lastHeartbeat: Long, - heartbeatFailed: Boolean, - urlRefused: Boolean, - publicIp: String?, - onNavigateToSettings: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(16.dp)) { - // Status counts row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - // Running - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = RunningGreen, - modifier = Modifier.size(10.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.summary_running, summary.running), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - } - // Stopped - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = StoppedRed, - modifier = Modifier.size(10.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.summary_stopped, summary.stopped), - style = MaterialTheme.typography.labelMedium, - ) - } - // Unknown -- only when there ARE any, because a permanent - // "0 unknown" is noise. Shown BEFORE not-installed: an app we - // cannot see is a live problem, one that is not installed is not. - if (summary.unknown > 0) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = UnknownAmber, - modifier = Modifier.size(10.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.summary_unknown, summary.unknown), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - } - } - // Not installed - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = NotInstalledGray, - modifier = Modifier.size(10.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.summary_na, summary.notInstalled), - style = MaterialTheme.typography.labelMedium, - ) - } - } - - Spacer(Modifier.height(12.dp)) - - // Bandwidth row - if (summary.totalTx > 0 || summary.totalRx > 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_arrow_upward), - contentDescription = stringResource(R.string.upload), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(2.dp)) - Text( - formatBytes(summary.totalTx), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_arrow_downward), - contentDescription = stringResource(R.string.download), - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(2.dp)) - Text( - formatBytes(summary.totalRx), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - stringResource(R.string.bandwidth_24h), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - Spacer(Modifier.height(8.dp)) - } - - // Public IP row - if (publicIp != null) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_language), - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(4.dp)) - Text( - "IP: $publicIp", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.height(8.dp)) - } - - // Server / heartbeat row - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - if (!serverConfigured) { - TextButton(onClick = onNavigateToSettings) { - Icon( - painterResource(R.drawable.ic_cloud_off), - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.not_paired), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - val dotColor by animateColorAsState( - targetValue = when { - heartbeatFailed || urlRefused -> StoppedRed - lastHeartbeat > 0 -> RunningGreen - else -> DisabledGray - }, - label = "heartbeat-dot", - ) - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = dotColor, - modifier = Modifier.size(8.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - when { - urlRefused -> stringResource(R.string.cleartext_refused_short) - lastHeartbeat == 0L -> stringResource(R.string.no_heartbeat_yet) - else -> stringResource(R.string.last_heartbeat, relativeTime(lastHeartbeat)) - }, - style = MaterialTheme.typography.bodySmall, - color = if (urlRefused) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } -} - -/** - * Shown INSTEAD of the app grid when no detection signal is available. - * - * Not a styling choice. With neither notification nor usage access every app - * resolves to [AppState.UNKNOWN], so the grid would be eleven identical "Can't - * tell" cards — the same sentence, repeated, with the actual fix nowhere on - * screen. This says it once and offers the two buttons that resolve it. - * - * Deliberately NOT dismissible, unlike [PermissionBanner]. Dismissing it would - * leave a screen that looks informative and knows nothing. - */ -@Composable -private fun PermissionBlocker() { - val context = LocalContext.current - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_visibility_off), - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onErrorContainer, - ) - Spacer(Modifier.width(8.dp)) - Text( - stringResource(R.string.permissions_blocking_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.permissions_blocking_body), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Spacer(Modifier.height(8.dp)) - TextButton( - onClick = { - context.startActivity( - Intent(AndroidSettings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - }, - ) { - Icon(painterResource(R.drawable.ic_notifications), null, Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.grant_notification_access)) - } - TextButton( - onClick = { - context.startActivity( - Intent(AndroidSettings.ACTION_USAGE_ACCESS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - }, - ) { - Icon(painterResource(R.drawable.ic_language), null, Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.grant_usage_access)) - } - } - } -} - -@Composable -private fun PermissionBanner(viewModel: MainViewModel) { - val hasNotif by viewModel.hasNotificationAccess.collectAsState() - val hasUsage by viewModel.hasUsageAccess.collectAsState() - var dismissed by rememberSaveable { mutableStateOf(false) } - val context = LocalContext.current - - // Reset dismissal if permissions were revoked after user dismissed the banner - LaunchedEffect(hasNotif, hasUsage) { - if (!hasNotif || !hasUsage) dismissed = false - } - - if (dismissed || (hasNotif && hasUsage)) return - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - ), - ) { - Row( - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painterResource(R.drawable.ic_warning), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onTertiaryContainer, - ) - Spacer(Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(R.string.permissions_needed), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - if (!hasNotif) { - TextButton( - onClick = { - context.startActivity( - Intent(AndroidSettings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - }, - contentPadding = PaddingValues(0.dp), - ) { - Icon(painterResource(R.drawable.ic_notifications), null, Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.grant_notification_access), style = MaterialTheme.typography.bodySmall) - } - } - if (!hasUsage) { - TextButton( - onClick = { - context.startActivity( - Intent(AndroidSettings.ACTION_USAGE_ACCESS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - }, - contentPadding = PaddingValues(0.dp), - ) { - Icon(painterResource(R.drawable.ic_visibility_off), null, Modifier.size(14.dp)) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.grant_usage_access), style = MaterialTheme.typography.bodySmall) - } - } - } - IconButton(onClick = { dismissed = true }) { - Icon( - painterResource(R.drawable.ic_close), - contentDescription = stringResource(R.string.dismiss), - modifier = Modifier.size(18.dp), - ) - } - } - } -} - -@Composable -private fun AppCard(info: AppDisplayInfo, earnings: PlatformEarnings? = null) { - val context = LocalContext.current - val borderColor = when (info.state) { - AppState.RUNNING -> RunningGreen - AppState.STOPPED -> StoppedRed - AppState.UNKNOWN -> UnknownAmber - AppState.DISABLED -> DisabledGray - AppState.NOT_INSTALLED -> Color.Transparent - } - val cardAlpha = when (info.state) { - AppState.NOT_INSTALLED -> 0.5f - AppState.DISABLED -> 0.65f - else -> 1f - } - - Card( - modifier = Modifier - .fillMaxWidth() - .alpha(cardAlpha) - .then( - if (info.state == AppState.NOT_INSTALLED) { - Modifier.clickable { - openAppInstall(context, info.app) - } - } else { - Modifier - }, - ), - border = if (info.state != AppState.NOT_INSTALLED) { - BorderStroke(1.5.dp, borderColor) - } else { - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) - }, - ) { - Column(modifier = Modifier.padding(12.dp)) { - // Status dot + app name - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_circle), - contentDescription = null, - tint = when (info.state) { - AppState.RUNNING -> RunningGreen - AppState.STOPPED -> StoppedRed - AppState.UNKNOWN -> UnknownAmber - AppState.DISABLED -> DisabledGray - AppState.NOT_INSTALLED -> NotInstalledGray - }, - modifier = Modifier.size(10.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - info.app.displayName, - style = MaterialTheme.typography.titleSmall, - fontWeight = if (info.state == AppState.RUNNING) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - // Per-app earnings. Only for apps that are actually on this - // device: a figure beside an app the user has not installed would - // be noise, and beside a DISABLED one it would be stale by design. - if (EarningsPresentation.showsPerAppEarnings(info.state)) { - Spacer(Modifier.height(4.dp)) - Text( - // An em-dash for null, never "$0.00". Nothing has been read, - // which is not the same as having earned nothing. - FormatUtils.formatPlatformEarnings(earnings?.usd), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - if (earnings?.sharedWithOtherWorkers == true) { - Text( - stringResource(R.string.earnings_shared), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - // The label alone says the figure is shared; it does not say - // what that means for the number directly above it. Without - // this line a user reasonably reads it as what THIS phone - // earned, which is the misreading the label exists to prevent. - // Follows the earnings_none_yet / _detail pair in EarningsCard. - Text( - stringResource(R.string.earnings_shared_detail), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - Spacer(Modifier.height(4.dp)) - - // State label - Text( - when (info.state) { - AppState.RUNNING -> stringResource(R.string.state_running) - AppState.STOPPED -> info.status?.lastActive?.let { stringResource(R.string.state_last_active, relativeTime(parseIso(it))) } ?: stringResource(R.string.state_stopped) - AppState.UNKNOWN -> stringResource(R.string.state_unknown) - AppState.DISABLED -> stringResource(R.string.state_disabled) - AppState.NOT_INSTALLED -> stringResource(R.string.state_not_installed) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - // Notification indicator for running apps - if (info.state == AppState.RUNNING && info.status?.notificationActive == true) { - Spacer(Modifier.height(2.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_notifications), - contentDescription = stringResource(R.string.notification_active), - modifier = Modifier.size(12.dp), - tint = RunningGreen.copy(alpha = 0.7f), - ) - Spacer(Modifier.width(2.dp)) - Text( - stringResource(R.string.notification_active), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } - } - - // Bandwidth for apps with data - val tx = info.status?.netTx24h ?: 0 - val rx = info.status?.netRx24h ?: 0 - if (tx > 0 || rx > 0) { - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_arrow_upward), - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - Text( - formatBytes(tx), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - Spacer(Modifier.width(6.dp)) - Icon( - painterResource(R.drawable.ic_arrow_downward), - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - Text( - formatBytes(rx), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) - } - } - } - } -} - -private fun formatBytes(bytes: Long): String = FormatUtils.formatBytes(bytes) - -private fun relativeTime(millis: Long): String { - if (millis == 0L) return "never" - return DateUtils.getRelativeTimeSpanString( - millis, - System.currentTimeMillis(), - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE, - ).toString() -} - -private fun parseIso(iso: String): Long = FormatUtils.parseIso(iso) - -private fun openAppInstall(context: Context, app: MonitoredApp) { - // Try referral URL first, fall back to Play Store on any failure - val referral = app.referralUrl - if (referral != null) { - try { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(referral)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - return - } catch (_: Exception) { /* fall through to Play Store */ } - } - try { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${app.packageName}")), - ) - } catch (_: Exception) { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=${app.packageName}")), - ) - } -} diff --git a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt b/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt deleted file mode 100644 index 1f2e832..0000000 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SettingsScreen.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.cashpilot.android.ui.screen - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.cashpilot.android.R -import com.cashpilot.android.model.KnownApps -import com.cashpilot.android.ui.MainViewModel -import com.cashpilot.android.util.ServerUrlPolicy - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(viewModel: MainViewModel, onBack: () -> Unit) { - val settings by viewModel.settings.collectAsState() - val hasBattery by viewModel.hasBatteryOptOut.collectAsState() - val context = LocalContext.current - - // Local state for text fields — avoids per-keystroke DataStore writes - var localUrl by rememberSaveable { mutableStateOf("") } - var localKey by rememberSaveable { mutableStateOf("") } - - // Sync each field independently so a partial DataStore write doesn't clobber the other - var urlSynced by rememberSaveable { mutableStateOf(false) } - var keySynced by rememberSaveable { mutableStateOf(false) } - val cleartext = localUrl.trim().startsWith("http://", ignoreCase = true) - val urlRefused = localUrl.isNotBlank() && !ServerUrlPolicy.allowsToken(localUrl) - LaunchedEffect(settings.serverUrl) { - if (!urlSynced && settings.serverUrl.isNotEmpty()) { - localUrl = settings.serverUrl - urlSynced = true - } - } - LaunchedEffect(settings.apiKey) { - if (!keySynced && settings.apiKey.isNotEmpty()) { - localKey = settings.apiKey - keySynced = true - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Settings") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { - Text("Server Connection", style = MaterialTheme.typography.titleMedium) - } - item { - OutlinedTextField( - value = localUrl, - onValueChange = { url -> - localUrl = url - viewModel.updateServerUrl(url) - }, - label = { Text("CashPilot Server URL") }, - placeholder = { Text("https://cashpilot.example.com") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = cleartext || urlRefused, - supportingText = when { - urlRefused -> { { Text(stringResource(R.string.cleartext_refused)) } } - cleartext -> { { Text(stringResource(R.string.cleartext_warning)) } } - else -> null - }, - ) - } - item { - OutlinedTextField( - value = localKey, - onValueChange = { key -> - localKey = key - viewModel.updateApiKey(key) - }, - label = { Text("Fleet API Key") }, - placeholder = { Text("Paste CASHPILOT_API_KEY from your server") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - - // Battery optimization - item { - Spacer(modifier = Modifier.height(8.dp)) - if (hasBattery) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painterResource(R.drawable.ic_check_circle), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - stringResource(R.string.battery_unrestricted), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - OutlinedButton( - onClick = { openBatteryOptimizationSettings(context) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.battery_disable_optimization)) - } - } - } - - // Monitored apps section - item { - Spacer(modifier = Modifier.height(8.dp)) - Text("Monitored Apps", style = MaterialTheme.typography.titleMedium) - Text( - "Toggle which apps to monitor. Only installed apps will be reported.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - items(KnownApps.all) { app -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(app.displayName, style = MaterialTheme.typography.bodyLarge) - Text( - app.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch( - checked = app.slug in settings.enabledSlugs, - onCheckedChange = { viewModel.toggleApp(app.slug) }, - ) - } - } - - // About section - item { - Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.about), style = MaterialTheme.typography.titleMedium) - } - item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot-android") }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(R.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.about_github_android)) - } - OutlinedButton( - onClick = { openUrl(context, "https://github.com/GeiserX/CashPilot") }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(R.drawable.ic_github), - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.about_github_server)) - } - Button( - onClick = { openUrl(context, "https://github.com/sponsors/GeiserX") }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.about_donate)) - } - } - } - - item { Spacer(modifier = Modifier.height(16.dp)) } - } - } -} - -private fun openUrl(context: Context, url: String) { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -} - -// 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) { - 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 deleted file mode 100644 index e1e7a03..0000000 --- a/app/src/main/java/com/cashpilot/android/ui/screen/SetupScreen.kt +++ /dev/null @@ -1,308 +0,0 @@ -package com.cashpilot.android.ui.screen - -import android.content.Context -import android.content.Intent -import android.provider.Settings as AndroidSettings -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.cashpilot.android.R -import com.cashpilot.android.ui.MainViewModel -import com.cashpilot.android.util.ServerUrlPolicy - -@Composable -fun SetupScreen(viewModel: MainViewModel, onComplete: () -> Unit) { - val settings by viewModel.settings.collectAsState() - val hasNotif by viewModel.hasNotificationAccess.collectAsState() - val hasUsage by viewModel.hasUsageAccess.collectAsState() - val hasBattery by viewModel.hasBatteryOptOut.collectAsState() - val context = LocalContext.current - - var localUrl by rememberSaveable { mutableStateOf("") } - var localKey by rememberSaveable { mutableStateOf("") } - - // Sync each field independently so a partial DataStore write doesn't clobber the other - var urlSynced by rememberSaveable { mutableStateOf(false) } - var keySynced by rememberSaveable { mutableStateOf(false) } - val cleartext = localUrl.trim().startsWith("http://", ignoreCase = true) - val urlRefused = localUrl.isNotBlank() && !ServerUrlPolicy.allowsToken(localUrl) - LaunchedEffect(settings.serverUrl) { - if (!urlSynced && settings.serverUrl.isNotEmpty()) { - localUrl = settings.serverUrl - urlSynced = true - } - } - LaunchedEffect(settings.apiKey) { - if (!keySynced && settings.apiKey.isNotEmpty()) { - localKey = settings.apiKey - keySynced = true - } - } - - val serverDone = localUrl.isNotBlank() && localKey.isNotBlank() - - val finishSetup = { - viewModel.updateSettings { it.copy(serverUrl = localUrl, apiKey = localKey, setupCompleted = true) } - onComplete() - } - - Scaffold { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Spacer(Modifier.height(24.dp)) - - Text( - stringResource(R.string.setup_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - Text( - stringResource(R.string.setup_subtitle), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(8.dp)) - - // Step 1: Server connection - SetupCard( - step = 1, - icon = painterResource(R.drawable.ic_cloud), - title = stringResource(R.string.setup_server_title), - description = stringResource(R.string.setup_server_desc) + " " + - stringResource(R.string.setup_server_what_it_adds), - optional = true, - done = serverDone, - ) { - OutlinedTextField( - value = localUrl, - onValueChange = { localUrl = it; viewModel.updateServerUrl(it) }, - label = { Text(stringResource(R.string.setup_server_url_label)) }, - placeholder = { Text("https://cashpilot.example.com") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - isError = cleartext || urlRefused, - supportingText = when { - urlRefused -> { { Text(stringResource(R.string.cleartext_refused)) } } - cleartext -> { { Text(stringResource(R.string.cleartext_warning)) } } - else -> null - }, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = localKey, - onValueChange = { localKey = it; viewModel.updateApiKey(it) }, - label = { Text(stringResource(R.string.setup_api_key_label)) }, - placeholder = { Text("CASHPILOT_API_KEY") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } - - // Step 2: Notification access - SetupCard( - step = 2, - icon = painterResource(R.drawable.ic_notifications), - title = stringResource(R.string.setup_notif_title), - description = stringResource(R.string.setup_notif_desc), - done = hasNotif, - ) { - TextButton( - onClick = { openNotificationListenerSettings(context) }, - ) { - Text(stringResource(R.string.setup_grant_access)) - Spacer(Modifier.width(4.dp)) - Icon(painterResource(R.drawable.ic_chevron_right), null, Modifier.size(18.dp)) - } - } - - // Step 3: Usage access - SetupCard( - step = 3, - icon = painterResource(R.drawable.ic_query_stats), - title = stringResource(R.string.setup_usage_title), - description = stringResource(R.string.setup_usage_desc), - done = hasUsage, - ) { - TextButton( - onClick = { openUsageAccessSettings(context) }, - ) { - Text(stringResource(R.string.setup_grant_access)) - Spacer(Modifier.width(4.dp)) - Icon(painterResource(R.drawable.ic_chevron_right), null, Modifier.size(18.dp)) - } - } - - // Step 4: Battery optimization - SetupCard( - step = 4, - icon = painterResource(R.drawable.ic_battery_alert), - title = stringResource(R.string.setup_battery_title), - description = stringResource(R.string.setup_battery_desc), - done = hasBattery, - ) { - TextButton( - onClick = { openBatteryOptimizationSettings(context) }, - ) { - Text(stringResource(R.string.setup_grant_access)) - Spacer(Modifier.width(4.dp)) - Icon(painterResource(R.drawable.ic_chevron_right), null, Modifier.size(18.dp)) - } - } - - Spacer(Modifier.height(8.dp)) - - // The PERMISSIONS are genuinely required -- without notification and - // usage access the detector is blind and the app can do nothing at - // all. A SERVER is not: the phone works out which apps are running - // entirely on its own, and that is most of the value. Requiring one - // here made the app useless to anyone who installs it from a store - // without ever having heard of the server (CashPilot-android-ni6). - Button( - onClick = finishSetup, - modifier = Modifier.fillMaxWidth(), - enabled = hasNotif && hasUsage, - ) { - Text( - stringResource( - if (serverDone) R.string.setup_continue else R.string.setup_continue_standalone, - ), - ) - } - - TextButton( - onClick = finishSetup, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.setup_skip)) - } - - Spacer(Modifier.height(16.dp)) - } - } -} - -@Composable -private fun SetupCard( - step: Int, - icon: Painter, - title: String, - description: String, - done: Boolean, - /** Shown with an "optional" note and never blocks finishing setup. */ - optional: Boolean = false, - content: @Composable () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (done) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (done) { - Icon( - painterResource(R.drawable.ic_check_circle), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) - } else { - Icon( - icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - } - Spacer(Modifier.width(12.dp)) - Column { - Text( - stringResource(R.string.setup_step, step, title) + - if (optional) " · " + stringResource(R.string.setup_server_optional) else "", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - } - } - Spacer(Modifier.height(8.dp)) - Text( - description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - content() - } - } -} - -private fun openNotificationListenerSettings(context: Context) { - context.startActivity( - Intent(AndroidSettings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -} - -private fun openUsageAccessSettings(context: Context) { - context.startActivity( - Intent(AndroidSettings.ACTION_USAGE_ACCESS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -} - -// 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) { - context.startActivity( - Intent(AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) -} diff --git a/app/src/main/java/com/cashpilot/android/ui/theme/Theme.kt b/app/src/main/java/com/cashpilot/android/ui/theme/Theme.kt index 5624aa1..86c5045 100644 --- a/app/src/main/java/com/cashpilot/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/cashpilot/android/ui/theme/Theme.kt @@ -1,48 +1,111 @@ package com.cashpilot.android.ui.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle -private val CashPilotGreen = Color(0xFF22C55E) -private val CashPilotDarkGreen = Color(0xFF16A34A) +/** + * The two palettes of the web dashboard: "Classic night" (data-theme="classic" + * in the server's style.css) and "Light", so the phone and the server read as + * one product. + * + * Dynamic colour is off on purpose. Material You repaints the app in the + * wallpaper's colours, and then the status colours (green running, red + * stopped) are whatever the wallpaper made of them. + * + * Every text colour here clears WCAG AA (4.5:1) on [bg], [card] and [raised], + * and each status colour does on every tinted fill too. PaletteContrastTest + * checks each pair, so a tweak that drops one below fails the build instead of + * shipping unreadable text. (Light's green and amber were darkened one step + * for that reason: on the tinted fills the first values fell to 4.2:1.) + */ +data class Palette( + val bg: Color, + val card: Color, + val raised: Color, + val line: Color, + val lineStrong: Color, + val text: Color, + val text2: Color, + val muted: Color, + val accent: Color, + val onAccent: Color, + val ok: Color, + val warn: Color, + val bad: Color, + val okSoft: Color, + val warnSoft: Color, + val badSoft: Color, + val accentSoft: Color, + val dark: Boolean, +) -private val DarkColorScheme = darkColorScheme( - primary = CashPilotGreen, - secondary = CashPilotDarkGreen, - background = Color(0xFF0F172A), - surface = Color(0xFF1E293B), +val ClassicNight = Palette( + bg = Color(0xFF0B0B14), card = Color(0xFF11111D), raised = Color(0xFF171726), + line = Color(0xFF25233A), lineStrong = Color(0xFF4B4670), + text = Color(0xFFE8E6F0), text2 = Color(0xFFBDB6CF), muted = Color(0xFFA59CB5), + accent = Color(0xFFF472B6), onAccent = Color(0xFF0B0B14), + ok = Color(0xFF46B37F), warn = Color(0xFFE0A53C), bad = Color(0xFFF06A5F), + okSoft = Color(0x1F46B37F), warnSoft = Color(0x1FE0A53C), badSoft = Color(0x1FF06A5F), + accentSoft = Color(0x1AF472B6), dark = true, ) -private val LightColorScheme = lightColorScheme( - primary = CashPilotDarkGreen, - secondary = CashPilotGreen, +val Light = Palette( + bg = Color(0xFFF6F7F8), card = Color(0xFFFFFFFF), raised = Color(0xFFEEF0F2), + line = Color(0xFFDDE1E5), lineStrong = Color(0xFF9AA2AB), + text = Color(0xFF16191C), text2 = Color(0xFF464D55), muted = Color(0xFF5F6770), + // The web's light theme uses near-black actions; the phone keeps the brand + // pink, darkened until it passes AA as text on white. + accent = Color(0xFFBE185D), onAccent = Color.White, + ok = Color(0xFF1A7046), warn = Color(0xFF875500), bad = Color(0xFFB42318), + okSoft = Color(0x1A1A7046), warnSoft = Color(0x1F875500), badSoft = Color(0x17B42318), + accentSoft = Color(0x14BE185D), dark = false, ) +val LocalPalette = staticCompositionLocalOf { ClassicNight } + +/** Figures line up in columns: tabular digits. */ +val Tnum = TextStyle(fontFeatureSettings = "tnum") + @Composable fun CashPilotTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val c = if (darkTheme) ClassicNight else Light + val scheme = if (darkTheme) { + darkColorScheme( + primary = c.accent, onPrimary = c.onAccent, secondary = c.accent, + background = c.bg, onBackground = c.text, surface = c.bg, onSurface = c.text, + surfaceVariant = c.raised, onSurfaceVariant = c.text2, outline = c.lineStrong, + outlineVariant = c.line, error = c.bad, surfaceContainer = c.card, + surfaceContainerHigh = c.raised, surfaceContainerLow = c.card, + surfaceContainerHighest = c.raised, + primaryContainer = c.accentSoft, onPrimaryContainer = c.text, + ) + } else { + lightColorScheme( + primary = c.accent, onPrimary = c.onAccent, secondary = c.accent, + background = c.bg, onBackground = c.text, surface = c.bg, onSurface = c.text, + surfaceVariant = c.raised, onSurfaceVariant = c.text2, outline = c.lineStrong, + outlineVariant = c.line, error = c.bad, surfaceContainer = c.card, + surfaceContainerHigh = c.raised, surfaceContainerLow = c.card, + surfaceContainerHighest = c.raised, + primaryContainer = c.accentSoft, onPrimaryContainer = c.text, + ) + } + CompositionLocalProvider(LocalPalette provides c) { + MaterialTheme(colorScheme = scheme) { + Surface(Modifier.fillMaxSize(), color = c.bg, contentColor = c.text) { content() } } - darkTheme -> DarkColorScheme - else -> LightColorScheme } - - MaterialTheme( - colorScheme = colorScheme, - content = content, - ) } diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml deleted file mode 100644 index 9d40967..0000000 --- a/app/src/main/res/drawable/ic_arrow_downward.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml deleted file mode 100644 index 839520a..0000000 --- a/app/src/main/res/drawable/ic_arrow_upward.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml deleted file mode 100644 index f8133f8..0000000 --- a/app/src/main/res/drawable/ic_circle.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml deleted file mode 100644 index 6b88a19..0000000 --- a/app/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml deleted file mode 100644 index 82248e8..0000000 --- a/app/src/main/res/drawable/ic_github.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml deleted file mode 100644 index ac56150..0000000 --- a/app/src/main/res/drawable/ic_language.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e196c5..d44a756 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,96 +1,4 @@ CashPilot - - - Settings - %d Running - %d Stopped - %d N/A - Upload - Download - 24h - - Not paired — running standalone - 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 report sent yet - - Last report %s · sent every 15 min - - - Permissions needed - Grant Notification Access - Grant Usage Access - Dismiss - - - Welcome to CashPilot - Complete these steps to start monitoring your passive income apps. - Server Connection - Connect to your CashPilot server so the app can report which passive income apps are running on this device. - CashPilot Server URL - Fleet API Key - Notification Access - CashPilot reads foreground notifications to detect when passive income apps are running. No notification content is collected — only presence is checked. - 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 - 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 - Step %1$d: %2$s - - - Running - Stopped - Disabled - Not installed — tap to install - Last: %s - Notification active - - - Battery optimization disabled - Disable Battery Optimization - - - 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. - Not sending heartbeats: this server URL needs https:// - Insecure: API key and app data will be sent unencrypted. Use https:// if possible. - - - About - CashPilot Android - CashPilot Server - Sponsor / Donate - - - Earnings - Last %1$d days - - Nothing read yet - CashPilot has not received an earnings figure for these apps yet. This is not the same as having earned nothing. - Last updated %1$s - May be out of date - - Shared across your devices - This provider reports one balance for your whole account, so this figure is not what this phone alone earned. - Pair a CashPilot server to see earnings - - %1$d app has no reading yet - %1$d apps have no reading yet - - - - Can\'t tell - CashPilot can\'t see your apps - Without notification and usage access this app cannot tell which earning apps are running. It will not guess — every app shows as unknown until you grant them. - %1$d unknown diff --git a/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt b/app/src/test/java/com/cashpilot/android/IconGoldenTest.kt index bd9b948..a6e99ac 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_launcher_background") + private val NOT_AN_ICON = setOf("ic_launcher_background") /** @@ -86,16 +86,11 @@ class IconGoldenTest { */ private val icons: List> = listOf( "arrow_back" to R.drawable.ic_arrow_back, - "arrow_downward" to R.drawable.ic_arrow_downward, - "arrow_upward" to R.drawable.ic_arrow_upward, "battery_alert" to R.drawable.ic_battery_alert, "check_circle" to R.drawable.ic_check_circle, "chevron_right" to R.drawable.ic_chevron_right, - "circle" to R.drawable.ic_circle, - "close" to R.drawable.ic_close, "cloud" to R.drawable.ic_cloud, "cloud_off" to R.drawable.ic_cloud_off, - "language" to R.drawable.ic_language, "notifications" to R.drawable.ic_notifications, "query_stats" to R.drawable.ic_query_stats, "settings" to R.drawable.ic_settings, diff --git a/app/src/test/java/com/cashpilot/android/StandaloneSetupTest.kt b/app/src/test/java/com/cashpilot/android/StandaloneSetupTest.kt deleted file mode 100644 index e1ae705..0000000 --- a/app/src/test/java/com/cashpilot/android/StandaloneSetupTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.cashpilot.android - -import java.io.File -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -/** - * CashPilot-android-ni6: the app demanded a server before it would do anything. - * - * `SetupScreen` gated its finish button on `serverDone && hasNotif && hasUsage`, - * so a user who installed the app without a CashPilot server could not complete - * onboarding at all. That is defensible while the only people holding the APK - * already run a server; it stops being defensible the moment it is listed on - * F-Droid or Play, where it would earn one-star reviews from people who did - * exactly what the listing invited them to do. - * - * The permissions are a different matter and still gate: without notification - * and usage access the detector is blind and there is nothing to show. - * - * These assert on the source because the gating is a Compose expression with no - * seam to call. They are deliberately narrow — the behavioural proof is the - * build plus the screenshot tests tracked separately. - */ -class StandaloneSetupTest { - - private fun source(path: String): String { - val file = File("src/main/java/com/cashpilot/android/$path") - assertTrue(file.exists(), "expected $path to exist; the test is looking in the wrong place") - return file.readText() - } - - @Test - fun `finishing setup no longer requires a server`() { - val text = source("ui/screen/SetupScreen.kt") - assertFalse( - text.contains("enabled = serverDone && hasNotif && hasUsage"), - "the finish button still demands a server URL and API key", - ) - } - - @Test - fun `but it still requires the permissions the detector cannot work without`() { - val text = source("ui/screen/SetupScreen.kt") - assertTrue( - text.contains("enabled = hasNotif && hasUsage"), - "notification and usage access must still gate setup — without them the app is blind", - ) - } - - @Test - fun `the server step is presented as optional`() { - val text = source("ui/screen/SetupScreen.kt") - assertTrue(text.contains("optional = true"), "the server card does not tell the user it can be skipped") - } - - @Test - fun `the button says what continuing without a server means`() { - val text = source("ui/screen/SetupScreen.kt") - assertTrue( - text.contains("R.string.setup_continue_standalone"), - "the button label does not change when no server is configured", - ) - } - - @Test - fun `standalone is not rendered as an error`() { - val text = source("ui/screen/DashboardScreen.kt") - assertFalse( - text.contains("R.string.not_connected"), - "the dashboard still calls standalone 'not connected', which reads as a failure", - ) - assertTrue(text.contains("R.string.not_paired"), "expected the neutral not_paired wording") - } - - @Test - fun `the strings explain what pairing actually adds`() { - val strings = File("src/main/res/values/strings.xml").readText() - assertTrue(strings.contains("not_paired"), "not_paired string missing") - assertTrue( - strings.contains("setup_server_what_it_adds"), - "nothing tells the user what they give up by skipping the server", - ) - assertFalse(strings.contains("\"not_connected\""), "the old error-framed string is still defined") - } -} diff --git a/app/src/test/java/com/cashpilot/android/board/AccessibilityTest.kt b/app/src/test/java/com/cashpilot/android/board/AccessibilityTest.kt new file mode 100644 index 0000000..3a26fff --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/AccessibilityTest.kt @@ -0,0 +1,167 @@ +package com.cashpilot.android.board + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.runtime.key +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.cashpilot.android.ui.board.BoardActions +import com.cashpilot.android.ui.board.HomeScreen +import com.cashpilot.android.ui.theme.CashPilotTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The accessibility pass, run on every screen in [Screens.all]: + * + * - every tappable thing is at least 48 x 48 dp, + * - every tappable thing has a name TalkBack can read, + * - every screen has a heading to jump to, + * - no text is out of reach (off the right edge, or below the screen with + * nothing to scroll it), at the default size and at font scale 1.3, + * - TalkBack reads the home screen in the order it is meant to be read. + * + * Contrast is PaletteContrastTest. Each rule here is shown to fail once on a + * deliberately broken screen, because a check nobody has seen fail proves nothing. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = PHONE) +class AccessibilityTest { + + @get:Rule + val rule = createComposeRule() + + private fun SemanticsNode.walk(): List = listOf(this) + children.flatMap { it.walk() } + + private fun nodes(merged: Boolean = true) = rule.onRoot(useUnmergedTree = !merged).fetchSemanticsNode().walk() + + private fun label(n: SemanticsNode): String = + (n.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + + n.config.getOrNull(SemanticsProperties.Text).orEmpty().map { it.text } + + listOfNotNull(n.config.getOrNull(SemanticsProperties.EditableText)?.text)).joinToString(" ").trim() + + private fun SemanticsNode.hasScrollingAncestor(): Boolean { + var p = parent + while (p != null) { + if (p.config.contains(SemanticsActions.ScrollBy)) return true + p = p.parent + } + return false + } + + /** Problems on whatever is on screen now, one line per offender. */ + private fun problems(): List { + val out = mutableListOf() + // The density the layout was drawn at. Both rule.density and the + // application's display metrics read 1.0 under Robolectric while Compose + // lays out at 2.0 (xhdpi), which let a 24 dp target pass as 48 dp. + val density = rule.onRoot().fetchSemanticsNode().layoutInfo.density.density + for (n in nodes().filter { it.config.contains(SemanticsActions.OnClick) }) { + // The node's own laid-out size, the size Accessibility Scanner checks. + // Not touchBoundsInRoot: Compose widens that to 48 dp for every + // clickable, so a check on it can never fail. And not the bounds on + // screen, which a scroll container clips to 0 x 0 below the fold. + val w = n.size.width / density + val h = n.size.height / density + val name = label(n) + if (w < 47.5f || h < 47.5f) out += "touch target ${w.toInt()}x${h.toInt()} dp: '$name'" + if (name.isBlank()) out += "tappable with no name at ${n.boundsInRoot}" + } + if (nodes(merged = false).none { it.config.contains(SemanticsProperties.Heading) }) out += "no heading" + // Text the user can never read: past the right edge (it did not wrap), + // or below the bottom edge with nothing to scroll it into view. Measured + // from layout positions, which a scroll container does not clip. + val root = rule.onRoot().fetchSemanticsNode() + for (n in nodes(merged = false)) { + val text = n.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text } ?: continue + val right = n.positionInRoot.x + n.size.width + val bottom = n.positionInRoot.y + n.size.height + if (right > root.size.width + 1) out += "text runs off the right edge: '$text'" + if (bottom > root.size.height + 1 && !n.hasScrollingAncestor()) out += "text below the screen, no scrolling: '$text'" + // A Column that cannot scroll gives what does not fit a height of 0. + if (text.isNotEmpty() && n.size.height == 0) out += "text squashed to nothing: '$text'" + } + return out + } + + @Test + fun everyScreenPassesTheAccessibilityPass() { + var current by mutableStateOf(Screens.all.first()) + rule.setContent { key(current.name) { Themed(current) } } + val failures = mutableListOf() + for (shot in Screens.all) { + current = shot + rule.waitForIdle() + problems().forEach { failures += "${shot.name}: $it" } + } + assertTrue(failures.joinToString("\n"), failures.isEmpty()) + } + + @Test + fun talkBackReadsTheHomeScreenInOrder() { + // Title, settings, the verdict, the server line, the task list, then + // the apps: the answer before the details. + rule.setContent { CashPilotTheme(darkTheme = true) { HomeScreen(Situations.problems, BoardActions()) } } + val spoken = nodes().map(::label).filter { it.isNotBlank() } + fun at(prefix: String) = spoken.indexOfFirst { it.startsWith(prefix) }.also { assertTrue("'$prefix' is not read at all: $spoken", it >= 0) } + val order = listOf("CashPilot", "Settings", "2 of 6 apps stopped", "No report has reached", "4 things need you", "1. Grass stopped", "2. Traffmonetizer stopped", "Grass Stopped", "Traffmonetizer Stopped", "Your server reports") + val indexes = order.map(::at) + assertEquals("read in the wrong order: $spoken", indexes.sorted(), indexes) + } + + // ---- Each rule can fail --------------------------------------------------- + + @Composable + private fun Broken() { + // 24 dp, no name, no heading. Inside a full-size Box, because the + // theme's Surface passes its minimum size down to a direct child. + Box(Modifier.fillMaxSize()) { Box(Modifier.size(24.dp).clickable { }) } + } + + @Composable + private fun TooLong() { + // Taller than the screen, in a Column that cannot scroll. + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) { + androidx.compose.material3.Text("Title", Modifier.semantics { heading() }) + repeat(80) { androidx.compose.material3.Text("Line $it") } + } + } + + @Test + fun unreachableTextIsCaught() { + rule.setContent { CashPilotTheme(darkTheme = true) { TooLong() } } + val p = problems() + assertTrue("squashed text not caught: ${p.take(5)}", p.any { it.startsWith("text squashed to nothing") }) + } + + @Test + fun theAccessibilityPassCanFail() { + rule.setContent { CashPilotTheme(darkTheme = true) { Broken() } } + val p = problems() + assertTrue("small target not caught: $p", p.any { it.startsWith("touch target 24x24") }) + assertTrue("missing name not caught: $p", p.any { it.startsWith("tappable with no name") }) + assertTrue("missing heading not caught: $p", p.contains("no heading")) + assertEquals(2f, rule.onRoot().fetchSemanticsNode().layoutInfo.density.density) + } +} diff --git a/app/src/test/java/com/cashpilot/android/board/BoardTest.kt b/app/src/test/java/com/cashpilot/android/board/BoardTest.kt new file mode 100644 index 0000000..c293137 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/BoardTest.kt @@ -0,0 +1,397 @@ +package com.cashpilot.android.board + +import com.cashpilot.android.board.Situations.NOW +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.Settings +import com.cashpilot.android.model.WorkerHeartbeatResponse +import com.cashpilot.android.service.Heartbeat +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.EarningsPresentation +import com.cashpilot.android.ui.board.Board +import com.cashpilot.android.ui.board.Demo +import com.cashpilot.android.ui.board.NOT_MEASURED +import com.cashpilot.android.ui.board.NO_READING_NOTE +import com.cashpilot.android.ui.board.PairResult +import com.cashpilot.android.ui.board.SHARED_FOOTNOTE +import com.cashpilot.android.ui.board.ServerState +import com.cashpilot.android.ui.board.TaskKind +import com.cashpilot.android.ui.board.Tone +import com.cashpilot.android.ui.board.Words +import com.cashpilot.android.ui.board.appEarnings +import com.cashpilot.android.ui.board.earningsLine +import com.cashpilot.android.ui.board.footnotes +import com.cashpilot.android.ui.board.measuredUse +import com.cashpilot.android.ui.board.pairMessage +import com.cashpilot.android.ui.board.pairResultOf +import com.cashpilot.android.ui.board.rowLine +import com.cashpilot.android.ui.board.serverLine +import com.cashpilot.android.ui.board.serverSetting +import com.cashpilot.android.ui.board.tasks +import com.cashpilot.android.ui.board.tasksToShow +import com.cashpilot.android.ui.board.verdict +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The state-to-UI mapping: what the status board says for each situation. + * Every assertion here is a sentence or colour a user reads. + */ +class BoardTest { + + // ---- Server line -------------------------------------------------------- + + @Test + fun `no server is a calm, muted line, never an error`() { + val line = Situations.standalone.serverLine() + assertEquals(Tone.MUTED, line.tone) + assertEquals("No server · everything stays on this phone", line.text) + } + + @Test + fun `a URL with no key is not a pairing`() { + val b = Situations.board(Situations.apps(emptyMap(), emptySet()), settings = Settings(serverUrl = "https://x.example")) + assertEquals(ServerState.NONE, b.server) + } + + @Test + fun `a working server says how old the last report is and how often they go`() { + assertEquals("Last report 6 min ago · sent every 15 min", Situations.healthy.serverLine().text) + assertEquals(Tone.OK, Situations.healthy.serverLine().tone) + } + + @Test + fun `paired with no report yet is waiting, not failing`() { + val b = Situations.board(Situations.apps(emptyMap(), emptySet()), lastHeartbeat = 0) + assertEquals("Paired with cashpilot.home.example · waiting for the first report", b.serverLine().text) + assertEquals(Tone.MUTED, b.serverLine().tone) + } + + @Test + fun `a failing server says for how long`() { + assertEquals("No report has reached cashpilot.home.example for 25 min", Situations.problems.serverLine().text) + assertEquals(Tone.WARN, Situations.problems.serverLine().tone) + } + + @Test + fun `http to a public host is named as the reason nothing is sent`() { + val b = Situations.board( + Situations.apps(emptyMap(), emptySet()), + settings = Settings(serverUrl = "http://8.8.8.8:8080", apiKey = "k", setupCompleted = true), + failed = true, + ) + assertEquals(ServerState.NEEDS_HTTPS, b.server) + assertTrue(b.serverLine().text.contains("https://")) + assertEquals(TaskKind.SERVER, b.tasks().single().kind) + } + + @Test + fun `a private http address is a normal pairing`() { + val b = Situations.board( + Situations.apps(emptyMap(), emptySet()), + settings = Settings(serverUrl = "http://192.168.1.20:8080", apiKey = "k", setupCompleted = true), + ) + assertEquals(ServerState.OK, b.server) + assertEquals("192.168.1.20", b.host) + } + + // ---- Verdict ------------------------------------------------------------ + + @Test + fun `stopped apps lead the verdict, in red, with their count`() { + val v = Situations.problems.verdict() + assertEquals(Tone.BAD, v.tone) + assertEquals(2, v.count) + assertEquals("2 of 6 apps stopped", v.title) + } + + @Test + fun `a healthy phone is a calm verdict`() { + val v = Situations.healthy.verdict() + assertEquals(Tone.OK, v.tone) + assertEquals("All 6 apps running", v.title) + assertEquals("Nothing needs you right now.", v.sub) + } + + @Test + fun `unknown apps are never counted as running`() { + // One access missing: some apps resolve UNKNOWN. "All running" would be a claim nobody measured. + val apps = Situations.apps( + mapOf("earnapp" to Situations.running("earnapp", 1, 1), "grass" to null), + setOf("earnapp", "grass"), + ) + val v = Situations.board(apps, usage = false).verdict() + assertEquals("1 of 2 apps running", v.title) + assertEquals(Tone.WARN, v.tone) + } + + @Test + fun `everything running but the server failing is not a green verdict`() { + val b = Situations.board(Situations.apps(mapOf("earnapp" to Situations.running("earnapp", 1, 1)), setOf("earnapp")), failed = true) + val v = b.verdict() + assertEquals(Tone.WARN, v.tone) + assertEquals("Your app is running", v.title) + } + + // ---- Tasks (the ordered list) ------------------------------------------- + + @Test + fun `tasks come in order - stopped apps by name, then server, then battery`() { + val t = Situations.problems.tasks() + assertEquals( + listOf(TaskKind.STOPPED_APP, TaskKind.STOPPED_APP, TaskKind.SERVER, TaskKind.BATTERY), + t.map { it.kind }, + ) + // Android knows when an app was last opened, not when it stopped, so no + // task claims a stop time and none is ranked by one. + assertEquals("grass", t[0].slug) + assertEquals("traffmonetizer", t[1].slug) + assertEquals("Traffmonetizer stopped", t[1].title) + } + + @Test + fun `every installed app switched off reads as nothing watched, never all running`() { + val installed = setOf("earnapp", "grass") + val b = Situations.board(Situations.apps(emptyMap(), installed, disabled = installed)) + assertTrue(b.watched.isEmpty()) + val v = b.verdict() + assertEquals("No apps watched", v.title) + assertEquals(Tone.MUTED, v.tone) + } + + @Test + fun `two or more problems show the list`() { + assertEquals(4, Situations.problems.tasksToShow().size) + } + + @Test + fun `a single stopped app shows no list, its row carries the Open button`() { + assertEquals(1, Situations.standalone.tasks().size) + assertTrue(Situations.standalone.tasksToShow().isEmpty()) + } + + @Test + fun `a single non-app problem is shown on its own`() { + val b = Situations.board(Situations.apps(mapOf("earnapp" to Situations.running("earnapp", 1, 1)), setOf("earnapp")), battery = false) + assertEquals(listOf(TaskKind.BATTERY), b.tasksToShow().map { it.kind }) + } + + @Test + fun `both accesses missing is the blind screen, not two tasks`() { + assertTrue(Situations.blind.blind) + assertTrue(Situations.blind.tasks().none { it.kind == TaskKind.NOTIFICATIONS || it.kind == TaskKind.USAGE }) + } + + @Test + fun `one access missing is a task`() { + val b = Situations.board(Situations.apps(emptyMap(), setOf("earnapp")), notif = false) + assertEquals(listOf(TaskKind.NOTIFICATIONS), b.tasks().map { it.kind }) + } + + @Test + fun `a healthy phone has nothing to do`() { + assertTrue(Situations.healthy.tasks().isEmpty()) + } + + // ---- Rows ----------------------------------------------------------------- + + @Test + fun `the board lists only installed, watched apps, problems first`() { + val b = Situations.problems + assertEquals(6, b.watched.size) + assertEquals(AppState.STOPPED, b.watched.first().state) + assertEquals(4, b.notInstalled.size) + } + + @Test + fun `data use is labelled as the last 24 hours, not today`() { + val earnapp = Situations.healthy.watched.first { it.slug == "earnapp" } + assertEquals("Running · 450.0 MB in 24 h", earnapp.rowLine(NOW)) + } + + @Test + fun `a stopped row says when it was last opened`() { + val grass = Situations.problems.watched.first { it.slug == "grass" } + assertEquals("Stopped · last opened 3 h ago", grass.rowLine(NOW)) + } + + @Test + fun `referral URLs reach the screen byte for byte`() { + Situations.healthy.apps.forEach { row -> + assertEquals(KnownApps.bySlug.getValue(row.slug).referralUrl, row.referralUrl, row.slug) + } + Demo.board(NOW).apps.forEach { row -> + assertEquals(KnownApps.bySlug.getValue(row.slug).referralUrl, row.referralUrl, row.slug) + } + } + + // ---- Earnings --------------------------------------------------------------- + + @Test + fun `no server asks for one instead of showing a number`() { + val e = Situations.standalone.earningsLine() + assertFalse(e.isFigure) + assertEquals("Add a server to see figures", e.value) + } + + @Test + fun `paired but never read is not zero`() { + val b = Situations.board(Situations.apps(emptyMap(), setOf("earnapp")), earnings = null) + assertEquals(EarningsPresentation.Mode.NOTHING_READ, b.earningsMode) + assertEquals("Nothing read yet", b.earningsLine().value) + } + + @Test + fun `a fresh figure is shown plainly`() { + val e = Situations.healthy.earningsLine() + assertTrue(e.isFigure) + assertEquals("$15.38", e.value) + assertNull(e.staleNote) + } + + @Test + fun `an old figure says how old`() { + assertEquals("From 7 h ago", Situations.problems.earningsLine().staleNote) + } + + @Test + fun `one footnote for whole-account balances, and one for missing readings`() { + assertEquals(listOf(SHARED_FOOTNOTE, NO_READING_NOTE), Situations.healthy.footnotes()) + assertTrue(Situations.standalone.footnotes().isEmpty()) + } + + @Test + fun `a whole-account figure is starred in the detail`() { + val b = Situations.healthy + assertEquals("$6.40 *", b.appEarnings(b.apps.first { it.slug == "mysterium" })) + assertEquals("$4.12", b.appEarnings(b.apps.first { it.slug == "earnapp" })) + assertEquals("Nothing read yet", b.appEarnings(b.apps.first { it.slug == "grass" })) + } + + @Test + fun `an app that is not installed shows no figure`() { + val b = Situations.healthy + assertEquals("—", b.appEarnings(b.apps.first { it.state == AppState.NOT_INSTALLED })) + } + + // ---- Settings and pairing ------------------------------------------------- + + @Test + fun `the API key is masked to its last four characters`() { + assertEquals("•••• •••• 9a7b", Words.maskKey("example-key-9a7b")) + assertEquals("••••", Words.maskKey("short")) + assertEquals("Not set", Words.maskKey("")) + } + + @Test + fun `the settings server row uses the 15-minute wording`() { + assertEquals("Reports every 15 min · last report 6 min ago", Situations.healthy.serverSetting().text) + } + + @Test + fun `each send outcome maps to its own pairing message`() { + assertEquals(PairResult.OK, pairResultOf(Heartbeat.Outcome.SENT)) + assertEquals(PairResult.KEY_REFUSED, pairResultOf(Heartbeat.Outcome.KEY_REFUSED)) + assertEquals(PairResult.UNREACHABLE, pairResultOf(Heartbeat.Outcome.UNREACHABLE)) + assertEquals(PairResult.URL_REFUSED, pairResultOf(Heartbeat.Outcome.URL_REFUSED)) + assertEquals(PairResult.REJECTED, pairResultOf(Heartbeat.Outcome.REJECTED)) + assertEquals(PairResult.NOT_CASHPILOT, pairResultOf(Heartbeat.Outcome.NOT_CASHPILOT)) + val titles = PairResult.entries.mapNotNull { pairMessage(it, "h", "Phone")?.title } + assertEquals(titles.size, titles.toSet().size, "two results share a message: $titles") + } + + @Test + fun `only a CashPilot answer counts as accepted, not any 200`() { + assertTrue(Heartbeat.acceptedBy(WorkerHeartbeatResponse(status = "ok", workerId = 7))) + assertFalse(Heartbeat.acceptedBy(WorkerHeartbeatResponse())) // a 200 whose JSON is something else + assertFalse(Heartbeat.acceptedBy(WorkerHeartbeatResponse(status = "ok"))) // some other endpoint's ok: nothing registered + assertFalse(Heartbeat.acceptedBy(null)) // a login page: not JSON at all + } + + @Test + fun `a new server address drops the phone's own key, so it never goes to another host`() { + val enrolled = Settings(serverUrl = "https://a.example", apiKey = "fleet", workerKey = "issued-by-a") + assertEquals("issued-by-a", enrolled.withServerUrl("https://a.example ").workerKey) + val moved = enrolled.withServerUrl("https://b.example") + assertEquals("", moved.workerKey) + assertEquals("fleet", moved.activeKey) + } + + @Test + fun `a report overdue by more than an hour is late, not fine`() { + val late = Situations.board(Situations.apps(emptyMap(), setOf("earnapp")), lastHeartbeat = NOW - 2 * 60 * 60 * 1000L) + assertEquals(ServerState.LATE, late.server) + assertEquals(Tone.WARN, late.serverLine().tone) + assertEquals("Last report 2 h ago · reports are late", late.serverLine().text) + assertTrue(late.tasks().any { it.kind == TaskKind.SERVER && it.title == "Reports are late" }) + val onTime = Situations.board(Situations.apps(emptyMap(), setOf("earnapp")), lastHeartbeat = NOW - 50 * 60 * 1000L) + assertEquals(ServerState.OK, onTime.server) + } + + @Test + fun `no reading shows as not measured, never 0 B or never`() { + val b = Situations.board(Situations.apps(emptyMap(), setOf("earnapp", "grass"), disabled = setOf("grass"))) + val grass = b.apps.first { it.slug == "grass" } + assertEquals(NOT_MEASURED to NOT_MEASURED, b.measuredUse(grass)) + val blindUsage = Situations.board(Situations.apps(mapOf("earnapp" to Situations.running("earnapp", 1, 1)), setOf("earnapp")), usage = false) + assertEquals(NOT_MEASURED to NOT_MEASURED, blindUsage.measuredUse(blindUsage.apps.first { it.slug == "earnapp" })) + val earnapp = Situations.healthy.apps.first { it.slug == "earnapp" } + assertEquals("450.0 MB", Situations.healthy.measuredUse(earnapp).first) + } + + @Test + fun `a new shared key drops the phone's own key, the same key keeps it`() { + val enrolled = Settings(serverUrl = "https://s.example", apiKey = "old", workerKey = "issued") + assertEquals("issued", enrolled.withApiKey("old").workerKey) + val changed = enrolled.withApiKey("new") + assertEquals("", changed.workerKey) + assertEquals("new", changed.activeKey) + } + + @Test + fun `401 and 403 are a refused key, anything else is a rejection`() { + assertEquals(Heartbeat.Outcome.KEY_REFUSED, Heartbeat.outcomeOf(401)) + assertEquals(Heartbeat.Outcome.KEY_REFUSED, Heartbeat.outcomeOf(403)) + assertEquals(Heartbeat.Outcome.REJECTED, Heartbeat.outcomeOf(500)) + assertEquals(Heartbeat.Outcome.REJECTED, Heartbeat.outcomeOf(404)) + } + + // ---- Words ------------------------------------------------------------------ + + @Test + fun `ages read in plain words`() { + assertEquals("never", Words.ago(0, NOW)) + assertEquals("just now", Words.ago(NOW - 30_000, NOW)) + assertEquals("25 min ago", Words.ago(NOW - 25 * 60_000, NOW)) + assertEquals("3 h ago", Words.ago(NOW - 3 * 3_600_000, NOW)) + assertEquals("yesterday", Words.ago(NOW - 26 * 3_600_000, NOW)) + assertEquals("4 days ago", Words.ago(NOW - 4 * 86_400_000L, NOW)) + assertEquals("for 25 min", Words.forHowLong(NOW - 25 * 60_000, NOW)) + } + + @Test + fun `the welcome screen counts the rest of the supported apps`() { + assertEquals("EarnApp, IPRoyal Pawns, Grass, MystNodes and ${KnownApps.all.size - 4} more", Words.supportedSample()) + } + + @Test + fun `no screen promises income`() { + // The store listing and the screens describe what the app watches, + // never what anyone will earn. + val promises = Regex("""(?i)earn up to|guarantee|make money|passive income of|\bget paid\b|free money""") + val offenders = File("src/main/java/com/cashpilot/android/ui").walkTopDown() + .filter { it.extension == "kt" } + .flatMap { f -> promises.findAll(f.readText()).map { "${f.name}: ${it.value}" } } + .toList() + assertTrue(offenders.isEmpty(), "income promises on screen: $offenders") + } + + @Test + fun `the no-income-promise check can fail`() { + // A check that cannot fail is not a check. + assertTrue(Regex("""(?i)earn up to""").containsMatchIn("Earn up to \$50")) + } +} diff --git a/app/src/test/java/com/cashpilot/android/board/PaletteContrastTest.kt b/app/src/test/java/com/cashpilot/android/board/PaletteContrastTest.kt new file mode 100644 index 0000000..aa30648 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/PaletteContrastTest.kt @@ -0,0 +1,69 @@ +package com.cashpilot.android.board + +import androidx.compose.ui.graphics.Color +import com.cashpilot.android.ui.theme.ClassicNight +import com.cashpilot.android.ui.theme.Light +import com.cashpilot.android.ui.theme.Palette +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** WCAG AA for every text colour on every surface it is drawn on, in both palettes. */ +class PaletteContrastTest { + + private fun channel(v: Float): Double = if (v <= 0.03928f) v / 12.92 else ((v + 0.055) / 1.055).pow(2.4) + + private fun luminance(c: Color) = 0.2126 * channel(c.red) + 0.7152 * channel(c.green) + 0.0722 * channel(c.blue) + + private fun ratio(a: Color, b: Color): Double { + val la = luminance(a) + val lb = luminance(b) + return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) + } + + /** A translucent fill as it actually appears over [base]. */ + private fun over(fill: Color, base: Color) = Color( + red = fill.red * fill.alpha + base.red * (1 - fill.alpha), + green = fill.green * fill.alpha + base.green * (1 - fill.alpha), + blue = fill.blue * fill.alpha + base.blue * (1 - fill.alpha), + ) + + private fun failures(p: Palette): List { + val texts = mapOf("text" to p.text, "text2" to p.text2, "muted" to p.muted, "accent" to p.accent, "ok" to p.ok, "warn" to p.warn, "bad" to p.bad) + val surfaces = mutableMapOf("bg" to p.bg, "card" to p.card, "raised" to p.raised) + listOf("okSoft" to p.okSoft, "warnSoft" to p.warnSoft, "badSoft" to p.badSoft, "accentSoft" to p.accentSoft).forEach { (n, s) -> + surfaces["$n/bg"] = over(s, p.bg) + surfaces["$n/card"] = over(s, p.card) + } + val out = mutableListOf() + for ((tn, t) in texts) for ((sn, s) in surfaces) { + // Muted text is never drawn on a tinted fill. + if (tn == "muted" && "/" in sn) continue + val r = ratio(t, s) + if (r < 4.5) out += "$tn on $sn = ${"%.2f".format(r)}" + } + val onAccent = ratio(p.onAccent, p.accent) + if (onAccent < 4.5) out += "onAccent on accent = ${"%.2f".format(onAccent)}" + return out + } + + @Test + fun `classic night text clears AA everywhere`() { + val f = failures(ClassicNight) + assertTrue(f.isEmpty(), "below 4.5:1 in Classic night: $f") + } + + @Test + fun `light text clears AA everywhere`() { + val f = failures(Light) + assertTrue(f.isEmpty(), "below 4.5:1 in Light: $f") + } + + @Test + fun `the contrast check can fail`() { + // The first Light green (1D7A4C) measured 4.2:1 on the tinted fills. + assertTrue(failures(Light.copy(ok = Color(0xFF1D7A4C))).isNotEmpty()) + } +} diff --git a/app/src/test/java/com/cashpilot/android/board/ScreenGoldenTest.kt b/app/src/test/java/com/cashpilot/android/board/ScreenGoldenTest.kt new file mode 100644 index 0000000..336c666 --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/ScreenGoldenTest.kt @@ -0,0 +1,54 @@ +package com.cashpilot.android.board + +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * A golden image of every screen, so a visual change is a reviewed change. + * + * Text is most of what these images hold, which is why the project kept to + * icon goldens until now. What makes screens stable enough to gate on: + * + * - Robolectric's NATIVE graphics draw text with the fonts bundled in its + * android-all jar, not the build machine's, so the build container and the + * CI runner render the same glyphs; + * - the SDK level is pinned, so a dependency bump does not swap the renderer; + * - every age on screen comes from [Situations.NOW], never the clock, and the + * version string is fixed, so nothing changes from one day or release to the next. + * + * ./scripts/remote-gradle.sh recordRoborazziDebug # after an intended change + * ./scripts/remote-gradle.sh verifyRoborazziDebug # what CI runs + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = PHONE) +class ScreenGoldenTest { + + @Test + fun everyScreenHasAGolden() { + Screens.all.forEach { shot -> captureRoboImage(shot.file) { Themed(shot) } } + } + + @Test + fun theScreenGoldenDirectoryHoldsExactlyTheExpectedFiles() { + // A screen removed from the list would otherwise leave its PNG + // committed forever, and a golden that goes missing is not compared. + val dir = File("src/test/screenshots/screens") + val onDisk = dir.listFiles { f -> f.isFile && f.extension == "png" }.orEmpty().map { it.name }.toSet() + val expected = Screens.all.map { File(it.file).name }.toSet() + assertTrue("golden files with no screen: ${onDisk - expected}", (onDisk - expected).isEmpty()) + assertTrue("screens with no golden file: ${expected - onDisk}", (expected - onDisk).isEmpty()) + } + + @Test + fun screenNamesAreUnique() { + val names = Screens.all.map { it.name } + assertTrue("duplicate screen names: ${names.groupBy { it }.filterValues { it.size > 1 }.keys}", names.size == names.toSet().size) + } +} diff --git a/app/src/test/java/com/cashpilot/android/board/Screens.kt b/app/src/test/java/com/cashpilot/android/board/Screens.kt new file mode 100644 index 0000000..491d7da --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/Screens.kt @@ -0,0 +1,93 @@ +package com.cashpilot.android.board + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import com.cashpilot.android.board.Situations.NOW +import com.cashpilot.android.ui.AppState +import com.cashpilot.android.ui.board.Access +import com.cashpilot.android.ui.board.AppDetailScreen +import com.cashpilot.android.ui.board.Board +import com.cashpilot.android.ui.board.BoardActions +import com.cashpilot.android.ui.board.Demo +import com.cashpilot.android.ui.board.DemoFrame +import com.cashpilot.android.ui.board.DisclosureScreen +import com.cashpilot.android.ui.board.HomeScreen +import com.cashpilot.android.ui.board.OnboardPermissions +import com.cashpilot.android.ui.board.OnboardServer +import com.cashpilot.android.ui.board.OnboardWelcome +import com.cashpilot.android.ui.board.PairResult +import com.cashpilot.android.ui.board.ServerForm +import com.cashpilot.android.ui.board.SettingsActions +import com.cashpilot.android.ui.board.SettingsScreen +import com.cashpilot.android.ui.board.SupportedAppsScreen +import com.cashpilot.android.ui.theme.CashPilotTheme + +/** A phone-sized canvas: 411 x 891 dp at xhdpi (822 x 1782 px). */ +const val PHONE = "w411dp-h891dp-xhdpi" + +/** One screen to photograph and to check for accessibility. */ +class Shot(val name: String, val dark: Boolean, val fontScale: Float = 1f, val content: @Composable () -> Unit) { + val file get() = "src/test/screenshots/screens/$name.png" +} + +@Composable +fun Themed(shot: Shot) { + CashPilotTheme(darkTheme = shot.dark) { + if (shot.fontScale == 1f) { + shot.content() + } else { + val d = LocalDensity.current + CompositionLocalProvider(LocalDensity provides Density(d.density, shot.fontScale)) { shot.content() } + } + } +} + +/** + * Every screen of the app, in each situation it can be in, dark and light. + * The same list drives the goldens and the accessibility checks, so a screen + * cannot be photographed without also being checked. + */ +object Screens { + private const val VERSION = "1.0.0" + private const val DEVICE = "Google Pixel 8" + private val none = BoardActions() + + /** The row a user is most likely to tap: a stopped app, else the shared one, else the first. */ + private fun detailRow(b: Board) = + b.watched.firstOrNull { it.state == AppState.STOPPED } + ?: b.watched.firstOrNull { it.shared } + ?: b.watched.first() + + private fun form(b: Board, result: PairResult = PairResult.NONE) = + if (b.paired) ServerForm("https://cashpilot.home.example", "example-fleet-k-7b9a", "example-fleet-k-7b9a", result) else ServerForm("", "", "", result) + + private fun both(name: String, content: @Composable () -> Unit) = + listOf(Shot("${name}_dark", true, content = content), Shot("${name}_light", false, content = content)) + + val all: List = buildList { + for ((s, b) in Situations.four) { + addAll(both("home_$s") { HomeScreen(b, none) }) + addAll(both("detail_$s") { AppDetailScreen(detailRow(b), b, none) {} }) + addAll(both("settings_$s") { SettingsScreen(b, form(b), VERSION, DEVICE, none, SettingsActions()) {} }) + addAll(both("supported_$s") { SupportedAppsScreen(b, none) {} }) + addAll(both("supported_all_$s") { SupportedAppsScreen(b, none, all = true) {} }) + addAll(both("onboard_2_permissions_$s") { OnboardPermissions(b.notif, b.usage, b.battery, {}, {}, {}) {} }) + } + addAll(both("onboard_1_welcome") { OnboardWelcome {} }) + for (r in listOf(PairResult.NONE, PairResult.OK, PairResult.KEY_REFUSED, PairResult.UNREACHABLE)) { + val (url, key) = if (r == PairResult.NONE) "" to "" else "https://cashpilot.home.example" to "example-fleet-k-7b9a" + addAll(both("onboard_3_server_${r.name.lowercase()}") { OnboardServer(url, key, {}, {}, r, DEVICE, {}, {}, {}) }) + } + addAll(both("disclose_notifications") { DisclosureScreen(Access.NOTIFICATIONS, {}, {}) }) + addAll(both("disclose_usage") { DisclosureScreen(Access.USAGE, {}, {}) }) + addAll(both("home_no_apps") { HomeScreen(Situations.empty, none) }) + addAll(both("home_demo") { DemoFrame({}) { HomeScreen(Demo.board(NOW), none) } }) + // Font scale 1.3: the screens with the most text, so a layout that only + // fits at the default size shows up here first. + add(Shot("font130_home_problems_dark", true, 1.3f) { HomeScreen(Situations.problems, none) }) + add(Shot("font130_onboard_2_permissions_dark", true, 1.3f) { OnboardPermissions(false, false, false, {}, {}, {}) {} }) + add(Shot("font130_settings_healthy_light", false, 1.3f) { SettingsScreen(Situations.healthy, form(Situations.healthy), VERSION, DEVICE, none, SettingsActions()) {} }) + } +} diff --git a/app/src/test/java/com/cashpilot/android/board/Situations.kt b/app/src/test/java/com/cashpilot/android/board/Situations.kt new file mode 100644 index 0000000..ea6f77b --- /dev/null +++ b/app/src/test/java/com/cashpilot/android/board/Situations.kt @@ -0,0 +1,140 @@ +package com.cashpilot.android.board + +import com.cashpilot.android.model.AppStatus +import com.cashpilot.android.model.Earnings +import com.cashpilot.android.model.KnownApps +import com.cashpilot.android.model.PlatformEarnings +import com.cashpilot.android.model.Settings +import com.cashpilot.android.ui.AppDisplayInfo +import com.cashpilot.android.ui.AppPresentation +import com.cashpilot.android.ui.board.Board +import java.time.Instant + +/** + * The four phone situations every screen is tested and photographed in, built + * through [Board.from] exactly as the app builds them, at a fixed moment so the + * ages ("6 min ago") never change between runs. + */ +object Situations { + val NOW: Long = Instant.parse("2026-11-07T10:00:00Z").toEpochMilli() + private const val MIN = 60_000L + private const val HOUR = 60 * MIN + private const val MB = 1024L * 1024L + + private fun iso(millisAgo: Long) = Instant.ofEpochMilli(NOW - millisAgo).toString() + + fun running(slug: String, tx: Long, rx: Long) = + AppStatus(slug, running = true, notificationActive = true, netTx24h = tx * MB, netRx24h = rx * MB, lastActive = iso(4 * MIN)) + + fun stopped(slug: String, agoMillis: Long) = + AppStatus(slug, running = false, netTx24h = 3 * MB, netRx24h = 1 * MB, lastActive = iso(agoMillis)) + + /** The display list exactly as MainViewModel.doRefresh builds and sorts it. */ + fun apps(statuses: Map, installed: Set, disabled: Set = emptySet()): List = + AppPresentation.sortForDashboard( + KnownApps.all.map { app -> + val status = statuses[app.slug] + AppDisplayInfo( + app, + AppPresentation.resolveState(app.slug in installed, app.slug !in disabled, status?.running), + status, + ) + }, + ) + + private val installedSet = setOf("earnapp", "iproyal", "mysterium", "traffmonetizer", "grass", "bytelixir") + + val earnings = Earnings( + windowDays = 30, + platforms = listOf( + PlatformEarnings("bytelixir", 0.94, false), + PlatformEarnings("earnapp", 4.12, false), + PlatformEarnings("grass", null, false), + PlatformEarnings("iproyal", 2.87, false), + PlatformEarnings("mysterium", 6.40, true), + PlatformEarnings("traffmonetizer", 1.05, false), + ), + totalUsd = 15.38, + platformsWithoutReadings = listOf("grass"), + ) + + private val paired = Settings(serverUrl = "https://cashpilot.home.example", apiKey = "example-fleet-k-7b9a", setupCompleted = true) + + fun board( + apps: List, + settings: Settings = paired, + lastHeartbeat: Long = NOW - 6 * MIN, + failed: Boolean = false, + earnings: Earnings? = this.earnings, + earningsAsOf: Long = NOW - 2 * MIN, + notif: Boolean = true, + usage: Boolean = true, + battery: Boolean = true, + ) = Board.from(apps, settings, lastHeartbeat, failed, earnings, earningsAsOf, notif, usage, battery, NOW) + + private val healthyApps = apps( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "mysterium" to running("mysterium", 1290, 210), + "traffmonetizer" to running("traffmonetizer", 233, 19), + "grass" to running("grass", 96, 12), + "bytelixir" to running("bytelixir", 120, 9), + ), + installedSet, + ) + + /** Paired, reporting, everything installed is running. */ + val healthy = board(healthyApps) + + /** Two apps stopped, the server stopped answering 25 minutes ago, battery optimisation is on. */ + val problems = board( + apps( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "mysterium" to running("mysterium", 1290, 210), + "traffmonetizer" to stopped("traffmonetizer", 26 * HOUR), + "grass" to stopped("grass", 3 * HOUR), + "bytelixir" to running("bytelixir", 120, 9), + ), + installedSet, + ), + lastHeartbeat = NOW - 25 * MIN, + failed = true, + earningsAsOf = NOW - 7 * HOUR, + battery = false, + ) + + /** No server, which is a supported way to use the app. One app stopped. */ + val standalone = board( + apps( + mapOf( + "earnapp" to running("earnapp", 412, 38), + "iproyal" to running("iproyal", 655, 41), + "grass" to stopped("grass", 3 * HOUR), + ), + setOf("earnapp", "iproyal", "grass"), + ), + settings = Settings(setupCompleted = true), + lastHeartbeat = 0, + earnings = null, + earningsAsOf = 0, + ) + + /** Neither access granted, so nothing can be seen. */ + val blind = board( + apps(installedSet.associateWith { null }, installedSet), + lastHeartbeat = 0, + earnings = null, + earningsAsOf = 0, + notif = false, + usage = false, + battery = false, + ) + + /** Nothing supported installed, no server: what a store reviewer sees. */ + val empty = board(apps(emptyMap(), emptySet()), settings = Settings(setupCompleted = true), lastHeartbeat = 0, earnings = null, earningsAsOf = 0) + + val four = linkedMapOf("healthy" to healthy, "problems" to problems, "standalone" to standalone, "blind" to blind) +} diff --git a/app/src/test/screenshots/icon_arrow_downward.png b/app/src/test/screenshots/icon_arrow_downward.png deleted file mode 100644 index 4e9463a..0000000 Binary files a/app/src/test/screenshots/icon_arrow_downward.png and /dev/null differ diff --git a/app/src/test/screenshots/icon_arrow_upward.png b/app/src/test/screenshots/icon_arrow_upward.png deleted file mode 100644 index 41cc147..0000000 Binary files a/app/src/test/screenshots/icon_arrow_upward.png and /dev/null differ diff --git a/app/src/test/screenshots/icon_circle.png b/app/src/test/screenshots/icon_circle.png deleted file mode 100644 index e4d597c..0000000 Binary files a/app/src/test/screenshots/icon_circle.png and /dev/null differ diff --git a/app/src/test/screenshots/icon_close.png b/app/src/test/screenshots/icon_close.png deleted file mode 100644 index 09a087a..0000000 Binary files a/app/src/test/screenshots/icon_close.png and /dev/null differ diff --git a/app/src/test/screenshots/icon_language.png b/app/src/test/screenshots/icon_language.png deleted file mode 100644 index 544ad15..0000000 Binary files a/app/src/test/screenshots/icon_language.png and /dev/null differ diff --git a/app/src/test/screenshots/screens/detail_blind_dark.png b/app/src/test/screenshots/screens/detail_blind_dark.png new file mode 100644 index 0000000..fdcc333 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/detail_blind_light.png b/app/src/test/screenshots/screens/detail_blind_light.png new file mode 100644 index 0000000..9724310 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_blind_light.png differ diff --git a/app/src/test/screenshots/screens/detail_healthy_dark.png b/app/src/test/screenshots/screens/detail_healthy_dark.png new file mode 100644 index 0000000..b439a65 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/detail_healthy_light.png b/app/src/test/screenshots/screens/detail_healthy_light.png new file mode 100644 index 0000000..4d281fa Binary files /dev/null and b/app/src/test/screenshots/screens/detail_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/detail_problems_dark.png b/app/src/test/screenshots/screens/detail_problems_dark.png new file mode 100644 index 0000000..9d7ae7e Binary files /dev/null and b/app/src/test/screenshots/screens/detail_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/detail_problems_light.png b/app/src/test/screenshots/screens/detail_problems_light.png new file mode 100644 index 0000000..c0e9157 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_problems_light.png differ diff --git a/app/src/test/screenshots/screens/detail_standalone_dark.png b/app/src/test/screenshots/screens/detail_standalone_dark.png new file mode 100644 index 0000000..e19e5e4 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/detail_standalone_light.png b/app/src/test/screenshots/screens/detail_standalone_light.png new file mode 100644 index 0000000..e808337 Binary files /dev/null and b/app/src/test/screenshots/screens/detail_standalone_light.png differ diff --git a/app/src/test/screenshots/screens/disclose_notifications_dark.png b/app/src/test/screenshots/screens/disclose_notifications_dark.png new file mode 100644 index 0000000..32a40c5 Binary files /dev/null and b/app/src/test/screenshots/screens/disclose_notifications_dark.png differ diff --git a/app/src/test/screenshots/screens/disclose_notifications_light.png b/app/src/test/screenshots/screens/disclose_notifications_light.png new file mode 100644 index 0000000..79484da Binary files /dev/null and b/app/src/test/screenshots/screens/disclose_notifications_light.png differ diff --git a/app/src/test/screenshots/screens/disclose_usage_dark.png b/app/src/test/screenshots/screens/disclose_usage_dark.png new file mode 100644 index 0000000..a8a3284 Binary files /dev/null and b/app/src/test/screenshots/screens/disclose_usage_dark.png differ diff --git a/app/src/test/screenshots/screens/disclose_usage_light.png b/app/src/test/screenshots/screens/disclose_usage_light.png new file mode 100644 index 0000000..3f4d780 Binary files /dev/null and b/app/src/test/screenshots/screens/disclose_usage_light.png differ diff --git a/app/src/test/screenshots/screens/font130_home_problems_dark.png b/app/src/test/screenshots/screens/font130_home_problems_dark.png new file mode 100644 index 0000000..692e726 Binary files /dev/null and b/app/src/test/screenshots/screens/font130_home_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/font130_onboard_2_permissions_dark.png b/app/src/test/screenshots/screens/font130_onboard_2_permissions_dark.png new file mode 100644 index 0000000..fdd057c Binary files /dev/null and b/app/src/test/screenshots/screens/font130_onboard_2_permissions_dark.png differ diff --git a/app/src/test/screenshots/screens/font130_settings_healthy_light.png b/app/src/test/screenshots/screens/font130_settings_healthy_light.png new file mode 100644 index 0000000..b4eeb4c Binary files /dev/null and b/app/src/test/screenshots/screens/font130_settings_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/home_blind_dark.png b/app/src/test/screenshots/screens/home_blind_dark.png new file mode 100644 index 0000000..35e83f6 Binary files /dev/null and b/app/src/test/screenshots/screens/home_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/home_blind_light.png b/app/src/test/screenshots/screens/home_blind_light.png new file mode 100644 index 0000000..1f949f5 Binary files /dev/null and b/app/src/test/screenshots/screens/home_blind_light.png differ diff --git a/app/src/test/screenshots/screens/home_demo_dark.png b/app/src/test/screenshots/screens/home_demo_dark.png new file mode 100644 index 0000000..0337164 Binary files /dev/null and b/app/src/test/screenshots/screens/home_demo_dark.png differ diff --git a/app/src/test/screenshots/screens/home_demo_light.png b/app/src/test/screenshots/screens/home_demo_light.png new file mode 100644 index 0000000..ece7daa Binary files /dev/null and b/app/src/test/screenshots/screens/home_demo_light.png differ diff --git a/app/src/test/screenshots/screens/home_healthy_dark.png b/app/src/test/screenshots/screens/home_healthy_dark.png new file mode 100644 index 0000000..0929f9d Binary files /dev/null and b/app/src/test/screenshots/screens/home_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/home_healthy_light.png b/app/src/test/screenshots/screens/home_healthy_light.png new file mode 100644 index 0000000..d439f0f Binary files /dev/null and b/app/src/test/screenshots/screens/home_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/home_no_apps_dark.png b/app/src/test/screenshots/screens/home_no_apps_dark.png new file mode 100644 index 0000000..d31747d Binary files /dev/null and b/app/src/test/screenshots/screens/home_no_apps_dark.png differ diff --git a/app/src/test/screenshots/screens/home_no_apps_light.png b/app/src/test/screenshots/screens/home_no_apps_light.png new file mode 100644 index 0000000..33ab2fd Binary files /dev/null and b/app/src/test/screenshots/screens/home_no_apps_light.png differ diff --git a/app/src/test/screenshots/screens/home_problems_dark.png b/app/src/test/screenshots/screens/home_problems_dark.png new file mode 100644 index 0000000..374ffd5 Binary files /dev/null and b/app/src/test/screenshots/screens/home_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/home_problems_light.png b/app/src/test/screenshots/screens/home_problems_light.png new file mode 100644 index 0000000..9911c0e Binary files /dev/null and b/app/src/test/screenshots/screens/home_problems_light.png differ diff --git a/app/src/test/screenshots/screens/home_standalone_dark.png b/app/src/test/screenshots/screens/home_standalone_dark.png new file mode 100644 index 0000000..8194272 Binary files /dev/null and b/app/src/test/screenshots/screens/home_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/home_standalone_light.png b/app/src/test/screenshots/screens/home_standalone_light.png new file mode 100644 index 0000000..a6666b8 Binary files /dev/null and b/app/src/test/screenshots/screens/home_standalone_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_1_welcome_dark.png b/app/src/test/screenshots/screens/onboard_1_welcome_dark.png new file mode 100644 index 0000000..5a99d3b Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_1_welcome_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_1_welcome_light.png b/app/src/test/screenshots/screens/onboard_1_welcome_light.png new file mode 100644 index 0000000..ad068d8 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_1_welcome_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_blind_dark.png b/app/src/test/screenshots/screens/onboard_2_permissions_blind_dark.png new file mode 100644 index 0000000..3e25ece Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_blind_light.png b/app/src/test/screenshots/screens/onboard_2_permissions_blind_light.png new file mode 100644 index 0000000..29425b5 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_blind_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_healthy_dark.png b/app/src/test/screenshots/screens/onboard_2_permissions_healthy_dark.png new file mode 100644 index 0000000..123b005 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_healthy_light.png b/app/src/test/screenshots/screens/onboard_2_permissions_healthy_light.png new file mode 100644 index 0000000..7da8b84 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_problems_dark.png b/app/src/test/screenshots/screens/onboard_2_permissions_problems_dark.png new file mode 100644 index 0000000..202870a Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_problems_light.png b/app/src/test/screenshots/screens/onboard_2_permissions_problems_light.png new file mode 100644 index 0000000..4ead5d2 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_problems_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_standalone_dark.png b/app/src/test/screenshots/screens/onboard_2_permissions_standalone_dark.png new file mode 100644 index 0000000..123b005 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_2_permissions_standalone_light.png b/app/src/test/screenshots/screens/onboard_2_permissions_standalone_light.png new file mode 100644 index 0000000..7da8b84 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_2_permissions_standalone_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_key_refused_dark.png b/app/src/test/screenshots/screens/onboard_3_server_key_refused_dark.png new file mode 100644 index 0000000..8f416ff Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_key_refused_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_key_refused_light.png b/app/src/test/screenshots/screens/onboard_3_server_key_refused_light.png new file mode 100644 index 0000000..9be647c Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_key_refused_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_none_dark.png b/app/src/test/screenshots/screens/onboard_3_server_none_dark.png new file mode 100644 index 0000000..8538089 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_none_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_none_light.png b/app/src/test/screenshots/screens/onboard_3_server_none_light.png new file mode 100644 index 0000000..b36325c Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_none_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_ok_dark.png b/app/src/test/screenshots/screens/onboard_3_server_ok_dark.png new file mode 100644 index 0000000..6a4b084 Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_ok_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_ok_light.png b/app/src/test/screenshots/screens/onboard_3_server_ok_light.png new file mode 100644 index 0000000..9d3beee Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_ok_light.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_unreachable_dark.png b/app/src/test/screenshots/screens/onboard_3_server_unreachable_dark.png new file mode 100644 index 0000000..ce4975f Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_unreachable_dark.png differ diff --git a/app/src/test/screenshots/screens/onboard_3_server_unreachable_light.png b/app/src/test/screenshots/screens/onboard_3_server_unreachable_light.png new file mode 100644 index 0000000..6bd535f Binary files /dev/null and b/app/src/test/screenshots/screens/onboard_3_server_unreachable_light.png differ diff --git a/app/src/test/screenshots/screens/settings_blind_dark.png b/app/src/test/screenshots/screens/settings_blind_dark.png new file mode 100644 index 0000000..87b8c52 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/settings_blind_light.png b/app/src/test/screenshots/screens/settings_blind_light.png new file mode 100644 index 0000000..2786491 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_blind_light.png differ diff --git a/app/src/test/screenshots/screens/settings_healthy_dark.png b/app/src/test/screenshots/screens/settings_healthy_dark.png new file mode 100644 index 0000000..be97a00 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/settings_healthy_light.png b/app/src/test/screenshots/screens/settings_healthy_light.png new file mode 100644 index 0000000..9371cb6 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/settings_problems_dark.png b/app/src/test/screenshots/screens/settings_problems_dark.png new file mode 100644 index 0000000..390d2f1 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/settings_problems_light.png b/app/src/test/screenshots/screens/settings_problems_light.png new file mode 100644 index 0000000..57dcbc4 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_problems_light.png differ diff --git a/app/src/test/screenshots/screens/settings_standalone_dark.png b/app/src/test/screenshots/screens/settings_standalone_dark.png new file mode 100644 index 0000000..bff4990 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/settings_standalone_light.png b/app/src/test/screenshots/screens/settings_standalone_light.png new file mode 100644 index 0000000..afbed96 Binary files /dev/null and b/app/src/test/screenshots/screens/settings_standalone_light.png differ diff --git a/app/src/test/screenshots/screens/supported_all_blind_dark.png b/app/src/test/screenshots/screens/supported_all_blind_dark.png new file mode 100644 index 0000000..1c39849 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_all_blind_light.png b/app/src/test/screenshots/screens/supported_all_blind_light.png new file mode 100644 index 0000000..67220f2 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_blind_light.png differ diff --git a/app/src/test/screenshots/screens/supported_all_healthy_dark.png b/app/src/test/screenshots/screens/supported_all_healthy_dark.png new file mode 100644 index 0000000..1c39849 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_all_healthy_light.png b/app/src/test/screenshots/screens/supported_all_healthy_light.png new file mode 100644 index 0000000..67220f2 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/supported_all_problems_dark.png b/app/src/test/screenshots/screens/supported_all_problems_dark.png new file mode 100644 index 0000000..0a0f8a8 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_all_problems_light.png b/app/src/test/screenshots/screens/supported_all_problems_light.png new file mode 100644 index 0000000..8fb818a Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_problems_light.png differ diff --git a/app/src/test/screenshots/screens/supported_all_standalone_dark.png b/app/src/test/screenshots/screens/supported_all_standalone_dark.png new file mode 100644 index 0000000..1394f00 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_all_standalone_light.png b/app/src/test/screenshots/screens/supported_all_standalone_light.png new file mode 100644 index 0000000..cddb541 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_all_standalone_light.png differ diff --git a/app/src/test/screenshots/screens/supported_blind_dark.png b/app/src/test/screenshots/screens/supported_blind_dark.png new file mode 100644 index 0000000..d071e97 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_blind_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_blind_light.png b/app/src/test/screenshots/screens/supported_blind_light.png new file mode 100644 index 0000000..6869549 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_blind_light.png differ diff --git a/app/src/test/screenshots/screens/supported_healthy_dark.png b/app/src/test/screenshots/screens/supported_healthy_dark.png new file mode 100644 index 0000000..d071e97 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_healthy_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_healthy_light.png b/app/src/test/screenshots/screens/supported_healthy_light.png new file mode 100644 index 0000000..6869549 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_healthy_light.png differ diff --git a/app/src/test/screenshots/screens/supported_problems_dark.png b/app/src/test/screenshots/screens/supported_problems_dark.png new file mode 100644 index 0000000..d071e97 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_problems_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_problems_light.png b/app/src/test/screenshots/screens/supported_problems_light.png new file mode 100644 index 0000000..6869549 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_problems_light.png differ diff --git a/app/src/test/screenshots/screens/supported_standalone_dark.png b/app/src/test/screenshots/screens/supported_standalone_dark.png new file mode 100644 index 0000000..d9699d5 Binary files /dev/null and b/app/src/test/screenshots/screens/supported_standalone_dark.png differ diff --git a/app/src/test/screenshots/screens/supported_standalone_light.png b/app/src/test/screenshots/screens/supported_standalone_light.png new file mode 100644 index 0000000..16e335a Binary files /dev/null and b/app/src/test/screenshots/screens/supported_standalone_light.png differ diff --git a/codecov.yml b/codecov.yml index 8e3266b..17222f3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -28,7 +28,10 @@ ignore: - "**/*Test.kt" - "**/*Test.java" - "*.gradle*" - - "**/ui/screen/*Screen*" + - "**/ui/board/*Screen*.kt" + - "**/ui/board/Kit.kt" + - "**/ui/board/Onboarding.kt" + - "**/ui/board/BoardApp.kt" - "**/ui/theme/**" - "**/ui/MainActivity*" - "**/ui/MainViewModel*" diff --git a/docs/configuration.md b/docs/configuration.md index f134219..7ddc9d5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,14 +6,14 @@ Everything the app stores, where you set it, and what it does. There are no conf | Setting | Where you set it | Stored as | Default | What it does | |---|---|---|---|---| -| CashPilot Server URL | Setup step 1, or Settings, Server Connection | `server_url` | empty | Base URL of your CashPilot server. Heartbeats go to `/api/workers/heartbeat`; a trailing `/` is ignored. Empty means standalone: no heartbeats, no earnings. | -| 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 10 on | Which apps the dashboard and the heartbeat include. A switched-off app shows as **Disabled** and is left out of the heartbeat. | +| Server address | Setup step 3, or Settings, Server | `server_url` | empty | Base URL of your CashPilot server. Heartbeats go to `/api/workers/heartbeat`; a trailing `/` is ignored. Empty means standalone: no heartbeats, no earnings. | +| API key | Setup step 3, or Settings, Server | `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. Typing a different API key drops it, so the next heartbeat enrolls again with the new key. | +| Watched apps | Settings, Apps to watch, or an app's detail page | `enabled_slugs` | all 10 on | Which apps the home screen and the heartbeat include. A switched-off app leaves both. | | 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. | +| Setup completed | Finishing the three setup steps | `setup_completed` | false | Whether setup 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 reads the settings again every time it runs, so a change takes effect on the next one; no restart is needed. +Changes to the address and the key are saved half a second after you stop typing, and at once when you tap **Test connection**. **Use without a server** at the end of setup clears both. 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,16 +26,16 @@ 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 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. +On the hosts above, the fleet key, the per-worker key and the app data travel unencrypted, and the address field says so: **Plain http:// on your own network: the key and app list travel unencrypted on it.** Any other `http://` URL is refused: the field turns red with **This address is on the public internet, so it needs https://. CashPilot never sends the key over plain http:// there.**, the home screen reads **Not sending to … · it 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 CashPilot servers from 1.0.0 give every worker its own key; the shared `CASHPILOT_API_KEY` is only for a worker's first contact. -1. The first heartbeat authenticates with the Fleet API Key you entered. +1. The first heartbeat authenticates with the API key you entered (the fleet key). 2. The server answers with a key for this phone. The app stores it as `worker_key` and uses it from the next heartbeat on. The server repeats the key in its answers until the phone has used it once. -3. From then on the phone keeps working even if you clear the Fleet API Key in Settings. -4. If the server answers **401** to the per-worker key (for example after the worker was removed on the server), the app deletes that key and enrolls again on the next heartbeat with the Fleet API Key. +3. From then on the phone keeps working even if you clear the API key in Settings. +4. If the server answers **401** to the per-worker key (for example after the worker was removed on the server), the app deletes that key and enrolls again on the next heartbeat with the fleet key. The server side of this is on CashPilot's [fleet page](https://geiserx.github.io/CashPilot/fleet/#authentication). diff --git a/docs/development.md b/docs/development.md index 7946923..40e2f53 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,13 +11,13 @@ ```bash ./gradlew test # unit tests (JVM, Robolectric; no emulator) -./gradlew verifyRoborazziDebug # icon goldens: fails on any visual change to an icon +./gradlew verifyRoborazziDebug # icon and screen goldens: fails on any visual change ./gradlew lintDebug # lint against app/lint-baseline.xml; new errors fail ``` -The icon goldens live in `app/src/test/screenshots/`. Re-record them with `./gradlew recordRoborazziDebug` only when an icon change is intended. +The icon goldens live in `app/src/test/screenshots/`, the screen goldens in `app/src/test/screenshots/screens/`: every screen in four situations (healthy, problems, no server, no access), dark and light, plus three at font scale 1.3. Re-record them with `./gradlew recordRoborazziDebug` only when a visual change is intended, and look at the new images before committing them. The list of screens is `app/src/test/java/com/cashpilot/android/board/Screens.kt`; the same list drives the accessibility checks in `AccessibilityTest`. -CI (`.github/workflows/ci.yml`) runs on pull requests and pushes to `main` that touch the app or the Gradle files: a check for credentials written into tracked files (`scripts/check-no-committed-credentials.sh`), the tests with coverage (`./gradlew jacocoTestReport`, sent to Codecov) and the icon goldens. +CI (`.github/workflows/ci.yml`) runs on pull requests and pushes to `main` that touch the app or the Gradle files: a check for credentials written into tracked files (`scripts/check-no-committed-credentials.sh`), the tests with coverage (`./gradlew jacocoTestReport`, sent to Codecov) and the goldens. ## Releases diff --git a/docs/getting-started.md b/docs/getting-started.md index dcde48a..f7df1d6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,19 +18,16 @@ To update, install the newer APK over the old one. Settings are kept. ## First run -The first launch opens **Welcome to CashPilot**, four steps on one screen. +The first launch walks through three short steps. -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**. 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. +1. **Know when an earning app stops.** What the app does, in four lines. **Get started**. +2. **Let CashPilot see your apps.** **Allow** on **Notification access** and **Usage access** first shows what CashPilot reads and what it never reads, then **Continue to Android settings** opens the right list; switch CashPilot on and come back. **Battery optimisation** is 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. **Continue without them** is there too; the home screen then asks for the two accesses. +3. **Connect your server** (optional). Enter the **Server address**, for example `https://cashpilot.example.com`, and the **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)). **Test connection** sends one report and shows **Connected**, **The server refused this API key**, **No answer from …** or **This address needs https://**. After **Connected**, **Finish**. **Use without a server** finishes without one; you can pair later from Settings. ## What a working start looks like -- The dashboard lists the 10 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. -- 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. +- The home screen leads with one line, for example **All 3 apps running**, and lists the installed supported apps. **N other supported apps** leads to the rest. +- With a server, the line under it reads **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 report also enrolls the phone: the server issues it its own key ([Configuration](configuration.md#the-fleet-key-and-enrollment)). +- Without a server, the line reads **No server · everything stays on this phone**. 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 ebe37ab..618ab18 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -19,7 +19,7 @@ CashPilot Android ├── HeartbeatWorker (WorkManager, POST every 15 min) ├── AppNotificationListener (instant app detection) ├── AppDetector (UsageStats + NetworkStats) -└── Jetpack Compose UI (dashboard + settings) +└── Jetpack Compose UI (status board, onboarding, settings) ``` ## Running, stopped or can't tell @@ -41,10 +41,10 @@ Usage Access covers two of the three signals (foreground time and traffic), so w - The same apps again as `containers`, the shape older CashPilot servers read, so they still list the phone. - `system_info`: `Android`, the Android version and API level, the CPU architecture, `device_type: android` and this app's version, which lets the fleet page flag phones on an old build. -The server's answer can carry this phone's own key (see [Configuration](configuration.md#the-fleet-key-and-enrollment)) and the earnings figures the dashboard shows. +The server's answer can carry this phone's own key (see [Configuration](configuration.md#the-fleet-key-and-enrollment)) and the earnings figures the home screen shows. ## Privacy -All app status data is sent only to your own CashPilot server. The app makes one more request, to `api.ipify.org`, to show your public IP on the dashboard, and only after the server URL and API key are configured. It sends app status to no other service; links you tap in the app (GitHub, signup pages, the Play Store) open in your browser. +The app talks to one server: your own CashPilot server, and only once you add one. It makes no other request; links you tap in the app (GitHub, signup pages, the Play Store) open in your browser. The app sends the bearer token over `http://` only to a host that cannot be on the public internet (loopback, a private, link-local or Tailscale address, or a name ending in `.local`, `.lan`, `.home.arpa`, `.internal` or `.ts.net`) and refuses `http://` to anything else; see [the server URL](configuration.md#the-server-url-use-https). Use an `https://` URL for any server that is not on your own network. diff --git a/docs/index.md b/docs/index.md index 86267fe..7afcf89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,7 @@ hide: --- - The dashboard, what Running, Stopped and Can't tell mean, and the earnings card. + The status board, what Running, Stopped and Can't see it mean, and the earnings strip. - :material-format-list-bulleted: **[Configuration](configuration.md)** @@ -50,9 +50,9 @@ hide: ## What it does -- Shows every monitored app as **Running**, **Stopped**, **Can't tell**, **Disabled** or **Not installed**, the ones that need attention first. +- Leads with one line saying whether every app is running, then each watched app as **Running**, **Stopped** or **Can't see it**, the ones that need attention first, with an **Open** button on a stopped one. - 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. +- Says **Can't see it** 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 15 minutes, and when you open or return to 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. @@ -75,7 +75,7 @@ hide: ## Privacy -- App states go only to the CashPilot server you enter. The one other request is to `api.ipify.org`, to show the phone's public IP, and only once a server is set. +- App states go only to the CashPilot server you enter, and the app makes no other request. - The heartbeat carries the phone's maker and model, a short Android ID, the Android version and the monitored apps' states and traffic; nothing about other apps. [How it works](how-it-works.md#what-a-heartbeat-carries) lists every field. - The app sends the key over `http://` only to loopback or a private name (`.lan`, `.local`, a Tailscale `.ts.net` name) and refuses `http://` to anything else, LAN and Tailscale IP addresses included. Use `https://` for any server outside your own network. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5a7eb40..0bf2e22 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,29 +2,29 @@ Symptom, cause, fix. The texts in bold are what the app shows. -## Every app shows Can't tell, or CashPilot can't see your apps +## Apps show Can't see it, or CashPilot can't see your apps yet **Cause:** Notification Access or Usage Access is off. Without both, a quiet app cannot be told apart from a stopped one, so the app says it cannot tell. -**Fix:** tap **Grant Notification Access** and **Grant Usage Access** on the dashboard, or find CashPilot under Android's Settings, Apps, Special app access, and switch both on. Come back to the app; it checks again when it opens. +**Fix:** tap **Allow notification access** and **Allow usage access** on the home screen (or **Turn on** in the list of what needs you), or find CashPilot under Android's Settings, Apps, Special app access, and switch both on. Come back to the app; it checks again when it opens. ## An app shows Stopped but it is running **Cause:** Stopped means that with both accesses granted, the app had no ongoing notification, no foreground use in the last 15 minutes and no more than 1 KB of traffic in the last 2 hours. Android's traffic counters are grouped in blocks of about two hours, so an app that only just started may not show traffic yet. -**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. +**Fix:** pull the home screen 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 arrive late when the screen is off **Cause:** first check that the phone has a connection: a report waits for a network, so a phone with no Wi-Fi or data sends nothing. If it is online, it is usually battery optimization. Android holds back scheduled work of optimized apps while the phone is idle, and some makers add their own limits on top. Reports are due every 15 minutes, but Android sets no upper bound on the delay; a phone Android rates as rarely used can wait hours. -**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. +**Fix:** Settings, This phone, **Battery optimisation**. It opens Android's list; choose All apps, then CashPilot, then Don't optimize, until the row reads **Off for CashPilot**. 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) -**Cause:** the server did not accept the key. If the phone had its own key, the app deletes it and enrolls again on the next heartbeat with the Fleet API Key. If the Fleet API Key is wrong or was changed on the server, enrollment fails too. +**Cause:** the server did not accept the key. If the phone had its own key, the app deletes it and enrolls again on the next heartbeat with the fleet key. If that key is wrong or was changed on the server, enrollment fails too. -**Fix:** copy the current `CASHPILOT_API_KEY` from the server's fleet page (**Reveal API Key**) into Settings, Fleet API Key. See [The fleet key and enrollment](configuration.md#the-fleet-key-and-enrollment). +**Fix:** copy the current `CASHPILOT_API_KEY` from the server's fleet page (**Reveal API Key**) into Settings, Server, API key, then tap **Test connection**: it reads **Connected** once the key is right. See [The fleet key and enrollment](configuration.md#the-fleet-key-and-enrollment). ## Server rejected heartbeat with another code @@ -32,23 +32,29 @@ 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. -## Red dot next to Last report +## No report has reached your server -**Cause:** the last report failed. If the server answered with an error, see the 401 and other-code entries above. Otherwise 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. +**Cause:** the last report failed; the home screen reads **No report has reached … for …** and lists **Reports aren't getting through**. If the server answered with an error, see the 401 and other-code entries above. Otherwise 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. +**Fix:** **Check connection** (or Settings, **Test connection**) sends a report now and names the cause: **No answer from …**, **The server refused this API key**, **The server answered with an error**, or **… answered, but not as CashPilot** (the address is a login page or another app). Then 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. -## Not sending heartbeats: this server URL needs https:// +## Reports are late + +**Cause:** the last report went through, but none has gone out for over an hour. Android runs background work when it chooses: battery optimisation, Doze while the screen is off, or no network all hold it back. + +**Fix:** open the app, which sends a report at once. If it keeps happening, turn battery optimisation off for CashPilot. + +## Not sending to …: it needs https:// **Cause:** the URL starts with `http://` and its host can be on the public internet: a public name or address, not loopback, a LAN, link-local or Tailscale address, or a `.lan`, `.local`, `.home.arpa`, `.internal` or `.ts.net` name. The app refuses to send the keys and makes no request. **Fix:** use the server's `https://` address, or reach it on your own network: its LAN or Tailscale address, or a private name. [Which hosts work over http](configuration.md#the-server-url-use-https). -## The URL field is red: Insecure +## The address field warns about plain http:// -**Cause:** the URL starts with `http://`, so the keys and the app data would travel unencrypted. +**Cause:** the address starts with `http://` on your own network, so the keys and the app data travel unencrypted on it. -**Fix:** use the server's `https://` address. On loopback or a private name `http://` works, and the warning stays. +**Fix:** use the server's `https://` address if it has one. On loopback or a private name `http://` works, and the note stays. ## Earnings say Nothing read yet diff --git a/docs/usage.md b/docs/usage.md index 0a44a51..e8d161d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,51 +1,69 @@ # Usage -Day to day you open the dashboard, look at what needs attention, and leave the app alone. It keeps checking in the background. +Day to day you open the app, read the first line, and leave it alone. It keeps checking in the background. -## The dashboard +## The home screen 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 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. +- **The verdict.** One line that answers "is everything running?": **All 6 apps running**, **2 of 6 apps stopped**, or **4 of 6 apps running** when CashPilot can't see the rest. The ring beside it is green, amber or red to match. +- **The server line.** **No server · everything stays on this phone** without a server. With one: **Last report 6 min ago · sent every 15 min**, **Paired with … · waiting for the first report** before the first, **No report has reached … for 25 min** in amber when reports fail, or **Last report 2 h ago · reports are late** in amber when none has gone out for over an hour. +- **What needs you.** When more than one thing is wrong, a numbered list in the order to fix them: stopped apps (by name), then the server, then a missing access, then battery optimisation. Each item has its button. A single stopped app needs no list: its row has an **Open** button. +- **The apps.** One row per installed, watched app, problems first. Tap a row for its details. +- **The earnings strip** (below), and **N other supported apps** for the ones not installed. -Pull the list down to check every app again. The app also checks each time you come back to it, for example after granting a permission. +Pull the screen down to check every app again. The app also checks each time you come back to it, for example after granting an access. ## What each app state means | State | Colour | Meaning | What to do | |---|---|---|---| -| Running | green | One signal proved the app is alive: an ongoing notification, foreground use in the last 15 minutes, or more than 1 KB of traffic in the last 2 hours. | Nothing. | -| Stopped | red | Every signal was available and none showed life. The card shows **Last:** with the last foreground time when there is one. | Open the app and start it again. | -| Can't tell | amber | Nothing showed life, and one of the two accesses is missing, so the app cannot know. | Grant the missing access; do not restart the app. | -| Disabled | grey | You switched the app off in Settings. | Switch it back on if you want it watched. | -| Not installed — tap to install | faded | The app is not on this phone. | Tap to open its signup page, or its Play Store page. | +| Running | green | One signal proved the app is alive: an ongoing notification, foreground use in the last 15 minutes, or more than 1 KB of traffic in the last 2 hours. The row shows its traffic over the last 24 hours. | Nothing. | +| Stopped | red | Every signal was available and none showed life. The row says when it was last opened. Android records that, not when the app stopped. | Tap **Open**. Opening the app usually starts it again. | +| Can't see it | amber | Nothing showed life, and one of the two accesses is missing, so the app cannot know. | Turn the missing access back on; do not restart the app. | +| Not watched | grey | You switched the app off. It leaves the home screen and the reports. | Switch it back on in Settings, Apps to watch. | -A card with an ongoing notification also shows **Notification active**. +Can't see it is not Stopped on purpose. A phone that cannot see used to report every app as stopped, and a restart does not fix a missing permission. The server gets the same three answers: `running`, `stopped` or `unknown`. -Can't tell is not Stopped on purpose. A phone that cannot see used to report every app as stopped, and a restart does not fix a missing permission. The server gets the same three answers: `running`, `stopped` or `unknown`. +## An app's details + +Tap a row to see the app's state, an **Open** button, the server's figure for it, its data use over the last 24 hours, when it was last opened, and a **Watched by CashPilot** switch. If the app has no launcher entry, **Open** shows its page in Android's settings instead. ## When the app cannot see -- **Neither access granted:** the app list is replaced by one card, **CashPilot can't see your apps**, with **Grant Notification Access** and **Grant Usage Access** buttons. It cannot be dismissed, because the list would say nothing true. -- **One access missing:** a **Permissions needed** banner above the list with the button for the missing one. You can dismiss it; it comes back if a permission is revoked. +- **Neither access granted:** the home screen is replaced by **CashPilot can't see your apps yet**, with **Allow notification access** and **Allow usage access**. Nothing is shown as stopped, because nothing is known. +- **One access missing:** it appears in the list of what needs you, with **Turn on**. + +Each **Allow** or **Turn on** first shows what CashPilot reads and what it never reads, then opens Android's settings. **Not now** goes back without opening them. ## Earnings -With a server paired, each heartbeat's answer can carry what the server has recorded for the platforms on this phone. The card shows the total for the **Last 30 days** (the server picks the window) and each app card shows its own figure. +With a server paired, each report's answer can carry what the server has recorded for the platforms on this phone. The strip shows the total for the last 30 days (the server picks the window), and each app's detail page shows its own figure. + +- **Nothing read yet** means the server has no figure for these apps. It is not zero, and the app never shows `$0.00` for it. +- **From 7 h ago** appears when the newest figure is more than an hour old. The app keeps the last figure while the phone is offline rather than blanking it. +- A figure marked **\*** is a provider balance for your whole account, because the app runs on more than one of your devices; one footnote under the strip says so. +- Without a server the strip reads **Add a server to see figures**; tap it to open Settings. + +## No supported app installed + +The home screen says **No supported app on this phone yet**, explains that CashPilot works without a server, and offers **Show a demo with sample data** and the list of supported apps. Installed apps appear by themselves. -- **Nothing read yet** means the server has no figure for these apps. It is not zero, and the app never shows `$0.00` for it. Apps without a figure show a dash. -- **May be out of date** and **Last updated** appear when the newest figure is more than an hour old. The app keeps the last figure while the phone is offline rather than blanking it. -- **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**. +## Supported apps + +The list of apps CashPilot can watch, with a note at the top: links marked **Referral** open the provider's sign-up page with CashPilot's referral code, which supports the project; the others open the app store. What any app pays depends on the provider, your connection and your region. + +## Demo mode + +Settings, **Demo mode** (or the button on the empty home screen) shows the home screen with sample data under a **Demo · sample data, nothing here is real** banner. **Exit** or Back leaves it. ## Settings -The gear icon on the dashboard opens Settings: +The gear icon on the home screen opens Settings, grouped by what you are checking: + +- **Server:** its state, the API key masked to its last four characters, the address and key fields, and **Test connection**, which sends one report now and says what the server answered ([Configuration](configuration.md)). +- **This phone:** notification access, usage access and battery optimisation, each with its current state. Tap one to change it. +- **Apps to watch:** a switch per installed app, and the full list of supported apps. +- **About:** the version and source code, the CashPilot server project, the sponsor page, and demo mode. -- **Server Connection:** the server URL and the Fleet API Key ([Configuration](configuration.md)). -- **Disable Battery Optimization**, or **Battery optimization disabled** when it already is. -- **Monitored Apps:** a switch per app, with its package name. Only installed apps are reported. -- **About:** links to this app's and CashPilot's GitHub pages, and to the sponsor page. +The app follows the phone's dark or light setting, with the CashPilot web dashboard's Classic night and Light colours. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 57ea5a6..2b90afc 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -11,6 +11,6 @@ All of this is read on the phone. Without a server, the app list is the whole ap 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. +Privacy: no account, no analytics, no ads. Data goes only to the server you set. The app makes no other request. CashPilot is free software under the GPL-3.0-or-later license.