Optional ntfy.sh-compatible HTTP webhook delivery#1
Open
d33mobile wants to merge 1 commit into
Open
Conversation
Each notification that lands in the local log can additionally be POSTed to
a user-configured HTTP endpoint, in a wire format compatible with ntfy.sh
(body = notification text; Title, Tags, optional Authorization: Bearer
headers). All upstream functionality is preserved and the path is fully
opt-in — the local log behaviour does not change when the webhook is off.
Design
* NotificationItem rows are atomic with their pending_webhook_deliveries
row: a new Room migration (6 -> 7) introduces the queue table, and
NotificationSaveUtil inserts into both inside the same Room transaction.
A process kill between log and HTTP cannot drop a notification.
* WebhookWorker (CoroutineWorker) drains the queue in FIFO id order via
HttpURLConnection. WorkManager constraint NetworkType.CONNECTED + an
exponential backoff (initial 30s) keep deliveries pending until network
returns and survive process kill / reboot via WorkManager's own SQLite
persistence. 2xx -> delete; 408/429/5xx/IOException -> Result.retry();
other 4xx -> drop with a warn log to avoid a permanent stall on a
misconfigured endpoint. The unknown-exception catch-all also retries
rather than dropping (at-least-once over silent loss).
* Application.onCreate re-enqueues the worker (KEEP unique policy) so any
rows left over from a previous process get drained on cold start.
* NotificationListenerService.onListenerConnected replays the active
notification shade to the webhook (skip-ongoing and per-app filter
honoured, deduped within the snapshot). Without this, after a reboot
the framework does not re-fire onNotificationPosted for what is already
in the shade and the subscriber would silently miss the current state.
* The duplicate_group_id that insertSyncHandlePossibleDuplicate already
computes is reused as the across-time dedup signal: if group_id != id
the row is identical to a strictly earlier one in the same package and
the webhook delivery is skipped (the local log keeps every entry). An
app re-posting an unchanged body (notification refresh) does not spam.
* NotificationParser learned three richer payloads that messengers use:
EXTRA_MESSAGES (MessagingStyle: Signal, Telegram, WhatsApp; "sender:
text" per line, historic + current combined), EXTRA_BIG_TEXT
(BigTextStyle: Gmail-style long bodies), EXTRA_TEXT_LINES (InboxStyle).
EXTRA_TEXT remains the final fallback. Both the webhook and the local
log get the richer text — for Signal the body is "Alice: hello" instead
of "(1 new message)".
Configuration UI
* Filter settings gains a Switch + URL EditText + bearer-token EditText
(textPassword), and a "Skip 'ongoing' notifications" Switch (default
on) with a hint warning about flood from foreground services.
* Per-app whitelist/blacklist is reused as the only routing filter — one
source of truth for both the local log and the webhook.
Manifest
* INTERNET, ACCESS_NETWORK_STATE, android:usesCleartextTraffic="true".
Cleartext is opt-in by configured URL; ntfy.sh itself is HTTPS, but
self-hosted ntfy on a LAN is commonly http://.
Tests
* Robolectric + MockWebServer covers the worker contract (success / 4xx
drop / 4xx 408&429 retry / 5xx retry / IO retry / FIFO order /
partial drain / restart recovery / runtime disable / blank URL /
bearer presence / RFC 2047 UTF-8 in Title / app-label prefix), and the
parser style fallbacks (plain, BigText, InboxStyle, MessagingStyle,
historic+current, sender-less, empty extras).
* Instrumented WebhookE2ETest exercises real Room + real WorkManager +
real HttpURLConnection against a local MockWebServer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an opt-in HTTP forwarding path on top of the local notification log,
in a wire format compatible with ntfy.sh (body =
notification text;
Title,Tags, optionalAuthorization: Bearer …headers).
This is the minimal version of the change — no
applicationIdrename, no app branding change, no CI/F-Droid scaffolding. Intended as a
basis for upstream review.
Highlights
same Room transaction as the log row; WorkManager (
NetworkType.CONNECTEDkill and reboot via WorkManager's own SQLite persistence.
of truth.
ongoingtoggle (default ON) suppresses webhook spam fromforeground services (LibreTorrent, music players, step counters).
duplicate_group_idreused for across-time webhook dedup. When anapp re-posts an unchanged body, the local log keeps the row but the
webhook isn't called again.
not re-fire
onNotificationPostedfor what is already in the shade;the listener now flushes the snapshot to the webhook on connect (deduped
within snapshot, skip-ongoing honoured) so a subscriber doesn't silently
miss the post-reboot state.
NotificationParser. Falls back through MessagingStyle(Signal/Telegram/WhatsApp
EXTRA_MESSAGES, sender + text per line, withEXTRA_HISTORIC_MESSAGES), BigTextStyle (EXTRA_BIG_TEXT), InboxStyle(
EXTRA_TEXT_LINES), then plainEXTRA_TEXT. Both the local log andthe webhook benefit.
Titlevia RFC 2047 encoded-word so Polish/German/etc.characters survive.
Tests
408+429 retry / 5xx retry / IO retry / FIFO order / partial drain /
restart recovery / runtime disable / blank URL / bearer presence / RFC
2047 / app-label prefix / blank-title fallback / restart drains)
MessagingStyle, historic+current, sender-less, empty extras)
WebhookE2ETest(real Room + real WorkManager + realHttpURLConnection against a local MockWebServer)
./gradlew :app:test :app:assembleDebugis green locally.Manifest changes
usesCleartextTraffic="true"is needed because users self-hosting ntfy ona LAN typically expose it on
http://. ntfy.sh itself is HTTPS so thedefault safe path is unaffected.
Out of scope (kept on a separate branch in the fork)
applicationIdrename