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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 9 additions & 56 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,68 +1,21 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Build debug APK
run: ./gradlew assembleDebug

- name: Run lint
run: ./gradlew lintDebug

- name: Check signing secrets
id: signing
run: echo "available=true" >> "$GITHUB_OUTPUT"
if: env.KEYSTORE_B64 != ''
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_BASE64 }}

- name: Decode keystore
if: steps.signing.outputs.available == 'true'
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/release.keystore

- name: Build release APK
if: steps.signing.outputs.available == 'true'
run: ./gradlew assembleRelease
env:
CASHPILOT_KEYSTORE_PATH: release.keystore
CASHPILOT_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
CASHPILOT_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
CASHPILOT_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}

- name: Remove keystore
if: always()
run: rm -f app/release.keystore

- name: Upload debug APK
uses: actions/upload-artifact@v4
distribution: temurin
java-version: 17
- run: chmod +x gradlew
- run: ./gradlew testDebugUnitTest jacocoTestReport || ./gradlew testDebugUnitTest
- uses: codecov/codecov-action@v5
if: github.event_name == 'push'
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk

- name: Upload release APK
if: steps.signing.outputs.available == 'true' && github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/apk/release/app-release.apk
fail_ci_if_error: false
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<img src="docs/images/banner.svg" alt="CashPilot Android banner" width="900"/>
</p>

[![codecov](https://codecov.io/gh/GeiserX/CashPilot-android/graph/badge.svg)](https://codecov.io/gh/GeiserX/CashPilot-android)

# CashPilot Android Agent

Lightweight Android companion for [CashPilot](https://github.com/GeiserX/CashPilot) — monitors passive income apps running on your phone and reports their status to the CashPilot fleet dashboard.
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,11 @@ dependencies {
implementation(libs.kotlinx.serialization.json)

debugImplementation(libs.androidx.ui.tooling)

testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<Test> {
useJUnitPlatform()
}
116 changes: 116 additions & 0 deletions app/src/test/java/com/cashpilot/android/AppStateTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.cashpilot.android

import com.cashpilot.android.model.AppStatus
import com.cashpilot.android.model.KnownApps
import com.cashpilot.android.model.MonitoredApp
import com.cashpilot.android.ui.AppDisplayInfo
import com.cashpilot.android.ui.AppState
import com.cashpilot.android.ui.FleetSummary
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class AppStateTest {

@Test
fun `app state ordinal ordering`() {
// Sorting by ordinal puts RUNNING first, NOT_INSTALLED last
assertTrue(AppState.RUNNING.ordinal < AppState.STOPPED.ordinal)
assertTrue(AppState.STOPPED.ordinal < AppState.NOT_INSTALLED.ordinal)
assertTrue(AppState.NOT_INSTALLED.ordinal < AppState.DISABLED.ordinal)
}

@Test
fun `fleet summary defaults`() {
val summary = FleetSummary()
assertEquals(0, summary.running)
assertEquals(0, summary.stopped)
assertEquals(0, summary.notInstalled)
assertEquals(0, summary.disabled)
assertEquals(0, summary.totalTx)
assertEquals(0, summary.totalRx)
}

@Test
fun `fleet summary with values`() {
val summary = FleetSummary(
running = 3,
stopped = 2,
notInstalled = 5,
disabled = 1,
totalTx = 1000000,
totalRx = 5000000,
)
assertEquals(3, summary.running)
assertEquals(5000000, summary.totalRx)
}

@Test
fun `app display info with running state`() {
val app = MonitoredApp("test", "com.test", "Test App")
val status = AppStatus(slug = "test", running = true, netTx24h = 100)
val info = AppDisplayInfo(app = app, state = AppState.RUNNING, status = status)
assertEquals(AppState.RUNNING, info.state)
assertEquals(100, info.status?.netTx24h)
}

@Test
fun `app display info with null status`() {
val app = MonitoredApp("test", "com.test", "Test App")
val info = AppDisplayInfo(app = app, state = AppState.NOT_INSTALLED)
assertNull(info.status)
}

@Test
fun `compute fleet summary from display list`() {
val apps = listOf(
AppDisplayInfo(
app = MonitoredApp("a", "com.a", "A"),
state = AppState.RUNNING,
status = AppStatus(slug = "a", running = true, netTx24h = 500, netRx24h = 1000),
),
AppDisplayInfo(
app = MonitoredApp("b", "com.b", "B"),
state = AppState.RUNNING,
status = AppStatus(slug = "b", running = true, netTx24h = 300, netRx24h = 700),
),
AppDisplayInfo(
app = MonitoredApp("c", "com.c", "C"),
state = AppState.STOPPED,
status = AppStatus(slug = "c", running = false, netTx24h = 0, netRx24h = 0),
),
AppDisplayInfo(
app = MonitoredApp("d", "com.d", "D"),
state = AppState.NOT_INSTALLED,
),
)
val summary = FleetSummary(
running = apps.count { it.state == AppState.RUNNING },
stopped = apps.count { it.state == AppState.STOPPED },
notInstalled = apps.count { it.state == AppState.NOT_INSTALLED },
disabled = apps.count { it.state == AppState.DISABLED },
totalTx = apps.mapNotNull { it.status?.netTx24h }.sum(),
totalRx = apps.mapNotNull { it.status?.netRx24h }.sum(),
)
assertEquals(2, summary.running)
assertEquals(1, summary.stopped)
assertEquals(1, summary.notInstalled)
assertEquals(0, summary.disabled)
assertEquals(800, summary.totalTx)
assertEquals(1700, summary.totalRx)
}

@Test
fun `sorting display list by state ordinal`() {
val list = listOf(
AppDisplayInfo(app = MonitoredApp("d", "com.d", "D"), state = AppState.DISABLED),
AppDisplayInfo(app = MonitoredApp("r", "com.r", "R"), state = AppState.RUNNING),
AppDisplayInfo(app = MonitoredApp("n", "com.n", "N"), state = AppState.NOT_INSTALLED),
AppDisplayInfo(app = MonitoredApp("s", "com.s", "S"), state = AppState.STOPPED),
)
val sorted = list.sortedBy { it.state.ordinal }
assertEquals(AppState.RUNNING, sorted[0].state)
assertEquals(AppState.STOPPED, sorted[1].state)
assertEquals(AppState.NOT_INSTALLED, sorted[2].state)
assertEquals(AppState.DISABLED, sorted[3].state)
}
}
119 changes: 119 additions & 0 deletions app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.cashpilot.android

import com.cashpilot.android.model.AppContainer
import com.cashpilot.android.model.AppStatus
import com.cashpilot.android.model.SystemInfo
import com.cashpilot.android.model.WorkerHeartbeat
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class HeartbeatModelTest {

@Test
fun `worker heartbeat defaults`() {
val hb = WorkerHeartbeat(name = "test-worker")
assertEquals("test-worker", hb.name)
assertEquals("", hb.url)
assertTrue(hb.containers.isEmpty())
assertTrue(hb.apps.isEmpty())
assertEquals("android", hb.systemInfo.deviceType)
}

@Test
fun `app container with running status`() {
val container = AppContainer(
slug = "earnapp",
name = "cashpilot-earnapp",
status = "running",
)
assertEquals("earnapp", container.slug)
assertEquals("running", container.status)
assertEquals("", container.image)
assertTrue(container.labels.isEmpty())
}

@Test
fun `app container with labels`() {
val container = AppContainer(
slug = "iproyal",
name = "cashpilot-iproyal",
status = "stopped",
labels = mapOf("cashpilot.managed" to "true", "cashpilot.service" to "iproyal"),
)
assertEquals("true", container.labels["cashpilot.managed"])
assertEquals("iproyal", container.labels["cashpilot.service"])
}

@Test
fun `system info defaults`() {
val info = SystemInfo()
assertEquals("", info.os)
assertEquals("", info.arch)
assertEquals("", info.osVersion)
assertEquals("android", info.deviceType)
assertTrue(info.apps.isEmpty())
}

@Test
fun `system info with values`() {
val info = SystemInfo(
os = "Android",
arch = "arm64-v8a",
osVersion = "Android 14 (API 34)",
)
assertEquals("Android", info.os)
assertEquals("arm64-v8a", info.arch)
}

@Test
fun `app status running`() {
val status = AppStatus(
slug = "earnapp",
running = true,
notificationActive = true,
netTx24h = 1024000,
netRx24h = 5120000,
)
assertTrue(status.running)
assertTrue(status.notificationActive)
assertEquals(1024000, status.netTx24h)
}

@Test
fun `app status stopped`() {
val status = AppStatus(
slug = "traffmonetizer",
running = false,
)
assertFalse(status.running)
assertFalse(status.notificationActive)
assertEquals(0, status.netTx24h)
assertEquals(0, status.netRx24h)
assertNull(status.lastActive)
}

@Test
fun `heartbeat with full payload`() {
val apps = listOf(
AppStatus(slug = "earnapp", running = true),
AppStatus(slug = "iproyal", running = false),
)
val containers = apps.map {
AppContainer(
slug = it.slug,
name = "cashpilot-${it.slug}",
status = if (it.running) "running" else "stopped",
)
}
val hb = WorkerHeartbeat(
name = "Pixel 8",
containers = containers,
apps = apps,
systemInfo = SystemInfo(os = "Android", arch = "arm64-v8a"),
)
assertEquals(2, hb.containers.size)
assertEquals(2, hb.apps.size)
assertEquals("running", hb.containers[0].status)
assertEquals("stopped", hb.containers[1].status)
}
}
Loading
Loading