Enhanced notifications for Android TV / Fire TV — show popups (text, images, video or live camera streams) on your TV from your home-automation system, for as long as you want.
Credits: PiPup was originally created by Rob Groenendijk (rogro82). This repository is a maintained fork of that project — all credit for the original idea and implementation goes to him. The fork modernizes the build (AndroidX, AGP 8, Kotlin 2, targetSdk 34) and adds the features below, aimed at Home Assistant use.
What this fork adds (compared to rogro82/PiPup)
- Indefinite popups —
duration: 0(or negative) shows a popup until it is cancelled or replaced, e.g. show a camera stream for exactly as long as there is motion. - Popup
id+ update-in-place — re-sending a notify with the sameidand content only reschedules the removal timer without rebuilding the view, so a video/web stream keeps playing without flicker. /stateendpoint — popup visibility, screen on/off (screenOn, since 0.2.3), popup counter, uptime, device info and a stable device id (since 0.2.5)./cancel(existed upstream but undocumented) with optional selective?id=.- Muted media (since 0.2.4) —
muted: trueon video/web media plays without audio, so a popup never claims audio focus (audio in a popup can freeze video playback on some devices). - Text-to-speech (since 0.2.5) — a
ttsfield speaks a text on the TV when the popup appears, with optionalttsLanguage(BCP-47). - mDNS/zeroconf discovery (since 0.2.5) — the app advertises
_pipup._tcpwith a stable device id, so clients (like the Home Assistant integration) find TVs automatically and follow them across DHCP address changes. - Overlay watchdog (since 0.2.6) — popup removal is guarded step-by-step and a 30s consistency
check force-removes any overlay left behind by a failed teardown, so a popup can no longer stay
on screen after its dismiss.
/statereportswatchdogCleanupsso you can see if it ever fired. - Buttons on the popup (since 0.3.0) —
buttons: [{id, label}]renders remote-operable buttons: the overlay only becomes focusable when buttons are present (it never steals the remote otherwise), OK activates (POST{popup, button, label, device, name}to thecallbackURL and dismiss), BACK dismisses without an action. - Countdown bar (since 0.3.0) —
showProgress: trueanimates a progress bar over a finite duration. - Urgency presets (since 0.3.0) —
urgency: info|warning|criticaladds a blue/orange/red border. - Localization (since 0.3.1) — the app UI follows the device language (English/Dutch).
- Lazy TTS engine (since 0.4.0) — the speech engine is only bound when a popup actually carries
a
ttsfield and is released again after 60s idle. On Google TV devices this keeps the separate ~100MBcom.google.android.ttsprocess out of memory, which matters a lot on 1GB TVs where the low-memory killer picks the heaviest processes. - Restart after an update (since 0.6.2) — the app listens for
MY_PACKAGE_REPLACED, so the service comes back by itself after a self-update (or anadb install -r). Before this, replacing the APK left the TV silently offline until something started the service again. - Crash fix: repeated start requests (since 0.6.1) —
startForeground()is now called on everystartForegroundService()(i.e. also inonStartCommand), not only on creation. Without it Android killed the process withRemoteServiceException: Context.startForegroundService() did not then call Service.startForeground(), so every keep-alive attempt — an automation, or the connectivity Receiver — crashed the app instead of keeping it alive.onStartCommandalso revives the web server when it is no longer alive. - Resilient web server startup (since 0.4.0) — binding port 7979 is retried (3 attempts, 500ms apart) and a definitive failure stops the service for a clean restart, instead of leaving a live process with a dead server behind.
- Self-update (since 0.6.0) — the app checks the fork's GitHub releases twice a day and can install a
newer version itself: it announces a new release once on screen with an Install button, exposes
updatein/state, and acceptsPOST /updateto trigger the update (used by the Home Assistant integration's update entity). Android only accepts an APK signed with the same key, so a tampered download can never replace the app. On Android 12+ the self-update is silent; on older devices the system shows its install confirmation on the TV, which someone has to accept with the remote. Grant the install permission once (survives updates, not reinstalls):adb shell appops set nl.rogro82.pipup REQUEST_INSTALL_PACKAGES allow - WebView media supports JavaScript, DOM storage and unattended (autoplay) playback, and cleartext (http) LAN URLs are allowed — required for camera streams from e.g. go2rtc/Frigate.
- Assorted fixes (request-body handling, message size/color defaults, WebView cleanup).
Home Assistant users: there is a companion integration —
mhoogenbosch/ha-pipup — with a config flow per TV,
a popup binary sensor and pipup.show / pipup.dismiss actions (including camera entities).
Sideloading requires ADB over the network, which is off by default:
- Android TV / Google TV: Settings → System → About → press Build number 7 times (unlocks Developer options) → Settings → System → Developer options → enable USB debugging (on recent Google TV also Wireless debugging).
- Fire TV: Settings → My Fire TV → About → press the device name 7 times → My Fire TV → Developer options → enable ADB debugging.
Then connect from your computer with adb connect <tv-ip>:5555 and accept the
authorization prompt on the TV (once per computer).
Download the APK from the releases page and install it with adb. If you have the original Play Store version installed you need to uninstall that first (different signature, same application id).
adb connect <tv-ip>:5555
adb install -r PiPup.apk
adb shell appops set nl.rogro82.pipup SYSTEM_ALERT_WINDOW allow
The second command grants the overlay permission, which has no settings UI on Android TV.
After installation or updating, open the application once (or reboot the TV) to make sure the background service is running. Starting the service without bringing the app to the foreground (handy from an automation, it does not interrupt whatever is playing) also works:
adb shell am start-foreground-service -n nl.rogro82.pipup/.PiPupService
Not on TCL Google TV. There a background start lands the service at
oom_score_adj500 and the vendor guard freezes it within seconds. Start the activity instead — see below.
TCL ships an extra guard (com.tcl.guard) with two separate mechanisms. Both look like an app bug
and neither is caused by memory pressure — measured on a 1 GB set, PiPup used 26 MB PSS while the
launcher used 119 MB and the screensaver 91 MB.
1. It blocks the automatic restart of a killed service unless the app holds the vendor-specific
APP_AUTO_START app-op — it logs forbid restart Servic ... callee_does't_have_OP_AUTO_START_permission
and the service never comes back after a kill. The on-screen menu ("Permission Guardian" →
"Auto-start permission") keeps per-app entries locked while its "Automatic management" master switch
is on, so grant the op over adb instead (note the internal name android:auto_start; the displayed
name APP_AUTO_START is not accepted):
adb shell cmd appops set nl.rogro82.pipup android:auto_start allow
adb shell dumpsys deviceidle whitelist +nl.rogro82.pipup
Like the overlay permission the app-op resets on reinstall, so repeat it after every update. On
brands without this op (Fire TV, Nokia, …) the command fails with Unknown operation string — that
is fine, nothing needs granting there. The deviceidle entry survives reboots and stops
am_stop_idle_service from tearing the service down.
2. It freezes processes (persist.sys.freeze=true, independent of the AOSP freezer). A frozen
process is alive but SIGSTOPped, which is why this failure mode is so confusing: ps still
lists PiPup while port 7979 no longer answers, so clients hang in a timeout instead of getting a
connection error. Recognise it like this:
adb shell 'P=$(pidof nl.rogro82.pipup); grep freezer /proc/$P/cgroup; cat /proc/$P/oom_score_adj'
# frozen -> 5:freezer:/frozen ... 500
# healthy -> 5:freezer:/thaw ... 200
adb shell netstat -ltn | grep 7979 # frozen: Recv-Q > 0 on LISTEN, plus CLOSE_WAIT rows
Incoming traffic does not thaw the app; only bringing it to the foreground does. What keeps it
running is oom_score_adj 200, and the app only reaches that when it is started from a foreground
context, i.e. via the activity:
adb shell input keyevent KEYCODE_WAKEUP # only needed while the screensaver is on
adb shell am start -n nl.rogro82.pipup/.MainActivity
adb shell input keyevent KEYCODE_HOME
Started this way the app stays up, screensaver included. Two caveats: am start -W hangs while
the TV is dreaming (use it without -W), and this briefly takes over the screen, so avoid it while
someone is watching. It is worth automating the recovery — the
ha-pipup integration README
has a ready-made Home Assistant automation, including the pitfall that a ps | grep pipup guard
silently defeats it (a frozen process is still listed).
PiPup runs an embedded webserver (NanoHTTPD) on port 7979 with no authentication,
and a popup's web media is rendered in a WebView with JavaScript and DOM storage enabled.
That means any device on the same network can display arbitrary content — including
JavaScript — on the TV. This is by design (camera/stream pages need it), but it makes the
trust boundary the network itself.
- Run PiPup TVs on a trusted network segment (not a guest/IoT VLAN that untrusted devices share).
- Traffic is plain HTTP (
usesCleartextTraffic), so treat everything sent to the popup — URLs, TTS text, button callbacks — as visible on the LAN. - Button presses POST to the
callbackURL supplied with the popup. If you drive security-sensitive automations from button events (e.g. unlocking a door), have the caller include an unguessable, single-use token in that callback URL and verify it on receipt — the ha-pipup integration does this automatically.
PiPup runs an embedded webserver (NanoHTTPD) on port 7979.
| Property | Value |
|---|---|
| Path: | /notify |
| Method: | POST |
| Content-Type: | application/json |
Example:
{
"duration": 30,
"id": "doorbell",
"position": 0,
"title": "Your awesome title",
"titleColor": "#0066cc",
"titleSize": 20,
"message": "What ever you want to say... do it here...",
"messageColor": "#000000",
"messageSize": 14,
"backgroundColor": "#ffffff",
"media": { "image": {
"uri": "https://your.host/image.png", "width": 480
}}
}All fields are optional. For media you can specify 3 types:
{ "image": { "uri": "address_to_your_image", "width": 480 }}
{ "video": { "uri": "address_to_your_video", "width": 480, "muted": true }}
{ "web": { "uri": "address_to_your_resource", "width": 640, "height": 480, "muted": true }}muted (since 0.2.4, default false): plays the video/web media without audio. For web media every
(also dynamically added) <video>/<audio> element on the page is muted, so the page never claims
audio focus — audio in a popup can freeze video playback on some Android TV / Fire TV devices.
tts (since 0.2.5): a text that is spoken aloud on the TV when the popup appears, using the
device's text-to-speech engine. Optional ttsLanguage takes a BCP-47 tag (e.g. "nl-NL");
the device's default locale is used when omitted. Re-sending the same popup id with unchanged
content and unchanged tts does not repeat the speech (only the removal timer is extended);
sending a different tts text speaks the new text.
{ "title": "Doorbell", "tts": "Er staat iemand voor de deur", "ttsLanguage": "nl-NL" }Since 0.3.0 three more optional fields:
{
"urgency": "critical",
"showProgress": true,
"buttons": [{ "id": "unlock", "label": "Open the door" }],
"callback": "http://your-ha:8123/api/webhook/pipup_buttons"
}urgency (info/warning/critical) adds a blue/orange/red border. showProgress animates a
countdown bar over a finite duration. buttons (with a callback URL) renders remote-operable
buttons: the overlay only takes input focus when buttons are present, OK activates the focused
button — the app POSTs {"popup", "button", "label", "device", "name"} to the callback and
dismisses — and BACK dismisses without an action.
duration: seconds to show the popup.0or negative shows it indefinitely, until/cancelis called or a new popup replaces it.id(string, optional): identifies the popup. Re-sending a notify with the sameidand identical content only reschedules the removal timer — the view (and a playing video/web stream) is kept as-is. Different content (or noid) rebuilds the popup as before.
| Property | Value |
|---|---|
| Path: | /notify |
| Method: | POST |
| Content-Type: | multipart/form-data |
Form-fields:
| Field | Type |
|---|---|
| duration | Integer (default=30, 0=indefinite) |
| id | String (optional popup identifier) |
| position | Integer (0..4, default=0) |
| title | String |
| titleSize | Integer (default=16) |
| titleColor | string (default=#FFFFFF, format=[AA]RRGGBB |
| message | String |
| messageSize | Integer (default=12) |
| messageColor | String (default=#FFFFFF, format=[AA]RRGGBB |
| backgroundColor | String (default=#CC000000, format=[AA]RRGGBB |
| image | File |
| imageWidth | Integer (default=480) |
| tts | String (optional, spoken aloud, since 0.2.5) |
| ttsLanguage | String (optional BCP-47 tag, since 0.2.5) |
position is an enum ranging from 0 to 4:
| Position | |
|---|---|
| 0 | TopRight |
| 1 | TopLeft |
| 2 | BottomRight |
| 3 | BottomLeft |
| 4 | Center |
Color-properties are in [AA]RRGGBB where the alpha channel is optional, e.g. #FFFFFF or #CCFFFFFF.
| Property | Value |
|---|---|
| Path: | /cancel |
| Method: | POST |
Removes the currently visible popup (if any). Optionally pass ?id=<popup id> to only cancel when
the visible popup has that id — e.g. POST /cancel?id=doorbell. If the visible popup has a different
id the call is a no-op (HTTP 200 with an explanatory message), so a delayed "hide camera" automation
cannot accidentally cancel a newer, unrelated popup.
| Property | Value |
|---|---|
| Path: | /state |
| Method: | GET (or POST) |
Returns the current state as JSON:
{
"app": "PiPup",
"version": "0.3.1",
"id": "6f1f9c1e-4a3f-4a44-9d2c-6f1f9c1e4a3f",
"name": "FireTV Veranda",
"visible": true,
"screenOn": true,
"popupsShown": 12,
"watchdogCleanups": 0,
"uptime": 86400,
"device": { "model": "AFTKA", "manufacturer": "Amazon", "android": "9" },
"popup": { "id": "doorbell", "duration": 0, "indefinite": true, "elapsed": 42 }
}Since v0.5.0 the response also contains lastPopup: the parameters of the last received popup
(id, position, duration, muted, media type/size, tts, buttons, secondsAgo) — it survives dismiss/expiry,
so you can always verify what your home-automation actually sent. The same block is rendered live on
the app's status screen on the TV.
Since v0.2.3 /state also reports whether the screen is on/interactive (screenOn), the number of
popups shown since the service started (popupsShown), the service uptime in seconds and basic
device info — all surfaced as entities by the Home Assistant integration. Since v0.2.5 it also
reports a stable device id (generated once, survives app updates) and the device name; since
v0.2.6 watchdogCleanups counts how often the overlay watchdog had to force-remove a stale popup.
Since v0.2.5 the app advertises itself over mDNS/zeroconf as _pipup._tcp (port 7979) with TXT
records id (the stable device id), name and version, enabling automatic discovery.
CI builds an APK on every push (see .github/workflows/build.yml); tagged releases get the APK
attached automatically. Locally: JDK 17 + Android SDK 35, then ./gradlew assembleDebug.
