From 4cec53199495181959041402374468bf9ffa791f Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 16 Jun 2026 08:03:10 +0100 Subject: [PATCH 1/3] Fix for notification icon --- CHANGELOG.md | 1 + .../iterableapi/IterableConstants.java | 3 + .../IterableNotificationHelper.java | 41 +++++++ .../iterableapi/IterableNotificationWorker.kt | 29 +---- .../iterableapi/IterableNotificationTest.java | 100 ++++++++++++++++++ 5 files changed, 146 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6255f113..37498cfd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- Notification small-icon resolution now falls back through standard conventions — the Firebase `com.google.firebase.messaging.default_notification_icon` meta-data, `@drawable/notification_icon` (Expo / React Native), and `@drawable/ic_notification` — before defaulting to the app launcher icon. This fixes white-square notification icons on Android 5.0+ for apps that configure their icon through these conventions but don't set `iterable_notification_icon`. - Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. - New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index eb2d3fc4d..4d4009cca 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -177,6 +177,9 @@ public final class IterableConstants { public static final String INSTANCE_ID_CLASS = "com.google.android.gms.iid.InstanceID"; public static final String ICON_FOLDER_IDENTIFIER = "drawable"; public static final String NOTIFICATION_ICON_NAME = "iterable_notification_icon"; + public static final String FIREBASE_NOTIFICATION_ICON_KEY = "com.google.firebase.messaging.default_notification_icon"; + public static final String NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON = "notification_icon"; + public static final String NOTIFICATION_ICON_DRAWABLE_IC_NOTIFICATION = "ic_notification"; public static final String NOTIFICAION_BADGING = "iterable_notification_badging"; public static final String NOTIFICATION_COLOR = "iterable_notification_color"; public static final String NOTIFICATION_CHANNEL_NAME = "iterable_notification_channel_name"; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index fdcae7024..61183b861 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -109,6 +109,10 @@ static Bundle mapToBundle(Map map) { return bundle; } + static int getIconId(Context context) { + return instance.getIconId(context); + } + static class IterableNotificationHelperImpl { public IterableNotificationBuilder createNotification(Context context, Bundle extras) { @@ -436,6 +440,43 @@ private int getIconId(Context context) { context.getPackageName()); } + //Check Firebase default notification icon set in the AndroidManifest.xml + if (iconId == 0) { + try { + ApplicationInfo info = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + if (info.metaData != null) { + iconId = info.metaData.getInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, 0); + if (iconId != 0) { + IterableLogger.d(IterableNotificationBuilder.TAG, "Using Firebase default notification icon"); + } + } + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + } + + //Check @drawable/notification_icon (Expo / React Native convention) + if (iconId == 0) { + iconId = context.getResources().getIdentifier( + IterableConstants.NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON, + IterableConstants.ICON_FOLDER_IDENTIFIER, + context.getPackageName()); + if (iconId != 0) { + IterableLogger.d(IterableNotificationBuilder.TAG, "Using @drawable/notification_icon"); + } + } + + //Check @drawable/ic_notification (common Android convention) + if (iconId == 0) { + iconId = context.getResources().getIdentifier( + IterableConstants.NOTIFICATION_ICON_DRAWABLE_IC_NOTIFICATION, + IterableConstants.ICON_FOLDER_IDENTIFIER, + context.getPackageName()); + if (iconId != 0) { + IterableLogger.d(IterableNotificationBuilder.TAG, "Using @drawable/ic_notification"); + } + } + //Get id from the default app settings if (iconId == 0) { if (context.getApplicationInfo().icon != 0) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt index 66c8771e2..b80dcd4ce 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt @@ -58,7 +58,7 @@ internal class IterableNotificationWorker( } val notification = NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(getSmallIconId()) + .setSmallIcon(IterableNotificationHelper.getIconId(applicationContext)) .setContentTitle(getAppName()) .setPriority(NotificationCompat.PRIORITY_LOW) .build() @@ -66,33 +66,6 @@ internal class IterableNotificationWorker( return ForegroundInfo(FOREGROUND_NOTIFICATION_ID, notification) } - private fun getSmallIconId(): Int { - var iconId = 0 - - try { - val info = applicationContext.packageManager.getApplicationInfo( - applicationContext.packageName, PackageManager.GET_META_DATA - ) - iconId = info.metaData?.getInt(IterableConstants.NOTIFICATION_ICON_NAME, 0) ?: 0 - } catch (e: PackageManager.NameNotFoundException) { - IterableLogger.w(TAG, "Could not read application metadata for icon") - } - - if (iconId == 0) { - iconId = applicationContext.resources.getIdentifier( - IterableApi.getNotificationIcon(applicationContext), - IterableConstants.ICON_FOLDER_IDENTIFIER, - applicationContext.packageName - ) - } - - if (iconId == 0) { - iconId = applicationContext.applicationInfo.icon - } - - return iconId - } - private fun getAppName(): String { return applicationContext.applicationInfo .loadLabel(applicationContext.packageManager).toString() diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java index 817547693..a8a65c9f5 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java @@ -4,6 +4,9 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; @@ -29,6 +32,9 @@ import static org.robolectric.Shadows.shadowOf; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + import org.json.JSONArray; import org.json.JSONObject; @@ -241,4 +247,98 @@ public void testPendingIntentFlags() throws Exception { assertTrue((textInputFlags & PendingIntent.FLAG_UPDATE_CURRENT) != 0); assertTrue((textInputFlags & PendingIntent.FLAG_MUTABLE) != 0); // Should be mutable for text input } + + @Test + public void testIconFallbackToAppIcon() throws Exception { + Context context = iconTestContext(new Bundle(), null, 0, android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.sym_def_app_icon, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconUsesIterableMetadata() throws Exception { + Bundle metaData = new Bundle(); + metaData.putInt(IterableConstants.NOTIFICATION_ICON_NAME, android.R.drawable.ic_dialog_email); + Context context = iconTestContext(metaData, null, 0, android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_dialog_email, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconFallbackToFirebaseMetadata() throws Exception { + Bundle metaData = new Bundle(); + metaData.putInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, android.R.drawable.ic_dialog_info); + Context context = iconTestContext(metaData, null, 0, android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_dialog_info, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconIterableMetadataTakesPriorityOverFirebase() throws Exception { + Bundle metaData = new Bundle(); + metaData.putInt(IterableConstants.NOTIFICATION_ICON_NAME, android.R.drawable.ic_dialog_alert); + metaData.putInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, android.R.drawable.ic_dialog_info); + Context context = iconTestContext(metaData, null, 0, android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_dialog_alert, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconFallbackToNotificationIconDrawable() throws Exception { + Context context = iconTestContext(new Bundle(), + IterableConstants.NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON, android.R.drawable.ic_menu_info_details, + android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_menu_info_details, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconFallbackToIcNotificationDrawable() throws Exception { + Context context = iconTestContext(new Bundle(), + IterableConstants.NOTIFICATION_ICON_DRAWABLE_IC_NOTIFICATION, android.R.drawable.ic_menu_help, + android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_menu_help, IterableNotificationHelper.getIconId(context)); + } + + @Test + public void testIconFirebaseMetadataTakesPriorityOverDrawable() throws Exception { + Bundle metaData = new Bundle(); + metaData.putInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, android.R.drawable.ic_dialog_info); + Context context = iconTestContext(metaData, + IterableConstants.NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON, android.R.drawable.ic_menu_info_details, + android.R.drawable.sym_def_app_icon); + + assertEquals(android.R.drawable.ic_dialog_info, IterableNotificationHelper.getIconId(context)); + } + + /** + * Builds a context spy with fully controlled icon inputs: the application meta-data bundle, an + * optional drawable name resolvable via {@link Resources#getIdentifier}, and the launcher icon. + * Lets each fallback tier of {@link IterableNotificationHelper#getIconId} be exercised in + * isolation without mutating shared Robolectric state. + */ + private Context iconTestContext(Bundle metaData, String drawableName, int drawableResId, int appIcon) throws Exception { + Context context = spy(getContext()); + + ApplicationInfo appInfo = new ApplicationInfo(); + appInfo.packageName = getContext().getPackageName(); + appInfo.metaData = metaData; + appInfo.icon = appIcon; + + PackageManager packageManager = spy(getContext().getPackageManager()); + when(packageManager.getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA)) + .thenReturn(appInfo); + when(context.getPackageManager()).thenReturn(packageManager); + when(context.getApplicationInfo()).thenReturn(appInfo); + + if (drawableName != null) { + Resources resources = spy(getContext().getResources()); + when(resources.getIdentifier(drawableName, IterableConstants.ICON_FOLDER_IDENTIFIER, getContext().getPackageName())) + .thenReturn(drawableResId); + when(context.getResources()).thenReturn(resources); + } + + return context; + } } \ No newline at end of file From 4d3d9172d6abe909d5e443537c6a3d1e15855416 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 24 Jun 2026 12:39:10 +0100 Subject: [PATCH 2/3] Simplify getIconId method for better readability --- .../IterableNotificationHelper.java | 92 ++++++++----------- 1 file changed, 37 insertions(+), 55 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index 61183b861..865125eb3 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -411,83 +411,65 @@ private String getChannelName(Context context) { } /** - * Returns the iconId from potential resource locations - * - * @param context - * @return + * Resolves the notification small-icon by checking each supported source in priority order, + * falling back to the app launcher icon. Returns 0 if no icon is available. */ private int getIconId(Context context) { - int iconId = 0; + Bundle metaData = getApplicationMetaData(context); + + // iterable_notification_icon meta-data in AndroidManifest.xml + int iconId = metaData.getInt(IterableConstants.NOTIFICATION_ICON_NAME, 0); - //Get the iconId set in the AndroidManifest.xml + // Icon name set in code via IterableApi.setNotificationIcon() if (iconId == 0) { - try { - ApplicationInfo info = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); - if (info.metaData != null) { - iconId = info.metaData.getInt(IterableConstants.NOTIFICATION_ICON_NAME, 0); - IterableLogger.d(IterableNotificationBuilder.TAG, "iconID: " + info.metaData.get(IterableConstants.NOTIFICATION_ICON_NAME)); - } - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } + iconId = resolveDrawable(context, IterableApi.getNotificationIcon(context)); } - //Get the iconId set in code + // Firebase default_notification_icon meta-data in AndroidManifest.xml if (iconId == 0) { - iconId = context.getResources().getIdentifier( - IterableApi.getNotificationIcon(context), - IterableConstants.ICON_FOLDER_IDENTIFIER, - context.getPackageName()); + iconId = metaData.getInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, 0); } - //Check Firebase default notification icon set in the AndroidManifest.xml + // @drawable/notification_icon (Expo / React Native convention) if (iconId == 0) { - try { - ApplicationInfo info = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); - if (info.metaData != null) { - iconId = info.metaData.getInt(IterableConstants.FIREBASE_NOTIFICATION_ICON_KEY, 0); - if (iconId != 0) { - IterableLogger.d(IterableNotificationBuilder.TAG, "Using Firebase default notification icon"); - } - } - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } + iconId = resolveDrawable(context, IterableConstants.NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON); } - //Check @drawable/notification_icon (Expo / React Native convention) + // @drawable/ic_notification (common Android convention) if (iconId == 0) { - iconId = context.getResources().getIdentifier( - IterableConstants.NOTIFICATION_ICON_DRAWABLE_NOTIFICATION_ICON, - IterableConstants.ICON_FOLDER_IDENTIFIER, - context.getPackageName()); - if (iconId != 0) { - IterableLogger.d(IterableNotificationBuilder.TAG, "Using @drawable/notification_icon"); - } + iconId = resolveDrawable(context, IterableConstants.NOTIFICATION_ICON_DRAWABLE_IC_NOTIFICATION); } - //Check @drawable/ic_notification (common Android convention) + // App launcher icon if (iconId == 0) { - iconId = context.getResources().getIdentifier( - IterableConstants.NOTIFICATION_ICON_DRAWABLE_IC_NOTIFICATION, - IterableConstants.ICON_FOLDER_IDENTIFIER, - context.getPackageName()); - if (iconId != 0) { - IterableLogger.d(IterableNotificationBuilder.TAG, "Using @drawable/ic_notification"); + iconId = context.getApplicationInfo().icon; + if (iconId == 0) { + IterableLogger.w(IterableNotificationBuilder.TAG, "No notification icon defined - push notifications will not be displayed"); } } - //Get id from the default app settings - if (iconId == 0) { - if (context.getApplicationInfo().icon != 0) { - IterableLogger.d(IterableNotificationBuilder.TAG, "No Notification Icon defined - defaulting to app icon"); - iconId = context.getApplicationInfo().icon; - } else { - IterableLogger.w(IterableNotificationBuilder.TAG, "No Notification Icon defined - push notifications will not be displayed"); + return iconId; + } + + private Bundle getApplicationMetaData(Context context) { + try { + ApplicationInfo info = context.getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + if (info.metaData != null) { + return info.metaData; } + } catch (PackageManager.NameNotFoundException e) { + IterableLogger.w(IterableNotificationBuilder.TAG, "Could not read application metadata for notification icon"); } + return new Bundle(); + } - return iconId; + private int resolveDrawable(Context context, String name) { + if (name == null || name.isEmpty()) { + return 0; + } + return context.getResources().getIdentifier( + name, IterableConstants.ICON_FOLDER_IDENTIFIER, context.getPackageName()); } boolean isIterablePush(Bundle extras) { From 7baf36580e3f19e5a2c1f7877c1ab0e89755717c Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 25 Jun 2026 11:50:57 +0100 Subject: [PATCH 3/3] Move notification icon entry to Unreleased after master merge The 3.9.0 release (#1069) shipped without this PR's notification-icon fallback feature, so the entry belongs under Unreleased, not 3.9.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b93092f9..06c62a943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Notification small-icon resolution now falls back through standard conventions — the Firebase `com.google.firebase.messaging.default_notification_icon` meta-data, `@drawable/notification_icon` (Expo / React Native), and `@drawable/ic_notification` — before defaulting to the app launcher icon. This fixes white-square notification icons on Android 5.0+ for apps that configure their icon through these conventions but don't set `iterable_notification_icon`. ## [3.9.0] ### Added -- Notification small-icon resolution now falls back through standard conventions — the Firebase `com.google.firebase.messaging.default_notification_icon` meta-data, `@drawable/notification_icon` (Expo / React Native), and `@drawable/ic_notification` — before defaulting to the app launcher icon. This fixes white-square notification icons on Android 5.0+ for apps that configure their icon through these conventions but don't set `iterable_notification_icon`. - Added support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`. - New `IterableInboxToolbarView` — an opt-in, reusable toolbar component for the inbox UI. Configurable via the new Kotlin sealed interface `InboxToolbarOption`: - `None` (default) — no toolbar; behavior is unchanged from prior SDK versions.