Android APK
A small companion app that shows the focus timer in a real Android Picture-in-Picture window, or as a floating overlay above other apps. It is not the whole product — it is a remote control for the timer already running on your device.
Debug build, unsignedThe CI workflow runs
assembleDebug, so the artifact is signed with Android’s shared debug key. It
installs fine and runs fine, but it cannot go on Play, cannot be updated over a release
build, and any other debug-signed APK can overwrite it. There is no release pipeline yet.
What it actually does #
The APK contains no application logic and no database access. It polls two endpoints on the server already running on your device and renders whatever they return:
| Endpoint | Direction | Purpose |
|---|---|---|
GET /api/pip/state | APK ← server | Latest timer snapshot: phase, remaining time, subject, attempts. |
POST /api/pip/action | APK → server | Correct / incorrect / skip / undo / target. The server fans these over SSE to the open /focus page, which applies them. |
Both live in server.mjs. Because every action is relayed to the web app
rather than applied directly, the APK cannot change your data on its own — if no
browser tab has /focus open, actions have nowhere to land.
This is a thin client on purposeBundling the whole web app into a WebView was the alternative, and it was rejected: two copies of the app would then disagree about state, and the APK would need its own Supabase credentials. Polling a local endpoint keeps exactly one source of truth.
Build it #
No Android SDK fits comfortably in Termux, so the build runs in CI. The workflow is
.github/workflows/pip-apk.yml.
Trigger the workflow. It also runs automatically on any push touching
pipapk/**.gh workflow run pip-apk.yml # watch it gh run watch "$(gh run list --workflow=pip-apk.yml --limit=1 --json databaseId -q '.[0].databaseId')"
Download the artifact. It is named
isotope-pip-debug.gh run download --name isotope-pip-debug --dir ~/apk-out ls -l ~/apk-out/app-debug.apk
Install it. Either open the file in Android’s package installer, or use ADB if you have it paired.
adb install -r ~/apk-out/app-debug.apk
Android will warn about installing from an unknown source. That is expected for a debug build and not something the workflow can avoid.
Building locallyIf you do have an Android SDK,
cd pipapk && ./gradlew :app:assembleDebug produces the same file at
pipapk/app/build/outputs/apk/debug/app-debug.apk. The wrapper pins its own
Gradle version, so nothing needs installing beyond a JDK 17.
Point it at your server #
The default is http://127.0.0.1:3000, which works when the APK and the
server are on the same device — the normal Termux case. The URL is editable in the app:
type a new one and press Save URL. It is stored in
SharedPreferences under pipapk_settings/server_url.
To drive a server on another machine, use its LAN address:
http://10.0.0.5:3000
Cleartext HTTP is permitted app-wide
network_security_config.xml sets cleartextTrafficPermitted="true"
for all hosts, not just loopback. Over a LAN that means timer state and your
actions travel unencrypted. On a home network that is a reasonable trade; on a shared or
public network it is not. Scoping this to a <domain-config> for
127.0.0.1 would be the correct fix and has not been done.
Permissions, and why each exists #
The manifest declares thirteen. Most are inherited from the reference project rather than genuinely needed by this build, which is worth knowing before you grant them.
| Permission | Used for |
|---|---|
INTERNET | Required. HTTP to the server, loopback included. |
SYSTEM_ALERT_WINDOW | The floating overlay. Must be granted by hand in Android settings; the app checks Settings.canDrawOverlays and declines to start the overlay without it. |
FOREGROUND_SERVICE, FOREGROUND_SERVICE_SPECIAL_USE | Keeps the overlay alive. Android requires a persistent notification for it. |
POST_NOTIFICATIONS | That notification, on Android 13+. |
RECEIVE_BOOT_COMPLETED | Optional auto-start. Governed by pipapk_settings/auto_start, default on. |
WAKE_LOCK, VIBRATE, SCHEDULE_EXACT_ALARM | Timer completion feedback. |
USE_BIOMETRIC, USE_FINGERPRINT | Unused by this build. Carried over from the reference app. |
READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE | Unused by this build. Write is capped at maxSdkVersion="28". |
Four declared permissions do nothing here. They should be removed from the manifest — a permission you do not use is one you cannot justify, and it makes the install prompt look worse than the app deserves.
Behaviour worth knowing #
| Detail | Value |
|---|---|
| Overlay poll | 1 s while running, 3 s otherwise (pollInterval()), with exponential backoff on failure — capped at 5 s for a loopback server, 8 s otherwise. |
| Overlay render tick | 40 ms (TICK_MS), interpolated from completionAtMs rather than from the last poll — so the countdown is exact between polls, not merely smooth. |
| Session-detection poll | 5 s (AUTO_POLL_MS). Watches for a session starting so it can pop the overlay; it renders nothing itself. |
| PiP aspect ratio | Clamped into Android’s legal 1/2.39–2.39 band, so an odd viewport cannot crash enterPictureInPictureMode. |
| Overlay window type | TYPE_APPLICATION_OVERLAY, the only type Android 8+ allows for third-party overlays. |
| Offline handling | For a loopback server the app retries state even when Android reports no network — a device with Wi-Fi off still has a working localhost. |
| Auto-start on boot | Requires overlay permission already granted; silently skipped otherwise. |
| Package name | in.isotopeai.pip (namespace app.isotopeai.pip). |
| Android version | minSdk 24 (7.0) · targetSdk 35 (15). |
The two poll rates are worth separating, because conflating them makes the app sound
worse than it is. AUTO_POLL_MS = 5000 drives autoStartPoll,
whose only job is to notice that a session has begun and launch the overlay
(PipActivity.kt:57). It renders nothing. The timer lives in the overlay
service, which polls every second while running.
So the one user-visible consequence of the 5 s figure is this: starting a focus session can take up to five seconds to pop the overlay. That is a reasonable trade — the alternative is six times the wake-ups on a loop that runs whenever the app is foregrounded, to make a window appear slightly sooner.
More importantly, the countdown does not depend on the poll rate at all.
/api/pip/state returns completionAtMs, an absolute timestamp,
and TimerState.displaySecondsNow() derives the display from
completionAtMs − now on every 40 ms tick. So the digits are correct
continuously, and a slow or missed poll shifts nothing on screen. Only a change made
elsewhere — a pause from the web app, say — waits for the next poll.
Why an absolute timestamp and not a remaining count
A server returning “312 seconds left” is stale the instant it is sent, and the
client cannot tell how stale. A completion timestamp is still true whenever it arrives,
so late delivery costs accuracy of nothing. pipapk.md proposes a 750 ms
poll to keep the display fresh; the timestamp approach makes that unnecessary.
What it cannot do #
- Work without the server. No server on
127.0.0.1:3000means an offline badge and nothing else. It is a remote control, not a client. - Work without a browser tab. Actions are relayed over SSE to the open
/focuspage. With no tab open they are accepted and dropped. - Show anything but the timer. No tasks, no syllabus, no analytics, no community.
- Update itself. Debug-signed with no release channel, so every upgrade is a manual reinstall.
- Be distributed. No Play listing and no signing key. Build it yourself or download the CI artifact.
If it does not connect #
| Symptom | Check |
|---|---|
| Offline badge, server is running | Confirm the URL. A trailing slash is trimmed on save, but a wrong port is not caught. curl -s http://127.0.0.1:3000/api/health from Termux first. |
| Timer shows but buttons do nothing | No browser tab has /focus open. The server accepts the action and has nobody to relay it to. |
| Overlay button does nothing | “Display over other apps” is not granted. Android settings → Apps → IsotopeAI PiP → Advanced. |
| Nothing after a reboot | Auto-start needs overlay permission at boot time. It exits silently rather than prompting. |
| PiP window never appears | Some launchers disable PiP per-app. Check Android settings → Apps → Special access → Picture-in-picture. |
| Install blocked | Expected for an unsigned debug build. Allow installs from your browser or file manager. |