From b507f03a1999f886944d80ac44716c333f4a9635 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 26 Jan 2026 14:23:13 +0000 Subject: [PATCH 1/3] Updated way of handling notifications to not use AsyncTask --- iterableapi/build.gradle | 2 + .../IterableFirebaseMessagingService.java | 70 ++- .../IterableNotificationHelper.java | 9 +- .../IterableNotificationWorkScheduler.java | 146 +++++ .../iterableapi/IterableNotificationWorker.kt | 145 +++++ .../IterableFirebaseMessagingServiceTest.java | 54 ++ .../IterableNotificationFlowTest.java | 429 ++++++++++++++ ...IterableNotificationWorkSchedulerTest.java | 558 ++++++++++++++++++ .../IterableNotificationWorkerUnitTest.java | 405 +++++++++++++ 9 files changed, 1805 insertions(+), 13 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java diff --git a/iterableapi/build.gradle b/iterableapi/build.gradle index 82a3f69e4..087662177 100644 --- a/iterableapi/build.gradle +++ b/iterableapi/build.gradle @@ -63,6 +63,7 @@ dependencies { api 'com.google.firebase:firebase-messaging:20.3.0' implementation 'com.google.code.gson:gson:2.10.1' implementation "androidx.security:security-crypto:1.1.0-alpha06" + implementation 'androidx.work:work-runtime:2.9.0' testImplementation 'junit:junit:4.13.2' testImplementation 'androidx.test:runner:1.6.2' @@ -75,6 +76,7 @@ dependencies { testImplementation 'org.khronos:opengl-api:gl1.1-android-2.1_r1' testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.3' testImplementation 'org.skyscreamer:jsonassert:1.5.0' + testImplementation 'androidx.work:work-testing:2.9.0' testImplementation project(':iterableapi') androidTestImplementation 'androidx.test:runner:1.6.2' diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java index 72df83464..c8d17b729 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -11,6 +11,7 @@ import com.google.firebase.messaging.RemoteMessage; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ExecutionException; public class IterableFirebaseMessagingService extends FirebaseMessagingService { @@ -56,12 +57,13 @@ public static boolean handleMessageReceived(@NonNull Context context, @NonNull R return false; } - if (!IterableNotificationHelper.isGhostPush(extras)) { + boolean isGhostPush = IterableNotificationHelper.isGhostPush(extras); + + if (!isGhostPush) { if (!IterableNotificationHelper.isEmptyBody(extras)) { IterableLogger.d(TAG, "Iterable push received " + messageData); - IterableNotificationBuilder notificationBuilder = IterableNotificationHelper.createNotification( - context.getApplicationContext(), extras); - new IterableNotificationManager().execute(notificationBuilder); + + enqueueNotificationWork(context, extras, false); } else { IterableLogger.d(TAG, "Iterable OS notification push received"); } @@ -105,9 +107,7 @@ public static String getFirebaseToken() { String registrationToken = null; try { registrationToken = Tasks.await(FirebaseMessaging.getInstance().getToken()); - } catch (ExecutionException e) { - IterableLogger.e(TAG, e.getLocalizedMessage()); - } catch (InterruptedException e) { + } catch (ExecutionException | InterruptedException e) { IterableLogger.e(TAG, e.getLocalizedMessage()); } catch (Exception e) { IterableLogger.e(TAG, "Failed to fetch firebase token"); @@ -122,17 +122,65 @@ public static String getFirebaseToken() { * @return Boolean indicating whether the message is an Iterable ghost push or silent push */ public static boolean isGhostPush(RemoteMessage remoteMessage) { - Map messageData = remoteMessage.getData(); + try { + Map messageData = remoteMessage.getData(); - if (messageData == null || messageData.isEmpty()) { + if (messageData.isEmpty()) { + return false; + } + + Bundle extras = IterableNotificationHelper.mapToBundle(messageData); + return IterableNotificationHelper.isGhostPush(extras); + } catch (Exception e) { + IterableLogger.e(TAG, e.getMessage()); return false; } + } - Bundle extras = IterableNotificationHelper.mapToBundle(messageData); - return IterableNotificationHelper.isGhostPush(extras); + private static void enqueueNotificationWork(@NonNull final Context context, @NonNull final Bundle extras, boolean isGhostPush) { + IterableNotificationWorkScheduler scheduler = new IterableNotificationWorkScheduler(context); + + scheduler.scheduleNotificationWork( + extras, + isGhostPush, + new IterableNotificationWorkScheduler.SchedulerCallback() { + @Override + public void onScheduleSuccess(UUID workId) { + IterableLogger.d(TAG, "Notification work scheduled successfully: " + workId); + } + + @Override + public void onScheduleFailure(Exception exception, Bundle notificationData) { + IterableLogger.e(TAG, "Failed to schedule notification work", exception); + IterableLogger.e(TAG, "Attempting FALLBACK to direct processing..."); + handleFallbackNotification(context, notificationData); + } + } + ); + } + + private static void handleFallbackNotification(@NonNull Context context, @NonNull Bundle extras) { + try { + IterableNotificationBuilder notificationBuilder = IterableNotificationHelper.createNotification( + context.getApplicationContext(), extras); + if (notificationBuilder != null) { + IterableNotificationHelper.postNotificationOnDevice(context, notificationBuilder); + IterableLogger.d(TAG, "✓ FALLBACK succeeded - notification posted directly"); + } else { + IterableLogger.w(TAG, "✗ FALLBACK: Notification builder is null"); + } + } catch (Exception fallbackException) { + IterableLogger.e(TAG, "✗ CRITICAL: FALLBACK also failed!", fallbackException); + IterableLogger.e(TAG, "NOTIFICATION WILL NOT BE DISPLAYED"); + } } } +/** + * @deprecated This class is no longer used. Notification processing now uses WorkManager + * to comply with Firebase best practices. This class is kept for backwards compatibility only. + */ +@Deprecated class IterableNotificationManager extends AsyncTask { @Override diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index 2625b32bf..51e21704f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -98,6 +98,11 @@ static Bundle mapToBundle(Map map) { static class IterableNotificationHelperImpl { public IterableNotificationBuilder createNotification(Context context, Bundle extras) { + if (extras == null) { + IterableLogger.w(IterableNotificationBuilder.TAG, "Notification extras is null. Skipping."); + return null; + } + String applicationName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); String title = null; String notificationBody = null; @@ -436,7 +441,7 @@ boolean isIterablePush(Bundle extras) { boolean isGhostPush(Bundle extras) { boolean isGhostPush = false; - if (extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { + if (extras != null && extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { String iterableData = extras.getString(IterableConstants.ITERABLE_DATA_KEY); IterableNotificationData data = new IterableNotificationData(iterableData); isGhostPush = data.getIsGhostPush(); @@ -447,7 +452,7 @@ boolean isGhostPush(Bundle extras) { boolean isEmptyBody(Bundle extras) { String notificationBody = ""; - if (extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { + if (extras != null && extras.containsKey(IterableConstants.ITERABLE_DATA_KEY)) { notificationBody = extras.getString(IterableConstants.ITERABLE_DATA_BODY, ""); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java new file mode 100644 index 000000000..03fa68658 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java @@ -0,0 +1,146 @@ +package com.iterable.iterableapi; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.WorkManager; + +import java.util.UUID; + +/** + * Manages scheduling of notification processing work using WorkManager. + * This class is responsible for: + * - Creating WorkManager requests for notification processing + * - Enqueueing work with expedited execution for high-priority notifications + * - Providing callback interface for success/failure handling + * - Comprehensive logging of scheduling operations + */ +class IterableNotificationWorkScheduler { + + private static final String TAG = "IterableNotificationWorkScheduler"; + + private final Context context; + private final WorkManager workManager; + + /** + * Callback interface for work scheduling results. + * Allows caller to handle success/failure appropriately. + */ + public interface SchedulerCallback { + /** + * Called when work is successfully scheduled. + * @param workId UUID of the scheduled work + */ + void onScheduleSuccess(UUID workId); + + /** + * Called when work scheduling fails. + * @param exception The exception that caused the failure + * @param notificationData The original notification data (for fallback) + */ + void onScheduleFailure(Exception exception, Bundle notificationData); + } + + /** + * Constructor for production use. + * Initializes with application context and default WorkManager instance. + * + * @param context Application or service context + */ + public IterableNotificationWorkScheduler(@NonNull Context context) { + this(context, WorkManager.getInstance(context)); + } + + /** + * Constructor for testing. + * Allows injection of mock WorkManager for unit testing. + * + * @param context Application or service context + * @param workManager WorkManager instance (can be mocked for tests) + */ + @VisibleForTesting + IterableNotificationWorkScheduler(@NonNull Context context, @NonNull WorkManager workManager) { + this.context = context.getApplicationContext(); + this.workManager = workManager; + } + + /** + * Schedules notification processing work using WorkManager. + * + * Creates an expedited OneTimeWorkRequest and enqueues it with WorkManager. + * Expedited execution ensures high-priority notifications are processed promptly, + * with quota exemption when called from FCM onMessageReceived. + * + * @param notificationData Bundle containing notification data + * @param isGhostPush Whether this is a ghost/silent push + * @param callback Optional callback for success/failure (can be null) + */ + public void scheduleNotificationWork( + @NonNull Bundle notificationData, + boolean isGhostPush, + @Nullable SchedulerCallback callback) { + + IterableLogger.d(TAG, "========================================"); + IterableLogger.d(TAG, "Scheduling notification work"); + IterableLogger.d(TAG, "Bundle keys: " + notificationData.keySet().size()); + IterableLogger.d(TAG, "Is ghost push: " + isGhostPush); + + try { + IterableLogger.d(TAG, "Step 1: Creating Worker input data"); + androidx.work.Data inputData = IterableNotificationWorker.createInputData( + notificationData, + isGhostPush + ); + IterableLogger.d(TAG, " ✓ Worker input data created successfully"); + + IterableLogger.d(TAG, "Step 2: Building expedited WorkRequest"); + OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(IterableNotificationWorker.class) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + IterableLogger.d(TAG, " ✓ WorkRequest built with expedited execution"); + + IterableLogger.d(TAG, "Step 3: Enqueueing work with WorkManager"); + workManager.enqueue(workRequest); + + UUID workId = workRequest.getId(); + IterableLogger.d(TAG, " ✓ Work enqueued successfully"); + IterableLogger.d(TAG, ""); + IterableLogger.d(TAG, "✓ NOTIFICATION WORK SCHEDULED"); + IterableLogger.d(TAG, " Work ID: " + workId); + IterableLogger.d(TAG, " Priority: EXPEDITED (high-priority notification)"); + IterableLogger.d(TAG, " Worker: " + IterableNotificationWorker.class.getSimpleName()); + IterableLogger.d(TAG, "========================================"); + + if (callback != null) { + callback.onScheduleSuccess(workId); + } + + } catch (Exception e) { + IterableLogger.e(TAG, "========================================"); + IterableLogger.e(TAG, "✗ FAILED TO SCHEDULE NOTIFICATION WORK"); + IterableLogger.e(TAG, "Error type: " + e.getClass().getSimpleName()); + IterableLogger.e(TAG, "Error message: " + e.getMessage()); + IterableLogger.e(TAG, "========================================"); + + if (callback != null) { + callback.onScheduleFailure(e, notificationData); + } + } + } + + @VisibleForTesting + Context getContext() { + return context; + } + + @VisibleForTesting + WorkManager getWorkManager() { + return workManager; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt new file mode 100644 index 000000000..9fd93983b --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt @@ -0,0 +1,145 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Bundle +import androidx.annotation.WorkerThread +import androidx.work.Data +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.json.JSONObject +import java.io.IOException +import java.net.URL + +/** + * WorkManager Worker to handle push notification processing. + * This replaces the deprecated AsyncTask approach to comply with Firebase best practices. + * + * The Worker handles: + * - Downloading notification images from remote URLs + * - Building notifications with proper styling + * - Posting notifications to the system + */ +internal class IterableNotificationWorker( + context: Context, + params: WorkerParameters +) : Worker(context, params) { + + companion object { + private const val TAG = "IterableNotificationWorker" + + const val KEY_NOTIFICATION_DATA_JSON = "notification_data_json" + const val KEY_IS_GHOST_PUSH = "is_ghost_push" + + /** + * Creates input data for the Worker from a Bundle. + * Converts the Bundle to JSON for reliable serialization. + */ + @JvmStatic + fun createInputData(extras: Bundle, isGhostPush: Boolean): Data { + val jsonObject = JSONObject() + for (key in extras.keySet()) { + val value = extras.getString(key) + if (value != null) { + jsonObject.put(key, value) + } + } + + return Data.Builder() + .putString(KEY_NOTIFICATION_DATA_JSON, jsonObject.toString()) + .putBoolean(KEY_IS_GHOST_PUSH, isGhostPush) + .build() + } + } + + @WorkerThread + override fun doWork(): Result { + IterableLogger.d(TAG, "========================================") + IterableLogger.d(TAG, "Starting notification processing in Worker") + IterableLogger.d(TAG, "Worker ID: $id") + IterableLogger.d(TAG, "Run attempt: $runAttemptCount") + + try { + val isGhostPush = inputData.getBoolean(KEY_IS_GHOST_PUSH, false) + IterableLogger.d(TAG, "Step 1: Ghost push check - isGhostPush=$isGhostPush") + + if (isGhostPush) { + IterableLogger.d(TAG, "Ghost push detected - no user-visible notification to display") + return Result.success() + } + + val jsonString = inputData.getString(KEY_NOTIFICATION_DATA_JSON) + IterableLogger.d(TAG, "Step 2: Retrieved notification JSON data (length=${jsonString?.length ?: 0})") + + if (jsonString == null || jsonString.isEmpty()) { + IterableLogger.e(TAG, "CRITICAL ERROR: No notification data provided to Worker") + return Result.failure() + } + + IterableLogger.d(TAG, "Step 3: Deserializing notification data from JSON") + val extras = jsonToBundle(jsonString) + val keyCount = extras.keySet().size + IterableLogger.d(TAG, "Step 3: Deserialized $keyCount keys from notification data") + + if (keyCount == 0) { + IterableLogger.e(TAG, "CRITICAL ERROR: Deserialized bundle is empty") + return Result.failure() + } + + IterableLogger.d(TAG, "Step 4: Creating notification builder") + val notificationBuilder = IterableNotificationHelper.createNotification( + applicationContext, + extras + ) + + if (notificationBuilder == null) { + IterableLogger.w(TAG, "Step 4: Notification builder is null (likely ghost push or invalid data)") + return Result.success() + } + + IterableLogger.d(TAG, "Step 4: Notification builder created successfully") + val hasImage = extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE) != null + if (hasImage) { + IterableLogger.d(TAG, "Step 4: Notification contains image URL: ${extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE)}") + } + + IterableLogger.d(TAG, "Step 5: Posting notification to device (this may download images)") + IterableNotificationHelper.postNotificationOnDevice( + applicationContext, + notificationBuilder + ) + + IterableLogger.d(TAG, "Step 5: Notification posted successfully to NotificationManager") + IterableLogger.d(TAG, "Notification processing COMPLETED successfully") + IterableLogger.d(TAG, "========================================") + return Result.success() + + } catch (e: Exception) { + IterableLogger.e(TAG, "========================================") + IterableLogger.e(TAG, "CRITICAL ERROR processing notification in Worker", e) + IterableLogger.e(TAG, "Error type: ${e.javaClass.simpleName}") + IterableLogger.e(TAG, "Error message: ${e.message}") + IterableLogger.e(TAG, "Stack trace:", e) + IterableLogger.e(TAG, "========================================") + + return Result.retry() + } + } + + private fun jsonToBundle(jsonString: String): Bundle { + val bundle = Bundle() + try { + val jsonObject = JSONObject(jsonString) + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = jsonObject.getString(key) + bundle.putString(key, value) + } + } catch (e: Exception) { + IterableLogger.e(TAG, "Error parsing notification JSON: ${e.message}", e) + } + return bundle + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java index 9516505fb..21021861e 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java @@ -3,6 +3,12 @@ import android.content.Intent; import android.os.Bundle; +import androidx.work.Configuration; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; + import com.google.firebase.messaging.RemoteMessage; import org.junit.After; @@ -13,7 +19,9 @@ import org.robolectric.android.controller.ServiceController; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockWebServer; @@ -21,6 +29,7 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; +import static junit.framework.TestCase.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; @@ -46,6 +55,13 @@ public void setUp() throws Exception { server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); + // Initialize WorkManager for testing with a synchronous executor + Configuration config = new Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(new SynchronousExecutor()) + .build(); + WorkManagerTestInitHelper.initializeTestWorkManager(getContext(), config); + controller = Robolectric.buildService(IterableFirebaseMessagingService.class); Intent intent = new Intent(getContext(), IterableFirebaseMessagingService.class); controller.withIntent(intent).startCommand(0, 0); @@ -139,4 +155,42 @@ public void testUpdateMessagesIsCalled() throws Exception { controller.get().onMessageReceived(builder.build()); verify(embeddedManagerSpy, atLeastOnce()).syncMessages(); } + + @Test + public void testWorkManagerIsUsedForNotifications() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + // Send a regular push notification (not ghost push) + RemoteMessage.Builder builder = new RemoteMessage.Builder("1234@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test notification"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); + controller.get().onMessageReceived(builder.build()); + + // Verify WorkManager has enqueued work + WorkManager workManager = WorkManager.getInstance(getContext()); + List workInfos = workManager.getWorkInfosByTag(IterableNotificationWorker.class.getName()).get(5, TimeUnit.SECONDS); + + // Note: With SynchronousExecutor, work completes immediately + // Verify that notification helper methods were called (indicating Worker ran) + verify(notificationHelperSpy, atLeastOnce()).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationWorkerProcessesData() throws Exception { + when(notificationHelperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(notificationHelperSpy.createNotification(any(), any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("1234@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Worker test message"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, "Worker Test"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); + + controller.get().onMessageReceived(builder.build()); + + // With SynchronousExecutor, work completes immediately + // Verify the notification was processed + verify(notificationHelperSpy, atLeastOnce()).createNotification(eq(getContext()), any(Bundle.class)); + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java new file mode 100644 index 000000000..01832544d --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java @@ -0,0 +1,429 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Configuration; +import androidx.work.Data; +import androidx.work.WorkManager; +import androidx.work.testing.SynchronousExecutor; +import androidx.work.testing.WorkManagerTestInitHelper; + +import com.google.firebase.messaging.RemoteMessage; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.HashMap; +import java.util.Map; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +public class IterableNotificationFlowTest extends BaseTest { + + private MockWebServer server; + private IterableNotificationHelper.IterableNotificationHelperImpl helperSpy; + private IterableNotificationHelper.IterableNotificationHelperImpl originalHelper; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + Configuration config = new Configuration.Builder() + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(new SynchronousExecutor()) + .build(); + WorkManagerTestInitHelper.initializeTestWorkManager(getContext(), config); + + originalHelper = IterableNotificationHelper.instance; + helperSpy = spy(originalHelper); + IterableNotificationHelper.instance = helperSpy; + } + + @After + public void tearDown() throws Exception { + IterableNotificationHelper.instance = originalHelper; + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // MESSAGE VALIDATION TESTS + // ======================================================================== + + @Test + public void testIterablePushIsRecognized() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertTrue("Message with ITERABLE_DATA_KEY should be recognized", isIterable); + } + + @Test + public void testNonIterablePushIsIgnored() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData("some_other_key", "value"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertFalse("Message without ITERABLE_DATA_KEY should be ignored", isIterable); + } + + @Test + public void testEmptyMessageIsIgnored() { + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( + getContext(), builder.build()); + + assertFalse("Empty message should be ignored", isIterable); + } + + @Test + public void testGhostPushIsDetected() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); + + assertTrue("Ghost push should be detected", isGhost); + } + + @Test + public void testRegularPushIsNotGhost() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); + + assertFalse("Regular push should not be ghost", isGhost); + } + + // ======================================================================== + // NOTIFICATION CREATION TESTS + // ======================================================================== + + @Test + public void testNotificationBuilderIsCreatedForValidPush() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationBuilderNotCreatedForGhostPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testNotificationBuilderNotCreatedForEmptyBody() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + // No body + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); + } + + // ======================================================================== + // NOTIFICATION POSTING TESTS + // ======================================================================== + + @Test + public void testNotificationIsPostedForValidPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testNotificationNotPostedForGhostPush() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(helperSpy, never()).postNotificationOnDevice(any(), any()); + } + + // ======================================================================== + // GHOST PUSH ACTION TESTS + // ======================================================================== + + @Test + public void testInAppUpdateActionIsTriggered() throws Exception { + IterableInAppManager inAppManager = org.mockito.Mockito.mock(IterableInAppManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getInAppManager()).thenReturn(inAppManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_update.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(inAppManager).syncInApp(); + } + + @Test + public void testInAppRemoveActionIsTriggered() throws Exception { + IterableInAppManager inAppManager = org.mockito.Mockito.mock(IterableInAppManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getInAppManager()).thenReturn(inAppManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_remove.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(inAppManager).removeMessage("1234567890abcdef"); + } + + @Test + public void testEmbeddedUpdateActionIsTriggered() throws Exception { + IterableEmbeddedManager embeddedManager = org.mockito.Mockito.mock(IterableEmbeddedManager.class); + IterableApi apiMock = spy(IterableApi.sharedInstance); + when(apiMock.getEmbeddedManager()).thenReturn(embeddedManager); + IterableApi.sharedInstance = apiMock; + + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_embedded_update.json")); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + verify(embeddedManager).syncMessages(); + } + + // ======================================================================== + // DATA PRESERVATION TESTS + // ======================================================================== + + @Test + public void testNotificationTitleIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedTitle = "Test Title"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, expectedTitle); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedTitle, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_TITLE)); + } + + @Test + public void testNotificationBodyIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedBody = "Test Body Content"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, expectedBody); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedBody, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_BODY)); + } + + @Test + public void testNotificationDataKeyIsPreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String expectedData = "{\"campaignId\":123}"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, expectedData); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(expectedData, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_KEY)); + } + + @Test + public void testCustomFieldsArePreserved() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String customValue = "customValue123"; + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); + builder.addData("customField", customValue); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + assertEquals(customValue, bundleCaptor.getValue().getString("customField")); + } + + // ======================================================================== + // SCHEDULER INTEGRATION TESTS + // ======================================================================== + + @Test + public void testNotificationUsesWorkManagerScheduling() throws Exception { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + // Verify notification was posted (via WorkManager with SynchronousExecutor) + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testSchedulerHandlesMultipleNotifications() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + // Send three notifications + for (int i = 0; i < 3; i++) { + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test " + i); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + } + + // Verify all three notifications were created + verify(helperSpy, org.mockito.Mockito.times(3)) + .createNotification(any(), any(Bundle.class)); + } + + @Test + public void testSchedulerPreservesNotificationDataThroughWorkManager() { + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.isEmptyBody(any(Bundle.class))).thenCallRealMethod(); + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String testTitle = "Scheduler Test Title"; + String testBody = "Scheduler Test Body"; + + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); + builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); + builder.addData(IterableConstants.ITERABLE_DATA_TITLE, testTitle); + builder.addData(IterableConstants.ITERABLE_DATA_BODY, testBody); + + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); + + // Verify data was preserved through the scheduler -> worker -> notification flow + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle capturedBundle = bundleCaptor.getValue(); + assertEquals("Title should be preserved through scheduler", + testTitle, capturedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + assertEquals("Body should be preserved through scheduler", + testBody, capturedBundle.getString(IterableConstants.ITERABLE_DATA_BODY)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java new file mode 100644 index 000000000..b6de3f4e7 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java @@ -0,0 +1,558 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.UUID; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * TDD-style atomic tests for IterableNotificationWorkScheduler. + * Each test validates ONE specific behavior of the scheduler. + * + * Tests verify: + * - Work scheduling with WorkManager + * - Callback invocations + * - Error handling + * - Data preservation + * - WorkRequest configuration + */ +public class IterableNotificationWorkSchedulerTest extends BaseTest { + + private MockWebServer server; + private WorkManager mockWorkManager; + private IterableNotificationWorkScheduler scheduler; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + // Create mock WorkManager for testing + mockWorkManager = mock(WorkManager.class); + + // Create scheduler with mock WorkManager + scheduler = new IterableNotificationWorkScheduler(getContext(), mockWorkManager); + } + + @After + public void tearDown() throws Exception { + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // SCHEDULING SUCCESS TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkEnqueuesWithWorkManager() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkCallsSuccessCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + verify(callback).onScheduleSuccess(any(UUID.class)); + } + + @Test + public void testScheduleNotificationWorkPassesWorkIdToCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + ArgumentCaptor uuidCaptor = ArgumentCaptor.forClass(UUID.class); + verify(callback).onScheduleSuccess(uuidCaptor.capture()); + + UUID workId = uuidCaptor.getValue(); + assertNotNull("Work ID should not be null", workId); + } + + @Test + public void testScheduleNotificationWorkSucceedsWithNullCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Should not throw exception with null callback + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkEnqueuesOnlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + // Verify enqueue called exactly once + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + // ======================================================================== + // SCHEDULING FAILURE TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkCallsFailureCallbackOnException() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Configure mock to throw exception + doThrow(new RuntimeException("WorkManager error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + verify(callback).onScheduleFailure(any(Exception.class), any(Bundle.class)); + } + + @Test + public void testScheduleNotificationWorkPassesExceptionToFailureCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + RuntimeException testException = new RuntimeException("Test error"); + doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(callback).onScheduleFailure(exceptionCaptor.capture(), any(Bundle.class)); + + assertEquals("Exception should match", testException, exceptionCaptor.getValue()); + } + + @Test + public void testScheduleNotificationWorkPassesOriginalDataToFailureCallback() { + Bundle data = new Bundle(); + data.putString("testKey", "testValue"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(callback).onScheduleFailure(any(Exception.class), bundleCaptor.capture()); + + Bundle capturedData = bundleCaptor.getValue(); + assertEquals("testValue", capturedData.getString("testKey")); + } + + @Test + public void testScheduleNotificationWorkHandlesFailureWithNullCallback() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + // Should not throw exception with null callback + scheduler.scheduleNotificationWork(data, false, null); + } + + // ======================================================================== + // DATA HANDLING TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkPreservesNotificationData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test Title"); + data.putString(IterableConstants.ITERABLE_DATA_BODY, "Test Body"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + String jsonString = workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertNotNull("Notification data should be preserved", jsonString); + assertTrue("Should contain title", jsonString.contains("Test Title")); + assertTrue("Should contain body", jsonString.contains("Test Body")); + } + + @Test + public void testScheduleNotificationWorkHandlesGhostPushFlagTrue() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, true, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + boolean isGhostPush = workData.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false); + assertEquals("Ghost push flag should be true", true, isGhostPush); + } + + @Test + public void testScheduleNotificationWorkHandlesGhostPushFlagFalse() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + boolean isGhostPush = workData.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, true); + assertEquals("Ghost push flag should be false", false, isGhostPush); + } + + @Test + public void testScheduleNotificationWorkHandlesEmptyBundle() { + Bundle emptyData = new Bundle(); + + scheduler.scheduleNotificationWork(emptyData, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkPreservesMultipleFields() { + Bundle data = new Bundle(); + data.putString("field1", "value1"); + data.putString("field2", "value2"); + data.putString("field3", "value3"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + String jsonString = workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertTrue("Should contain field1", jsonString.contains("field1")); + assertTrue("Should contain field2", jsonString.contains("field2")); + assertTrue("Should contain field3", jsonString.contains("field3")); + } + + // ======================================================================== + // WORKMANAGER INTEGRATION TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkUsesCorrectWorkerClass() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + assertEquals("Should use IterableNotificationWorker", + IterableNotificationWorker.class.getName(), + capturedRequest.getWorkSpec().workerClassName); + } + + @Test + public void testScheduleNotificationWorkCreatesOneTimeRequest() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + // Verify a OneTimeWorkRequest was enqueued + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkSetsInputData() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + OneTimeWorkRequest capturedRequest = requestCaptor.getValue(); + Data workData = capturedRequest.getWorkSpec().input; + + assertNotNull("Input data should be set", workData); + assertNotNull("Should have notification JSON", + workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); + } + + // ======================================================================== + // CALLBACK BEHAVIOR TESTS + // ======================================================================== + + @Test + public void testSuccessCallbackIsCalledExactlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + verify(callback).onScheduleSuccess(any(UUID.class)); + verify(callback, never()).onScheduleFailure(any(Exception.class), any(Bundle.class)); + } + + @Test + public void testFailureCallbackIsCalledExactlyOnce() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + verify(callback).onScheduleFailure(any(Exception.class), any(Bundle.class)); + verify(callback, never()).onScheduleSuccess(any(UUID.class)); + } + + @Test + public void testCallbacksAreOptional() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + // Should work without callbacks (null) + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testFailureCallbackReceivesCorrectException() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + IllegalStateException testException = new IllegalStateException("Test exception"); + doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + ArgumentCaptor exceptionCaptor = ArgumentCaptor.forClass(Exception.class); + verify(callback).onScheduleFailure(exceptionCaptor.capture(), any(Bundle.class)); + + assertEquals("Should pass the same exception", testException, exceptionCaptor.getValue()); + } + + @Test + public void testFailureCallbackReceivesOriginalNotificationData() { + Bundle data = new Bundle(); + data.putString("originalKey", "originalValue"); + + doThrow(new RuntimeException("Error")) + .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + + IterableNotificationWorkScheduler.SchedulerCallback callback = + mock(IterableNotificationWorkScheduler.SchedulerCallback.class); + + scheduler.scheduleNotificationWork(data, false, callback); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(callback).onScheduleFailure(any(Exception.class), bundleCaptor.capture()); + + Bundle capturedData = bundleCaptor.getValue(); + assertEquals("originalValue", capturedData.getString("originalKey")); + } + + // ======================================================================== + // CONSTRUCTOR AND INITIALIZATION TESTS + // ======================================================================== + + @Test + public void testConstructorWithContext() { + // Create scheduler with just context (production constructor) + IterableNotificationWorkScheduler productionScheduler = + new IterableNotificationWorkScheduler(getContext()); + + assertNotNull("Scheduler should be created", productionScheduler); + assertNotNull("Context should be set", productionScheduler.getContext()); + assertNotNull("WorkManager should be initialized", productionScheduler.getWorkManager()); + } + + @Test + public void testConstructorWithContextAndWorkManager() { + WorkManager testWorkManager = mock(WorkManager.class); + + IterableNotificationWorkScheduler testScheduler = + new IterableNotificationWorkScheduler(getContext(), testWorkManager); + + assertNotNull("Scheduler should be created", testScheduler); + assertEquals("Should use injected WorkManager", testWorkManager, testScheduler.getWorkManager()); + } + + @Test + public void testConstructorUsesApplicationContext() { + IterableNotificationWorkScheduler testScheduler = + new IterableNotificationWorkScheduler(getContext()); + + assertEquals("Should use application context", + getContext().getApplicationContext(), + testScheduler.getContext()); + } + + // ======================================================================== + // DATA CREATION TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkCreatesValidInputData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + Data inputData = requestCaptor.getValue().getWorkSpec().input; + assertNotNull("Input data should not be null", inputData); + } + + @Test + public void testScheduleNotificationWorkIncludesAllRequiredKeys() { + Bundle data = new Bundle(); + data.putString("key", "value"); + + scheduler.scheduleNotificationWork(data, false, null); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(OneTimeWorkRequest.class); + verify(mockWorkManager).enqueue(requestCaptor.capture()); + + Data inputData = requestCaptor.getValue().getWorkSpec().input; + + // Verify required keys are present + assertNotNull("Should have notification JSON", + inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); + + // Ghost push flag should be present (default false) + boolean hasFlag = inputData.getKeyValueMap() + .containsKey(IterableNotificationWorker.KEY_IS_GHOST_PUSH); + assertTrue("Should have ghost push flag", hasFlag); + } + + @Test + public void testScheduleNotificationWorkWithComplexData() { + Bundle data = new Bundle(); + data.putString(IterableConstants.ITERABLE_DATA_KEY, "{\"campaignId\":123}"); + data.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + data.putString(IterableConstants.ITERABLE_DATA_BODY, "Body"); + data.putString("customField", "customValue"); + + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + // ======================================================================== + // EDGE CASE TESTS + // ======================================================================== + + @Test + public void testScheduleNotificationWorkHandlesSpecialCharactersInData() { + Bundle data = new Bundle(); + data.putString("special", "Value with symbols: !@#$% and \"quotes\""); + + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkHandlesUnicodeInData() { + Bundle data = new Bundle(); + data.putString("unicode", "Unicode: 你好 👋 émojis 🎉"); + + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } + + @Test + public void testScheduleNotificationWorkHandlesLargeBundle() { + Bundle data = new Bundle(); + for (int i = 0; i < 100; i++) { + data.putString("key" + i, "value" + i); + } + + scheduler.scheduleNotificationWork(data, false, null); + + verify(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java new file mode 100644 index 000000000..3d216cc9a --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java @@ -0,0 +1,405 @@ +package com.iterable.iterableapi; + +import android.os.Bundle; + +import androidx.work.Data; +import androidx.work.ListenableWorker; +import androidx.work.testing.TestListenableWorkerBuilder; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import okhttp3.mockwebserver.MockWebServer; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * TDD-style atomic tests for IterableNotificationWorker. + * Each test validates ONE specific behavior of the Worker. + */ +public class IterableNotificationWorkerUnitTest extends BaseTest { + + private MockWebServer server; + private IterableNotificationHelper.IterableNotificationHelperImpl helperSpy; + private IterableNotificationHelper.IterableNotificationHelperImpl originalHelper; + + @Before + public void setUp() throws Exception { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew(); + + server = new MockWebServer(); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + originalHelper = IterableNotificationHelper.instance; + helperSpy = spy(originalHelper); + IterableNotificationHelper.instance = helperSpy; + } + + @After + public void tearDown() throws Exception { + IterableNotificationHelper.instance = originalHelper; + if (server != null) { + server.shutdown(); + } + } + + // ======================================================================== + // WORKER RESULT TESTS + // ======================================================================== + + @Test + public void testWorkerReturnsSuccessWithValidData() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + when(helperSpy.isIterablePush(any())).thenCallRealMethod(); + when(helperSpy.isGhostPush(any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.success(), result); + } + + @Test + public void testWorkerReturnsSuccessForGhostPush() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("someKey", "someValue"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, true); // isGhostPush=true + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.success(), result); + } + + @Test + public void testWorkerReturnsFailureWithNullData() throws Exception { + Data inputData = new Data.Builder() + .putBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false) + // No JSON data + .build(); + + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.failure(), result); + } + + @Test + public void testWorkerReturnsFailureWithEmptyData() throws Exception { + Data inputData = new Data.Builder() + .putString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON, "") + .putBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false) + .build(); + + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals(ListenableWorker.Result.failure(), result); + } + + // ======================================================================== + // WORKER BEHAVIOR TESTS + // ======================================================================== + + @Test + public void testWorkerCallsCreateNotificationWithValidData() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy).createNotification(any(), any(Bundle.class)); + } + + @Test + public void testWorkerDoesNotCallCreateNotificationForGhostPush() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, true); // isGhostPush=true + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy, never()).createNotification(any(), any()); + } + + @Test + public void testWorkerCallsPostNotificationWithValidBuilder() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + when(helperSpy.isIterablePush(any())).thenCallRealMethod(); + when(helperSpy.isGhostPush(any())).thenCallRealMethod(); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + IterableTestUtils.getResourceString("push_payload_custom_action.json")); + bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); + } + + @Test + public void testWorkerDoesNotCallPostNotificationWhenBuilderIsNull() throws Exception { + when(helperSpy.createNotification(any(), any())).thenReturn(null); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + verify(helperSpy, never()).postNotificationOnDevice(any(), any()); + } + + @Test + public void testWorkerSucceedsWhenBuilderIsNull() throws Exception { + when(helperSpy.createNotification(any(), any())).thenReturn(null); + + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ListenableWorker.Result result = worker.doWork(); + + assertEquals("Worker should succeed even when builder is null", + ListenableWorker.Result.success(), result); + } + + // ======================================================================== + // DATA SERIALIZATION TESTS - Input Creation + // ======================================================================== + + @Test + public void testCreateInputDataReturnsNonNullData() { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + + assertNotNull("Input data should not be null", inputData); + } + + @Test + public void testCreateInputDataIncludesJsonString() { + Bundle bundle = new Bundle(); + bundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + + String json = inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON); + assertNotNull("JSON string should be present", json); + } + + @Test + public void testCreateInputDataIncludesGhostPushFlag() { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputDataTrue = IterableNotificationWorker.createInputData(bundle, true); + Data inputDataFalse = IterableNotificationWorker.createInputData(bundle, false); + + assertEquals(true, inputDataTrue.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, false)); + assertEquals(false, inputDataFalse.getBoolean(IterableNotificationWorker.KEY_IS_GHOST_PUSH, true)); + } + + @Test + public void testCreateInputDataHandlesEmptyBundle() { + Bundle bundle = new Bundle(); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + + assertNotNull("Input data should not be null for empty bundle", inputData); + } + + // ======================================================================== + // DATA SERIALIZATION TESTS - Deserialization + // ======================================================================== + + @Test + public void testDeserializationPreservesSingleField() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Test Title"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Test Title", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + } + + @Test + public void testDeserializationPreservesMultipleFields() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_TITLE, "Title"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Body"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + originalBundle.putString("custom", "value"); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Title", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); + assertEquals("Body", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_BODY)); + assertEquals("{}", deserializedBundle.getString(IterableConstants.ITERABLE_DATA_KEY)); + assertEquals("value", deserializedBundle.getString("custom")); + } + + @Test + public void testDeserializationPreservesSpecialCharacters() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String specialValue = "Test with spaces, symbols: !@#$%, and \"quotes\""; + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + originalBundle.putString("special", specialValue); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals(specialValue, deserializedBundle.getString("special")); + } + + @Test + public void testDeserializationPreservesKeyCount() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + Bundle originalBundle = new Bundle(); + originalBundle.putString("key1", "value1"); + originalBundle.putString("key2", "value2"); + originalBundle.putString("key3", "value3"); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, "{}"); + + int originalCount = originalBundle.keySet().size(); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals("Key count should match", originalCount, deserializedBundle.keySet().size()); + } + + @Test + public void testDeserializationHandlesJsonWithNestedObjects() throws Exception { + when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); + + String complexJson = "{\"campaignId\":123,\"metadata\":{\"key\":\"value\"}}"; + Bundle originalBundle = new Bundle(); + originalBundle.putString(IterableConstants.ITERABLE_DATA_KEY, complexJson); + + Data inputData = IterableNotificationWorker.createInputData(originalBundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + worker.doWork(); + + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); + verify(helperSpy).createNotification(any(), bundleCaptor.capture()); + + Bundle deserializedBundle = bundleCaptor.getValue(); + assertEquals(complexJson, deserializedBundle.getString(IterableConstants.ITERABLE_DATA_KEY)); + } +} From 73d39aaff26502718efe65bfe613710d3f9307a0 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 5 Feb 2026 14:29:34 +0000 Subject: [PATCH 2/3] fix checkstyle --- .../IterableFirebaseMessagingService.java | 4 +- .../IterableNotificationWorkScheduler.java | 14 +-- .../IterableFirebaseMessagingServiceTest.java | 7 +- .../IterableNotificationFlowTest.java | 112 +++++++++--------- ...IterableNotificationWorkSchedulerTest.java | 58 ++++----- .../IterableNotificationWorkerUnitTest.java | 10 +- 6 files changed, 98 insertions(+), 107 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java index c8d17b729..b2ba1a7a1 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -58,7 +58,7 @@ public static boolean handleMessageReceived(@NonNull Context context, @NonNull R } boolean isGhostPush = IterableNotificationHelper.isGhostPush(extras); - + if (!isGhostPush) { if (!IterableNotificationHelper.isEmptyBody(extras)) { IterableLogger.d(TAG, "Iterable push received " + messageData); @@ -139,7 +139,7 @@ public static boolean isGhostPush(RemoteMessage remoteMessage) { private static void enqueueNotificationWork(@NonNull final Context context, @NonNull final Bundle extras, boolean isGhostPush) { IterableNotificationWorkScheduler scheduler = new IterableNotificationWorkScheduler(context); - + scheduler.scheduleNotificationWork( extras, isGhostPush, diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java index 03fa68658..b6a680e6a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java @@ -31,7 +31,7 @@ class IterableNotificationWorkScheduler { * Callback interface for work scheduling results. * Allows caller to handle success/failure appropriately. */ - public interface SchedulerCallback { + interface SchedulerCallback { /** * Called when work is successfully scheduled. * @param workId UUID of the scheduled work @@ -49,17 +49,17 @@ public interface SchedulerCallback { /** * Constructor for production use. * Initializes with application context and default WorkManager instance. - * + * * @param context Application or service context */ - public IterableNotificationWorkScheduler(@NonNull Context context) { + IterableNotificationWorkScheduler(@NonNull Context context) { this(context, WorkManager.getInstance(context)); } /** * Constructor for testing. * Allows injection of mock WorkManager for unit testing. - * + * * @param context Application or service context * @param workManager WorkManager instance (can be mocked for tests) */ @@ -71,11 +71,11 @@ public IterableNotificationWorkScheduler(@NonNull Context context) { /** * Schedules notification processing work using WorkManager. - * + * * Creates an expedited OneTimeWorkRequest and enqueues it with WorkManager. * Expedited execution ensures high-priority notifications are processed promptly, * with quota exemption when called from FCM onMessageReceived. - * + * * @param notificationData Bundle containing notification data * @param isGhostPush Whether this is a ghost/silent push * @param callback Optional callback for success/failure (can be null) @@ -93,7 +93,7 @@ public void scheduleNotificationWork( try { IterableLogger.d(TAG, "Step 1: Creating Worker input data"); androidx.work.Data inputData = IterableNotificationWorker.createInputData( - notificationData, + notificationData, isGhostPush ); IterableLogger.d(TAG, " ✓ Worker input data created successfully"); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java index 21021861e..eb84daf79 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableFirebaseMessagingServiceTest.java @@ -29,7 +29,6 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertTrue; -import static junit.framework.TestCase.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; @@ -171,7 +170,7 @@ public void testWorkManagerIsUsedForNotifications() throws Exception { // Verify WorkManager has enqueued work WorkManager workManager = WorkManager.getInstance(getContext()); List workInfos = workManager.getWorkInfosByTag(IterableNotificationWorker.class.getName()).get(5, TimeUnit.SECONDS); - + // Note: With SynchronousExecutor, work completes immediately // Verify that notification helper methods were called (indicating Worker ran) verify(notificationHelperSpy, atLeastOnce()).createNotification(any(), any(Bundle.class)); @@ -186,9 +185,9 @@ public void testNotificationWorkerProcessesData() throws Exception { builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Worker test message"); builder.addData(IterableConstants.ITERABLE_DATA_TITLE, "Worker Test"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); - + controller.get().onMessageReceived(builder.build()); - + // With SynchronousExecutor, work completes immediately // Verify the notification was processed verify(notificationHelperSpy, atLeastOnce()).createNotification(eq(getContext()), any(Bundle.class)); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java index 01832544d..faa3a6c10 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationFlowTest.java @@ -3,8 +3,6 @@ import android.os.Bundle; import androidx.work.Configuration; -import androidx.work.Data; -import androidx.work.WorkManager; import androidx.work.testing.SynchronousExecutor; import androidx.work.testing.WorkManagerTestInitHelper; @@ -15,14 +13,10 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; -import java.util.HashMap; -import java.util.Map; - import okhttp3.mockwebserver.MockWebServer; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; -import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -41,7 +35,7 @@ public class IterableNotificationFlowTest extends BaseTest { public void setUp() throws Exception { IterableTestUtils.resetIterableApi(); IterableTestUtils.createIterableApiNew(); - + server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); @@ -74,10 +68,10 @@ public void testIterablePushIsRecognized() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); - + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( getContext(), builder.build()); - + assertTrue("Message with ITERABLE_DATA_KEY should be recognized", isIterable); } @@ -87,20 +81,20 @@ public void testNonIterablePushIsIgnored() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData("some_other_key", "value"); - + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( getContext(), builder.build()); - + assertFalse("Message without ITERABLE_DATA_KEY should be ignored", isIterable); } @Test public void testEmptyMessageIsIgnored() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); - + boolean isIterable = IterableFirebaseMessagingService.handleMessageReceived( getContext(), builder.build()); - + assertFalse("Empty message should be ignored", isIterable); } @@ -111,9 +105,9 @@ public void testGhostPushIsDetected() throws Exception { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); - + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); - + assertTrue("Ghost push should be detected", isGhost); } @@ -125,9 +119,9 @@ public void testRegularPushIsNotGhost() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); - + boolean isGhost = IterableFirebaseMessagingService.isGhostPush(builder.build()); - + assertFalse("Regular push should not be ghost", isGhost); } @@ -144,9 +138,9 @@ public void testNotificationBuilderIsCreatedForValidPush() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test body"); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(helperSpy).createNotification(any(), any(Bundle.class)); } @@ -157,9 +151,9 @@ public void testNotificationBuilderNotCreatedForGhostPush() throws Exception { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); } @@ -172,9 +166,9 @@ public void testNotificationBuilderNotCreatedForEmptyBody() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); // No body - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(helperSpy, never()).createNotification(any(), any(Bundle.class)); } @@ -190,12 +184,12 @@ public void testNotificationIsPostedForValidPush() throws Exception { when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); - builder.addData(IterableConstants.ITERABLE_DATA_KEY, + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); } @@ -206,9 +200,9 @@ public void testNotificationNotPostedForGhostPush() throws Exception { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_ghost_push.json")); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(helperSpy, never()).postNotificationOnDevice(any(), any()); } @@ -222,15 +216,15 @@ public void testInAppUpdateActionIsTriggered() throws Exception { IterableApi apiMock = spy(IterableApi.sharedInstance); when(apiMock.getInAppManager()).thenReturn(inAppManager); IterableApi.sharedInstance = apiMock; - + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_update.json")); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(inAppManager).syncInApp(); } @@ -240,15 +234,15 @@ public void testInAppRemoveActionIsTriggered() throws Exception { IterableApi apiMock = spy(IterableApi.sharedInstance); when(apiMock.getInAppManager()).thenReturn(inAppManager); IterableApi.sharedInstance = apiMock; - + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_inapp_remove.json")); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(inAppManager).removeMessage("1234567890abcdef"); } @@ -258,15 +252,15 @@ public void testEmbeddedUpdateActionIsTriggered() throws Exception { IterableApi apiMock = spy(IterableApi.sharedInstance); when(apiMock.getEmbeddedManager()).thenReturn(embeddedManager); IterableApi.sharedInstance = apiMock; - + when(helperSpy.isIterablePush(any(Bundle.class))).thenCallRealMethod(); when(helperSpy.isGhostPush(any(Bundle.class))).thenCallRealMethod(); RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.setData(IterableTestUtils.getMapFromJsonResource("push_payload_embedded_update.json")); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + verify(embeddedManager).syncMessages(); } @@ -286,12 +280,12 @@ public void testNotificationTitleIsPreserved() { builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_TITLE, expectedTitle); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); verify(helperSpy).createNotification(any(), bundleCaptor.capture()); - + assertEquals(expectedTitle, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_TITLE)); } @@ -306,12 +300,12 @@ public void testNotificationBodyIsPreserved() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_BODY, expectedBody); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); verify(helperSpy).createNotification(any(), bundleCaptor.capture()); - + assertEquals(expectedBody, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_BODY)); } @@ -326,12 +320,12 @@ public void testNotificationDataKeyIsPreserved() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, expectedData); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); verify(helperSpy).createNotification(any(), bundleCaptor.capture()); - + assertEquals(expectedData, bundleCaptor.getValue().getString(IterableConstants.ITERABLE_DATA_KEY)); } @@ -347,12 +341,12 @@ public void testCustomFieldsArePreserved() { builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Body"); builder.addData("customField", customValue); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); verify(helperSpy).createNotification(any(), bundleCaptor.capture()); - + assertEquals(customValue, bundleCaptor.getValue().getString("customField")); } @@ -368,12 +362,12 @@ public void testNotificationUsesWorkManagerScheduling() throws Exception { when(helperSpy.createNotification(any(), any())).thenCallRealMethod(); RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); - builder.addData(IterableConstants.ITERABLE_DATA_KEY, + builder.addData(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test"); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + // Verify notification was posted (via WorkManager with SynchronousExecutor) verify(helperSpy).postNotificationOnDevice(any(), any(IterableNotificationBuilder.class)); } @@ -390,10 +384,10 @@ public void testSchedulerHandlesMultipleNotifications() { RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_BODY, "Test " + i); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); } - + // Verify all three notifications were created verify(helperSpy, org.mockito.Mockito.times(3)) .createNotification(any(), any(Bundle.class)); @@ -408,20 +402,20 @@ public void testSchedulerPreservesNotificationDataThroughWorkManager() { String testTitle = "Scheduler Test Title"; String testBody = "Scheduler Test Body"; - + RemoteMessage.Builder builder = new RemoteMessage.Builder("test@gcm.googleapis.com"); builder.addData(IterableConstants.ITERABLE_DATA_KEY, "{}"); builder.addData(IterableConstants.ITERABLE_DATA_TITLE, testTitle); builder.addData(IterableConstants.ITERABLE_DATA_BODY, testBody); - + IterableFirebaseMessagingService.handleMessageReceived(getContext(), builder.build()); - + // Verify data was preserved through the scheduler -> worker -> notification flow ArgumentCaptor bundleCaptor = ArgumentCaptor.forClass(Bundle.class); verify(helperSpy).createNotification(any(), bundleCaptor.capture()); - + Bundle capturedBundle = bundleCaptor.getValue(); - assertEquals("Title should be preserved through scheduler", + assertEquals("Title should be preserved through scheduler", testTitle, capturedBundle.getString(IterableConstants.ITERABLE_DATA_TITLE)); assertEquals("Body should be preserved through scheduler", testBody, capturedBundle.getString(IterableConstants.ITERABLE_DATA_BODY)); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java index b6de3f4e7..9cab1762d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkSchedulerTest.java @@ -27,7 +27,7 @@ /** * TDD-style atomic tests for IterableNotificationWorkScheduler. * Each test validates ONE specific behavior of the scheduler. - * + * * Tests verify: * - Work scheduling with WorkManager * - Callback invocations @@ -45,13 +45,13 @@ public class IterableNotificationWorkSchedulerTest extends BaseTest { public void setUp() throws Exception { IterableTestUtils.resetIterableApi(); IterableTestUtils.createIterableApiNew(); - + server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); // Create mock WorkManager for testing mockWorkManager = mock(WorkManager.class); - + // Create scheduler with mock WorkManager scheduler = new IterableNotificationWorkScheduler(getContext(), mockWorkManager); } @@ -82,7 +82,7 @@ public void testScheduleNotificationWorkCallsSuccessCallback() { Bundle data = new Bundle(); data.putString("key", "value"); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -95,7 +95,7 @@ public void testScheduleNotificationWorkPassesWorkIdToCallback() { Bundle data = new Bundle(); data.putString("key", "value"); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -142,7 +142,7 @@ public void testScheduleNotificationWorkCallsFailureCallbackOnException() { doThrow(new RuntimeException("WorkManager error")) .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -158,7 +158,7 @@ public void testScheduleNotificationWorkPassesExceptionToFailureCallback() { RuntimeException testException = new RuntimeException("Test error"); doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -177,7 +177,7 @@ public void testScheduleNotificationWorkPassesOriginalDataToFailureCallback() { doThrow(new RuntimeException("Error")) .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -213,7 +213,7 @@ public void testScheduleNotificationWorkPreservesNotificationData() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -233,7 +233,7 @@ public void testScheduleNotificationWorkHandlesGhostPushFlagTrue() { scheduler.scheduleNotificationWork(data, true, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -251,7 +251,7 @@ public void testScheduleNotificationWorkHandlesGhostPushFlagFalse() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -280,7 +280,7 @@ public void testScheduleNotificationWorkPreservesMultipleFields() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -304,7 +304,7 @@ public void testScheduleNotificationWorkUsesCorrectWorkerClass() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -332,7 +332,7 @@ public void testScheduleNotificationWorkSetsInputData() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -340,7 +340,7 @@ public void testScheduleNotificationWorkSetsInputData() { Data workData = capturedRequest.getWorkSpec().input; assertNotNull("Input data should be set", workData); - assertNotNull("Should have notification JSON", + assertNotNull("Should have notification JSON", workData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); } @@ -353,7 +353,7 @@ public void testSuccessCallbackIsCalledExactlyOnce() { Bundle data = new Bundle(); data.putString("key", "value"); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -370,7 +370,7 @@ public void testFailureCallbackIsCalledExactlyOnce() { doThrow(new RuntimeException("Error")) .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -398,7 +398,7 @@ public void testFailureCallbackReceivesCorrectException() { IllegalStateException testException = new IllegalStateException("Test exception"); doThrow(testException).when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -417,7 +417,7 @@ public void testFailureCallbackReceivesOriginalNotificationData() { doThrow(new RuntimeException("Error")) .when(mockWorkManager).enqueue(any(OneTimeWorkRequest.class)); - IterableNotificationWorkScheduler.SchedulerCallback callback = + IterableNotificationWorkScheduler.SchedulerCallback callback = mock(IterableNotificationWorkScheduler.SchedulerCallback.class); scheduler.scheduleNotificationWork(data, false, callback); @@ -436,7 +436,7 @@ public void testFailureCallbackReceivesOriginalNotificationData() { @Test public void testConstructorWithContext() { // Create scheduler with just context (production constructor) - IterableNotificationWorkScheduler productionScheduler = + IterableNotificationWorkScheduler productionScheduler = new IterableNotificationWorkScheduler(getContext()); assertNotNull("Scheduler should be created", productionScheduler); @@ -448,7 +448,7 @@ public void testConstructorWithContext() { public void testConstructorWithContextAndWorkManager() { WorkManager testWorkManager = mock(WorkManager.class); - IterableNotificationWorkScheduler testScheduler = + IterableNotificationWorkScheduler testScheduler = new IterableNotificationWorkScheduler(getContext(), testWorkManager); assertNotNull("Scheduler should be created", testScheduler); @@ -457,11 +457,11 @@ public void testConstructorWithContextAndWorkManager() { @Test public void testConstructorUsesApplicationContext() { - IterableNotificationWorkScheduler testScheduler = + IterableNotificationWorkScheduler testScheduler = new IterableNotificationWorkScheduler(getContext()); - assertEquals("Should use application context", - getContext().getApplicationContext(), + assertEquals("Should use application context", + getContext().getApplicationContext(), testScheduler.getContext()); } @@ -476,7 +476,7 @@ public void testScheduleNotificationWorkCreatesValidInputData() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); @@ -491,16 +491,16 @@ public void testScheduleNotificationWorkIncludesAllRequiredKeys() { scheduler.scheduleNotificationWork(data, false, null); - ArgumentCaptor requestCaptor = + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(OneTimeWorkRequest.class); verify(mockWorkManager).enqueue(requestCaptor.capture()); Data inputData = requestCaptor.getValue().getWorkSpec().input; - + // Verify required keys are present - assertNotNull("Should have notification JSON", + assertNotNull("Should have notification JSON", inputData.getString(IterableNotificationWorker.KEY_NOTIFICATION_DATA_JSON)); - + // Ghost push flag should be present (default false) boolean hasFlag = inputData.getKeyValueMap() .containsKey(IterableNotificationWorker.KEY_IS_GHOST_PUSH); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java index 3d216cc9a..797f170c0 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java @@ -6,7 +6,6 @@ import androidx.work.ListenableWorker; import androidx.work.testing.TestListenableWorkerBuilder; -import org.json.JSONObject; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -16,7 +15,6 @@ import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; -import static junit.framework.Assert.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -37,7 +35,7 @@ public class IterableNotificationWorkerUnitTest extends BaseTest { public void setUp() throws Exception { IterableTestUtils.resetIterableApi(); IterableTestUtils.createIterableApiNew(); - + server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); @@ -65,7 +63,7 @@ public void testWorkerReturnsSuccessWithValidData() throws Exception { when(helperSpy.isGhostPush(any())).thenCallRealMethod(); Bundle bundle = new Bundle(); - bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); @@ -176,7 +174,7 @@ public void testWorkerCallsPostNotificationWithValidBuilder() throws Exception { when(helperSpy.isGhostPush(any())).thenCallRealMethod(); Bundle bundle = new Bundle(); - bundle.putString(IterableConstants.ITERABLE_DATA_KEY, + bundle.putString(IterableConstants.ITERABLE_DATA_KEY, IterableTestUtils.getResourceString("push_payload_custom_action.json")); bundle.putString(IterableConstants.ITERABLE_DATA_BODY, "Test"); @@ -224,7 +222,7 @@ public void testWorkerSucceedsWhenBuilderIsNull() throws Exception { ListenableWorker.Result result = worker.doWork(); - assertEquals("Worker should succeed even when builder is null", + assertEquals("Worker should succeed even when builder is null", ListenableWorker.Result.success(), result); } From bfd33a5ed4661a9fb30d4121cbe3813d1e3442d7 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 11 Feb 2026 14:06:31 +0000 Subject: [PATCH 3/3] Compatibility with versions prior to Android 12 --- .../IterableFirebaseMessagingService.java | 7 +- .../IterableNotificationWorkScheduler.java | 70 +------- .../iterableapi/IterableNotificationWorker.kt | 153 +++++++++++------- .../IterableNotificationWorkerUnitTest.java | 94 ++++++++++- 4 files changed, 185 insertions(+), 139 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java index b2ba1a7a1..628440f7f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableFirebaseMessagingService.java @@ -142,17 +142,16 @@ private static void enqueueNotificationWork(@NonNull final Context context, @Non scheduler.scheduleNotificationWork( extras, - isGhostPush, + false, new IterableNotificationWorkScheduler.SchedulerCallback() { @Override public void onScheduleSuccess(UUID workId) { - IterableLogger.d(TAG, "Notification work scheduled successfully: " + workId); + IterableLogger.d(TAG, "Notification work scheduled: " + workId); } @Override public void onScheduleFailure(Exception exception, Bundle notificationData) { - IterableLogger.e(TAG, "Failed to schedule notification work", exception); - IterableLogger.e(TAG, "Attempting FALLBACK to direct processing..."); + IterableLogger.e(TAG, "Failed to schedule notification work, falling back", exception); handleFallbackNotification(context, notificationData); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java index b6a680e6a..4d2e61287 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorkScheduler.java @@ -12,14 +12,6 @@ import java.util.UUID; -/** - * Manages scheduling of notification processing work using WorkManager. - * This class is responsible for: - * - Creating WorkManager requests for notification processing - * - Enqueueing work with expedited execution for high-priority notifications - * - Providing callback interface for success/failure handling - * - Comprehensive logging of scheduling operations - */ class IterableNotificationWorkScheduler { private static final String TAG = "IterableNotificationWorkScheduler"; @@ -27,106 +19,48 @@ class IterableNotificationWorkScheduler { private final Context context; private final WorkManager workManager; - /** - * Callback interface for work scheduling results. - * Allows caller to handle success/failure appropriately. - */ interface SchedulerCallback { - /** - * Called when work is successfully scheduled. - * @param workId UUID of the scheduled work - */ void onScheduleSuccess(UUID workId); - - /** - * Called when work scheduling fails. - * @param exception The exception that caused the failure - * @param notificationData The original notification data (for fallback) - */ void onScheduleFailure(Exception exception, Bundle notificationData); } - /** - * Constructor for production use. - * Initializes with application context and default WorkManager instance. - * - * @param context Application or service context - */ IterableNotificationWorkScheduler(@NonNull Context context) { this(context, WorkManager.getInstance(context)); } - /** - * Constructor for testing. - * Allows injection of mock WorkManager for unit testing. - * - * @param context Application or service context - * @param workManager WorkManager instance (can be mocked for tests) - */ @VisibleForTesting IterableNotificationWorkScheduler(@NonNull Context context, @NonNull WorkManager workManager) { this.context = context.getApplicationContext(); this.workManager = workManager; } - /** - * Schedules notification processing work using WorkManager. - * - * Creates an expedited OneTimeWorkRequest and enqueues it with WorkManager. - * Expedited execution ensures high-priority notifications are processed promptly, - * with quota exemption when called from FCM onMessageReceived. - * - * @param notificationData Bundle containing notification data - * @param isGhostPush Whether this is a ghost/silent push - * @param callback Optional callback for success/failure (can be null) - */ public void scheduleNotificationWork( @NonNull Bundle notificationData, boolean isGhostPush, @Nullable SchedulerCallback callback) { - IterableLogger.d(TAG, "========================================"); - IterableLogger.d(TAG, "Scheduling notification work"); - IterableLogger.d(TAG, "Bundle keys: " + notificationData.keySet().size()); - IterableLogger.d(TAG, "Is ghost push: " + isGhostPush); - try { - IterableLogger.d(TAG, "Step 1: Creating Worker input data"); androidx.work.Data inputData = IterableNotificationWorker.createInputData( notificationData, isGhostPush ); - IterableLogger.d(TAG, " ✓ Worker input data created successfully"); - IterableLogger.d(TAG, "Step 2: Building expedited WorkRequest"); OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(IterableNotificationWorker.class) .setInputData(inputData) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build(); - IterableLogger.d(TAG, " ✓ WorkRequest built with expedited execution"); - IterableLogger.d(TAG, "Step 3: Enqueueing work with WorkManager"); workManager.enqueue(workRequest); UUID workId = workRequest.getId(); - IterableLogger.d(TAG, " ✓ Work enqueued successfully"); - IterableLogger.d(TAG, ""); - IterableLogger.d(TAG, "✓ NOTIFICATION WORK SCHEDULED"); - IterableLogger.d(TAG, " Work ID: " + workId); - IterableLogger.d(TAG, " Priority: EXPEDITED (high-priority notification)"); - IterableLogger.d(TAG, " Worker: " + IterableNotificationWorker.class.getSimpleName()); - IterableLogger.d(TAG, "========================================"); + IterableLogger.d(TAG, "Notification work scheduled: " + workId); if (callback != null) { callback.onScheduleSuccess(workId); } } catch (Exception e) { - IterableLogger.e(TAG, "========================================"); - IterableLogger.e(TAG, "✗ FAILED TO SCHEDULE NOTIFICATION WORK"); - IterableLogger.e(TAG, "Error type: " + e.getClass().getSimpleName()); - IterableLogger.e(TAG, "Error message: " + e.getMessage()); - IterableLogger.e(TAG, "========================================"); + IterableLogger.e(TAG, "Failed to schedule notification work", e); if (callback != null) { callback.onScheduleFailure(e, notificationData); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt index 9fd93983b..c5b3ad321 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationWorker.kt @@ -1,26 +1,19 @@ package com.iterable.iterableapi +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.annotation.WorkerThread +import androidx.core.app.NotificationCompat import androidx.work.Data +import androidx.work.ForegroundInfo import androidx.work.Worker import androidx.work.WorkerParameters import org.json.JSONObject -import java.io.IOException -import java.net.URL - -/** - * WorkManager Worker to handle push notification processing. - * This replaces the deprecated AsyncTask approach to comply with Firebase best practices. - * - * The Worker handles: - * - Downloading notification images from remote URLs - * - Building notifications with proper styling - * - Posting notifications to the system - */ + internal class IterableNotificationWorker( context: Context, params: WorkerParameters @@ -28,14 +21,11 @@ internal class IterableNotificationWorker( companion object { private const val TAG = "IterableNotificationWorker" - + private const val FOREGROUND_NOTIFICATION_ID = 10101 + const val KEY_NOTIFICATION_DATA_JSON = "notification_data_json" const val KEY_IS_GHOST_PUSH = "is_ghost_push" - - /** - * Creates input data for the Worker from a Bundle. - * Converts the Bundle to JSON for reliable serialization. - */ + @JvmStatic fun createInputData(extras: Bundle, isGhostPush: Boolean): Data { val jsonObject = JSONObject() @@ -45,7 +35,7 @@ internal class IterableNotificationWorker( jsonObject.put(key, value) } } - + return Data.Builder() .putString(KEY_NOTIFICATION_DATA_JSON, jsonObject.toString()) .putBoolean(KEY_IS_GHOST_PUSH, isGhostPush) @@ -53,76 +43,121 @@ internal class IterableNotificationWorker( } } + override fun getForegroundInfo(): ForegroundInfo { + val channelId = applicationContext.packageName + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationManager.getNotificationChannel(channelId) == null) { + val channel = NotificationChannel( + channelId, + getChannelName(), + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSmallIcon(getSmallIconId()) + .setContentTitle(getAppName()) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + 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() + } + + private fun getChannelName(): String { + return try { + val info = applicationContext.packageManager.getApplicationInfo( + applicationContext.packageName, PackageManager.GET_META_DATA + ) + info.metaData?.getString("iterable_notification_channel_name") + ?: "Notifications" + } catch (e: PackageManager.NameNotFoundException) { + "Notifications" + } + } + @WorkerThread override fun doWork(): Result { - IterableLogger.d(TAG, "========================================") IterableLogger.d(TAG, "Starting notification processing in Worker") - IterableLogger.d(TAG, "Worker ID: $id") - IterableLogger.d(TAG, "Run attempt: $runAttemptCount") - + try { val isGhostPush = inputData.getBoolean(KEY_IS_GHOST_PUSH, false) - IterableLogger.d(TAG, "Step 1: Ghost push check - isGhostPush=$isGhostPush") - + if (isGhostPush) { - IterableLogger.d(TAG, "Ghost push detected - no user-visible notification to display") + IterableLogger.d(TAG, "Ghost push detected, skipping notification display") return Result.success() } val jsonString = inputData.getString(KEY_NOTIFICATION_DATA_JSON) - IterableLogger.d(TAG, "Step 2: Retrieved notification JSON data (length=${jsonString?.length ?: 0})") - + if (jsonString == null || jsonString.isEmpty()) { - IterableLogger.e(TAG, "CRITICAL ERROR: No notification data provided to Worker") + IterableLogger.e(TAG, "No notification data provided to Worker") return Result.failure() } - IterableLogger.d(TAG, "Step 3: Deserializing notification data from JSON") val extras = jsonToBundle(jsonString) - val keyCount = extras.keySet().size - IterableLogger.d(TAG, "Step 3: Deserialized $keyCount keys from notification data") - - if (keyCount == 0) { - IterableLogger.e(TAG, "CRITICAL ERROR: Deserialized bundle is empty") + + if (extras.keySet().size == 0) { + IterableLogger.e(TAG, "Deserialized bundle is empty") return Result.failure() } - IterableLogger.d(TAG, "Step 4: Creating notification builder") val notificationBuilder = IterableNotificationHelper.createNotification( applicationContext, extras ) - + if (notificationBuilder == null) { - IterableLogger.w(TAG, "Step 4: Notification builder is null (likely ghost push or invalid data)") + IterableLogger.w(TAG, "Notification builder is null, skipping") return Result.success() } - - IterableLogger.d(TAG, "Step 4: Notification builder created successfully") - val hasImage = extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE) != null - if (hasImage) { - IterableLogger.d(TAG, "Step 4: Notification contains image URL: ${extras.getString(IterableConstants.ITERABLE_DATA_PUSH_IMAGE)}") - } - IterableLogger.d(TAG, "Step 5: Posting notification to device (this may download images)") IterableNotificationHelper.postNotificationOnDevice( applicationContext, notificationBuilder ) - - IterableLogger.d(TAG, "Step 5: Notification posted successfully to NotificationManager") - IterableLogger.d(TAG, "Notification processing COMPLETED successfully") - IterableLogger.d(TAG, "========================================") + + IterableLogger.d(TAG, "Notification posted successfully") return Result.success() - + } catch (e: Exception) { - IterableLogger.e(TAG, "========================================") - IterableLogger.e(TAG, "CRITICAL ERROR processing notification in Worker", e) - IterableLogger.e(TAG, "Error type: ${e.javaClass.simpleName}") - IterableLogger.e(TAG, "Error message: ${e.message}") - IterableLogger.e(TAG, "Stack trace:", e) - IterableLogger.e(TAG, "========================================") - + IterableLogger.e(TAG, "Error processing notification in Worker", e) return Result.retry() } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java index 797f170c0..36b057698 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationWorkerUnitTest.java @@ -1,8 +1,17 @@ package com.iterable.iterableapi; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import android.os.Bundle; import androidx.work.Data; +import androidx.work.ForegroundInfo; import androidx.work.ListenableWorker; import androidx.work.testing.TestListenableWorkerBuilder; @@ -13,14 +22,6 @@ import okhttp3.mockwebserver.MockWebServer; -import static junit.framework.Assert.assertEquals; -import static junit.framework.Assert.assertNotNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - /** * TDD-style atomic tests for IterableNotificationWorker. * Each test validates ONE specific behavior of the Worker. @@ -400,4 +401,81 @@ public void testDeserializationHandlesJsonWithNestedObjects() throws Exception { Bundle deserializedBundle = bundleCaptor.getValue(); assertEquals(complexJson, deserializedBundle.getString(IterableConstants.ITERABLE_DATA_KEY)); } + + // ======================================================================== + // FOREGROUND INFO TESTS (pre-Android 12 expedited work support) + // ======================================================================== + + @Test + public void testGetForegroundInfoReturnsNonNull() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + + assertNotNull("getForegroundInfo should return non-null ForegroundInfo", foregroundInfo); + } + + @Test + public void testGetForegroundInfoReturnsValidNotificationId() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + + assertNotNull("ForegroundInfo should contain a notification", foregroundInfo.getNotification()); + } + + @Test + public void testGetForegroundInfoDoesNotThrow() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + // Should not throw any exception - this is critical for pre-Android 12 expedited work + try { + ForegroundInfo foregroundInfo = worker.getForegroundInfo(); + assertNotNull(foregroundInfo); + } catch (Exception e) { + throw new AssertionError( + "getForegroundInfo() must not throw on pre-Android 12 devices. " + + "Without this, setExpedited() causes IllegalStateException. Error: " + e.getMessage(), e); + } + } + + @Test + public void testGetForegroundInfoCanBeCalledMultipleTimes() throws Exception { + Bundle bundle = new Bundle(); + bundle.putString("key", "value"); + + Data inputData = IterableNotificationWorker.createInputData(bundle, false); + IterableNotificationWorker worker = TestListenableWorkerBuilder + .from(getContext(), IterableNotificationWorker.class) + .setInputData(inputData) + .build(); + + // Should be safe to call multiple times without issues + ForegroundInfo first = worker.getForegroundInfo(); + ForegroundInfo second = worker.getForegroundInfo(); + + assertNotNull(first); + assertNotNull(second); + } }