Skip to content

fix: scheduled local notifications on Android and iOS - #181

Open
mirzemehdi wants to merge 2 commits into
mainfrom
fix/android-scheduled-notifications
Open

mirzemehdi wants to merge 2 commits into
mainfrom
fix/android-scheduled-notifications

Conversation

@mirzemehdi

@mirzemehdi mirzemehdi commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Problem

Android: notify { scheduledAt = <future> } sets an alarm, but when it fires NotificationReceiver only logs Scheduled notification triggered: <id> and emits EVENT_SCHEDULED_NOTIFICATION_FIRED_INTERNAL through onAction. It never calls NotificationManager.notify, and title, body, image and actions are not stored anywhere. So scheduled notifications never show. This has been the case since scheduling was added in #162; the sample app hid it by re-posting from onAction.

iOS: scheduled notifications are delivered, but behavior differs from Android and the attachments have problems:

  • The scheduled-fired event is never emitted. IOS_ACTION_SCHEDULED_FIRED is defined but unused.
  • An interval trigger is computed at scheduling time, rather than a trigger for the absolute fire date.
  • The action category is registered asynchronously while the request is added right away, so immediate notifications could miss their buttons. Categories also pile up.
  • NotificationImage.File attaches the caller's file directly. The system moves attachment files, so the original was deleted.
  • URL images are always written as .jpg. iOS infers the type from the extension, so PNG/GIF could be rejected.

Changes — Android

  • Post on fire. The alarm intent now carries the notification content (title, body, payload, image, actions) and the Android configuration (icon, color, channel). The receiver rebuilds the notification and posts it via goAsync(), then emits the onAction event as before. It uses the live configuration when available and falls back to the stored one in a cold process, before KMPNotifier.initialize. Internal extras are removed from the emitted payload. Alarms set by older versions (without stored content) fall back to the old emit-only behavior.
  • Shared posting code. AndroidNotificationPoster now holds the notification-building code, so immediate and scheduled notifications produce identical results.
  • remove(id) cancels alarms. The cancel intent had no component, so FLAG_NO_CREATE never matched the scheduled PendingIntent.
  • Relative scheduledAt. The raw value was passed to AlarmManager, so the alarm fired immediately. The fire time is now resolved once and reused.
  • Exact alarms (API 31+). canScheduleExactAlarms() is checked first, and setAndAllowWhileIdle is used otherwise. No more SecurityException on every scheduled call.

Changes — iOS

  • Event parity. willPresent emits EVENT_SCHEDULED_NOTIFICATION_FIRED_INTERNAL for scheduled local notifications. This only happens in the foreground: iOS presents notifications itself while the app is in the background, so there is no hook there.
  • UNCalendarNotificationTrigger for the absolute fire date, with the time zone pinned and the time rounded up to the next second. If less than 1s remains, a 1s interval trigger is used. Immediate notifications now use a nil trigger, so they no longer wait 1 second.
  • Action categories are registered (awaited) before the request is added, and replace an existing category with the same id.
  • File images are copied to a temp file before attaching.
  • Image extension is detected from the data (PNG/JPEG/GIF). If detection fails it falls back to the URL/file extension, then to .jpg.

Other

  • Sample app: removed the workaround that re-posted on the scheduled event. With this change it would overwrite the real notification on Android, and on iOS (foreground) too.
  • CHANGELOG: Unreleased → Fixed entries for both platforms. Behavior note: apps that used the re-post workaround must remove it on both platforms.
  • No public API changes (apiCheck passes); all new code is internal.

Tests

Android (Robolectric, androidHostTest):

  • the alarm intent, when delivered to the receiver, posts a notification with title, body, channel and actions
  • the content intent carries the payload
  • cold process: receiver posts using the configuration from the intent, emits onAction, and strips internal keys from the payload
  • remove(id) cancels the pending alarm (checked to fail on the old code)
  • relative scheduledAt schedules in the future
  • exact vs inexact alarm depending on canScheduleExactAlarms()

iOS (iosTest, simulator):

  • fire-time resolution: immediate, past, relative
  • calendar trigger whose nextTriggerDate equals the fire date
  • interval fallback when less than 1s remains
  • scheduled-trigger detection
  • image extension sniffing for PNG/JPEG/GIF, plus the hint and .jpg fallbacks

./gradlew :kmpnotifier-local:testAndroidHostTest :kmpnotifier-local:iosSimulatorArm64Test apiCheck passes. Not yet checked end-to-end on a device or emulator (Android alarm in a killed process, iOS foreground event and attachments).

NotificationReceiver only emitted EVENT_SCHEDULED_NOTIFICATION_FIRED_INTERNAL
and never posted the notification, so scheduled notifications never showed
on Android (broken since scheduling was added in #162).

- Store title, body, payload, image, actions and the Android configuration
  in the alarm intent; the receiver rebuilds and posts the notification,
  falling back to the stored configuration in a cold process before
  KMPNotifier.initialize. The onAction event is still emitted afterwards.
- Extract notification building into AndroidNotificationPoster, shared by
  AndroidNotifier and NotificationReceiver.
- remove(id): build the cancel intent with the receiver component so it
  matches the scheduled PendingIntent and the alarm is actually cancelled.
- Resolve relative scheduledAt before setting the alarm (it fired
  immediately before).
- Check canScheduleExactAlarms() on API 31+ and use setAndAllowWhileIdle
  when exact alarms aren't permitted instead of catching SecurityException.
- Sample app: drop the workaround that re-posted on the scheduled event.
- Emit the scheduled-fired onAction event (EVENT_SCHEDULED_NOTIFICATION_FIRED_INTERNAL)
  from willPresent for scheduled local notifications, matching Android. Only
  observable in the foreground; iOS presents background notifications itself.
- Schedule with UNCalendarNotificationTrigger for the absolute fire date
  (time zone pinned, rounded up to the next second); immediate notifications
  use a nil trigger instead of a 1s interval.
- Await action-category registration before adding the request, and replace
  an existing category for the same id instead of accumulating duplicates.
- Attach a temp copy of NotificationImage.File images; the system moves
  attachment files, which deleted the caller's file.
- Detect the image extension from the data (PNG/JPEG/GIF) instead of always
  writing .jpg.
@mirzemehdi mirzemehdi changed the title fix(android): post scheduled local notifications when the alarm fires fix: scheduled local notifications on Android and iOS Oct 18, 2026

This branch has not been deployed

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