Skip to content

feat(notifications): add backend-agnostic push notifications common module - #3338

Open
ekjotmultani wants to merge 2 commits into
mainfrom
feat/push-notifications-common
Open

feat(notifications): add backend-agnostic push notifications common module#3338
ekjotmultani wants to merge 2 commits into
mainfrom
feat/push-notifications-common

Conversation

@ekjotmultani

Copy link
Copy Markdown
Member

Summary

Adds aws-push-notifications-common, a backend-agnostic library of Android push notification primitives. It is a Pinpoint-free successor to aws-push-notifications-pinpoint-common, providing the same building blocks without any coupling to Amazon Pinpoint or the AWS SDK.

The module provides:

  • Generic FCM payload parsing (PushNotificationPayload): builds a displayable payload from a NotificationPayload, a raw FCM data map, a RemoteMessage, or an Intent, reading standard payload keys (title, body, imageUrl, url, deeplink, openApp, channelId, silentPush). It parses plain FCM pushes that carry no backend-specific keys and never drops them, so a valid non-Pinpoint push is still displayable.
  • Notification display (PushNotificationsUtils): showNotification backed by NotificationCompat, plus isAppInForeground and areNotificationsEnabled.
  • Permissions (PushNotificationPermission, PermissionRequestResult): POST_NOTIFICATIONS handling for API 33+.
  • Constants and channel helpers (PushNotificationsConstants, PushNotificationChannels): the generic payload keys and a notification channel DSL.

The public NotificationPayload and NotificationContentProvider types continue to come from common-core and are re-exported, so consumers get them transitively.

Why

This is a foundation library that other consumers depend on:

  • The Amplify JavaScript React Native Android bridge currently depends on aws-push-notifications-pinpoint-common. This module gives it a Pinpoint-free replacement so it can migrate off Pinpoint with close to a dependency swap.
  • The Flutter Android push layer, which today routes through the Pinpoint-specific parser and drops non-Pinpoint FCM pushes, can consume this instead to fix that gap.
  • Native app developers who want the low level pieces directly.

The public symbol names and shapes are kept aligned with what these consumers already import so the migration stays small.

Dependencies

Depends only on common-core, annotations, androidx, and firebase-messaging. No AWS SDK and no Pinpoint dependency.

Testing

16 unit tests, all passing, including a regression guard that a payload with no Pinpoint keys is parsed and not dropped. Build, ktlintCheck, apiCheck, and lintRelease all pass against current main after rebase.

Open questions for reviewers

  • Public vs internal API surface. The successor API is exposed as a clean public API, whereas the original Pinpoint payload and constants were marked @InternalAmplifyApi (ERROR level opt-in). Making it public means consumers can drop their @OptIn annotations, but it also commits us to binary compatibility on these symbols. Happy to mark them internal instead if we would rather not commit yet.
  • firebase-messaging as an api dependency. PushNotificationPayload.fromRemoteMessage(RemoteMessage) exposes a Firebase type, so firebase-messaging is currently an api dependency and leaks transitively. If we would rather keep Firebase off the public surface, I can drop that overload and keep only fromData(Map<String, String>), which is what the RN bridge already uses.

…odule

Introduce aws-push-notifications-common, a vendor-neutral library of push
notification primitives that does not depend on any specific backend.

It provides:
- PushNotificationPayload: parses a NotificationPayload / FCM data map /
  RemoteMessage / Intent into a displayable payload using standard payload
  keys. Unlike the previous parser it never drops a valid FCM message, so
  pushes without backend-specific keys are parsed and displayed.
- PushNotificationsUtils: showNotification, isAppInForeground and
  areNotificationsEnabled backed by NotificationCompat.
- PushNotificationPermission and PermissionRequestResult for POST_NOTIFICATIONS
  handling on API 33+.
- PushNotificationsConstants and the notification channel DSL.

The public NotificationPayload and NotificationContentProvider types continue
to come from common-core and are re-exported. Depends only on common-core and
androidx/firebase-messaging.
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.59716% with 138 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.30%. Comparing base (88e51cb) to head (1ae4ebf).
⚠️ Report is 23 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3338      +/-   ##
==========================================
- Coverage   56.44%   56.30%   -0.15%     
==========================================
  Files        1088     1095       +7     
  Lines       32175    32386     +211     
  Branches     4799     4834      +35     
==========================================
+ Hits        18162    18235      +73     
- Misses      12124    12250     +126     
- Partials     1889     1901      +12     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major finding:

@SuppressLint("NewApi")
fun showNotification(notificationId: Int, payload: PushNotificationPayload, targetClass: Class<*>?) {
CoroutineScope(Dispatchers.IO).launch {
val largeImageIcon = payload.imageUrl?.let { downloadImage(it) }

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] CoroutineScope(Dispatchers.IO) with no Job or lifecycle attachment creates a scope that's never cancelled. If the context is destroyed while the image download is running, the coroutine keeps going with a captured context reference and then calls notify() on it. Pass a CoroutineScope into the constructor so callers can tie the lifetime to a LifecycleOwner.lifecycleScope or cancel it themselves:

class PushNotificationsUtils(
    private val context: Context,
    private val scope: CoroutineScope,
    private val channelId: String = ...
)

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major finding:

setContentTitle(payload.title)
setContentText(payload.body)
setSmallIcon(R.drawable.ic_launcher_foreground)
setContentIntent(pendingIntent)

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] R.drawable.ic_launcher_foreground references this library's own resource, not the consuming app's. On API 26+ notification small icons must be monochrome/silhouette — adaptive launcher icons render as a solid white square in the status bar. There's also no way for library consumers to provide their own icon.

The small icon should be a constructor parameter:

class PushNotificationsUtils(
    private val context: Context,
    private val smallIconRes: Int,
    ...
)

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major finding:


with(NotificationManagerCompat.from(context)) {
notify(notificationId, builder.build())
}

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] On API 33+, calling notify() without POST_NOTIFICATIONS permission silently drops the notification — no exception, no log. There's already an areNotificationsEnabled() helper on this class; worth calling it here and returning early so callers aren't left debugging phantom missing notifications. (Pre-existing behaviour carried over from aws-push-notifications-pinpoint-common, but worth fixing before this surface goes public.)

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another finding:

) {
init {
retrieveNotificationChannel()
}

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] retrieveNotificationChannel() returns NotificationChannel? and the result is silently dropped here. Since the channel is re-fetched on every showNotification() call anyway, this eager call is mostly a side-effect trigger — a debug log on null return would make channel-creation failures visible during integration testing.

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings:

private val flow = MutableSharedFlow<IdAndResult>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] With extraBufferCapacity = 1 and DROP_OLDEST, if two requestPermission() calls are in-flight simultaneously and both activities post results before either collector runs, the first result is dropped and the corresponding listen().first() suspends forever (no timeout, no cancellation signal). Bumping the buffer capacity or adding a withTimeout on the first() call side would close this window.

action[PushNotificationsConstants.URL] = it.replaceFirst("http://", "https://")
}
data[PushNotificationsConstants.DEEPLINK]?.let { action[PushNotificationsConstants.DEEPLINK] = it }
return action

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] replaceFirst("http://", "https://") mutates the sender-provided URL silently. If the target is an intranet address that only speaks HTTP, the tap action will fail quietly. Also replaceFirst matches on substring position rather than the URI scheme component — URI.scheme would be more defensive. Worth a note in the PushNotificationsConstants.URL KDoc so senders know the library enforces HTTPS.

}

var sound: Uri? = Settings.System.DEFAULT_NOTIFICATION_URI
set(value) {

@soberm soberm Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] sound and audioAttributes are mutually dependent: each setter calls builder.setSound(sound, audioAttributes) using the current value of the other. The interaction is subtle: a caller who sets audioAttributes first then sound = null gets the right result; the reverse order leaves the builder with the default sound URI even though silence was intended. A brief comment spelling out the dependency would save the next reader a trip through both setters.

val uri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
context.startActivity(intent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] startActivity() from a non-Activity Context (service, application context, background receiver) requires Intent.FLAG_ACTIVITY_NEW_TASK — without it this crashes at runtime with AndroidRuntimeException. Since PushNotificationPermission accepts a bare Context, this is reachable in practice. requestPermission() correctly sets the flag on its intent; openSettings() needs the same:

intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)

(Pre-existing in aws-push-notifications-pinpoint-common, but a public API is a good opportunity to fix it.)

@soberm soberm left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity & integration notes

This is a well-scoped foundation layer and the Pinpoint-free design is the right direction. A few behavioral changes and one structural concern are worth flagging before this gets consumed by downstream packages.

body / message key preference flip (silent behavioral change)
The old parser preferred data["message"] and fell back to data["pinpoint.notification.body"]. The new parser prefers data["body"] and falls back to data["message"]. For any sender that includes both keys in the same payload the displayed body will silently change after migration. Low probability in the wild, but worth a note in the migration guide.

Default channel ID changed: "PINPOINT.NOTIFICATION""amplify.notifications"
On devices that have already configured per-channel notification preferences (muted, custom sound, etc.) for the old channel, those settings will be lost silently on migration — Android channel prefs are keyed by ID with no migration path. Worth calling out in the changelog/migration guide so app developers can decide whether to keep the old channel alive as an alias.

notificationId deduplication will degrade for Pinpoint-originated pushes
The RN bridge currently computes a stable notification ID by hashing pinpoint.campaign.campaign_id + campaign_activity_id (or journey equivalents), which prevents duplicate notifications from the same campaign activity stacking up. Those keys are not present in this module. Once the bridge migrates, campaign/journey pushes will get random IDs on every delivery, so a second delivery of the same campaign activity will show two notifications instead of replacing the first. If deduplication matters for downstream consumers, the new module (or the bridge migration PR) should define a notificationId strategy — either a standard "notificationId" FCM data key, or a hash of title+body.

Four [major] items in PushNotificationsUtils — see inline comments. The openSettings() FLAG_ACTIVITY_NEW_TASK gap and the missing areNotificationsEnabled() pre-check are pre-existing bugs carried over from aws-push-notifications-pinpoint-common (not regressions introduced here), but since this is now a public API it's a good opportunity to fix them before they reach more consumers.

- PushNotificationsUtils takes a CoroutineScope so callers own the lifetime
  of the image download and notification post instead of a scope that is
  never cancelled.
- Small icon is now a required constructor parameter so consumers supply an
  icon that renders correctly on API 26+; the bundled drawable is removed.
- showNotification returns early with a log when notifications are disabled,
  instead of silently dropping on API 33+ without POST_NOTIFICATIONS.
- retrieveNotificationChannel logs when no channel could be resolved.
- PushNotificationPermission.openSettings sets FLAG_ACTIVITY_NEW_TASK so it
  works from a non-Activity context.
- PermissionRequestChannel buffer capacity increased so concurrent results
  are not dropped, which could leave a request suspended.
- URL https enforcement checks the parsed URI scheme and rewrites only the
  leading scheme, documented on PushNotificationsConstants.URL.
- Add a notificationId strategy: a standard notificationId key with a stable
  title and body hash fallback, with migration notes on the payload.
- Clarify the sound and audioAttributes setter interdependence.

Adds unit tests for the URL scheme handling, the permission pre-check, the
NEW_TASK flag, and the notificationId derivation, and refreshes the API dump.
@ekjotmultani
ekjotmultani marked this pull request as ready for review July 13, 2026 06:31
@ekjotmultani
ekjotmultani requested review from a team as code owners July 13, 2026 06:31
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