Skip to content

fix: without permissions the app claimed every earning app had stopped - #49

Merged
GeiserX merged 1 commit into
mainfrom
fix/unknown-is-not-stopped
Aug 5, 2026
Merged

fix: without permissions the app claimed every earning app had stopped#49
GeiserX merged 1 commit into
mainfrom
fix/unknown-is-not-stopped

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes CashPilot-android-1oo, which I filed on starting the permissions half of kv8.

The bead described a UI nit. The defect underneath is much worse.

AppDetector.kt:64 reduced detection to one line:

running = notificationActive || recentlyActive || hasRecentNetworkActivity

All three inputs degrade to false when the permissions are denied:

signal without permission
notificationActive false — the listener gets no callbacks at all
recentlyActive getLastActiveTime returns null, call site is ?: false
hasRecentNetworkActivity getNetworkStats returns 0L to 0L — NetworkStatsManager needs PACKAGE_USAGE_STATS too

And AppStatus.running was a non-nullable Boolean, so the type could not even express "could not determine".

So a device with access revoked reported every app as STOPPED — stating as fact that your earning apps had died when it simply could not see them. Anyone acting on that would go restart apps that were running perfectly. The same claim went upstream in the heartbeat, so a paired server inherited it.

It also got louder with the attention-first sort I shipped earlier: eleven false STOPPED cards, now sorted to the top of the screen.

The rule

A negative is only trustworthy when every signal source was available. A positive needs just one, because each is proof of life.

any positive, whatever is missing   -> true
nothing, with FULL access           -> false   (act on the app)
nothing, with partial or no access  -> null    (act on the permission)

That middle row is what STOPPED should mean. Most bandwidth apps run with no visible notification and are caught solely by network activity — so "nothing seen" with usage access denied is precisely the app we failed to see.

AppState gains UNKNOWN, deliberately not a flavour of STOPPED: different cause, different fix. It ranks second — an unknown app might be fine, a stopped one definitely is not — and renders amber rather than the stopped red.

Why AppDetector never noticed

getSystemService hands out a manager even when the permission is denied — enforcement happens at query time. So a non-null manager proved nothing, and the empty results it returned looked exactly like "stopped". The two access checks lived only in MainViewModel; they are now in util/Permissions.kt and both call the same one.

The wire format matters too

Detection.wireStatus spells it "unknown", never "stopped". The server maps a falsy running to "stopped" (main.py:241), so collapsing them here would hand the fleet page the same false claim. A server-side fix is filed separately — this PR stops the phone lying; the server still needs to stop translating null into "stopped".

Six tests were asserting the old behaviour

Including two of my own from the ordering change — I had written "an undetectable app counts as stopped, not as fine" and argued for it. That reasoning was wrong once the cause was traced.

Three others had copied the production expression if (app.running) "running" else "stopped" into themselves — the same copy-not-call trap this repo keeps producing. That mapping now lives in Detection.wireStatus and they call it.

Evidence

367 tests, 0 failures, lintDebug clean, via scripts/remote-gradle.sh (a pre-check — CI still builds the signed release variant and lints against the baseline).

Four negative controls fire: restoring the one-liner, collapsing unknown to stopped on the wire, ignoring a positive signal when a permission is missing, and mapping UNKNOWN back to STOPPED in the UI.

Still to come in kv8

The blocking permission screen itself. Strings are in place, and it is now honest to show it — because with no access every app genuinely reads UNKNOWN rather than being mislabelled as dead.

Summary by CodeRabbit

  • New Features

    • Added an Unknown app status when permissions prevent reliable activity detection.
    • Clearly distinguishes unknown apps from stopped apps in status labels, colors, sorting, and heartbeat reporting.
    • Added guidance explaining when notification or usage access permissions block detection.
  • Bug Fixes

    • Prevented apps with unavailable detection signals from being incorrectly reported as stopped.
    • Notification counts now include only confirmed-running apps.
  • Tests

    • Expanded coverage for unknown states, permission scenarios, status ranking, and heartbeat reporting.

Started on the permissions half of kv8, which described a UI nit: a dismissible
banner that ought to block. The defect underneath it is far worse.

AppDetector.kt:64 reduced detection to one line:

    running = notificationActive || recentlyActive || hasRecentNetworkActivity

ALL THREE INPUTS DEGRADE TO FALSE WHEN THE PERMISSIONS ARE DENIED. Without
notification-listener access the service gets no callbacks; without usage access
getLastActiveTime returns null (call site `?: false`) and getNetworkStats
returns `0L to 0L` -- NetworkStatsManager needs PACKAGE_USAGE_STATS too. And
AppStatus.running was a NON-NULLABLE Boolean, so the type could not even express
"could not determine".

So a device with access revoked reported EVERY app as STOPPED. The screen stated
as fact that the user's earning apps had died when it simply could not see them,
and a user acting on that would go restart apps that were running perfectly. The
same claim went upstream in the heartbeat, so a paired server inherited it.

It also got LOUDER with the attention-first sort shipped earlier: eleven false
STOPPED cards, now sorted to the top of the screen.

THE RULE, in service/Detection.kt: a NEGATIVE is only trustworthy when every
signal source was available; a POSITIVE needs just one, because each is proof of
life. Most bandwidth apps run with no visible notification and are caught solely
by network activity, so "nothing seen" with usage access denied is precisely the
app we failed to see.

  any positive, whatever is missing  -> true
  nothing, with FULL access          -> false   (act on the app)
  nothing, with partial or no access -> null    (act on the permission)

AppState gains UNKNOWN, deliberately not a flavour of STOPPED: different cause,
different fix. It ranks second -- an unknown app MIGHT be fine, a stopped one is
definitely not -- and renders amber rather than the stopped red.

The wire spelling is "unknown", not "stopped". The server maps a falsy running
to "stopped" (main.py:241), so collapsing them here would hand the fleet page
the same false claim. A server-side fix is filed separately.

Permissions.kt extracts the two access checks that previously lived only in
MainViewModel -- AppDetector had no way to ask whether it could see anything,
and did not: getSystemService hands out a manager even when the permission is
denied (enforcement is at QUERY time), so the empty results looked like
"stopped".

SIX TESTS WERE ASSERTING THE OLD BEHAVIOUR and are updated, including two of my
own from the ordering change. Three of them had COPIED the production expression
`if (app.running) "running" else "stopped"` into themselves; that mapping now
lives in Detection.wireStatus and they call it.

Four negative controls fire: restoring the one-liner, collapsing unknown to
stopped on the wire, ignoring a positive signal when a permission is missing,
and mapping UNKNOWN back to STOPPED in the UI.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds permission-aware tri-state app detection. It preserves unknown status in heartbeat payloads, adds AppState.UNKNOWN, updates dashboard ordering and visuals, and expands tests for detection, serialization, reporting, and presentation.

Changes

Tri-state detection flow

Layer / File(s) Summary
Detection contracts and permission-aware resolution
app/src/main/java/com/cashpilot/android/model/Heartbeat.kt, app/src/main/java/com/cashpilot/android/service/..., app/src/main/java/com/cashpilot/android/util/Permissions.kt, app/src/test/java/com/cashpilot/android/DetectionTest.kt
AppStatus.running is nullable. Permission visibility now controls whether detection returns true, false, or null. Wire statuses distinguish all three values.
Heartbeat status and notification reporting
app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt, app/src/test/java/com/cashpilot/android/*Heartbeat*Test.kt, app/src/test/java/com/cashpilot/android/*Serialization*Test.kt, app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt
Heartbeat containers preserve unknown status. Notification reporting counts confirmed-running and unknown apps separately. Tests use nullable-aware assertions and shared status mapping.
UNKNOWN state presentation and ordering
app/src/main/java/com/cashpilot/android/ui/..., app/src/main/res/values/strings.xml, app/src/test/java/com/cashpilot/android/App*Test.kt, app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt
The UI adds AppState.UNKNOWN, assigns explicit attention ranks, and displays amber borders, indicators, and labels for unknown apps.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: preventing apps from being reported as stopped when detection permissions are unavailable.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/unknown-is-not-stopped

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GeiserX
GeiserX merged commit 55c3745 into main Aug 5, 2026
2 of 3 checks passed
@GeiserX
GeiserX deleted the fix/unknown-is-not-stopped branch August 5, 2026 19:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt`:
- Around line 106-110: Update the SystemInfo construction in HeartbeatService to
pass the populated apps map via apps = apps, while retaining the existing
root-level apps field for backward compatibility and preserving the detected
status values.

In `@app/src/test/java/com/cashpilot/android/SerializationTest.kt`:
- Line 270: Update the status mapping in the serialization test to use
Detection.wireStatus(it.running), preserving the "unknown" result when running
is null while retaining the existing running and stopped mappings. Add a fixture
assertion covering a null running value and its expected "unknown" wire status.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 21fac023-ce3f-4473-9b34-96f7428d7d87

📥 Commits

Reviewing files that changed from the base of the PR and between 2cea2d3 and 0466b14.

📒 Files selected for processing (20)
  • app/src/main/java/com/cashpilot/android/model/Heartbeat.kt
  • app/src/main/java/com/cashpilot/android/service/AppDetector.kt
  • app/src/main/java/com/cashpilot/android/service/Detection.kt
  • app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt
  • app/src/main/java/com/cashpilot/android/ui/AppPresentation.kt
  • app/src/main/java/com/cashpilot/android/ui/MainViewModel.kt
  • app/src/main/java/com/cashpilot/android/ui/screen/DashboardScreen.kt
  • app/src/main/java/com/cashpilot/android/util/Permissions.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/com/cashpilot/android/AppPresentationTest.kt
  • app/src/test/java/com/cashpilot/android/AppStateResolutionTest.kt
  • app/src/test/java/com/cashpilot/android/AppStateTest.kt
  • app/src/test/java/com/cashpilot/android/DataClassContractTest.kt
  • app/src/test/java/com/cashpilot/android/DetectionTest.kt
  • app/src/test/java/com/cashpilot/android/HeartbeatLogicTest.kt
  • app/src/test/java/com/cashpilot/android/HeartbeatModelTest.kt
  • app/src/test/java/com/cashpilot/android/HeartbeatPayloadBuildTest.kt
  • app/src/test/java/com/cashpilot/android/ModelEdgeCaseTest.kt
  • app/src/test/java/com/cashpilot/android/SerializationEdgeCaseTest.kt
  • app/src/test/java/com/cashpilot/android/SerializationTest.kt

Comment on lines +106 to +110
// Three-valued on the wire too. `unknown` is NOT "stopped":
// the server maps a falsy running to "stopped", so sending
// false while blind would hand the fleet page the same false
// claim this fix removes from the phone.
status = Detection.wireStatus(app.running),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Populate system_info.apps with detected app status.

This map updates legacy containers only. Line 121 sets the root apps field, but Lines 122-128 construct SystemInfo without apps = apps. The heartbeat therefore sends an empty system_info.apps list.

Pass apps = apps to SystemInfo, while retaining the root field for backward compatibility.

As per coding guidelines: “Heartbeat POST payload must match server's WorkerHeartbeat schema with Android-specific app status in system_info.apps.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/cashpilot/android/service/HeartbeatService.kt` around
lines 106 - 110, Update the SystemInfo construction in HeartbeatService to pass
the populated apps map via apps = apps, while retaining the existing root-level
apps field for backward compatibility and preserving the detected status values.

Source: Coding guidelines

slug = it.slug,
name = "cashpilot-${it.slug}",
status = if (it.running) "running" else "stopped",
status = if (it.running == true) "running" else "stopped",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the unknown wire status.

Line 270 maps running == null to "stopped". The tri-state contract requires "unknown". Use Detection.wireStatus(it.running) and add a null-running fixture assertion.

Proposed fix
-                status = if (it.running == true) "running" else "stopped",
+                status = Detection.wireStatus(it.running),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/test/java/com/cashpilot/android/SerializationTest.kt` at line 270,
Update the status mapping in the serialization test to use
Detection.wireStatus(it.running), preserving the "unknown" result when running
is null while retaining the existing running and stopped mappings. Add a fixture
assertion covering a null running value and its expected "unknown" wire status.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant