diff --git a/.github/actions/detect-platform-change/action.yml b/.github/actions/detect-platform-change/action.yml index e81669762a0078..671b1984546a0e 100644 --- a/.github/actions/detect-platform-change/action.yml +++ b/.github/actions/detect-platform-change/action.yml @@ -41,3 +41,14 @@ runs: ios: [ ${{ inputs.ios-paths }}, ${{ inputs.shared-ignores }} ] android: [ ${{ inputs.android-paths }}, ${{ inputs.shared-ignores }} ] common: [ ${{ inputs.common-paths }}, ${{ inputs.shared-ignores }} ] + - name: 📋 Print detection summary + shell: bash + run: | + echo "### Platform detection results" + echo "" + echo "**iOS-specific changed files:** ${{ steps.changes.outputs.ios_all_changed_files || '(none)' }}" + echo "**Android-specific changed files:** ${{ steps.changes.outputs.android_all_changed_files || '(none)' }}" + echo "**Common changed files:** ${{ steps.changes.outputs.common_all_changed_files || '(none)' }}" + echo "" + echo "**should_run_ios:** ${{ steps.changes.outputs.ios_any_changed == 'true' || steps.changes.outputs.common_any_changed == 'true' }}" + echo "**should_run_android:** ${{ steps.changes.outputs.android_any_changed == 'true' || steps.changes.outputs.common_any_changed == 'true' }}" diff --git a/.github/workflows/android-instrumentation-tests.yml b/.github/workflows/android-instrumentation-tests.yml index 9b00d4f2f0403b..c791b6ce0ca38d 100644 --- a/.github/workflows/android-instrumentation-tests.yml +++ b/.github/workflows/android-instrumentation-tests.yml @@ -18,6 +18,9 @@ on: - tools/src/commands/AndroidNativeUnitTests.ts - yarn.lock - '!packages/@expo/cli/**' + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' pull_request: paths: - .github/workflows/android-instrumentation-tests.yml @@ -33,6 +36,9 @@ on: - tools/src/commands/AndroidNativeUnitTests.ts - yarn.lock - '!packages/@expo/cli/**' + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} diff --git a/.github/workflows/brownfield.yml b/.github/workflows/brownfield.yml index d4cb7a06e3988c..b4359e9826c3d4 100644 --- a/.github/workflows/brownfield.yml +++ b/.github/workflows/brownfield.yml @@ -14,6 +14,8 @@ on: - packages/expo-updates/** - yarn.lock - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' pull_request: paths: - .github/workflows/brownfield.yml @@ -25,6 +27,8 @@ on: - packages/expo-updates/** - yarn.lock - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} diff --git a/.github/workflows/test-suite-brownfield-isolated.yml b/.github/workflows/test-suite-brownfield-isolated.yml index 6c99313a99d475..d39050275f7e87 100644 --- a/.github/workflows/test-suite-brownfield-isolated.yml +++ b/.github/workflows/test-suite-brownfield-isolated.yml @@ -8,11 +8,17 @@ on: - .github/workflows/test-suite-brownfield-isolated.yml - apps/brownfield-tester/expo-app/** - packages/expo-brownfield/** + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' pull_request: paths: - .github/workflows/test-suite-brownfield-isolated.yml - apps/brownfield-tester/expo-app/** - packages/expo-brownfield/** + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} diff --git a/.github/workflows/test-suite-brownfield.yml b/.github/workflows/test-suite-brownfield.yml index 4d6f32e501c37d..18aac6d473e931 100644 --- a/.github/workflows/test-suite-brownfield.yml +++ b/.github/workflows/test-suite-brownfield.yml @@ -10,6 +10,9 @@ on: - packages/expo-modules-core/** - packages/expo-dev-client/** - packages/expo-updates/** + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} diff --git a/.github/workflows/test-suite-macos.yml b/.github/workflows/test-suite-macos.yml index 10027975ad04a2..04aac225825d35 100644 --- a/.github/workflows/test-suite-macos.yml +++ b/.github/workflows/test-suite-macos.yml @@ -40,6 +40,8 @@ on: - '!**/.gitattributes' - '!**/.watchmanconfig' - '!**/.fingerprintignore' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d30ca6e657c886..3f3fde9f11cec4 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -5,39 +5,48 @@ on: push: branches: [main, 'sdk-*'] paths: + - .github/actions/detect-platform-change/action.yml - .github/workflows/test-suite.yml - .github/actions/use-android-emulator/action.yml - apps/bare-expo/** - apps/test-suite/** - packages/** - yarn.lock - # When adding new paths, also update the paths of the "Get the base commit" step below + - '!packages/@expo/cli/**' + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' pull_request: paths: + - .github/actions/detect-platform-change/action.yml - .github/workflows/test-suite.yml - .github/actions/use-android-emulator/action.yml - apps/bare-expo/** - apps/test-suite/** - packages/** - yarn.lock - # Ignore Expo CLI for now... - '!packages/@expo/cli/**' + - '!**.md' + - '!**/__tests__/**' + - '!**/__mocks__/**' concurrency: group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: - detect-platform-for-e2e: + detect-platform-changes: runs-on: ubuntu-24.04 outputs: - should_run_ios: ${{ steps.changes.outputs.should_run_ios }} - should_run_android: ${{ steps.changes.outputs.should_run_android }} + should_build_ios: ${{ steps.build.outputs.should_run_ios }} + should_build_android: ${{ steps.build.outputs.should_run_android }} + should_test_ios: ${{ steps.e2e.outputs.should_run_ios }} + should_test_android: ${{ steps.e2e.outputs.should_run_android }} steps: - name: 👀 Checkout uses: actions/checkout@v5 - - name: 🧐 Detect platform change - id: changes + - name: 🧐 Detect build-relevant platform changes + id: build uses: ./.github/actions/detect-platform-change with: android-paths: >- @@ -57,10 +66,68 @@ jobs: yarn.lock, "!packages/**/{ios,android}/**", "!apps/bare-expo/**/{ios,android}/**" + + - name: 🧐 Detect e2e-test relevant platform changes + id: e2e + uses: ./.github/actions/detect-platform-change + with: + android-paths: >- + "packages/expo-modules-core/**/android/**", + "packages/expo-modules-autolinking/**/android/**", + "packages/expo/**/android/**", + "packages/expo-constants/**/android/**", + "packages/expo-crypto/**/android/**", + "packages/expo-file-system/**/android/**", + "packages/expo-haptics/**/android/**", + "packages/expo-image/**/android/**", + "packages/expo-keep-awake/**/android/**", + "packages/expo-linear-gradient/**/android/**", + "packages/expo-localization/**/android/**", + "packages/expo-sqlite/**/android/**", + "packages/expo-video/**/android/**", + "apps/bare-expo/**/android/**" + + ios-paths: >- + "packages/expo-modules-core/**/ios/**", + "packages/expo-modules-autolinking/**/ios/**", + "packages/expo/**/ios/**", + "packages/expo-constants/**/ios/**", + "packages/expo-crypto/**/ios/**", + "packages/expo-file-system/**/ios/**", + "packages/expo-haptics/**/ios/**", + "packages/expo-image/**/ios/**", + "packages/expo-keep-awake/**/ios/**", + "packages/expo-linear-gradient/**/ios/**", + "packages/expo-localization/**/ios/**", + "packages/expo-sqlite/**/ios/**", + "packages/expo-video/**/ios/**", + "apps/bare-expo/**/ios/**" + + common-paths: >- + .github/workflows/test-suite.yml, + .github/actions/use-android-emulator/action.yml, + "apps/bare-expo/**", + "apps/test-suite/**", + "packages/expo-modules-core/**", + "packages/expo-modules-autolinking/**", + "packages/expo/**", + "packages/expo-constants/**", + "packages/expo-crypto/**", + "packages/expo-file-system/**", + "packages/expo-haptics/**", + "packages/expo-image/**", + "packages/expo-keep-awake/**", + "packages/expo-linear-gradient/**", + "packages/expo-localization/**", + "packages/expo-sqlite/**", + "packages/expo-video/**", + yarn.lock, + "!packages/**/{ios,android}/**", + "!apps/bare-expo/**/{ios,android}/**" ios-build: runs-on: macos-15 - needs: detect-platform-for-e2e - if: needs.detect-platform-for-e2e.outputs.should_run_ios == 'true' + needs: detect-platform-changes + if: needs.detect-platform-changes.outputs.should_build_ios == 'true' permissions: # REQUIRED: Allow updating fingerprint in action caches actions: write @@ -147,7 +214,8 @@ jobs: author_name: Test Suite e2e (iOS) ios-test-e2e: - needs: ios-build + needs: [ios-build, detect-platform-changes] + if: needs.detect-platform-changes.outputs.should_test_ios == 'true' runs-on: macos-15 steps: - name: 👀 Checkout @@ -248,8 +316,8 @@ jobs: android-build: runs-on: ubuntu-24.04 - needs: detect-platform-for-e2e - if: needs.detect-platform-for-e2e.outputs.should_run_android == 'true' + needs: detect-platform-changes + if: needs.detect-platform-changes.outputs.should_build_android == 'true' permissions: # REQUIRED: Allow updating fingerprint in action caches actions: write @@ -333,7 +401,8 @@ jobs: author_name: Test Suite e2e (Android) android-test-e2e: - needs: android-build + needs: [android-build, detect-platform-changes] + if: needs.detect-platform-changes.outputs.should_test_android == 'true' runs-on: ubuntu-24.04 strategy: matrix: diff --git a/apps/bare-expo/e2e/TestSuite-test.native.js b/apps/bare-expo/e2e/TestSuite-test.native.js index f815af56c061c2..9c63094f1c4a1f 100644 --- a/apps/bare-expo/e2e/TestSuite-test.native.js +++ b/apps/bare-expo/e2e/TestSuite-test.native.js @@ -1,5 +1,6 @@ /** * The test cases for bare-expo E2E testing. + * When adding or removing tests, also update the paths in .github/workflows/test-suite.yml */ const TESTS = [ 'Basic', diff --git a/apps/native-component-list/src/api/sendPushNotificationsAsync.ts b/apps/native-component-list/src/api/sendPushNotificationsAsync.ts index b61771497cc0ea..4e451bf8366c12 100644 --- a/apps/native-component-list/src/api/sendPushNotificationsAsync.ts +++ b/apps/native-component-list/src/api/sendPushNotificationsAsync.ts @@ -43,17 +43,49 @@ export async function sendPushNotificationsAsync({ } } - const receipts = result.data; - if (receipts) { - const receipt = receipts[0]; - if (receipt.status === 'error') { - if (receipt.details) { + const tickets = result.data; + if (tickets) { + const ticket = tickets[0]; + if (ticket.status === 'error') { + if (ticket.details) { console.warn( - `Expo push service reported an error sending a notification: ${receipt.details.error}` + `Expo push service reported an error accepting a notification: ${ticket.details.error}` ); } - if (receipt.__debug) { - console.warn(receipt.__debug); + if (ticket.__debug) { + console.warn(ticket.__debug); + } + } + } + + // Check push receipts after a delay to confirm delivery + const receiptIds = (tickets ?? []) + .filter((ticket: { status: string; id?: string }) => ticket.status === 'ok') + .map((ticket: { id: string }) => ticket.id); + + if (receiptIds.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const receiptResponse = await fetch(PUSH_ENDPOINT.replace('/send', '/getReceipts'), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ids: receiptIds }), + }); + + const receiptResult = await receiptResponse.json(); + console.log({ receipts: JSON.stringify(receiptResult, null, 2) }); + + if (receiptResult.data) { + for (const [id, receipt] of Object.entries(receiptResult.data) as [string, any][]) { + if (receipt.status === 'error') { + console.warn(`Receipt ${id} error: ${receipt.message}`); + if (receipt.details?.error) { + console.warn(`Error code: ${receipt.details.error}`); + } + } } } } diff --git a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/HeadlessAppLoaderNotifier.kt b/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/HeadlessAppLoaderNotifier.kt deleted file mode 100644 index 9d651f789918e1..00000000000000 --- a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/HeadlessAppLoaderNotifier.kt +++ /dev/null @@ -1,31 +0,0 @@ -package expo.modules.adapters.react.apploader - -import java.lang.ref.WeakReference - -interface HeadlessAppLoaderListener { - - fun appLoaded(appScopeKey: String) - - fun appDestroyed(appScopeKey: String) -} - -object HeadlessAppLoaderNotifier { - - val listeners: MutableSet> = mutableSetOf() - - fun registerListener(listener: HeadlessAppLoaderListener) { - listeners.add(WeakReference(listener)) - } - - fun notifyAppLoaded(appScopeKey: String?) { - if (appScopeKey != null) { - listeners.forEach { it.get()?.appLoaded(appScopeKey) } - } - } - - fun notifyAppDestroyed(appScopeKey: String?) { - if (appScopeKey != null) { - listeners.forEach { it.get()?.appDestroyed(appScopeKey) } - } - } -} diff --git a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/RNHeadlessAppLoader.kt b/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/RNHeadlessAppLoader.kt index 19803f41658044..254f85c69e8a11 100644 --- a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/RNHeadlessAppLoader.kt +++ b/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/apploader/RNHeadlessAppLoader.kt @@ -4,10 +4,8 @@ import android.annotation.SuppressLint import android.content.Context import com.facebook.react.ReactApplication import com.facebook.react.ReactInstanceEventListener -import com.facebook.react.ReactInstanceManager import com.facebook.react.bridge.ReactContext import com.facebook.react.common.LifecycleState -import expo.modules.BuildConfig import expo.modules.apploader.HeadlessAppLoader import expo.modules.core.interfaces.Consumer import expo.modules.core.interfaces.DoNotStrip @@ -18,54 +16,38 @@ class RNHeadlessAppLoader @DoNotStrip constructor(private val context: Context) //region HeadlessAppLoader - override fun loadApp(context: Context, params: HeadlessAppLoader.Params?, alreadyRunning: Runnable?, callback: Consumer?) { + override fun loadApp( + context: Context, + params: HeadlessAppLoader.Params?, + alreadyRunning: Runnable?, + callback: Consumer? + ) { if (params == null || params.appScopeKey == null) { throw IllegalArgumentException("Params must be set with appScopeKey!") } - if (context.applicationContext is ReactApplication) { - if (!appRecords.containsKey(params.appScopeKey)) { - // In old arch reactHost will be null - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // New architecture - val reactHost = (context.applicationContext as ReactApplication).reactHost ?: throw IllegalStateException("Your application does not have a valid reactHost") - reactHost.addReactInstanceEventListener( - object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - reactHost.removeReactInstanceEventListener(this) - HeadlessAppLoaderNotifier.notifyAppLoaded(params.appScopeKey) - appRecords[params.appScopeKey] = context - callback?.apply(true) - } - } - ) - // Ensure that we're starting the react host on the main thread - android.os.Handler(context.mainLooper).post { - reactHost.start() - } - } else { - // Old architecture - val reactInstanceManager = (context.applicationContext as ReactApplication).reactNativeHost.reactInstanceManager - reactInstanceManager.addReactInstanceEventListener( - object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - HeadlessAppLoaderNotifier.notifyAppLoaded(params.appScopeKey) - reactInstanceManager.removeReactInstanceEventListener(this) - appRecords[params.appScopeKey] = context - callback?.apply(true) - } - } - ) - // Ensure that we're starting the react host on the main thread - android.os.Handler(context.mainLooper).post { - reactInstanceManager.createReactContextInBackground() + if (context.applicationContext !is ReactApplication) { + throw IllegalStateException("Your application must implement ReactApplication") + } + + if (!appRecords.containsKey(params.appScopeKey)) { + val reactHost = (context.applicationContext as ReactApplication).reactHost + ?: throw IllegalStateException("Your application does not have a valid reactHost") + reactHost.addReactInstanceEventListener( + object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + reactHost.removeReactInstanceEventListener(this) + appRecords[params.appScopeKey] = context + callback?.apply(true) } } - } else { - alreadyRunning?.run() + ) + // Ensure that we're starting the react host on the main thread + android.os.Handler(context.mainLooper).post { + reactHost.start() } } else { - throw IllegalStateException("Your application must implement ReactApplication") + alreadyRunning?.run() } } @@ -73,35 +55,16 @@ class RNHeadlessAppLoader @DoNotStrip constructor(private val context: Context) override fun invalidateApp(appScopeKey: String?): Boolean { return if (appRecords.containsKey(appScopeKey) && appRecords[appScopeKey] != null) { val reactContext = appRecords[appScopeKey] ?: return false - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // New architecture - val reactHost = (reactContext.applicationContext as ReactApplication).reactHost ?: throw IllegalStateException("Your application does not have a valid reactHost") - android.os.Handler(reactContext.mainLooper).post { - // Only destroy the `ReactInstanceManager` if it does not bind with an Activity. - // And The Activity would take over the ownership of `ReactInstanceManager`. - // This case happens when a user clicks a background task triggered notification immediately. - if (reactHost.lifecycleState == LifecycleState.BEFORE_CREATE) { - reactHost.destroy("Closing headless task app", null) - } - HeadlessAppLoaderNotifier.notifyAppDestroyed(appScopeKey) - appRecords.remove(appScopeKey) - } - } else { - // Old architecture - val reactNativeHost = (reactContext.applicationContext as ReactApplication).reactNativeHost - if (reactNativeHost.hasInstance()) { - val reactInstanceManager: ReactInstanceManager = reactNativeHost.reactInstanceManager - android.os.Handler(reactContext.mainLooper).post { - // Only destroy the `ReactInstanceManager` if it does not bind with an Activity. - // And The Activity would take over the ownership of `ReactInstanceManager`. - // This case happens when a user clicks a background task triggered notification immediately. - if (reactInstanceManager.lifecycleState == LifecycleState.BEFORE_CREATE) { - reactInstanceManager.destroy() - } - HeadlessAppLoaderNotifier.notifyAppDestroyed(appScopeKey) - appRecords.remove(appScopeKey) - } + val reactHost = (reactContext.applicationContext as ReactApplication).reactHost + ?: throw IllegalStateException("Your application does not have a valid reactHost") + android.os.Handler(reactContext.mainLooper).post { + // Only destroy the `ReactInstanceManager` if it does not bind with an Activity. + // And The Activity would take over the ownership of `ReactInstanceManager`. + // This case happens when a user clicks a background task triggered notification immediately. + if (reactHost.lifecycleState == LifecycleState.BEFORE_CREATE) { + reactHost.destroy("Closing headless task app", null) } + appRecords.remove(appScopeKey) } true } else { @@ -110,17 +73,9 @@ class RNHeadlessAppLoader @DoNotStrip constructor(private val context: Context) } override fun isRunning(appScopeKey: String?): Boolean { - val reactContext = appRecords[appScopeKey] ?: return false - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // New architecture - We can return true since the fact that we have a reactContext - // means that we've already called start on the reactHost - return true - } else { - // Old architecture - val reactNativeHost = (reactContext.applicationContext as ReactApplication).reactNativeHost - return reactNativeHost.reactInstanceManager.hasStartedCreatingInitialContext() - } + // New architecture - We can return true since the fact that we have a reactContext + // means that we've already called start on the reactHost + return appRecords[appScopeKey] != null } - //endregion HeadlessAppLoader } diff --git a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/services/UIManagerModuleWrapper.java b/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/services/UIManagerModuleWrapper.java index 4b0aa2bd4336f1..46a672a9d76a15 100644 --- a/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/services/UIManagerModuleWrapper.java +++ b/packages/expo-modules-core/android/src/main/java/expo/modules/adapters/react/services/UIManagerModuleWrapper.java @@ -2,32 +2,20 @@ import android.app.Activity; import android.content.Intent; -import android.util.Log; -import android.view.View; import com.facebook.react.bridge.ReactContext; import com.facebook.react.common.annotations.FrameworkAPI; import com.facebook.react.common.annotations.UnstableReactNativeAPI; -import com.facebook.react.fabric.FabricUIManager; -import com.facebook.react.fabric.interop.UIBlockViewResolver; import com.facebook.react.turbomodule.core.CallInvokerHolderImpl; -import com.facebook.react.uimanager.IllegalViewOperationException; -import com.facebook.react.uimanager.NativeViewHierarchyManager; -import com.facebook.react.uimanager.UIManagerHelper; -import com.facebook.react.uimanager.UIManagerModule; -import com.facebook.react.uimanager.common.UIManagerType; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.WeakHashMap; -import androidx.annotation.Nullable; import androidx.annotation.OptIn; -import expo.modules.BuildConfig; import expo.modules.core.interfaces.ActivityEventListener; import expo.modules.core.interfaces.ActivityProvider; import expo.modules.core.interfaces.InternalModule; @@ -62,99 +50,6 @@ public List getExportedInterfaces() { ); } - private void addToUIManager(final UIBlockInterface block) { - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - com.facebook.react.bridge.UIManager uiManager = UIManagerHelper.getUIManager(getContext(), UIManagerType.FABRIC); - Objects.requireNonNull(((FabricUIManager) uiManager)).addUIBlock(block); - } else { - UIManagerModule uiManager = getContext().getNativeModule(UIManagerModule.class); - Objects.requireNonNull(uiManager).addUIBlock(block); - } - } - - @Override - @SuppressWarnings("deprecation") - public void addUIBlock(final int tag, final UIBlock block, final Class tClass) { - UIBlockInterface uiBlock = new UIBlockInterface() { - @Override - public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { - executeImpl(nativeViewHierarchyManager, null); - } - - @Override - public void execute(UIBlockViewResolver uiBlockViewResolver) { - executeImpl(null, uiBlockViewResolver); - } - - private void executeImpl(NativeViewHierarchyManager nativeViewHierarchyManager, UIBlockViewResolver uiBlockViewResolver) { - View view = nativeViewHierarchyManager.resolveView(tag); - if (view == null) { - block.reject(new IllegalArgumentException("Expected view for this tag not to be null.")); - } else { - try { - if (tClass.isInstance(view)) { - block.resolve(tClass.cast(view)); - } else { - block.reject(new IllegalStateException( - "Expected view to be of " + tClass + "; found " + view.getClass() + " instead")); - } - } catch (Exception e) { - block.reject(e); - } - } - } - }; - - addToUIManager(uiBlock); - } - - @Override - @SuppressWarnings("deprecation") - public void addUIBlock(final GroupUIBlock block) { - UIBlockInterface uiBlock = new UIBlockInterface() { - @Override - public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { - executeImpl(nativeViewHierarchyManager, null); - } - - @Override - public void execute(UIBlockViewResolver uiBlockViewResolver) { - executeImpl(null, uiBlockViewResolver); - } - - private void executeImpl(NativeViewHierarchyManager nativeViewHierarchyManager, UIBlockViewResolver uiBlockViewResolver) { - block.execute(new ViewHolder() { - @Override - public View get(Object key) { - if (key instanceof Number) { - try { - return nativeViewHierarchyManager.resolveView(((Number) key).intValue()); - } catch (IllegalViewOperationException e) { - return null; - } - } else { - Log.w("E_INVALID_TAG", "Provided tag is of class " + key.getClass() + " whereas React expects tags to be integers. Are you sure you're providing proper argument to addUIBlock?"); - } - return null; - } - }); - } - }; - - addToUIManager(uiBlock); - } - - @Nullable - @Override - @SuppressWarnings("deprecation") - public View resolveView(int viewTag) { - final com.facebook.react.bridge.UIManager uiManager = UIManagerHelper.getUIManagerForReactTag(getContext(), viewTag); - if (uiManager == null) { - return null; - } - return uiManager.resolveView(viewTag); - } - @Override public void runOnUiQueueThread(Runnable runnable) { if (getContext().isOnUiQueueThread()) { @@ -173,15 +68,6 @@ public void runOnClientCodeQueueThread(Runnable runnable) { } } - public void runOnNativeModulesQueueThread(Runnable runnable) { - if (mReactContext.isOnNativeModulesQueueThread()) { - runnable.run(); - } else { - mReactContext.runOnNativeModulesQueueThread(runnable); - } - } - - @Override public void registerLifecycleEventListener(final LifecycleEventListener listener) { final WeakReference weakListener = new WeakReference<>(listener); @@ -279,8 +165,3 @@ public Activity getCurrentActivity() { return getContext().getCurrentActivity(); } } - -@OptIn(markerClass = UnstableReactNativeAPI.class) -@SuppressWarnings("deprecation") -interface UIBlockInterface extends com.facebook.react.uimanager.UIBlock, com.facebook.react.fabric.interop.UIBlock { -} diff --git a/packages/expo-modules-core/android/src/main/java/expo/modules/core/interfaces/services/UIManager.java b/packages/expo-modules-core/android/src/main/java/expo/modules/core/interfaces/services/UIManager.java index d98138d55b456c..8ec97278f79943 100644 --- a/packages/expo-modules-core/android/src/main/java/expo/modules/core/interfaces/services/UIManager.java +++ b/packages/expo-modules-core/android/src/main/java/expo/modules/core/interfaces/services/UIManager.java @@ -1,43 +1,13 @@ package expo.modules.core.interfaces.services; -import android.view.View; - -import androidx.annotation.Nullable; - import expo.modules.core.interfaces.ActivityEventListener; import expo.modules.core.interfaces.LifecycleEventListener; public interface UIManager { - interface UIBlock { - void resolve(T view); - - void reject(Throwable throwable); - } - - interface ViewHolder { - View get(Object key); - } - - interface GroupUIBlock { - void execute(ViewHolder viewHolder); - } - - @Deprecated - void addUIBlock(int viewTag, UIBlock block, Class tClass); - - @Deprecated - void addUIBlock(GroupUIBlock block); - - @Deprecated - @Nullable - View resolveView(int viewTag); - void runOnUiQueueThread(Runnable runnable); void runOnClientCodeQueueThread(Runnable runnable); - void runOnNativeModulesQueueThread(Runnable runnable); - void registerLifecycleEventListener(LifecycleEventListener listener); void unregisterLifecycleEventListener(LifecycleEventListener listener); diff --git a/packages/expo-notifications/CHANGELOG.md b/packages/expo-notifications/CHANGELOG.md index 53ec1bd81053ec..4251b2e92692ed 100644 --- a/packages/expo-notifications/CHANGELOG.md +++ b/packages/expo-notifications/CHANGELOG.md @@ -10,6 +10,7 @@ ### 🐛 Bug fixes +- add FCM intent origin validation ([#43206](https://github.com/expo/expo/pull/43206) by [@vonovak](https://github.com/vonovak)) - Fixed crash in `NotificationForwarderActivity` on Android 11/12 when Parcelable extras fail to deserialize by using byte array serialization as fallback. ([#43203](https://github.com/expo/expo/pull/43203) by [@vonovak](https://github.com/vonovak)) ### 💡 Others diff --git a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java index 5e210f0ccc0711..bcc1b6dcad2c98 100644 --- a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java +++ b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java @@ -6,6 +6,8 @@ import android.os.Bundle; +import expo.modules.notifications.service.NotificationsService; + import androidx.annotation.Nullable; import com.google.firebase.messaging.RemoteMessage; @@ -206,7 +208,7 @@ public static Bundle toResponseBundleFromExtras(Bundle extras) { serializedTrigger.putString("channelId", extras.getString("channelId")); Bundle serializedRequest = new Bundle(); - serializedRequest.putString("identifier", extras.getString("google.message_id")); + serializedRequest.putString("identifier", extras.getString(NotificationsService.GOOGLE_MESSAGE_ID_KEY)); serializedRequest.putBundle("trigger", serializedTrigger); serializedRequest.putBundle("content", serializedContent); diff --git a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/NotificationsService.kt b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/NotificationsService.kt index e9222a9f6c604b..74554bedd2e715 100644 --- a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/NotificationsService.kt +++ b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/NotificationsService.kt @@ -64,6 +64,10 @@ open class NotificationsService : BroadcastReceiver() { const val EXCEPTION_KEY = "exception" const val RECEIVER_KEY = "receiver" + // FCM always includes this key in intent extras for push notifications. + // Used to distinguish real notification intents from OEM-injected extras. + const val GOOGLE_MESSAGE_ID_KEY = "google.message_id" + // Specific messages parts const val NOTIFICATION_KEY = "notification" const val NOTIFICATION_RESPONSE_KEY = "notificationResponse" diff --git a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/ExpoNotificationLifecycleListener.kt b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/ExpoNotificationLifecycleListener.kt index 6518aed7f39d5a..e47011437b0677 100644 --- a/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/ExpoNotificationLifecycleListener.kt +++ b/packages/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/ExpoNotificationLifecycleListener.kt @@ -7,6 +7,7 @@ import android.util.Log import expo.modules.core.interfaces.ReactActivityLifecycleListener import expo.modules.notifications.notifications.NotificationManager import expo.modules.notifications.notifications.debug.DebugLogging +import expo.modules.notifications.service.NotificationsService.Companion.GOOGLE_MESSAGE_ID_KEY import expo.modules.notifications.service.NotificationsService.Companion.NOTIFICATION_RESPONSE_KEY import expo.modules.notifications.service.NotificationsService.Companion.TEXT_INPUT_NOTIFICATION_RESPONSE_KEY @@ -25,6 +26,9 @@ class ExpoNotificationLifecycleListener : ReactActivityLifecycleListener { Log.d("ReactNativeJS", "[native] ExpoNotificationLifecycleListener contains an unmarshalled notification response. Skipping.") return } + if (!isFCMIntent(extras)) { + return + } DebugLogging.logBundle("ExpoNotificationLifeCycleListener.onCreate:", extras) NotificationManager.onNotificationResponseFromExtras(extras) } @@ -45,9 +49,21 @@ class ExpoNotificationLifecycleListener : ReactActivityLifecycleListener { // NotificationForwarderActivity -> NotificationsService.onReceiveNotificationResponse -> NotificationEmitter.onNotificationResponseReceived return false } + if (!isFCMIntent(extras)) { + return false + } DebugLogging.logBundle("ExpoNotificationLifeCycleListener.onNewIntent:", extras) NotificationManager.onNotificationResponseFromExtras(extras) } return false } + + /** + * Returns true if the extras represent a genuine FCM notification intent. + * FCM always includes [GOOGLE_MESSAGE_ID_KEY] in notification data. + * OEM extras (e.g. Samsung's "anim_not_finish") do not contain this key. + */ + private fun isFCMIntent(extras: Bundle): Boolean { + return extras.containsKey(GOOGLE_MESSAGE_ID_KEY) + } }