Skip to content

Optional ntfy.sh-compatible HTTP webhook delivery#1

Open
d33mobile wants to merge 1 commit into
masterfrom
webhook-minimal
Open

Optional ntfy.sh-compatible HTTP webhook delivery#1
d33mobile wants to merge 1 commit into
masterfrom
webhook-minimal

Conversation

@d33mobile

Copy link
Copy Markdown
Owner

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, optional Authorization: Bearer …
headers).

This is the minimal version of the change — no applicationId
rename, no app branding change, no CI/F-Droid scaffolding. Intended as a
basis for upstream review.

Highlights

  • Offline-resilient by design. Pending deliveries are persisted in the
    same Room transaction as the log row; WorkManager (NetworkType.CONNECTED
    • exponential backoff) drains them and survives airplane mode, process
      kill and reboot via WorkManager's own SQLite persistence.
  • Per-app filter reuses the existing whitelist/blacklist — one source
    of truth.
  • Skip-ongoing toggle (default ON) suppresses webhook spam from
    foreground services (LibreTorrent, music players, step counters).
  • duplicate_group_id reused for across-time webhook dedup. When an
    app re-posts an unchanged body, the local log keeps the row but the
    webhook isn't called again.
  • Replay on listener (re)connect. After a reboot the framework does
    not re-fire onNotificationPosted for 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.
  • Richer NotificationParser. Falls back through MessagingStyle
    (Signal/Telegram/WhatsApp EXTRA_MESSAGES, sender + text per line, with
    EXTRA_HISTORIC_MESSAGES), BigTextStyle (EXTRA_BIG_TEXT), InboxStyle
    (EXTRA_TEXT_LINES), then plain EXTRA_TEXT. Both the local log and
    the webhook benefit.
  • UTF-8 in Title via RFC 2047 encoded-word so Polish/German/etc.
    characters survive.

Tests

  • 17 Robolectric + MockWebServer cases for the worker (success / 4xx drop /
    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)
  • 7 Robolectric cases for the parser (plain, BigText, InboxStyle,
    MessagingStyle, historic+current, sender-less, empty extras)
  • 1 instrumented WebhookE2ETest (real Room + real WorkManager + real
    HttpURLConnection against a local MockWebServer)

./gradlew :app:test :app:assembleDebug is green locally.

Manifest changes

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
…
<applicationandroid:usesCleartextTraffic="true" …>

usesCleartextTraffic="true" is needed because users self-hosting ntfy on
a LAN typically expose it on http://. ntfy.sh itself is HTTPS so the
default safe path is unaffected.

Out of scope (kept on a separate branch in the fork)

  • applicationId rename
  • versioning/signing config from env vars
  • GitHub Actions workflow + F-Droid repo metadata

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>
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