From 241dbfa8dedddf44e869aeb86ea53b2af39c3a9d Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 17:59:06 +0100 Subject: [PATCH 1/7] fix(SDK-789): implement embedded sync callbacks so the Android bridge compiles against native 3.10.1 The published iterableapi 3.10.1 AAR is compiled with Kotlin 1.9 without jvm-default, so onEmbeddedMessagingSyncSucceeded and onEmbeddedMessagingSyncFailed are abstract to Java implementers. RNIterableAPIModuleImpl did not override them, so the module failed javac. Add no-op overrides that match the native defaults. --- .../iterable/reactnative/RNIterableAPIModuleImpl.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 3e266b224..c35c0dc56 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -729,6 +729,16 @@ public void onEmbeddedMessagingDisabled() { IterableLogger.d(TAG, "onEmbeddedMessagingDisabled"); sendEvent(EventName.handleEmbeddedMessagingDisabledCalled.name(), null); } + + @Override + public void onEmbeddedMessagingSyncSucceeded() { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncSucceeded"); + } + + @Override + public void onEmbeddedMessagingSyncFailed(@Nullable String reason) { + IterableLogger.d(TAG, "onEmbeddedMessagingSyncFailed: " + reason); + } // --------------------------------------------------------------------------------------- // endregion From 3fc0fffc68f4fb666efe2a277baadff0960eb0fa Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 17:59:08 +0100 Subject: [PATCH 2/7] fix(SDK-789): show in-app messages on the main thread on Android Iterable.inAppManager.showMessage called IterableInAppManager.showMessage on the React Native native-modules thread. Since native Android 3.9.0, a host activity that is not a FragmentActivity takes the Dialog path, whose LifecycleRegistry.addObserver call throws IllegalStateException off the main thread and kills the process. - Post the native call with UiThreadUtil.runOnUiThread, matching setAutoDisplayPaused and the native iOS SDK. - Reject when the message id is missing instead of passing null into native (NPE). - Resolve null when the message is dismissed without a URL instead of NPE on toString. - Fix the empty messageId check, which used reference equality. Adds the first JVM unit tests for the Android bridge (Robolectric + Mockito, versions mirrored from iterable-android-sdk) and excludes them from the npm package. --- CHANGELOG.md | 5 + android/build.gradle | 5 + .../reactnative/RNIterableAPIModuleImpl.java | 21 ++- ...NIterableAPIModuleImplShowMessageTest.java | 126 ++++++++++++++++++ package.json | 1 + 5 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f244d4cc1..771a9fca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ - Fixed an Android crash when marking an in-app message as read after that message was no longer in the native queue. - `setReadForMessage` logs and returns when the message id is missing, matching the iOS bridge. The Android bridge was passing that missing message into `IterableInAppManager.setRead`, which threw a `NullPointerException` and killed the app process. +- Fixed the Android module failing to compile against its pinned native Android SDK 3.10.1 in 3.2.0. The bridge now implements `onEmbeddedMessagingSyncSucceeded` and `onEmbeddedMessagingSyncFailed` from `IterableEmbeddedUpdateHandler` as no-ops, matching the native defaults. +- Fixed an Android crash in `Iterable.inAppManager.showMessage` (`IllegalStateException: Method addObserver must be called on the main thread`), present since 3.2.0. + - The bridge now displays the message on the main thread, matching `setAutoDisplayPaused` and the iOS SDK. It was calling the native in-app displayer on the React Native native-modules thread, which crashes when the current activity is not a `FragmentActivity` (for example a Jetpack Compose `ComponentActivity`). + - The promise now rejects when the message id is no longer in the native queue instead of crashing with a `NullPointerException`. + - Dismissing a message by tapping outside it now resolves the promise with `null` instead of crashing on `url.toString()`. ## 3.2.0 diff --git a/android/build.gradle b/android/build.gradle index 623d9e5fc..663f80532 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -107,5 +107,10 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "com.iterable:iterableapi:3.10.1" // api project(":iterableapi") // links to local android SDK repo rather than by release + + testImplementation "junit:junit:4.13.2" + testImplementation "org.mockito:mockito-core:4.8.0" + testImplementation "org.mockito:mockito-inline:4.8.0" + testImplementation "org.robolectric:robolectric:4.14.1" } diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index c35c0dc56..c3396295d 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -286,15 +286,26 @@ public void updateSubscriptions(@Nullable ReadableArray emailListIds, @Nullable ); } - public void showMessage(String messageId, boolean consume, final Promise promise) { - if (messageId == null || messageId == "") { + public void showMessage(String messageId, final boolean consume, final Promise promise) { + if (messageId == null || messageId.isEmpty()) { promise.reject("", "messageId is null or empty"); return; } - IterableApi.getInstance().getInAppManager().showMessage(RNIterableInternal.getMessageById(messageId), consume, new IterableHelper.IterableUrlCallback() { + final IterableInAppMessage message = RNIterableInternal.getMessageById(messageId); + if (message == null) { + promise.reject("", "Could not find message with id: " + messageId); + return; + } + // In-app display attaches lifecycle observers and fragments, which AndroidX requires on the main thread. + UiThreadUtil.runOnUiThread(new Runnable() { @Override - public void execute(@Nullable Uri url) { - promise.resolve(url.toString()); + public void run() { + IterableApi.getInstance().getInAppManager().showMessage(message, consume, new IterableHelper.IterableUrlCallback() { + @Override + public void execute(@Nullable Uri url) { + promise.resolve(url != null ? url.toString() : null); + } + }); } }); } diff --git a/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java b/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java new file mode 100644 index 000000000..5f0fd1708 --- /dev/null +++ b/android/src/test/java/com/iterable/iterableapi/RNIterableAPIModuleImplShowMessageTest.java @@ -0,0 +1,126 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.net.Uri; +import android.os.Looper; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.iterable.reactnative.RNIterableAPIModuleImpl; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.concurrent.atomic.AtomicReference; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class RNIterableAPIModuleImplShowMessageTest { + + private static final String MESSAGE_ID = "message-id"; + + private IterableApi originalApi; + private IterableInAppManager inAppManager; + private IterableInAppMessage message; + private Promise promise; + private RNIterableAPIModuleImpl module; + + @Before + public void setUp() { + originalApi = IterableApi.sharedInstance; + IterableApi api = mock(IterableApi.class); + inAppManager = mock(IterableInAppManager.class); + message = mock(IterableInAppMessage.class); + promise = mock(Promise.class); + when(api.getInAppManager()).thenReturn(inAppManager); + when(inAppManager.getMessageById(MESSAGE_ID)).thenReturn(message); + IterableApi.sharedInstance = api; + module = new RNIterableAPIModuleImpl(mock(ReactApplicationContext.class)); + } + + @After + public void tearDown() { + IterableApi.sharedInstance = originalApi; + } + + @Test + public void showMessageFromBridgeThreadDisplaysOnMainThread() throws InterruptedException { + final AtomicReference displayThread = new AtomicReference<>(); + doAnswer(invocation -> { + displayThread.set(Thread.currentThread()); + return null; + }).when(inAppManager).showMessage(eq(message), eq(true), any(IterableHelper.IterableUrlCallback.class)); + + Thread bridgeThread = new Thread(() -> module.showMessage(MESSAGE_ID, true, promise)); + bridgeThread.start(); + bridgeThread.join(); + + assertNull(displayThread.get()); + + shadowOf(Looper.getMainLooper()).idle(); + + assertSame(Looper.getMainLooper().getThread(), displayThread.get()); + } + + @Test + public void showMessageRejectsWhenMessageIsMissing() { + module.showMessage("missing", true, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).reject("", "Could not find message with id: missing"); + verify(inAppManager, never()).showMessage(any(IterableInAppMessage.class), anyBoolean(), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void showMessageRejectsWhenMessageIdIsEmpty() { + module.showMessage(new String(""), true, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).reject("", "messageId is null or empty"); + verify(inAppManager, never()).showMessage(any(IterableInAppMessage.class), anyBoolean(), any(IterableHelper.IterableUrlCallback.class)); + } + + @Test + public void showMessageResolvesClickedUrl() { + stubClickWith(Uri.parse("https://iterable.com")); + + module.showMessage(MESSAGE_ID, false, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).resolve("https://iterable.com"); + } + + @Test + public void showMessageResolvesNullWhenClosedWithoutUrl() { + stubClickWith(null); + + module.showMessage(MESSAGE_ID, false, promise); + shadowOf(Looper.getMainLooper()).idle(); + + verify(promise).resolve(isNull()); + } + + private void stubClickWith(final Uri url) { + doAnswer(invocation -> { + IterableHelper.IterableUrlCallback callback = invocation.getArgument(2); + callback.execute(url); + return null; + }).when(inAppManager).showMessage(eq(message), eq(false), any(IterableHelper.IterableUrlCallback.class)); + } +} diff --git a/package.json b/package.json index 3ffca9aa5..a4067b684 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "!android/gradlew", "!android/gradlew.bat", "!android/local.properties", + "!android/src/test", "!**/*.test.{js,ts,tsx}", "!**/__tests__", "!**/__fixtures__", From d6f2e988ab5d018ce08ea2d55c084a63e413f316 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 18:12:28 +0100 Subject: [PATCH 3/7] ci(SDK-790): run CI on master and run Android bridge unit tests ci.yml triggered on main while the default branch is master, so it has never run. Add a job that runs the Android bridge JVM tests on every PR. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/unit-test.yml | 34 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 4 ++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 273b19918..8ce2bc4ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,10 @@ name: CI on: push: branches: - - main + - master pull_request: branches: - - main + - master merge_group: types: - checks_requested diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index bc18af429..e3ad5cf10 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -49,3 +49,37 @@ jobs: with: token: ${{ secrets.QLTY_COVERAGE_TOKEN }} files: coverage/lcov.info + + android-bridge-tests: + name: Android bridge unit tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup + uses: ./.github/actions/setup + + - name: Install JDK + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: '17' + + - name: Finalize Android SDK + run: | + /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/wrapper + ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Run Android bridge unit tests + working-directory: example/android + run: ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon --console=plain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56a62b946..fb2548bb5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,10 @@ Contributions (in the form of issues or pull requests) are welcome! * If you have a patch for a bug, or an implementation of a new feature, you're more than welcome to submit a pull request! * If the pull request is for an outstanding issue, please include a link to that issue. Otherwise, there's no need to create a new issue; simply explain the purpose of the Pull Request in the body. +#### **Running tests** +* JS: `yarn test` +* Android bridge (JDK 17 and the Android SDK required): `cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest` + Thanks, Team Iterable From 6d02e673e1e01ab3074c472dc1d2d46e8402617b Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Thu, 8 Oct 2026 18:27:36 +0100 Subject: [PATCH 4/7] ci(SDK-790): build iOS with Xcode 16.4 and run Android tests through yarn React Native 0.85 requires Xcode 16.1+, macos-14 ships 15.4. Mirror the CodeQL Swift job. Run the Android bridge tests via an example script so autolinking finds the RN CLI. --- .github/workflows/ci.yml | 6 +++++- .github/workflows/unit-test.yml | 3 +-- CONTRIBUTING.md | 2 +- example/package.json | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ce2bc4ab..aa3fdc442 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" build-ios: - runs-on: macos-14 + runs-on: macos-15 env: TURBO_CACHE_DIR: .turbo/ios steps: @@ -120,6 +120,10 @@ jobs: - name: Setup uses: ./.github/actions/setup + - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + with: + xcode-version: '16.4' + - name: Cache turborepo for iOS uses: actions/cache@v3 with: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index e3ad5cf10..220c8f23b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -81,5 +81,4 @@ jobs: ${{ runner.os }}-gradle- - name: Run Android bridge unit tests - working-directory: example/android - run: ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon --console=plain + run: yarn example test:android diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb2548bb5..934e0483a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Contributions (in the form of issues or pull requests) are welcome! #### **Running tests** * JS: `yarn test` -* Android bridge (JDK 17 and the Android SDK required): `cd example/android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest` +* Android bridge (JDK 17 and the Android SDK required): `yarn example test:android` Thanks, Team Iterable diff --git a/example/package.json b/example/package.json index 64d0dfb51..ae9ac4c66 100644 --- a/example/package.json +++ b/example/package.json @@ -7,6 +7,7 @@ "ios": "react-native run-ios", "start": "react-native start", "build:android": "react-native build-android --extra-params \"--no-daemon --console=plain -PreactNativeArchitectures=arm64-v8a\"", + "test:android": "cd android && ./gradlew :iterable_react-native-sdk:testDebugUnitTest --no-daemon --console=plain", "build:ios": "react-native build-ios --scheme ReactNativeSdkExample --mode Debug --extra-params \"-sdk iphonesimulator CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ GCC_OPTIMIZATION_LEVEL=0 GCC_PRECOMPILE_PREFIX_HEADER=YES ASSETCATALOG_COMPILER_OPTIMIZATION=time DEBUG_INFORMATION_FORMAT=dwarf COMPILER_INDEX_STORE_ENABLE=NO\"" }, "dependencies": { From 2b9d6a2192a9c7e0e35eee6eca12e35af1d95ec7 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 10:30:06 +0100 Subject: [PATCH 5/7] ci(SDK-790): fix turbo inputs so native changes invalidate the build cache The tasks run in the example workspace, so inputs resolve relative to example/ and directory names without ** match no files. The cache only hashed example/package.json and example/src, so SDK android/ and ios/ changes would hit the cache and skip native builds. Also hash android/build.gradle into the Gradle cache key for the bridge test job. --- .github/workflows/unit-test.yml | 2 +- turbo.json | 39 +++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 220c8f23b..eb07cf020 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -76,7 +76,7 @@ jobs: path: | ~/.gradle/wrapper ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle') }} restore-keys: | ${{ runner.os }}-gradle- diff --git a/turbo.json b/turbo.json index 0ba47c448..a6f9c1ed6 100644 --- a/turbo.json +++ b/turbo.json @@ -4,31 +4,36 @@ "build:android": { "env": ["ORG_GRADLE_PROJECT_newArchEnabled"], "inputs": [ + "../.github/workflows/ci.yml", + "../package.json", + "../android/**", + "!../android/build/**", + "!../android/.gradle/**", + "!../android/.cxx/**", + "../src/*.ts", + "../src/*.tsx", "package.json", - "android", - "!android/build", - "src/*.ts", - "src/*.tsx", - "example/package.json", - "example/android", - "!example/android/.gradle", - "!example/android/build", - "!example/android/app/build" + "android/**", + "!android/.gradle/**", + "!android/build/**", + "!android/app/build/**", + "!android/app/.cxx/**" ], "outputs": [] }, "build:ios": { "env": ["RCT_NEW_ARCH_ENABLED"], "inputs": [ + "../.github/workflows/ci.yml", + "../package.json", + "../*.podspec", + "../ios/**", + "../src/*.ts", + "../src/*.tsx", "package.json", - "*.podspec", - "ios", - "src/*.ts", - "src/*.tsx", - "example/package.json", - "example/ios", - "!example/ios/build", - "!example/ios/Pods" + "ios/**", + "!ios/build/**", + "!ios/Pods/**" ], "outputs": [".turbo/cache"] } From cb2211c69bb67137ad6b80878453b9aefcddfde0 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 11:35:04 +0100 Subject: [PATCH 6/7] ci(SDK-790): always run pod install on iOS builds The CocoaPods cache restores Pods but not the codegen output in ios/build/generated, so skipping pod install on a cache hit left ReactAppDependencyProvider missing. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa3fdc442..737fcdbe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: ${{ runner.os }}-cocoapods- - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' + if: env.turbo_cache_hit != 1 run: | cd example/ios pod install From 014eced9b3cf6a429e5c73ffd4e21a184d6ec8c0 Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 9 Oct 2026 18:39:35 +0100 Subject: [PATCH 7/7] ci(SDK-790): hash library and example sources in native build caches Inputs only covered src/*.ts one level deep and dropped example/src, so codegen spec or example app changes could replay a cached native build. Hash src/** for the library and the example, plus the example entry points and autolinking config. --- turbo.json | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/turbo.json b/turbo.json index a6f9c1ed6..52144cbc3 100644 --- a/turbo.json +++ b/turbo.json @@ -10,9 +10,14 @@ "!../android/build/**", "!../android/.gradle/**", "!../android/.cxx/**", - "../src/*.ts", - "../src/*.tsx", + "../src/**/*.ts", + "../src/**/*.tsx", "package.json", + "index.js", + "app.json", + "react-native.config.js", + "src/**/*.ts", + "src/**/*.tsx", "android/**", "!android/.gradle/**", "!android/build/**", @@ -28,9 +33,14 @@ "../package.json", "../*.podspec", "../ios/**", - "../src/*.ts", - "../src/*.tsx", + "../src/**/*.ts", + "../src/**/*.tsx", "package.json", + "index.js", + "app.json", + "react-native.config.js", + "src/**/*.ts", + "src/**/*.tsx", "ios/**", "!ios/build/**", "!ios/Pods/**"