Fixing Notification Action Reliability on Android#8
Merged
ScottMorris merged 1 commit intomainfrom Feb 24, 2026
Merged
Conversation
1547e4f to
c6278a0
Compare
Adds a new blog post documenting the investigation and resolution of three
separate issues in Tauri's Android notification plugin, surfaced while building
Threshold and addressed as upstream contributions to the plugin itself.
**Context**
Tauri's notification plugin allows apps to define action buttons on notifications
(dismiss, snooze, etc.). On Android, these actions were being silently dropped
across all apps using the plugin. The work described spans two repositories:
ScottMorris/tauri-plugins-workspace (notification-actions-fix branch) and
liminal-hq/threshold.
**Discovery 1: Action-Group Storage Keying**
NotificationStorage.kt was writing action groups using the action type's string
ID as a key suffix (e.g. "idalarm-actions") but reading them back using numeric
indices ("id0", "id1"). The keys never matched, so every action group lookup
returned nothing. This meant actionId was always empty when a notification action
arrived, causing the app-side handler to silently discard the event.
This issue was first surfaced by @Innominus in June 2025 (tauri-apps PR #2805),
who also extended the fix to support registering action types from Rust. The PR
sat unreviewed for eight months before being independently rediscovered while
working on Threshold. The post documents the collaboration to get the fix across
the finish line.
**Discovery 2: Payload Shape Mismatch**
Android can serialise notification action extras in two shapes. When a
notification is reconstructed after a channel reset, or when extras round-trip
through org.json.JSONObject.toString() and back, a "nameValuePairs" wrapper
leaks into the output. The Tauri bridge was passing this through to JavaScript
verbatim, causing actionId to be undefined. A normalisation pass was added to
the guest JS layer to recursively unwrap the artefact and rebuild a clean
ActionPerformedNotification. The normaliser also deduplicates events and handles
array-like bridge objects.
**Discovery 3: Cold-Start Timing Gap**
Android fires the BroadcastReceiver the moment a user taps a notification action.
On a cold boot, Threshold's Rust core takes ~400ms to initialise before the
event bridge is open. Any action tapped in that window was lost. The same gap
exists after a WebView reload.
The fix introduces a listener-ready handshake: the Kotlin plugin buffers incoming
action events in a keyed, persistent queue (SharedPreferences) until the
JavaScript layer calls register_action_listener_ready. The plugin then drains the
queue through the registered callback. Persisted events survive reloads and are
TTL-filtered (24h) and deduplicated on restore. The new command is wired into
Tauri v2's permission system via an autogenerated allow/deny TOML pair, included
in the plugin's default permission set.
**Threshold Architecture**
Alongside the plugin work, Threshold's notification architecture was refactored
to match the cleaner model the fixed plugin enables. Action type ownership was
moved from a central startup registry to context owners (provider-based
registration). AlarmNotificationService was extracted to own the full ringing
alarm lifecycle. The plugin fork is vendored as a git submodule with CI updated
to build it before dependency resolution.
**Post details**
- File: content/blog/2026-02-24-notification-action-reliability.md
- Slug: notification-action-reliability-android
- Tags: Threshold, Tauri, Android, Notifications, Rust, Kotlin, Open Source
- Canadian spelling throughout
- Links to tauri-apps PR #2805 and Tauri notification plugin docs
c6278a0 to
51a6239
Compare
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 a new blog post documenting the investigation and resolution of three separate issues in Tauri's Android notification plugin, surfaced while building Threshold and addressed as upstream contributions to the plugin.
Context
Tauri's notification plugin allows apps to define action buttons on notifications (dismiss, snooze, etc.). On Android, these actions were being silently dropped across all apps using the plugin. The work spans two repositories:
ScottMorris/tauri-plugins-workspace(notification-actions-fixbranch) andliminal-hq/threshold.Discovery 1: Action-Group Storage Keying
NotificationStorage.ktwas writing action groups using the action type's string ID as a key suffix (e.g."idalarm-actions") but reading them back using numeric indices ("id0","id1"). The keys never matched, so every action group lookup returned nothing andactionIdwas always empty on delivery, causing the app-side handler to silently discard the event.This issue was first surfaced by @Innominus in June 2025 (tauri-apps/plugins-workspace#2805), who also extended the fix to support registering action types from Rust. The PR sat unreviewed for eight months. The post documents the collaboration to get it across the finish line.
Discovery 2: Payload Shape Mismatch
When a notification is reconstructed after a channel reset, or when extras round-trip through
org.json.JSONObject.toString()and back, anameValuePairswrapper leaks into the serialised output. The Tauri bridge was passing this through verbatim, causingactionIdto beundefinedin JavaScript. A recursive normalisation pass was added to the guest JS layer to unwrap the artefact and rebuild a cleanActionPerformedNotification.Discovery 3: Cold-Start Timing Gap
Android fires the
BroadcastReceiverthe moment a user taps a notification action. On a cold boot, Threshold's Rust core takes ~400ms to initialise before the event bridge is open. Any action tapped in that window was lost. The fix introduces a listener-ready handshake: the Kotlin plugin buffers events in a keyed, persistent queue (SharedPreferences) until the JS layer callsregister_action_listener_ready, then drains through the registered callback. Persisted events survive reloads, are TTL-filtered (24h), and are deduplicated on restore. The new command is wired into Tauri v2's permission system.Threshold Architecture
Alongside the plugin work, Threshold's notification architecture was refactored to match the cleaner model the fixed plugin enables. Action type ownership was moved to context owners via a provider-based model.
AlarmNotificationServicewas extracted to own the full ringing alarm lifecycle. The plugin fork is vendored as a git submodule with CI updated to build it before dependency resolution.