Skip to content

fix(mobile): let the Android app draw its own push notifications - #2465

Merged
DoodleBears merged 3 commits into
mainfrom
claude/project-thread-9rg7zp
Oct 21, 2026
Merged

DoodleBears merged 3 commits into
mainfrom
claude/project-thread-9rg7zp

Conversation

@DoodleBears

@DoodleBears DoodleBears commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Vain · project thread

Why

The #2397 Android phone check (evidence, root cause) found every notification posted by the FCM SDK (tag FCM-Notification, BigTextStyle), with no conversation style, no avatar and no approval buttons. An Expo push with a top-level title reaches Android as an FCM notification message. While the app is in the background, the Firebase SDK draws that message itself, so expo-notifications and our ConversationNotificationsService never see it.

Change outline

Host (packages/core/src/push)

  • register accepts render: "app", stored in a new push_devices.render column (migration 105).
  • For an Android device with render = 'app', a Bot push has no top-level title/body, which makes it data-only. The shown text goes into the data as title and message, plus categoryId. The text is not put in data.body, because expo-notifications parses that key as JSON. priority: 'high', channelId and categoryId stay on the message. iOS devices and older Android builds get exactly what they got before.

App

  • push-session registers with render: 'app' on Android.
  • ConversationNotificationsService also overrides the handling delegate. Expo presents a background data-only push only when it has a title or text, so ConversationHandlingDelegate first rebuilds the content with title/text taken from the data. It keeps sound, vibration, category, badge and the same trigger/channel, then hands the content to the stock ExpoHandlingDelegate. Presentation then goes through ConversationPresentationDelegate (MessagingStyle, avatars, shortcut) and expo's category actions.

Verification

  • pnpm typecheck and oxfmt --check pass. Push delivery tests pass (18), including a new test of the data-only Android payload. Mobile push tests pass (13), including a real-Host gateway test where an Android session registers and receives a data-only push.
  • CI compiles the Kotlin module (:conversation-notifications:compileDebugKotlin).
  • Not yet done: a phone check. It needs a new Android store build from this branch, against a Host that runs this branch.

Special things to note

  • Needs a new Android build and a Host update. An old app on a new Host keeps the system-drawn push, and so does a new app on an old Host.
  • HyperOS still freezes a backgrounded app unless battery saver is set to "no restrictions". This is not new: an FCM notification message is also drawn in the app process.
  • Not verified offline: for a data-only push, whether the Expo push service puts the message's channelId/categoryId as top-level FCM data keys. categoryId is also sent in our data as a fallback. If the channel doesn't map, the notification uses expo's default channel. The phone check will show which happens.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JrtPca5pD72Gdx4CFkQdNi

An Expo push with a title reaches Android as an FCM notification
message, which the Firebase SDK draws while the app is in the
background, so the conversation style, avatars and approval buttons
never applied. The app now registers with render "app", and the Host
sends that device data-only pushes with the shown title and body in the
data. A handling delegate fills them in so expo-notifications presents
the push through the conversation presentation. Older builds keep the
system-drawn push.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrtPca5pD72Gdx4CFkQdNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrtPca5pD72Gdx4CFkQdNi
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 27 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 01220a30-49d1-44ad-b8e6-ef5a89d0cbcf
📥 Commits

Reviewing files that changed from the base of the PR and between 5c351d0 and b14c3d3.

📒 Files selected for processing: 10
  • apps/mobile/modules/conversation-notifications/README.md
  • apps/mobile/modules/conversation-notifications/android/src/main/java/expo/modules/conversationnotifications/ConversationNotificationsService.kt
  • apps/mobile/src/host/push-session.ts
  • apps/mobile/src/host/push.ts
  • apps/mobile/test/push.test.ts
  • changes/2465-android-app-drawn-push.md
  • packages/core/src/database/schema-plan.ts
  • packages/core/src/push/delivery.ts
  • packages/core/src/push/http.ts
  • packages/core/test/push-delivery.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

expo-notifications parses data.body as the JSON payload, so keep the
shown text out of that key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrtPca5pD72Gdx4CFkQdNi

DoodleBears commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Android phone check: data-only pushes sent through the Expo push service are not displayed (Xiaomi, Android 16 / HyperOS 3, store build from main 5c351d0, battery saver set to unrestricted and autostart on, pushes sent from a script directly to the Expo push API)

Evidence: 2397-android-push/phone (commit 77956f8): data-only-verification.md, 2-foreground-push-conversation-style.png

Payload App state Result
Current (top-level title/body) Background Posted by the FCM SDK: BigTextStyle, no channel image, no actions
Current Foreground The #2397 style works fully: MessagingStyle, conversation title "#QA Lounge", sender Lumi, channel image as the large icon, conversation shortcut. The native code is fine; only the delivery path is wrong.
Data-only, data.channelId = BotHarness channel ID Both Nothing shown. channelId is taken as an Android notification channel that does not exist.
Data-only, channelId = requests, title and message in data Background and foreground Nothing shown

Cause: expo-notifications 58.1.5 has a breaking change ("make data-only notifications consistent with iOS"). It treats a remote message without a notification part as silent. It only displays one when the top-level FCM data itself carries title or text. The source comments say this cannot be triggered through the Expo push service, only through FCM directly, because Expo wraps our data into a JSON body field. So this PR's Android data-only payload will likely show nothing on a phone. [Edit: this test used main 5c351d0, which does not have this PR's ConversationHandlingDelegate. That delegate rebuilds title, text and category from the wrapped JSON body before Expo's silent check, so the result above does not apply to this PR. A retest from this branch's head (b14c3d3+) is queued.]

Possible fixes:

  1. The app registers an expo-notifications background task (expo-task-manager). On a data-only message, it schedules a local notification. Local notifications also go through the feat(mobile): show Channel and Bot avatars in push notifications #2397 presenter, and categoryIdentifier adds the approval actions. The Host sends data-only to Android and moves the BotHarness channel ID off the channelId key.
  2. The conversation-notifications module takes over the Firebase messaging entry point, so notification messages also reach the expo-notifications presenter in the background. This is native and riskier.
  3. The Host sends to FCM directly, bypassing Expo. This needs FCM credentials on the Host.

Whatever the approach, HyperOS still drops pushes while the app is frozen.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

This is a reply to the phone check. That test ran a build from main 5c351d0, which does not include this PR's native change, so "data-only is treated as silent" is the expected result there. It is also the root cause this PR fixes.

  • ConversationHandlingDelegate wraps ExpoHandlingDelegate.handleNotification. Before Expo's shouldPresent() check, it fills in title and text, and carries categoryId, from content.body (the JSON body that Expo wraps around data). After that, the push is no longer counted as silent, in the background or the foreground.
  • An unknown channelId does not drop the push. BaseNotificationBuilder falls back to the default channel.

We'll hold off merging until an APK built from this branch passes on the phone. If it still doesn't show, we'll switch to registering a background task that posts a local notification.


Generated by Claude Code

@DoodleBears
DoodleBears merged commit 7b435bc into main Oct 21, 2026
11 checks passed
DoodleBears added a commit that referenced this pull request Oct 21, 2026
…ata-only probes)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

Android phone result for this PR (b14c3d3): the foreground passes, but the background still fails (Xiaomi, Android 16 / HyperOS 3, store build plus isolated QA Host from b14c3d3, device re-registered, real Host pushes)

Evidence: 2465-android-push/phone (commit 7ba8808): notification-fields.md, background-logcat-notifications.txt, and screenshots 1 to 4.

Case Result
Foreground: group, DM, approval App-drawn (tag is not FCM-Notification), MessagingStyle, conversation shortcut, in Conversations, channel requests. Group shows "#QA Lounge" with the channel image. Approval shows "允许这次 / 拒绝". 1-foreground-shade-app-notifications.png, 2-foreground-approval-expanded.png
Foreground DM, generated (SVG) avatar Blank avatar. Fine with an uploaded PNG (3-dm-with-image-avatar.png). Fixed by #2483, to be rechecked.
Background (battery saver unrestricted, autostart on): group, DM, secret input, approval Only empty FCM SDK notifications: android.title and android.text are null, nothing is drawn by the app, and there are no actions.
Background control: a script sends a true data-only message (only to, priority: high and data with title, message and categoryId) App-drawn: MessagingStyle, "#QA Lounge" with the channel image, shortcut, approval buttons (4-background-data-only-probes.png). But every notification lands in expo_notifications_fallback_notification_channel.

Cause: with render=app, the Host drops only the top-level title and body. It still sends sound, badge, channelId, categoryId and mutableContent, so Expo still builds an FCM notification part. In the background, the FCM SDK posts that part as an empty notification and never calls onMessageReceived, so ConversationHandlingDelegate never runs.

Suggested fixes:

  1. Host: for render=app, send only to, priority and data. categoryId is already in data.
  2. App: pin the Android channel to requests in ConversationHandlingDelegate, by rebuilding the trigger or adding a separate data field. Otherwise notifications fall into the fallback channel. Putting channelId in data does not help: Expo wraps data into body, and it also broke the shortcut and the group image.

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Android phone result on main 2d0bf6e (#2465 + #2483 + #2485): PASS, with one new issue (Xiaomi, Android 16 / HyperOS 3, fresh isolated QA Host and store build, device registered with render=app, battery saver unrestricted and autostart on, all Bots on the default generated avatar)

Evidence: 2465-android-push/phone-main (commit 5784528): 3-background-shade.png, 4-background-mira-dm.png, 5-foreground-shade.png, notification-fields.md

Background and foreground were each tested with a Mira DM reply, an approval (Nova in the background, Echo in the foreground), a secret-input request and a group channel reply:

Check Result
Drawn by the app (no FCM-Notification empty notifications) PASS
MessagingStyle, conversation shortcut, Conversations section PASS
Generated Bot avatars (Mira, Nova, Echo) PASS (#2483)
Group: "#QA Lounge" title with the channel image PASS
Approval: "允许这次 / 拒绝" actions PASS (not tapped)
Channel PASS: every notification, including the secret-input request, is in requests (no fallback channel)

New issue: an unread DM notification is removed by the badge push. In the background run, the Mira DM reply (posted 19:26:58) and the secret-input request were both removed in the same second, 19:27:46, the moment the Host sent a kind=badge push. Removing the secret-input notification is right, because it had just been submitted. The DM reply was still unread, so it should have stayed. A later DM reply with no badge push after it stayed in the shade. The same disappearance was seen once in the foreground on b485d61. Likely cause (inferred): on a badge push, the app removes every notification that isn't in the Host's current pending list, which also drops unread conversation replies.

Separately, on b485d61 one Mira DM reply was accepted by Expo (ticket ok) but never appeared. A resend worked, and the cause is unknown; it may be the same removal.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Android phone result on main 37971eb (#2491): an unread DM now stays (PASS), but handled request notifications are not removed (FAIL) (Xiaomi, Android 16 / HyperOS 3, fresh isolated QA Host and store build, battery saver unrestricted and autostart on, app in the background, real Host pushes)

Evidence: 2465-android-push/phone-37971eba (commit cf3e655): 1-after-private-input-submitted.png, 2-after-app-opened.png, notification-persistence.md

Steps: (1) Mira DM reply, left unread. (2) Echo secret-input request, submitted with fake data. (3) Echo secret_cli approval, denied via the CLI. Echo replied QA-SECRET-REFUSED, and the Host sent a badge push.

Check Result
Unread Mira DM stays after the badge push PASS: the badge going to 0 no longer clears the shade
Handled secret-input and approval notifications disappear FAIL: both were still there after 60 s in the background, and still there 30 s after opening the app to the foreground

Likely cause (inferred; the release build can't be debugged): push-session reflect() removes handled notifications by reading getPresentedNotificationsAsync() data and matching it against the pending set. The posted notifications are MessagingStyle notifications rebuilt by ConversationStyle. They may no longer carry the extras where Expo stores the request data, so nothing matches. Options: keep the original extras on the rebuilt notification, or record the notification ID per itemId and cancel it directly. Also, the JS does not run on a badge push in the background, so removing these in the background may need native support.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Android phone check: all cases pass (Xiaomi, Android 16 / HyperOS 3, store build, Host on the same commit; the final round used main 77ddca9). This covers #2465, #2483, #2485, #2491 and #2494.

Case Result Screenshots
Before the fix (main, #2397) FCM default style, no avatar, no buttons 2397-android-push/phone
#2465 foreground: MessagingStyle, group title, channel avatar ✅ 2465-android-push/phone
Background, generated Bot avatars, requests channel (#2483, #2485) ✅ 2465-android-push/phone-main
Badge reset to zero doesn't clear unread DMs (#2491) ✅ 2465-android-push/phone-37971eba
Handled requests removed from the shade (#2494) ✅ 2494-android-push/phone-b71823e6
Tapping opens the DM, group or approval card; "允许这次" and "拒绝" buttons work ✅ 2494-android-push/phone-tap

Details: tap and action result.

Known behavior, not addressed in this series: opening a chat clears all of that chat's notifications, including a still-pending approval. On HyperOS, the action buttons only show after the notification is expanded.


Generated by Claude Code

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.

2 participants