diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 737fcdbe7..60f5cf187 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,10 @@ jobs: run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + - name: Run Android library unit tests + if: env.turbo_cache_hit != 1 + run: yarn test:android:unit + build-ios: runs-on: macos-15 env: diff --git a/.gitignore b/.gitignore index de804bf93..655bf6b15 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,7 @@ docs/ # Agents .agent/ +.artifacts/ .claude/ .cursor/ .opencode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c0673d32c..de8835520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## Unreleased + +### Fixes + +- Fixed `Iterable.trackPushOpenWithCampaignId` on Android so the `appAlreadyRunning` argument is forwarded to the native SDK instead of always being sent as `false`. + +### Updates + +- Added optional placement IDs to `Iterable.embeddedManager.syncMessages()` so apps can sync embedded messages for specific placements only (SDK-751). When omitted, null, or empty, behavior matches the previous sync-all call. + - **Android:** Scoped sync (`syncMessages([id, …])`) matches the pinned iterableapi behavior and may remove cached messages for placements not included in the sync response (SDK-798). iOS preserves unrequested placements. Prefer `syncMessages()` without placement IDs on Android until the native fix ships and the Android SDK pin is bumped. +- Added `IterableInAppMessage.jsonOnly` so `inAppHandler` and in-app message fetches can distinguish JSON-only messages from HTML in-apps (SDK-750). +- Added `IterableConfig.onEmbeddedMessagingSyncSucceeded` and + `IterableConfig.onEmbeddedMessagingSyncFailed` so apps can observe embedded + messaging sync outcomes (SDK-749). Failure callbacks receive a nullable reason + string forwarded from the native SDKs. On iOS, subscription-inactive or + invalid API key failures may invoke both `onEmbeddedMessagingDisabled` and + `onEmbeddedMessagingSyncFailed`; on Android only + `onEmbeddedMessagingDisabled` is invoked for those cases (native SDK + behavior). +- Added `Iterable.authManager.getAuthToken()` to read the JWT currently held by the native SDK (SDK-559). + - iOS: `IterableAPI.authToken`. + - Android: `IterableApi.getAuthToken()`. +- Added `IterableConfig.decryptionFailureHandler` (Android only) to notify the app when native keychain decryption fails (SDK-557). The SDK clears stored PII and disables encryption for the device before invoking the callback; iOS has no equivalent native API and ignores this option. +- Added `IterableConfig.keychainEncryption` (default `true`, Android only) so React Native apps can control native `IterableConfig.Builder.setKeychainEncryption`. +- Deprecated `IterableConfig.encryptionEnforced`, which never had an effect. +- Added `Iterable.disableDeviceForAllUsers()` to unregister this device's push token from every user associated with the device (SDK-550). + - iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`. + - Android: graceful no-op that logs a warning; use `disableDeviceForCurrentUser()` to disable push for the current user. There is no public native "all users" equivalent. ## 3.2.1 ### Fixes diff --git a/android/build.gradle b/android/build.gradle index 663f80532..8a82c80ee 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -81,6 +81,14 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + unitTests.all { + testLogging { + exceptionFormat "full" + } + } + } + sourceSets { main { if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java index db8fdbc98..064971282 100644 --- a/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java +++ b/android/src/main/java/com/iterable/iterableapi/RNIterableInternal.java @@ -2,12 +2,11 @@ import androidx.annotation.Nullable; +import org.json.JSONException; import org.json.JSONObject; public class RNIterableInternal { - private static String TAG = "RNIterableInternal"; - public static String getEmail() { return IterableApi.getInstance().getEmail(); } @@ -16,16 +15,31 @@ public static String getUserId() { return IterableApi.getInstance().getUserId(); } - public static JSONObject getInAppMessageJson(IterableInAppMessage message) { - return message.toJSONObject(); + @Nullable + public static String getAuthToken() { + return IterableApi.getInstance().getAuthToken(); + } + + public static JSONObject getInAppMessageJson(IterableInAppMessage message) + throws JSONException { + JSONObject messageJson = message.toJSONObject(); + // Overwrite native serialization (may use integer 1) so the bridge sends a boolean for JS. + messageJson.put("jsonOnly", message.isJsonOnly()); + return messageJson; } public static IterableInAppMessage getMessageById(String messageId) { return IterableApi.getInstance().getInAppManager().getMessageById(messageId); } - public static void trackPushOpenWithCampaignId(Integer campaignId, Integer templateId, String messageId, @Nullable JSONObject dataFields) { - IterableApi.getInstance().trackPushOpen(campaignId, templateId, messageId, dataFields); + public static void trackPushOpenWithCampaignId( + Integer campaignId, + Integer templateId, + String messageId, + boolean appAlreadyRunning, + @Nullable JSONObject dataFields) { + IterableApi.getInstance().trackPushOpen( + campaignId, templateId, messageId, appAlreadyRunning, dataFields); } public static void setAttributionInfo(IterableAttributionInfo attributionInfo) { diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c3396295d..9c1fddd39 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -29,6 +29,7 @@ import com.iterable.iterableapi.IterableAuthManager; import com.iterable.iterableapi.IterableConfig; import com.iterable.iterableapi.IterableCustomActionHandler; +import com.iterable.iterableapi.IterableDecryptionFailureHandler; import com.iterable.iterableapi.IterableEmbeddedMessage; import com.iterable.iterableapi.IterableEmbeddedUpdateHandler; import com.iterable.iterableapi.IterableHelper; @@ -53,7 +54,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { +public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableDecryptionFailureHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { public static final String NAME = "RNIterableAPI"; private static String TAG = "RNIterableAPIModule"; @@ -93,6 +94,10 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } + // IterableKeychain is created once with config.decryptionFailureHandler; the JS + // listener remains conditional on decryptionFailureHandlerPresent. + configBuilder.setDecryptionFailureHandler(this); + // Check if embedded messaging is enabled before building config boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging"); @@ -161,6 +166,9 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } + // See initializeWithApiKey — keychain caches this handler at first construction. + configBuilder.setDecryptionFailureHandler(this); + // NOTE: There does not seem to be a way to set the API endpoint // override in the Android SDK. Check with @Ayyanchira and @evantk91 to // see what the best approach is. @@ -249,6 +257,10 @@ public void getUserId(Promise promise) { promise.resolve(RNIterableInternal.getUserId()); } + public void getAuthToken(Promise promise) { + promise.resolve(RNIterableInternal.getAuthToken()); + } + public void trackEvent(String name, @Nullable ReadableMap dataFields) { IterableLogger.v(TAG, "trackEvent"); IterableApi.getInstance().track(name, optSerializedDataFields(dataFields)); @@ -265,7 +277,12 @@ public void trackPurchase(double total, ReadableArray items, @Nullable ReadableM } public void trackPushOpenWithCampaignId(double campaignId, @Nullable Double templateId, String messageId, boolean appAlreadyRunning, @Nullable ReadableMap dataFields) { - RNIterableInternal.trackPushOpenWithCampaignId((int) campaignId, templateId != null ? templateId.intValue() : null, messageId, optSerializedDataFields(dataFields)); + RNIterableInternal.trackPushOpenWithCampaignId( + (int) campaignId, + templateId != null ? templateId.intValue() : null, + messageId, + appAlreadyRunning, + optSerializedDataFields(dataFields)); } public void updateSubscriptions(@Nullable ReadableArray emailListIds, @Nullable ReadableArray unsubscribedChannelIds, @Nullable ReadableArray unsubscribedMessageTypeIds, @Nullable ReadableArray subscribedMessageTypeIds, double campaignId, double templateId) { @@ -391,6 +408,10 @@ public void disableDeviceForCurrentUser() { IterableApi.getInstance().disablePush(); } + public void disableDeviceForAllUsers() { + IterableLogger.w(TAG, "disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent."); + } + public void registerDeviceToken(String token) { IterableLogger.v(TAG, "registerDeviceToken"); IterableApi.getInstance().registerDeviceToken(token); @@ -628,9 +649,8 @@ public boolean handleIterableCustomAction(@NonNull IterableAction action, @NonNu public IterableInAppHandler.InAppResponse onNewInApp(@NonNull IterableInAppMessage message) { IterableLogger.printInfo(); - JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); - try { + JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); WritableMap eventData = Serialization.convertJsonToMap(messageJson); jsCallBackLatch = new CountDownLatch(1); sendEvent(EventName.handleInAppCalled.name(), eventData); @@ -677,6 +697,24 @@ public String onAuthTokenRequested() { } } + private static final String DECRYPTION_FAILURE_DEFAULT_MESSAGE = "Decryption failed"; + + @Override + public void onDecryptionFailed(Exception exception) { + JSONObject messageJson = new JSONObject(); + try { + String message = exception != null ? exception.getMessage() : null; + if (message == null || message.isEmpty()) { + message = DECRYPTION_FAILURE_DEFAULT_MESSAGE; + } + messageJson.put("message", message); + WritableMap eventData = Serialization.convertJsonToMap(messageJson); + sendEvent(EventName.handleDecryptionFailureCalled.name(), eventData); + } catch (JSONException e) { + IterableLogger.e(TAG, "Failed to send decryption failure event"); + } + } + @Override public void onAuthFailure(AuthFailure authFailure) { // Create a JSON object for the authFailure object @@ -744,11 +782,13 @@ public void onEmbeddedMessagingDisabled() { @Override public void onEmbeddedMessagingSyncSucceeded() { IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); + sendEvent(EventName.handleEmbeddedMessagingSyncSucceededCalled.name(), null); } @Override public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed: " + reason); + sendEvent(EventName.handleEmbeddedMessagingSyncFailedCalled.name(), reason); } // --------------------------------------------------------------------------------------- // endregion @@ -756,9 +796,19 @@ public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { // --------------------------------------------------------------------------------------- // region Embedded messaging - public void syncEmbeddedMessages() { - IterableLogger.d(TAG, "syncEmbeddedMessages"); - IterableApi.getInstance().getEmbeddedManager().syncMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + IterableLogger.d(TAG, "syncEmbeddedMessages for placements: " + placementIds); + + if (placementIds == null || placementIds.size() == 0) { + IterableApi.getInstance().getEmbeddedManager().syncMessages(); + } else { + IterableLogger.w(TAG, "Scoped syncEmbeddedMessages may remove cached messages for placements not in the sync response (SDK-798). Prefer syncMessages() without placement IDs on Android until iterableapi is fixed."); + Long[] ids = new Long[placementIds.size()]; + for (int i = 0; i < placementIds.size(); i++) { + ids[i] = (long) placementIds.getInt(i); + } + IterableApi.getInstance().getEmbeddedManager().syncMessages(ids); + } } public void startEmbeddedSession() { @@ -839,8 +889,11 @@ enum EventName { handleAuthFailureCalled, handleAuthSuccessCalled, handleCustomActionCalled, + handleDecryptionFailureCalled, handleEmbeddedMessageUpdateCalled, handleEmbeddedMessagingDisabledCalled, + handleEmbeddedMessagingSyncSucceededCalled, + handleEmbeddedMessagingSyncFailedCalled, handleInAppCalled, handleUrlCalled, receivedIterableEmbeddedMessagesChanged, diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 97aa52f49..55c40e38f 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -129,7 +129,8 @@ static JSONObject messageContentToJsonObject(IterableInAppMessage.Content conten return messageContent; } - static JSONArray serializeInAppMessages(List inAppMessages) { + static JSONArray serializeInAppMessages(List inAppMessages) + throws JSONException { JSONArray inAppMessagesJson = new JSONArray(); for (IterableInAppMessage message : inAppMessages) { JSONObject messageJson = RNIterableInternal.getInAppMessageJson(message); @@ -250,6 +251,9 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); } + configBuilder.setKeychainEncryption( + iterableContextJSON.optBoolean("keychainEncryption", true)); + if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); int maxRetry = retryPolicyJson.getInt("maxRetry"); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index fb71142b8..ab07951ce 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -72,6 +72,11 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @Override + public void getAuthToken(Promise promise) { + moduleImpl.getAuthToken(promise); + } + @Override public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); @@ -137,6 +142,11 @@ public void disableDeviceForCurrentUser() { moduleImpl.disableDeviceForCurrentUser(); } + @Override + public void disableDeviceForAllUsers() { + moduleImpl.disableDeviceForAllUsers(); + } + @Override public void registerDeviceToken(String token) { moduleImpl.registerDeviceToken(token); @@ -230,8 +240,8 @@ public void pauseAuthRetries(boolean pauseRetry) { } @Override - public void syncEmbeddedMessages() { - moduleImpl.syncEmbeddedMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + moduleImpl.syncEmbeddedMessages(placementIds); } @Override diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c2f9b3161..4ee68aeb2 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -73,6 +73,11 @@ public void getUserId(Promise promise) { moduleImpl.getUserId(promise); } + @ReactMethod + public void getAuthToken(Promise promise) { + moduleImpl.getAuthToken(promise); + } + @ReactMethod public void trackEvent(String name, @Nullable ReadableMap dataFields) { moduleImpl.trackEvent(name, dataFields); @@ -138,6 +143,11 @@ public void disableDeviceForCurrentUser() { moduleImpl.disableDeviceForCurrentUser(); } + @ReactMethod + public void disableDeviceForAllUsers() { + moduleImpl.disableDeviceForAllUsers(); + } + @ReactMethod public void registerDeviceToken(String token) { moduleImpl.registerDeviceToken(token); @@ -234,8 +244,8 @@ public void pauseAuthRetries(boolean pauseRetry) { } @ReactMethod - public void syncEmbeddedMessages() { - moduleImpl.syncEmbeddedMessages(); + public void syncEmbeddedMessages(@Nullable ReadableArray placementIds) { + moduleImpl.syncEmbeddedMessages(placementIds); } @ReactMethod diff --git a/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java new file mode 100644 index 000000000..b0db1d823 --- /dev/null +++ b/android/src/test/java/com/iterable/iterableapi/RNIterableInternalTest.java @@ -0,0 +1,79 @@ +package com.iterable.iterableapi; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class RNIterableInternalTest { + + @Test + public void trackPushOpenWithCampaignId_forwardsAppAlreadyRunningTrue() { + IterableApi api = mock(IterableApi.class); + try (MockedStatic iterableApi = mockStatic(IterableApi.class)) { + iterableApi.when(IterableApi::getInstance).thenReturn(api); + + RNIterableInternal.trackPushOpenWithCampaignId( + 10, 20, "message-id", true, null); + + verify(api).trackPushOpen( + eq(10), eq(20), eq("message-id"), eq(true), isNull()); + } + } + + @Test + public void trackPushOpenWithCampaignId_forwardsAppAlreadyRunningFalse() { + IterableApi api = mock(IterableApi.class); + try (MockedStatic iterableApi = mockStatic(IterableApi.class)) { + iterableApi.when(IterableApi::getInstance).thenReturn(api); + + RNIterableInternal.trackPushOpenWithCampaignId( + 10, 20, "message-id", false, null); + + verify(api).trackPushOpen( + eq(10), eq(20), eq("message-id"), eq(false), isNull()); + } + } + + @Test + public void getInAppMessageJson_overwritesNativeIntegerJsonOnlyWithBooleanTrue() + throws JSONException { + IterableInAppMessage message = mock(IterableInAppMessage.class); + JSONObject nativeJson = new JSONObject(); + nativeJson.put("jsonOnly", 1); + when(message.toJSONObject()).thenReturn(nativeJson); + when(message.isJsonOnly()).thenReturn(true); + + JSONObject bridgeJson = RNIterableInternal.getInAppMessageJson(message); + + assertTrue(bridgeJson.getBoolean("jsonOnly")); + } + + @Test + public void getInAppMessageJson_writesBooleanFalseForHtmlMessages() + throws JSONException { + IterableInAppMessage message = mock(IterableInAppMessage.class); + JSONObject nativeJson = new JSONObject(); + nativeJson.put("messageId", "msg-1"); + when(message.toJSONObject()).thenReturn(nativeJson); + when(message.isJsonOnly()).thenReturn(false); + + JSONObject bridgeJson = RNIterableInternal.getInAppMessageJson(message); + + assertFalse(bridgeJson.getBoolean("jsonOnly")); + } +} diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 965b2d81e..ae7e47ba3 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -230,6 +230,10 @@ - (void)disableDeviceForCurrentUser { [_swiftAPI disableDeviceForCurrentUser]; } +- (void)disableDeviceForAllUsers { + [_swiftAPI disableDeviceForAllUsers]; +} + - (void)registerDeviceToken:(NSString *)token { [_swiftAPI registerDeviceToken:token]; } @@ -279,6 +283,11 @@ - (void)updateVisibleRows:(NSArray *)visibleRows { [_swiftAPI updateVisibleRows:visibleRows]; } +- (void)getAuthToken:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_swiftAPI getAuthToken:resolve rejecter:reject]; +} + - (void)passAlongAuthToken:(NSString *_Nullable)authToken { [_swiftAPI passAlongAuthToken:authToken]; } @@ -295,8 +304,8 @@ - (void)endEmbeddedSession { [_swiftAPI endEmbeddedSession]; } -- (void)syncEmbeddedMessages { - [_swiftAPI syncEmbeddedMessages]; +- (void)syncEmbeddedMessages:(NSArray *_Nullable)placementIds { + [_swiftAPI syncEmbeddedMessages:placementIds]; } - (void)getEmbeddedMessages:(NSArray *_Nullable)placementIds @@ -516,6 +525,10 @@ - (void)wakeApp { [_swiftAPI disableDeviceForCurrentUser]; } +RCT_EXPORT_METHOD(disableDeviceForAllUsers) { + [_swiftAPI disableDeviceForAllUsers]; +} + RCT_EXPORT_METHOD(registerDeviceToken : (NSString *)token) { [_swiftAPI registerDeviceToken:token]; } @@ -563,6 +576,11 @@ - (void)wakeApp { [_swiftAPI updateVisibleRows:visibleRows]; } +RCT_EXPORT_METHOD(getAuthToken : (RCTPromiseResolveBlock) + resolve rejecter : (RCTPromiseRejectBlock)reject) { + [_swiftAPI getAuthToken:resolve rejecter:reject]; +} + RCT_EXPORT_METHOD(passAlongAuthToken : (NSString *_Nullable)authToken) { [_swiftAPI passAlongAuthToken:authToken]; } @@ -579,8 +597,8 @@ - (void)wakeApp { [_swiftAPI endEmbeddedSession]; } -RCT_EXPORT_METHOD(syncEmbeddedMessages) { - [_swiftAPI syncEmbeddedMessages]; +RCT_EXPORT_METHOD(syncEmbeddedMessages : (NSArray *_Nullable)placementIds) { + [_swiftAPI syncEmbeddedMessages:placementIds]; } RCT_EXPORT_METHOD(getEmbeddedMessages : (NSArray *_Nullable)placementIds resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { diff --git a/ios/RNIterableAPI/ReactIterableAPI.swift b/ios/RNIterableAPI/ReactIterableAPI.swift index a216a0e69..05009ad37 100644 --- a/ios/RNIterableAPI/ReactIterableAPI.swift +++ b/ios/RNIterableAPI/ReactIterableAPI.swift @@ -35,6 +35,10 @@ import React case handleAuthFailureCalled case handleEmbeddedMessageUpdateCalled case handleEmbeddedMessagingDisabledCalled + case handleEmbeddedMessagingSyncSucceededCalled + case handleEmbeddedMessagingSyncFailedCalled + // Android-only native API; listed so JS addListener does not warn on iOS. + case handleDecryptionFailureCalled } @objc public static var supportedEvents: [String] { @@ -144,6 +148,12 @@ import React IterableAPI.disableDeviceForCurrentUser() } + @objc(disableDeviceForAllUsers) + public func disableDeviceForAllUsers() { + ITBInfo() + IterableAPI.disableDeviceForAllUsers() + } + @objc(registerDeviceToken:) public func registerDeviceToken(token: String) { ITBInfo() @@ -494,6 +504,12 @@ import React // MARK: - SDK Auth Manager Functions + @objc(getAuthToken:rejecter:) + public func getAuthToken(resolver: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { + ITBInfo() + resolver(IterableAPI.authToken) + } + @objc(passAlongAuthToken:) public func passAlongAuthToken(authToken: String?) { ITBInfo() @@ -521,10 +537,15 @@ import React EmbeddedSessionManager.shared.endSession() } - @objc(syncEmbeddedMessages) - public func syncEmbeddedMessages() { + @objc(syncEmbeddedMessages:) + public func syncEmbeddedMessages(placementIds: [NSNumber]?) { ITBInfo() - IterableAPI.embeddedManager.syncMessages { } + if let placementIds = placementIds, !placementIds.isEmpty { + let ids = placementIds.map { $0.intValue } + IterableAPI.embeddedManager.syncMessages(placementIds: ids) { } + } else { + IterableAPI.embeddedManager.syncMessages { } + } } @objc(getEmbeddedMessages:resolver:rejecter:) @@ -696,8 +717,16 @@ import React // Add embedded update listener if any callback is present let onEmbeddedMessageUpdatePresent = configDict["onEmbeddedMessageUpdatePresent"] as? Bool ?? false let onEmbeddedMessagingDisabledPresent = configDict["onEmbeddedMessagingDisabledPresent"] as? Bool ?? false - - if onEmbeddedMessageUpdatePresent || onEmbeddedMessagingDisabledPresent { + let onEmbeddedMessagingSyncSucceededPresent = + configDict["onEmbeddedMessagingSyncSucceededPresent"] as? Bool ?? false + let onEmbeddedMessagingSyncFailedPresent = + configDict["onEmbeddedMessagingSyncFailedPresent"] as? Bool ?? false + + if onEmbeddedMessageUpdatePresent + || onEmbeddedMessagingDisabledPresent + || onEmbeddedMessagingSyncSucceededPresent + || onEmbeddedMessagingSyncFailedPresent + { IterableAPI.embeddedManager.addUpdateListener(self) } @@ -870,7 +899,7 @@ extension ReactIterableAPI: IterableEmbeddedUpdateDelegate { withName: EventName.handleEmbeddedMessageUpdateCalled.rawValue, body: nil as Any?) } - + public func onEmbeddedMessagingDisabled() { ITBInfo() guard shouldEmit else { @@ -880,4 +909,24 @@ extension ReactIterableAPI: IterableEmbeddedUpdateDelegate { withName: EventName.handleEmbeddedMessagingDisabledCalled.rawValue, body: nil as Any?) } + + public func onEmbeddedMessagingSyncSucceeded() { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleEmbeddedMessagingSyncSucceededCalled.rawValue, + body: nil as Any?) + } + + public func onEmbeddedMessagingSyncFailed(_ error: String?) { + ITBInfo() + guard shouldEmit else { + return + } + delegate?.sendEvent( + withName: EventName.handleEmbeddedMessagingSyncFailedCalled.rawValue, + body: error as Any?) + } } diff --git a/ios/RNIterableAPI/Serialization.swift b/ios/RNIterableAPI/Serialization.swift index fee4c49b6..4fcfcdda8 100644 --- a/ios/RNIterableAPI/Serialization.swift +++ b/ios/RNIterableAPI/Serialization.swift @@ -199,6 +199,7 @@ extension IterableInAppMessage { dict["customPayload"] = customPayload dict["read"] = read dict["priorityLevel"] = priorityLevel + dict["jsonOnly"] = jsonOnly return dict } diff --git a/package.json b/package.json index 1245c59a0..d03c467b0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:android:unit": "cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "check:circular": "dpdm --no-warning --no-tree -T --circular --exit-code circular:1 src/index.tsx", diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 60013bdbb..23ba55ba3 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -4,6 +4,7 @@ import { IterableInAppMessage } from '../inApp'; export class MockRNIterableAPI { static email?: string; static userId?: string; + static authToken?: string | null; static token?: string; static lastPushPayload?: unknown; static attributionInfo?: IterableAttributionInfo; @@ -34,6 +35,8 @@ export class MockRNIterableAPI { static disableDeviceForCurrentUser = jest.fn(); + static disableDeviceForAllUsers = jest.fn(); + static registerDeviceToken = jest.fn((token: string): void => { MockRNIterableAPI.token = token; }); @@ -80,6 +83,12 @@ export class MockRNIterableAPI { static setInAppShowResponse = jest.fn(); + static async getAuthToken(): Promise { + return await new Promise((resolve) => { + resolve(MockRNIterableAPI.authToken ?? null); + }); + } + static passAlongAuthToken = jest.fn(); static pauseAuthRetries = jest.fn(); diff --git a/src/__mocks__/jest.setup.ts b/src/__mocks__/jest.setup.ts index 96c17ca52..e619f4735 100644 --- a/src/__mocks__/jest.setup.ts +++ b/src/__mocks__/jest.setup.ts @@ -27,6 +27,8 @@ const mockNativeEventEmitterConstructor = jest.fn().mockImplementation(() => ({ eventType: string, listener: (...args: unknown[]) => void ) => mockNativeEventEmitter.removeListener(eventType, listener), + listenerCount: (eventType: string) => + mockNativeEventEmitter.listenerCount(eventType), })); jest.mock( diff --git a/src/__tests__/IterableInApp.test.ts b/src/__tests__/IterableInApp.test.ts index bddb3f3f9..ca48934da 100644 --- a/src/__tests__/IterableInApp.test.ts +++ b/src/__tests__/IterableInApp.test.ts @@ -178,6 +178,106 @@ describe('Iterable In App', () => { ); }); + test('inAppHandler_jsonOnlyTrue_flagSetOnMessage', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleInAppCalled); + + const config = new IterableConfig(); + config.inAppHandler = jest.fn((_message: IterableInAppMessage) => { + return IterableInAppShowResponse.show; + }); + + Iterable.initialize('apiKey', config); + + const messageDict = { + messageId: 'json-only-msg', + campaignId: 1234, + trigger: { type: IterableInAppTriggerType.immediate }, + priorityLevel: 300.5, + jsonOnly: true, + }; + const expectedMessage = new IterableInAppMessage( + 'json-only-msg', + 1234, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + undefined, + undefined, + false, + undefined, + undefined, + false, + 300.5, + true + ); + + nativeEmitter.emit(IterableEventName.handleInAppCalled, messageDict); + + expect(config.inAppHandler).toBeCalledWith(expectedMessage); + }); + + test('inAppHandler_jsonOnlyFalse_flagSetOnHtmlMessage', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners(IterableEventName.handleInAppCalled); + + const config = new IterableConfig(); + config.inAppHandler = jest.fn((_message: IterableInAppMessage) => { + return IterableInAppShowResponse.show; + }); + + Iterable.initialize('apiKey', config); + + const messageDict = { + messageId: 'html-msg', + campaignId: 1234, + trigger: { type: IterableInAppTriggerType.immediate }, + priorityLevel: 300.5, + jsonOnly: false, + }; + const expectedMessage = new IterableInAppMessage( + 'html-msg', + 1234, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + undefined, + undefined, + false, + undefined, + undefined, + false, + 300.5, + false + ); + + nativeEmitter.emit(IterableEventName.handleInAppCalled, messageDict); + + expect(config.inAppHandler).toBeCalledWith(expectedMessage); + }); + + test('getMessages_jsonOnlyFlag_roundTripsFromBridgePayload', async () => { + const messageDicts = [ + { + messageId: 'json-only', + campaignId: 1, + trigger: { type: IterableInAppTriggerType.immediate }, + jsonOnly: true, + }, + { + messageId: 'html', + campaignId: 2, + trigger: { type: IterableInAppTriggerType.immediate }, + jsonOnly: false, + }, + ]; + const messages = messageDicts.map((message) => + IterableInAppMessage.fromDict(message) + ); + + MockRNIterableAPI.setMessages(messages); + + const messagesObtained = await Iterable.inAppManager?.getMessages(); + expect(messagesObtained?.[0]?.jsonOnly).toBe(true); + expect(messagesObtained?.[1]?.jsonOnly).toBe(false); + }); + test('getMessages_noParams_returnsMessages', async () => { // GIVEN a list of in-app messages representing the local queue const messageDicts = [ diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 7e96bc649..8ef525f2a 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -147,6 +147,7 @@ export interface Spec extends TurboModule { // Device management disableDeviceForCurrentUser(): void; + disableDeviceForAllUsers(): void; registerDeviceToken(token: string): void; getLastPushPayload(): Promise<{ [key: string]: string | number | boolean; @@ -178,11 +179,12 @@ export interface Spec extends TurboModule { ): void; // Auth + getAuthToken(): Promise; passAlongAuthToken(authToken?: string | null): void; pauseAuthRetries(pauseRetry: boolean): void; // Embedded Messaging - syncEmbeddedMessages(): void; + syncEmbeddedMessages(placementIds: Array | null): void; startEmbeddedSession(): void; endEmbeddedSession(): void; startEmbeddedImpression(messageId: string, placementId: number): void; diff --git a/src/core/classes/Iterable.test.ts b/src/core/classes/Iterable.test.ts index baccc3e41..05216c1a9 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -48,6 +48,15 @@ describe('Iterable', () => { nativeEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); // Clear any pending timers jest.clearAllTimers(); @@ -143,6 +152,16 @@ describe('Iterable', () => { }); }); + describe('disableDeviceForAllUsers', () => { + it('should disable the device for all users', () => { + // GIVEN no parameters + // WHEN Iterable.disableDeviceForAllUsers is called + Iterable.disableDeviceForAllUsers(); + // THEN corresponding method is called on RNIterableAPI + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + }); + }); + describe('registerDeviceToken', () => { it('should register the device token for the current user', () => { // GIVEN a device token @@ -326,6 +345,7 @@ describe('Iterable', () => { expect(config.customActionHandler).toBe(undefined); expect(config.dataRegion).toBe(IterableDataRegion.US); expect(config.enableEmbeddedMessaging).toBe(false); + expect(config.keychainEncryption).toBe(true); expect(config.encryptionEnforced).toBe(false); expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(config.inAppDisplayInterval).toBe(30.0); @@ -345,6 +365,7 @@ describe('Iterable', () => { expect(configDict.customActionHandlerPresent).toBe(false); expect(configDict.dataRegion).toBe(IterableDataRegion.US); expect(configDict.enableEmbeddedMessaging).toBe(false); + expect(configDict.keychainEncryption).toBe(true); expect(configDict.encryptionEnforced).toBe(false); expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); expect(configDict.inAppDisplayInterval).toBe(30.0); @@ -365,6 +386,14 @@ describe('Iterable', () => { expect(configDict.androidWakeDelayMs).toBe(1500); expect(configDict.authCallbackTimeoutMs).toBe(2500); }); + + it('should allow opting out of Android keychain encryption', () => { + const config = new IterableConfig(); + config.keychainEncryption = false; + const configDict = config.toDict(); + expect(config.keychainEncryption).toBe(false); + expect(configDict.keychainEncryption).toBe(false); + }); }); describe('urlHandler', () => { @@ -1028,6 +1057,25 @@ describe('Iterable', () => { }); }); + describe('getAuthToken', () => { + it('should return the auth token from RNIterableAPI', async () => { + const expectedToken = 'jwt-token'; + MockRNIterableAPI.authToken = expectedToken; + + const result = await Iterable.authManager.getAuthToken(); + + expect(result).toBe(expectedToken); + }); + + it('should return null when RNIterableAPI has no auth token', async () => { + MockRNIterableAPI.authToken = null; + + const result = await Iterable.authManager.getAuthToken(); + + expect(result).toBeNull(); + }); + }); + describe('passAlongAuthToken', () => { it('should call RNIterableAPI.passAlongAuthToken with a valid string token', async () => { // GIVEN a valid auth token @@ -1261,6 +1309,108 @@ describe('Iterable', () => { }); }); + describe('decryptionFailureHandler', () => { + it('should call decryptionFailureHandler when handleDecryptionFailureCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'Keychain decrypt error', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'Keychain decrypt error', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledTimes(1); + }); + + it('should use a generic message when the event payload message is empty', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: ' ', + }); + expect(config.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'Decryption failed', + }); + }); + + it('should not set up listener if decryptionFailureHandler is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', config); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(0); + expect(() => { + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'ignored', + }); + }).not.toThrow(); + }); + + it('should include decryptionFailureHandlerPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.decryptionFailureHandler = jest.fn(); + const configDict = config.toDict(); + expect(configDict.decryptionFailureHandlerPresent).toBe(true); + }); + + it('should set decryptionFailureHandlerPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.decryptionFailureHandlerPresent).toBe(false); + }); + + it('should invoke decryptionFailureHandler after re-initialize enables the callback', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); + const configWithoutHandler = new IterableConfig(); + configWithoutHandler.logReactNativeSdkCalls = false; + Iterable.initialize('apiKey', configWithoutHandler); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(0); + + const configWithHandler = new IterableConfig(); + configWithHandler.logReactNativeSdkCalls = false; + configWithHandler.decryptionFailureHandler = jest.fn(); + Iterable.initialize('apiKey', configWithHandler); + expect( + nativeEmitter.listenerCount( + IterableEventName.handleDecryptionFailureCalled + ) + ).toBe(1); + + nativeEmitter.emit(IterableEventName.handleDecryptionFailureCalled, { + message: 'late handler', + }); + expect(configWithHandler.decryptionFailureHandler).toHaveBeenCalledWith({ + message: 'late handler', + }); + }); + }); + describe('embedded messaging callbacks', () => { describe('onEmbeddedMessageUpdate', () => { it('should call onEmbeddedMessageUpdate when handleEmbeddedMessageUpdateCalled event is emitted', () => { @@ -1484,6 +1634,221 @@ describe('Iterable', () => { expect(configDict.onEmbeddedMessagingDisabledPresent).toBe(true); }); }); + + describe('onEmbeddedMessagingSyncSucceeded', () => { + it('should call onEmbeddedMessagingSyncSucceeded when handleEmbeddedMessagingSyncSucceededCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalledTimes( + 1 + ); + }); + + it('should not set up listener if onEmbeddedMessagingSyncSucceeded is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + }).not.toThrow(); + }); + + it('should not set up listener when enableEmbeddedMessaging is false even if callback is provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + }); + + it('should include onEmbeddedMessagingSyncSucceededPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncSucceededPresent).toBe(true); + }); + + it('should set onEmbeddedMessagingSyncSucceededPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncSucceededPresent).toBe(false); + }); + }); + + describe('onEmbeddedMessagingSyncFailed', () => { + it('should call onEmbeddedMessagingSyncFailed with reason when handleEmbeddedMessagingSyncFailedCalled event is emitted', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'network error' + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith( + 'network error' + ); + }); + + it('should deliver null when failure event has no reason', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + null + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith(null); + }); + + it('should deliver null when failure event omits the reason payload', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith(null); + }); + + it('should not set up listener if onEmbeddedMessagingSyncFailed is not provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + Iterable.initialize('apiKey', config); + expect(() => { + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'ignored' + ); + }).not.toThrow(); + }); + + it('should not set up listener when enableEmbeddedMessaging is false even if callback is provided', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'ignored' + ); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + }); + + it('should include onEmbeddedMessagingSyncFailedPresent flag in config dict when callback is provided', () => { + const config = new IterableConfig(); + config.onEmbeddedMessagingSyncFailed = jest.fn(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncFailedPresent).toBe(true); + }); + + it('should set onEmbeddedMessagingSyncFailedPresent flag to false when callback is not provided', () => { + const config = new IterableConfig(); + const configDict = config.toDict(); + expect(configDict.onEmbeddedMessagingSyncFailedPresent).toBe(false); + }); + }); + + describe('sync callbacks vs onEmbeddedMessageUpdate', () => { + it('should invoke only the matching callback when update and sync handlers are configured', () => { + const nativeEmitter = new NativeEventEmitter(); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessageUpdateCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + nativeEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + const config = new IterableConfig(); + config.logReactNativeSdkCalls = false; + config.enableEmbeddedMessaging = true; + config.onEmbeddedMessageUpdate = jest.fn(); + config.onEmbeddedMessagingSyncSucceeded = jest.fn(); + config.onEmbeddedMessagingSyncFailed = jest.fn(); + Iterable.initialize('apiKey', config); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessageUpdateCalled + ); + expect(config.onEmbeddedMessageUpdate).toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + expect(config.onEmbeddedMessagingSyncSucceeded).toHaveBeenCalled(); + expect(config.onEmbeddedMessageUpdate).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncFailed).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + nativeEmitter.emit( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + 'sync failed' + ); + expect(config.onEmbeddedMessagingSyncFailed).toHaveBeenCalledWith( + 'sync failed' + ); + expect(config.onEmbeddedMessageUpdate).not.toHaveBeenCalled(); + expect(config.onEmbeddedMessagingSyncSucceeded).not.toHaveBeenCalled(); + }); + }); }); describe('getAttributionInfo', () => { diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index aaaa84073..d279d939a 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -12,6 +12,7 @@ import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult'; import { IterableEventName } from '../enums/IterableEventName'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure'; import { callUrlHandler } from '../utils/callUrlHandler'; import { IterableAction } from './IterableAction'; import { IterableActionContext } from './IterableActionContext'; @@ -345,6 +346,27 @@ export class Iterable { IterableApi.disableDeviceForCurrentUser(); } + /** + * Disable this device's push token for every user associated with the device, + * not only the currently signed-in user. + * + * On iOS this forwards to native `IterableAPI.disableDeviceForAllUsers()`. + * On Android this is a no-op that logs a warning in Metro and logcat: the + * native Android SDK has no public "all users" equivalent. Use + * {@link disableDeviceForCurrentUser} to disable push for the current user + * on both platforms. The native Android method is still invoked. + * + * Fire-and-forget: native success or failure is not surfaced to JS. + * + * @example + * ```typescript + * Iterable.disableDeviceForAllUsers(); + * ``` + */ + static disableDeviceForAllUsers() { + IterableApi.disableDeviceForAllUsers(); + } + /** * Register the device's token for the current user, re-enabling push notifications. * @@ -941,7 +963,28 @@ export class Iterable { /** * Logs out the current user from the Iterable SDK. * - * This method will remove all event listeners for the Iterable SDK and set the email and user ID to null. + * This is the React Native equivalent of native `logoutUser`. There is no + * dedicated bridged `logoutUser` method. `logout()` removes JS event + * listeners and clears identity via `setEmail(null)` and `setUserId(null)`, + * which triggers the native logout cleanup on both platforms. + * + * Native cleanup includes: + * - resetting the auth manager + * - resetting the in-app (and embedded) manager + * - disabling the current push token when + * `IterableConfig.autoPushRegistration` is `true` + * + * Both identity-clears are required. On iOS, `setEmail(null)` is a no-op + * when the user is identified only by userId, and `setUserId(null)` is a + * no-op when the user is identified only by email. + * + * iOS has a public `IterableAPI.logoutUser()`; it is not wired through the + * RN bridge. Android has no public `logoutUser` — private + * `logoutPreviousUser()` runs as part of identity-clear. The iOS + * `logoutUser(withOnSuccess:onFailure:)` overload is also not exposed. + * + * Calling `logout()` when no user is signed in is safe: listeners are + * removed and native identity-clear is effectively a no-op. * * @example * ```typescript @@ -976,6 +1019,15 @@ export class Iterable { RNEventEmitter.removeAllListeners( IterableEventName.handleEmbeddedMessagingDisabledCalled ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled + ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled + ); + RNEventEmitter.removeAllListeners( + IterableEventName.handleDecryptionFailureCalled + ); } /** @@ -1267,6 +1319,40 @@ export class Iterable { } ); } + + if (Iterable.savedConfig.onEmbeddedMessagingSyncSucceeded) { + RNEventEmitter.addListener( + IterableEventName.handleEmbeddedMessagingSyncSucceededCalled, + () => { + Iterable.savedConfig.onEmbeddedMessagingSyncSucceeded?.(); + } + ); + } + + if (Iterable.savedConfig.onEmbeddedMessagingSyncFailed) { + RNEventEmitter.addListener( + IterableEventName.handleEmbeddedMessagingSyncFailedCalled, + (reason: string | null | undefined) => { + Iterable.savedConfig.onEmbeddedMessagingSyncFailed?.( + reason ?? null + ); + } + ); + } + } + + if (Iterable.savedConfig.decryptionFailureHandler) { + RNEventEmitter.addListener( + IterableEventName.handleDecryptionFailureCalled, + (payload: IterableDecryptionFailure) => { + const rawMessage = payload?.message?.trim(); + const message = + rawMessage && rawMessage.length > 0 + ? rawMessage + : 'Decryption failed'; + Iterable.savedConfig.decryptionFailureHandler?.({ message }); + } + ); } } diff --git a/src/core/classes/IterableApi.test.ts b/src/core/classes/IterableApi.test.ts index d639d79ed..d77083712 100644 --- a/src/core/classes/IterableApi.test.ts +++ b/src/core/classes/IterableApi.test.ts @@ -3,6 +3,7 @@ import { Platform } from 'react-native'; import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; import { IterableApi } from './IterableApi'; import { IterableConfig } from './IterableConfig'; +import { IterableLogger } from './IterableLogger'; import { IterableAttributionInfo } from './IterableAttributionInfo'; import { IterableCommerceItem } from './IterableCommerceItem'; import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; @@ -282,6 +283,59 @@ describe('IterableApi', () => { }); }); + describe('disableDeviceForAllUsers', () => { + it('should call RNIterableAPI.disableDeviceForAllUsers', () => { + // GIVEN no parameters + // WHEN disableDeviceForAllUsers is called + IterableApi.disableDeviceForAllUsers(); + + // THEN RNIterableAPI.disableDeviceForAllUsers is called + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + }); + + it('should log an unsupported warning on Android and still call native', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'android', + writable: true, + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + + IterableApi.disableDeviceForAllUsers(); + + expect(logSpy).toHaveBeenCalledWith( + 'disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent.' + ); + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + + logSpy.mockRestore(); + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + + it('should log the method name on iOS and still call native', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + const logSpy = jest.spyOn(IterableLogger, 'log'); + + IterableApi.disableDeviceForAllUsers(); + + expect(logSpy).toHaveBeenCalledWith('disableDeviceForAllUsers'); + expect(MockRNIterableAPI.disableDeviceForAllUsers).toBeCalled(); + + logSpy.mockRestore(); + Object.defineProperty(Platform, 'OS', { + value: originalPlatform, + writable: true, + }); + }); + }); + describe('registerDeviceToken', () => { it('should call RNIterableAPI.registerDeviceToken with the token', () => { // GIVEN a device token @@ -623,6 +677,25 @@ describe('IterableApi', () => { // ======================= AUTH ======================= // // ====================================================== // + describe('getAuthToken', () => { + it('should return the auth token from RNIterableAPI', async () => { + const expectedToken = 'jwt-token'; + MockRNIterableAPI.authToken = expectedToken; + + const result = await IterableApi.getAuthToken(); + + expect(result).toBe(expectedToken); + }); + + it('should return null when RNIterableAPI has no auth token', async () => { + MockRNIterableAPI.authToken = null; + + const result = await IterableApi.getAuthToken(); + + expect(result).toBeNull(); + }); + }); + describe('pauseAuthRetries', () => { it('should call RNIterableAPI.pauseAuthRetries with true', () => { // GIVEN pauseRetry is true @@ -1226,13 +1299,42 @@ describe('IterableApi', () => { }); describe('syncEmbeddedMessages', () => { - it('should call RNIterableAPI.syncEmbeddedMessages', () => { + it('should call RNIterableAPI.syncEmbeddedMessages with null when omitted', () => { // GIVEN no parameters // WHEN syncEmbeddedMessages is called IterableApi.syncEmbeddedMessages(); - // THEN RNIterableAPI.syncEmbeddedMessages is called - expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalled(); + // THEN RNIterableAPI.syncEmbeddedMessages is called with null (sync all) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith(null); + }); + + it('should call RNIterableAPI.syncEmbeddedMessages with null when passed null', () => { + // WHEN syncEmbeddedMessages is called with null + IterableApi.syncEmbeddedMessages(null); + + // THEN RNIterableAPI.syncEmbeddedMessages is called with null (sync all) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith(null); + }); + + it('should call RNIterableAPI.syncEmbeddedMessages with placement IDs', () => { + // GIVEN placement IDs + const placementIds = [1, 2]; + + // WHEN syncEmbeddedMessages is called + IterableApi.syncEmbeddedMessages(placementIds); + + // THEN RNIterableAPI.syncEmbeddedMessages is called with placement IDs + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith( + placementIds + ); + }); + + it('should call RNIterableAPI.syncEmbeddedMessages with empty array for sync all', () => { + // WHEN syncEmbeddedMessages is called with an empty array + IterableApi.syncEmbeddedMessages([]); + + // THEN the empty array is forwarded (native syncs all placements) + expect(MockRNIterableAPI.syncEmbeddedMessages).toBeCalledWith([]); }); }); diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2fad862a7..58066eb5d 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -137,6 +137,25 @@ export class IterableApi { return RNIterableAPI.disableDeviceForCurrentUser(); } + /** + * Disable this device's push token for every user associated with the device. + * + * iOS: forwards to native `IterableAPI.disableDeviceForAllUsers()`. + * Android: logged no-op — there is no public native "all users" equivalent. + * The warning is logged in JS (Metro) as well as native (logcat); the + * native method is still invoked. + */ + static disableDeviceForAllUsers() { + if (Platform.OS === 'android') { + IterableLogger.log( + 'disableDeviceForAllUsers is not supported on Android; use disableDeviceForCurrentUser. There is no public native equivalent.' + ); + } else { + IterableLogger.log('disableDeviceForAllUsers'); + } + return RNIterableAPI.disableDeviceForAllUsers(); + } + /** * Register the device token for the current user, re-enabling push notifications. * @@ -352,6 +371,14 @@ export class IterableApi { return RNIterableAPI.pauseAuthRetries(pauseRetry); } + /** + * Get the JWT currently held by the native SDK for the signed-in user. + */ + static getAuthToken() { + IterableLogger.log('getAuthToken'); + return RNIterableAPI.getAuthToken(); + } + /** * Pass along an auth token to the SDK. * @@ -523,10 +550,16 @@ export class IterableApi { /** * Syncs embedded local cache with the server. + * + * @param placementIds - When provided, limits the sync to those placements. + * See {@link IterableEmbeddedManager.syncMessages} for Android scoped-sync + * caveats (SDK-798). */ - static syncEmbeddedMessages() { - IterableLogger.log('syncEmbeddedMessages'); - return RNIterableAPI.syncEmbeddedMessages(); + static syncEmbeddedMessages(placementIds?: number[] | null) { + IterableLogger.log('syncEmbeddedMessages: ', placementIds); + return RNIterableAPI.syncEmbeddedMessages( + placementIds === undefined ? null : placementIds + ); } /** diff --git a/src/core/classes/IterableAuthManager.ts b/src/core/classes/IterableAuthManager.ts index 44ece1af0..645459bb0 100644 --- a/src/core/classes/IterableAuthManager.ts +++ b/src/core/classes/IterableAuthManager.ts @@ -25,6 +25,20 @@ export class IterableAuthManager { return IterableApi.pauseAuthRetries(pauseRetry); } + /** + * Get the JWT currently held by the native SDK for the signed-in user. + * + * @returns The current auth token, or `null` when no token is stored + * + * @example + * ```typescript + * const token = await Iterable.authManager.getAuthToken(); + * ``` + */ + getAuthToken(): Promise { + return IterableApi.getAuthToken(); + } + /** * Pass along an auth token to the SDK. * diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index b4de2fb41..b4d50197c 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -4,6 +4,7 @@ import { IterableDataRegion } from '../enums/IterableDataRegion'; import { IterableLogLevel } from '../enums/IterableLogLevel'; import { IterablePushPlatform } from '../enums/IterablePushPlatform'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import type { IterableDecryptionFailure } from '../types/IterableDecryptionFailure'; import type { IterableRetryPolicy } from '../types/IterableRetryPolicy'; import { IterableAction } from './IterableAction'; import type { IterableActionContext } from './IterableActionContext'; @@ -225,6 +226,29 @@ export class IterableConfig { */ onJwtError?: (authFailure: IterableAuthFailure) => void; + /** + * A callback invoked when the Android SDK fails to decrypt PII in keychain + * storage. Before calling this handler, the native SDK clears stored PII, + * disables encryption for the device, and requires the user to sign in again. + * + * **Android only.** iOS does not surface decryption failures this way; setting + * this callback on iOS has no effect. + * + * @param failure - Details about the decryption failure. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.decryptionFailureHandler = (failure) => { + * console.error('Iterable decryption failed:', failure.message); + * // Prompt the user to log in again + * }; + * ``` + */ + decryptionFailureHandler?: ( + failure: IterableDecryptionFailure + ) => void; + /** * Set the verbosity of Android and iOS project's log system. * @@ -324,8 +348,14 @@ export class IterableConfig { pushPlatform: IterablePushPlatform = IterablePushPlatform.auto; /** - * Android only feature: This controls whether the SDK should enforce encryption for all PII stored on disk. - * By default, the SDK will not enforce encryption and may fallback to unencrypted storage in case the encryption fails. + * Android only: whether Iterable encrypts PII (email, userId, auth token) in on-device keychain storage. + * When `true` (the default), data is encrypted; when `false`, it is stored in plaintext. + * iOS always uses the system Keychain and ignores this option. + */ + keychainEncryption = true; + + /** + * @deprecated Has no effect on any platform. Use {@link IterableConfig.keychainEncryption}. */ encryptionEnforced = false; @@ -437,6 +467,44 @@ export class IterableConfig { */ onEmbeddedMessagingDisabled?: () => void; + /** + * A callback function that is called when an embedded messaging sync completes + * successfully. + * + * This is distinct from {@link onEmbeddedMessageUpdate}, which fires when the + * local embedded message cache changes. Sync success indicates the network + * sync operation finished without error. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.enableEmbeddedMessaging = true; + * config.onEmbeddedMessagingSyncSucceeded = () => { + * console.log('Embedded sync succeeded'); + * }; + * Iterable.initialize('', config); + * ``` + */ + onEmbeddedMessagingSyncSucceeded?: () => void; + + /** + * A callback function that is called when an embedded messaging sync fails. + * + * @param reason - Failure reason from the native SDK, or `null` when none is + * available. + * + * @example + * ```typescript + * const config = new IterableConfig(); + * config.enableEmbeddedMessaging = true; + * config.onEmbeddedMessagingSyncFailed = (reason) => { + * console.warn('Embedded sync failed', reason); + * }; + * Iterable.initialize('', config); + * ``` + */ + onEmbeddedMessagingSyncFailed?: (reason: string | null) => void; + /** * Converts the IterableConfig instance to a dictionary object. * @@ -463,6 +531,8 @@ export class IterableConfig { * A boolean indicating if an authentication handler is present. */ authHandlerPresent: this.authHandler !== undefined, + decryptionFailureHandlerPresent: + this.decryptionFailureHandler !== undefined, /** * A boolean indicating if an embedded message update callback is present. */ @@ -473,6 +543,16 @@ export class IterableConfig { */ onEmbeddedMessagingDisabledPresent: this.onEmbeddedMessagingDisabled !== undefined, + /** + * A boolean indicating if an embedded messaging sync succeeded callback is present. + */ + onEmbeddedMessagingSyncSucceededPresent: + this.onEmbeddedMessagingSyncSucceeded !== undefined, + /** + * A boolean indicating if an embedded messaging sync failed callback is present. + */ + onEmbeddedMessagingSyncFailedPresent: + this.onEmbeddedMessagingSyncFailed !== undefined, /** The log level for the SDK. */ logLevel: this.logLevel, expiringAuthTokenRefreshPeriod: this.expiringAuthTokenRefreshPeriod, @@ -482,6 +562,7 @@ export class IterableConfig { useInMemoryStorageForInApps: this.useInMemoryStorageForInApps, dataRegion: this.dataRegion, pushPlatform: this.pushPlatform, + keychainEncryption: this.keychainEncryption, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, enableEmbeddedMessaging: this.enableEmbeddedMessaging, diff --git a/src/core/enums/IterableEventName.ts b/src/core/enums/IterableEventName.ts index 6ea79c754..a50b6ca20 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -23,4 +23,14 @@ export enum IterableEventName { handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled', /** Event that fires when embedded messaging is disabled */ handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled', + /** Event that fires when an embedded messaging sync succeeds */ + handleEmbeddedMessagingSyncSucceededCalled = + 'handleEmbeddedMessagingSyncSucceededCalled', + /** Event that fires when an embedded messaging sync fails */ + handleEmbeddedMessagingSyncFailedCalled = + 'handleEmbeddedMessagingSyncFailedCalled', + /** + * Event that fires when Android keychain decryption fails (Android only). + */ + handleDecryptionFailureCalled = 'handleDecryptionFailureCalled', } diff --git a/src/core/types/IterableDecryptionFailure.ts b/src/core/types/IterableDecryptionFailure.ts new file mode 100644 index 000000000..01c9f3fc9 --- /dev/null +++ b/src/core/types/IterableDecryptionFailure.ts @@ -0,0 +1,7 @@ +/** + * Payload for Android keychain decryption failure notifications. + */ +export interface IterableDecryptionFailure { + /** Human-readable description of the decryption failure */ + message?: string; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 7659a76e4..a8f7f2daa 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -1,3 +1,4 @@ export * from './IterableAuthFailure'; +export * from './IterableDecryptionFailure'; export * from './IterableEdgeInsetDetails'; export * from './IterableRetryPolicy'; diff --git a/src/embedded/classes/IterableEmbeddedManager.test.ts b/src/embedded/classes/IterableEmbeddedManager.test.ts index c460572f0..80a910237 100644 --- a/src/embedded/classes/IterableEmbeddedManager.test.ts +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -97,16 +97,49 @@ describe('IterableEmbeddedManager', () => { }); describe('syncMessages', () => { - it('should call IterableApi.syncEmbeddedMessages', async () => { + it('should call IterableApi.syncEmbeddedMessages with null when omitted', async () => { // WHEN syncMessages is called const result = await embeddedManager.syncMessages(); - // THEN IterableApi.syncEmbeddedMessages is called + // THEN IterableApi.syncEmbeddedMessages is called with null expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith(null); // AND the result is returned expect(result).toBeUndefined(); }); + + it('should call IterableApi.syncEmbeddedMessages with placement IDs', () => { + // GIVEN placement IDs + const placementIds = [1, 2]; + + // WHEN syncMessages is called + embeddedManager.syncMessages(placementIds); + + // THEN IterableApi.syncEmbeddedMessages is called with placement IDs + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith( + placementIds + ); + }); + + it('should call IterableApi.syncEmbeddedMessages with null placement IDs', () => { + // WHEN syncMessages is called with null + embeddedManager.syncMessages(null); + + // THEN IterableApi.syncEmbeddedMessages is called with null + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith(null); + }); + + it('should call IterableApi.syncEmbeddedMessages with empty placement IDs', () => { + // WHEN syncMessages is called with an empty array + embeddedManager.syncMessages([]); + + // THEN the empty array is forwarded (native syncs all placements) + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledWith([]); + }); }); describe('getMessages', () => { diff --git a/src/embedded/classes/IterableEmbeddedManager.ts b/src/embedded/classes/IterableEmbeddedManager.ts index 8d53af49c..0575c6311 100644 --- a/src/embedded/classes/IterableEmbeddedManager.ts +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -71,13 +71,23 @@ export class IterableEmbeddedManager { * * However, do not poll for new embedded messages at a regular interval. * + * @param placementIds - Optional placement IDs to sync. When omitted, null, or + * empty, syncs all placements. + * + * @remarks On Android, scoped sync may clear cached messages for placements + * that are not in the sync response (iterableapi SDK-798). iOS merges + * unrequested placements from the existing cache. Until Android iterableapi + * is fixed and this SDK bumps its Android pin, prefer calling `syncMessages()` + * without placement IDs on Android when multiple placements are in use. + * * @example * ```typescript * IterableEmbeddedManager.syncMessages(); + * IterableEmbeddedManager.syncMessages([1, 2, 3]); * ``` */ - syncMessages() { - return IterableApi.syncEmbeddedMessages(); + syncMessages(placementIds?: number[] | null) { + return IterableApi.syncEmbeddedMessages(placementIds); } /** diff --git a/src/inApp/classes/IterableInAppMessage.test.ts b/src/inApp/classes/IterableInAppMessage.test.ts index 156bed1bc..9fed54d6b 100644 --- a/src/inApp/classes/IterableInAppMessage.test.ts +++ b/src/inApp/classes/IterableInAppMessage.test.ts @@ -108,4 +108,30 @@ describe('IterableInAppMessage.fromDict', () => { expect(message.inboxMetadata).toBeUndefined(); }); }); + + describe('jsonOnly', () => { + it('parses jsonOnly true when present', () => { + const message = IterableInAppMessage.fromDict({ + ...baseDict, + jsonOnly: true, + }); + + expect(message.jsonOnly).toBe(true); + }); + + it('parses jsonOnly false when present', () => { + const message = IterableInAppMessage.fromDict({ + ...baseDict, + jsonOnly: false, + }); + + expect(message.jsonOnly).toBe(false); + }); + + it('defaults jsonOnly to false when omitted', () => { + const message = IterableInAppMessage.fromDict({ ...baseDict }); + + expect(message.jsonOnly).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/src/inApp/classes/IterableInAppMessage.ts b/src/inApp/classes/IterableInAppMessage.ts index c77b08e63..3dee68e04 100644 --- a/src/inApp/classes/IterableInAppMessage.ts +++ b/src/inApp/classes/IterableInAppMessage.ts @@ -90,6 +90,13 @@ export class IterableInAppMessage { */ readonly priorityLevel: number; + /** + * Whether this message is JSON-only (custom payload without Iterable HTML UI). + * For JSON-only messages, `inAppHandler` show/skip does not control display on native; + * use this flag to branch custom handling instead of inferring from `customPayload` alone. + */ + readonly jsonOnly: boolean; + /** * Constructs an instance of IterableInAppMessage. * @@ -103,6 +110,7 @@ export class IterableInAppMessage { * @param customPayload - A custom payload associated with the message. * @param read - A boolean indicating whether the message has been read. * @param priorityLevel - The priority level of the message. + * @param jsonOnly - Whether the message is JSON-only (defaults to `false`). */ constructor( messageId: string, @@ -114,7 +122,8 @@ export class IterableInAppMessage { inboxMetadata: IterableInboxMetadata | undefined, customPayload: unknown | undefined, read: boolean, - priorityLevel: number + priorityLevel: number, + jsonOnly: boolean = false ) { this.campaignId = campaignId; this.messageId = messageId; @@ -126,6 +135,7 @@ export class IterableInAppMessage { this.customPayload = customPayload; this.read = read; this.priorityLevel = priorityLevel; + this.jsonOnly = jsonOnly; } /** @@ -147,7 +157,8 @@ export class IterableInAppMessage { inAppMessage?.inboxMetadata, inAppMessage?.customPayload, inAppMessage?.read, - inAppMessage?.priorityLevel + inAppMessage?.priorityLevel, + inAppMessage?.jsonOnly ?? false ); } @@ -197,6 +208,10 @@ export class IterableInAppMessage { const read = IterableUtil.readBoolean(dict, 'read'); const priorityLevel = dict.priorityLevel; + const jsonOnly = IterableUtil.readBoolean( + dict as Record, + 'jsonOnly' + ); return new IterableInAppMessage( messageId, @@ -212,7 +227,8 @@ export class IterableInAppMessage { inboxMetadata, customPayload, read, - priorityLevel + priorityLevel, + jsonOnly ); } } diff --git a/src/inApp/types/IterableInAppMessageRaw.ts b/src/inApp/types/IterableInAppMessageRaw.ts index 78a1748a3..07048b4d3 100644 --- a/src/inApp/types/IterableInAppMessageRaw.ts +++ b/src/inApp/types/IterableInAppMessageRaw.ts @@ -29,4 +29,6 @@ export type IterableInAppMessageRaw = { read?: boolean; /** The priority level of the message. */ priorityLevel?: number; + /** Whether the message is JSON-only (no HTML UI); show/skip in `inAppHandler` does not apply. */ + jsonOnly?: boolean; }; diff --git a/src/index.tsx b/src/index.tsx index 13cb6915d..9d88a05aa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,6 +29,7 @@ export { } from './core/hooks'; export type { IterableAuthFailure, + IterableDecryptionFailure, IterableEdgeInsetDetails, IterableRetryPolicy, } from './core/types';