How We Built Decrypted Push Previews Without Putting Message Content in Push
A generic-first Android notification architecture that keeps push payloads minimal and performs authenticated, device-specific decryption locally.
Push notifications are convenient precisely because they sit outside the normal application lifecycle. That also makes them a difficult fit for end-to-end encrypted messaging.
The easiest notification architecture is obvious:
{
"title": "Alice",
"body": "Meet me at 7"
}
It is also incompatible with a strong privacy goal. The notification provider receives display-ready message content.
A second approach is to put sender metadata and encrypted content into push and decrypt it locally. That is better for message confidentiality, but it still expands the amount of conversation metadata sent through the push system.
For Tula, we chose a stricter model:
The push notification wakes the secure messaging system. It does not become the messaging system.
The push contains only the minimum identifier needed to retrieve the actual encrypted envelope.
The Target Flow
Our Android path became:
high-priority data-only push
↓
message_id only
↓
background task starts
↓
generic notification posted immediately
↓
device-bound bearer loaded locally
↓
authenticated envelope GET
↓
server selects this device's ciphertext
↓
canonical receive pipeline
↓
local decrypt + durable projection
↓
same notification silently enriched
At no point does the push need:
- sender name;
- conversation title;
- avatar URL;
- plaintext;
- ciphertext;
- attachment key;
- conversation identifier.
Those details are either resolved locally or retrieved through the authenticated encrypted-message API.
Why Generic First?
A decrypted notification is an enhancement, not the only notification.
That distinction became critical during testing.
Background work can fail for many reasons:
- no network;
- DNS failure;
- the backend is slow;
- authentication is unavailable;
- the envelope has expired or was deleted;
- Android terminates the headless job;
- ciphertext is invalid;
- projection fails.
If we waited for all of that before showing anything, a transient background problem would become a silent missed notification.
So the first operation in the task is intentionally boring:
New message
That generic banner is the reliability floor.
If enrichment succeeds, the same notification is updated with locally resolved information.
If enrichment fails, the generic banner remains.
Why Data-Only Push Matters on Android
Android push delivery has an important distinction between notification messages and data messages.
A system-handled notification payload can produce a banner while the app's background processing path never runs. That is useful for generic legacy delivery, but it cannot power local decryption.
For capable installations we therefore use a high-priority data-only message.
Conceptually:
{
"data": {
"message_id": "..."
},
"android": {
"priority": "high"
}
}
Legacy installations keep their existing notification behavior.
This rollout is capability-gated rather than globally switched. A client declares that it supports the new path, and the backend chooses the appropriate delivery mode.
That gives us a rollback path without forcing every installation into the new behavior at once.
The Server Still Knows Which Device Is Asking
The push contains only a message identifier, but the encrypted message itself may be different for every recipient device.
That is especially important for Signal-style 1:1 sessions.
Our backend already stores one ciphertext per recipient device.
The fetch therefore looks conceptually like:
authenticated bearer
↓
user + device identity
↓
message_id
↓
conversation membership
↓
ciphertext addressed to this device
Possessing a valid message identifier is not authorization.
The server resolves the conversation internally and checks current membership. A sibling device belonging to the same user does not receive another device's Signal ciphertext.
The endpoint is read-only and idempotent. Fetching does not mark the message delivered and does not consume the envelope.
That means a normal WebSocket reconnect may later deliver the same message again. The client's receive idempotency handles that duplicate.
Why the Bearer Is Device-Bound
The background task cannot depend on hydrated UI state.
There may be no React tree, no current screen, and no Onyx state loaded.
The credential used for envelope retrieval therefore has to come from durable secure storage.
The server-side session already binds authentication to a device identity. That lets the headless task send only:
Authorization: Bearer <stored token>
No profile header, conversation header, or separately supplied device identifier is required.
This is important because additional caller-supplied routing information can easily become an authorization footgun.
The Generic Notification Must Be First
The ordering is deliberate:
1. task invoked
2. generic notification posted
3. credential read
4. network request
5. crypto
6. projection
7. notification enrichment
Not:
credential
network
decrypt
save
notify
The first version is more resilient because every later failure leaves a visible notification behind.
It also creates a clean cryptographic rule:
No cryptographic operation begins until a valid encrypted envelope has been received.
A 404, malformed response, or network stall never reaches libsignal.
Updating the Same Notification
An early prototype produced duplicate notifications because every task invocation generated a new notification identity.
That is unacceptable in an at-least-once delivery system.
The notification identity must be deterministic per logical message.
Conceptually:
notification key = f(message_id)
Then:
push #1 → generic N
push #2 → generic N
successful processing → update N
instead of:
push #1 → notification A
push #2 → notification B
success → notification C
We also discovered that replacing an Android notification can alert the user again. The enrichment update therefore has to be silent.
The desired experience is:
- one audible/vibrating generic notification;
- the same tray item quietly changes to the decrypted preview.
Where the Sender Name Comes From
We deliberately do not put the sender name into push.
Instead, once the message is durably available locally, the notification uses the same display-name resolver as the normal chat UI.
That detail matters.
During physical testing we briefly had a notification that said "New message" while the chat screen correctly named the peer. Both code paths were internally consistent; they simply used different naming logic.
The fix was not to add another notification-specific resolver. It was to use the existing canonical resolver.
That principle generalizes well:
If the notification and the screen describe the same entity, they should use the same local identity rules.
The Message Body Comes From Durable Storage
The notification should not enrich itself directly from a transient decrypt return value before persistence is complete.
The safer sequence is:
decrypt
↓
crypto transaction commits
↓
message projection commits
↓
read durable display content
↓
enrich notification
That prevents the notification from claiming a message exists when persistence actually failed.
It also means already-processed messages can be enriched without decrypting them again. If a duplicate push arrives, the app can use the durable message store.
What Happens When the Network Fails?
This produced one of the most surprising findings in the project.
In our killed-app Android headless context:
- HTTP responses were observable;
- transport-level failures were not reliably observable;
- even an immediate DNS failure could fail to return control to JavaScript;
- JavaScript timer-based deadlines did not reliably fire.
So our background enrichment path does not depend on a clean "network error" callback.
It is intentionally one-shot:
generic banner
↓
one authenticated request
↓
HTTP response?
├─ yes → handle it
└─ transport disappears → Android eventually ends job
The user still sees the generic notification.
Later WebSocket or history delivery reconciles the message.
This makes enrichment best-effort without making message delivery best-effort.
Why We Do Not Retry in the Headless Task
Retries initially looked helpful.
They turned out to be more dangerous.
The retry implementation used timer-based backoff:
request fails
↓
await sleep(...)
↓
retry
But if the timer never fires in the headless runtime, a fast transport failure can hang the task indefinitely.
The final background design therefore has:
- one request;
- no retry backoff;
- no JavaScript timeout guarantee;
- no persistent notification-fetch retry queue.
Normal foreground API traffic can still use ordinary retry behavior.
The background rail is intentionally narrower.
Failure Ladder
The complete fallback ladder is:
Envelope 200, decrypt succeeds
Generic notification becomes decrypted preview.
Envelope 400/404
Generic remains.
No crypto is attempted.
Server failure
Generic remains.
Transport stall
Generic remains while Android eventually ends the task.
Decrypt failure
Generic remains.
Crypto transaction rolls back.
Projection failure
Generic remains.
No fake rich notification is shown.
Duplicate delivery
Same notification identity, same logical message, no second decrypt.
The notification system is therefore designed around graceful degradation.
Privacy Benefits
This architecture reduces what the push provider needs to carry.
The provider can route a wake-up event without being handed display-ready conversation metadata.
It also avoids creating multiple notification-specific representations of the same encrypted message.
The backend keeps serving the same canonical encrypted envelope the normal receive path already understands.
That is a useful architectural constraint:
Notification processing should reuse the secure receive pipeline rather than invent a second crypto protocol.
Reliability Benefits
The generic-first design also improves reliability independently of privacy.
Because the user-visible fallback occurs before network or cryptographic work, many failures become degradations instead of disappearances.
The system can say:
"A message exists, but enrichment could not complete."
instead of:
"No notification was shown because a background dependency failed."
For mobile systems, that difference matters.
What We Learned
Several principles came out of this work:
Keep push payloads minimal.
Send the information required to locate secure state, not the information required to render the UI.Post the fallback before enrichment.
Reliability should not depend on the success of background decryption.Use device-bound authorization.
The server should choose the correct encrypted envelope from authenticated device identity, not from caller-supplied routing metadata.Reuse the canonical receive pipeline.
Notification crypto should not fork from normal message crypto.Make notification identity deterministic.
At-least-once delivery must not become duplicate tray entries.Resolve display metadata locally.
The same name resolver should power both the chat screen and the notification.Treat background enrichment as best effort.
Message integrity must not depend on a fragile background runtime.
Closing Thoughts
The design question was never just:
"How do we show decrypted text in a push notification?"
The more useful question was:
"How do we preserve E2EE boundaries, notification reliability, duplicate safety, and local identity consistency while still giving the user a useful preview?"
The answer was to make push small, make the generic banner early, make encrypted retrieval authenticated, and let the existing receive path remain the authority.
The decrypted preview is the final layer.
It is not the foundation.


