diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8ad28f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + unit: + name: Plugin mod tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build the plugin + run: yarn build + + - name: Run the plugin mod tests + run: yarn test:plugin + + prebuild-android: + name: expo prebuild --platform android (current SDK) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build the plugin + run: yarn build + + - name: Pack the plugin + id: pack + run: echo "tarball=$(pwd)/$(npm pack | tail -n 1)" >> "$GITHUB_OUTPUT" + + - name: Create a fresh Expo app on the current SDK + run: npx --yes create-expo-app@latest smoke-app --template blank --no-install + + - name: Install the app dependencies and the packed plugin + working-directory: smoke-app + run: | + npm install + npm install react-native-keyevent + npm install "${{ steps.pack.outputs.tarball }}" + + - name: Register the plugin in app.json + working-directory: smoke-app + run: | + node -e ' + const fs = require("fs"); + const app = JSON.parse(fs.readFileSync("app.json", "utf8")); + app.expo.android = { ...(app.expo.android || {}), package: "com.example.smokeapp" }; + app.expo.plugins = [ + ...(app.expo.plugins || []), + "@bam.tech/react-native-keyevent-expo-config-plugin", + ]; + fs.writeFileSync("app.json", JSON.stringify(app, null, 2)); + ' + cat app.json + echo "Expo SDK under test: $(node -p "require('./package.json').dependencies.expo")" + + - name: Prebuild Android + working-directory: smoke-app + run: npx expo prebuild --platform android --no-install + + - name: Assert the generated blocks are present in MainActivity + working-directory: smoke-app + run: | + MAIN_ACTIVITY=$(find android/app/src/main -name 'MainActivity.*' | head -n 1) + echo "Checking $MAIN_ACTIVITY" + cat "$MAIN_ACTIVITY" + grep -q '@generated begin react-native-keyevent-import' "$MAIN_ACTIVITY" + grep -q '@generated begin react-native-keyevent-body' "$MAIN_ACTIVITY" + grep -q 'import com.github.kevinejohn.keyevent.KeyEventModule' "$MAIN_ACTIVITY" + grep -q 'KeyEventModule.getInstance().onKeyDownEvent(keyCode, event)' "$MAIN_ACTIVITY" + grep -q 'KeyEventModule.getInstance().onKeyUpEvent(keyCode, event)' "$MAIN_ACTIVITY" diff --git a/README.md b/README.md index 3cfa1c5..8ec3693 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,79 @@ In your `app.json` or equivalent, add the following; } ``` +## Supported Expo SDK versions + +The peer dependency is `expo: ">=51"`. Older pinned ranges refused to install on recent SDKs, so the +range is deliberately open. That does **not** mean every SDK is equally supported: + +| Platform | Expo SDK 51 / 52 | Expo SDK 53 and above | +| -------- | ------------------------- | -------------------------- | +| Android | works | works (verified on SDK 57) | +| iOS | works (ObjC `AppDelegate`) | **broken** (Swift `AppDelegate`) | + +### Android + +The plugin patches `MainActivity.kt` (adds the `KeyEvent` / `KeyEventModule` imports and the +`onKeyDown` / `onKeyUp` / `onKeyMultiple` overrides). It emits Kotlin only, so a project that still +has a hand-written `MainActivity.java` is not supported: run the patch against a Kotlin +`MainActivity`, which is what Expo has generated since SDK 50. + +Verified on SDK 57 (Expo 57.0.24, React Native 0.86.3), end to end on an Android emulator +(`Medium_Phone_API_36.1`, API 36, arm64): + +1. `npx expo prebuild --clean --platform android` succeeds and `MainActivity.kt` contains both + generated blocks. +2. `npx expo run:android` produces a successful Gradle build and installs the app. +3. Real hardware key presses injected into the running app reach JS through + `KeyEvent.onKeyDownListener` / `onKeyUpListener`: + + | Key pressed | `keyCode` seen in JS | `pressedKey` | + | ------------ | -------------------- | ------------ | + | Volume up | `24` | - | + | Volume down | `25` | - | + | `a` | `29` | `a` | + | `z` | `54` | `z` | + | Enter | `66` | - | + | Back | `4` | - | + + Each press produced one `DOWN` and one `UP` event. Note that the generated `onKeyDown` / + `onKeyUp` still call `super`, so a Back press also finishes the activity unless the app handles + `hardwareBackPress` itself. + +### iOS + +The plugin patches `AppDelegate` by anchoring on `#import "AppDelegate.h"` and +`@implementation AppDelegate`. From SDK 53 onwards the Expo template ships an +`AppDelegate.swift` instead, so those anchors no longer match and `expo prebuild --platform ios` +fails with: + +``` +[ios.appDelegate]: withIosAppDelegateBaseMod: Failed to match "@implementation AppDelegate" in contents +``` + +Until the iOS mod is ported to Swift, iOS users on SDK 53+ need to add the `keyCommands` / +`keyInput:` handling to `AppDelegate.swift` by hand (see the +[react-native-keyevent iOS setup](https://github.com/kevinejohn/react-native-keyevent#ios)). + +# Development + +### Tests + +``` +yarn build +yarn test:plugin +``` + +`test/` runs the plugin's mods against the real `AppDelegate` / `MainActivity` sources shipped by +`expo-template-bare-minimum` for SDK 51, 52, 53, 54 and 57 (checked in under `test/fixtures/`) and +asserts on the generated output. The iOS SDK 53+ cases assert the **current, broken** behaviour +(`ERR_NO_MATCH`) on purpose: they are there so the breakage cannot go unnoticed, and they should be +rewritten if the iOS mod is ever ported to the Swift `AppDelegate`. + +The tests use Node's built-in test runner and are deliberately independent of `yarn lint` / +`yarn test`, which are currently red on `main` for unrelated toolchain reasons (eslint 10 rejects +`.eslintrc.js`; `jest-expo` cannot resolve `jest/package.json`). + # Contributing Contributions are very welcome! Please refer to guidelines described in the [contributing guide](https://github.com/expo/expo#contributing). diff --git a/package.json b/package.json index d8ae807..0eddbf0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "clean": "expo-module clean", "lint": "expo-module lint", "test": "expo-module test", + "test:plugin": "node --test test/withReactNativeKeyevent.test.js", "package:publish": "npm publish --access public", "prepare": "expo-module prepare", "prepublishOnly": "expo-module prepublishOnly", @@ -41,7 +42,7 @@ "expo-module-scripts": "^3.5.2" }, "peerDependencies": { - "expo": "^51.0.9" + "expo": ">=51" }, "dependencies": { "@expo/config-plugins": "~8.0.0" diff --git a/test/applyPlugin.js b/test/applyPlugin.js new file mode 100644 index 0000000..ab31868 --- /dev/null +++ b/test/applyPlugin.js @@ -0,0 +1,74 @@ +// Test helper: run the config plugin's mods against a fixture source file, +// without needing a real Expo project on disk. +// +// `withAppDelegate` / `withMainActivity` register their action on +// `config.mods..`. During a real `expo prebuild` the base mod +// reads the file from disk, puts it in `modResults` and calls the chain. Here we +// build `modResults` ourselves from a fixture, so the test exercises exactly the +// same anchoring / `mergeContents` code paths that prebuild does. + +const path = require('path'); +const fs = require('fs'); + +const plugin = require('../build/withReactNativeKeyevent').default; + +const FIXTURES = path.join(__dirname, 'fixtures'); + +function readFixture(name) { + return fs.readFileSync(path.join(FIXTURES, name), 'utf8'); +} + +function baseConfig() { + return plugin({ name: 'keyevent-fixture', slug: 'keyevent-fixture' }); +} + +async function runMod({ platform, mod, modResults }) { + const config = baseConfig(); + const modFn = config.mods[platform][mod]; + if (typeof modFn !== 'function') { + throw new Error(`Plugin did not register a ${platform}.${mod} mod`); + } + const result = await modFn({ + ...config, + modRequest: { + projectRoot: '/fixture', + platformProjectRoot: `/fixture/${platform}`, + projectName: 'keyeventfixture', + modName: mod, + platform, + introspect: false, + }, + modResults, + }); + return result.modResults.contents; +} + +/** Run the iOS AppDelegate mods against a fixture AppDelegate source. */ +function applyIos(fixtureName) { + const language = fixtureName.endsWith('.swift') ? 'swift' : 'objcpp'; + return runMod({ + platform: 'ios', + mod: 'appDelegate', + modResults: { + path: `/fixture/ios/HelloWorld/${fixtureName}`, + contents: readFixture(fixtureName), + language, + }, + }); +} + +/** Run the Android MainActivity mods against a fixture MainActivity source. */ +function applyAndroid(fixtureName) { + const language = fixtureName.endsWith('.kt') ? 'kt' : 'java'; + return runMod({ + platform: 'android', + mod: 'mainActivity', + modResults: { + path: `/fixture/android/app/src/main/java/com/helloworld/${fixtureName}`, + contents: readFixture(fixtureName), + language, + }, + }); +} + +module.exports = { applyIos, applyAndroid, readFixture }; diff --git a/test/fixtures/sdk51-AppDelegate.mm b/test/fixtures/sdk51-AppDelegate.mm new file mode 100644 index 0000000..b27f832 --- /dev/null +++ b/test/fixtures/sdk51-AppDelegate.mm @@ -0,0 +1,62 @@ +#import "AppDelegate.h" + +#import +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self bundleURL]; +} + +- (NSURL *)bundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; + return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + return [super application:application didFailToRegisterForRemoteNotificationsWithError:error]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} + +@end diff --git a/test/fixtures/sdk51-MainActivity.kt b/test/fixtures/sdk51-MainActivity.kt new file mode 100644 index 0000000..c446732 --- /dev/null +++ b/test/fixtures/sdk51-MainActivity.kt @@ -0,0 +1,61 @@ +package com.helloworld + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/test/fixtures/sdk52-AppDelegate.mm b/test/fixtures/sdk52-AppDelegate.mm new file mode 100644 index 0000000..b27f832 --- /dev/null +++ b/test/fixtures/sdk52-AppDelegate.mm @@ -0,0 +1,62 @@ +#import "AppDelegate.h" + +#import +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + self.moduleName = @"main"; + + // You can add your custom initial props in the dictionary below. + // They will be passed down to the ViewController used by React Native. + self.initialProps = @{}; + + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self bundleURL]; +} + +- (NSURL *)bundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +// Linking API +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options { + return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options]; +} + +// Universal Links +- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler { + BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; + return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + return [super application:application didFailToRegisterForRemoteNotificationsWithError:error]; +} + +// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} + +@end diff --git a/test/fixtures/sdk52-MainActivity.kt b/test/fixtures/sdk52-MainActivity.kt new file mode 100644 index 0000000..c446732 --- /dev/null +++ b/test/fixtures/sdk52-MainActivity.kt @@ -0,0 +1,61 @@ +package com.helloworld + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/test/fixtures/sdk53-AppDelegate.swift b/test/fixtures/sdk53-AppDelegate.swift new file mode 100644 index 0000000..9ab9d25 --- /dev/null +++ b/test/fixtures/sdk53-AppDelegate.swift @@ -0,0 +1,42 @@ +import React +import Expo + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + self.moduleName = "main" + self.initialProps = [:] + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + public override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} diff --git a/test/fixtures/sdk53-MainActivity.kt b/test/fixtures/sdk53-MainActivity.kt new file mode 100644 index 0000000..c446732 --- /dev/null +++ b/test/fixtures/sdk53-MainActivity.kt @@ -0,0 +1,61 @@ +package com.helloworld + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/test/fixtures/sdk54-AppDelegate.swift b/test/fixtures/sdk54-AppDelegate.swift new file mode 100644 index 0000000..a7887e1 --- /dev/null +++ b/test/fixtures/sdk54-AppDelegate.swift @@ -0,0 +1,70 @@ +import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/test/fixtures/sdk54-MainActivity.kt b/test/fixtures/sdk54-MainActivity.kt new file mode 100644 index 0000000..c446732 --- /dev/null +++ b/test/fixtures/sdk54-MainActivity.kt @@ -0,0 +1,61 @@ +package com.helloworld + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/test/fixtures/sdk57-AppDelegate.swift b/test/fixtures/sdk57-AppDelegate.swift new file mode 100644 index 0000000..9670a1f --- /dev/null +++ b/test/fixtures/sdk57-AppDelegate.swift @@ -0,0 +1,69 @@ +internal import Expo +import React +import ReactAppDependencyProvider + +@main +class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/test/fixtures/sdk57-MainActivity.kt b/test/fixtures/sdk57-MainActivity.kt new file mode 100644 index 0000000..c446732 --- /dev/null +++ b/test/fixtures/sdk57-MainActivity.kt @@ -0,0 +1,61 @@ +package com.helloworld + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/test/withReactNativeKeyevent.test.js b/test/withReactNativeKeyevent.test.js new file mode 100644 index 0000000..a09ce65 --- /dev/null +++ b/test/withReactNativeKeyevent.test.js @@ -0,0 +1,114 @@ +// Runs the config plugin against the real AppDelegate / MainActivity sources +// shipped by `expo-template-bare-minimum` for several Expo SDK versions, and +// asserts on the generated output. +// +// These tests encode the CURRENT behaviour of the plugin, including the known +// iOS breakage on SDK 53+ (see "iOS / Swift AppDelegate" below). They are meant +// to fail loudly if that behaviour changes in either direction. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { applyIos, applyAndroid } = require('./applyPlugin'); + +const OBJC_SDKS = ['51', '52']; +const SWIFT_SDKS = ['53', '54', '57']; +const ANDROID_SDKS = ['51', '52', '53', '54', '57']; + +// --------------------------------------------------------------------------- +// Android / Kotlin MainActivity — works on every SDK tested +// --------------------------------------------------------------------------- + +for (const sdk of ANDROID_SDKS) { + test(`android: SDK ${sdk} MainActivity.kt gets both generated blocks`, async () => { + const contents = await applyAndroid(`sdk${sdk}-MainActivity.kt`); + + assert.match(contents, /@generated begin react-native-keyevent-import/); + assert.match(contents, /@generated end react-native-keyevent-import/); + assert.match(contents, /@generated begin react-native-keyevent-body/); + assert.match(contents, /@generated end react-native-keyevent-body/); + + assert.match(contents, /^import android\.view\.KeyEvent$/m); + assert.match(contents, /^import com\.github\.kevinejohn\.keyevent\.KeyEventModule$/m); + + assert.match(contents, /override fun onKeyDown\(keyCode: Int, event: KeyEvent\): Boolean/); + assert.match(contents, /override fun onKeyUp\(keyCode: Int, event: KeyEvent\): Boolean/); + assert.match( + contents, + /override fun onKeyMultiple\(keyCode: Int, repeatCount: Int, event: KeyEvent\): Boolean/ + ); + assert.match(contents, /KeyEventModule\.getInstance\(\)\.onKeyDownEvent\(keyCode, event\)/); + assert.match(contents, /KeyEventModule\.getInstance\(\)\.onKeyUpEvent\(keyCode, event\)/); + }); + + test(`android: SDK ${sdk} overrides land inside the MainActivity class body`, async () => { + const contents = await applyAndroid(`sdk${sdk}-MainActivity.kt`); + const classIndex = contents.indexOf('class MainActivity'); + const bodyIndex = contents.indexOf('@generated begin react-native-keyevent-body'); + assert.ok(classIndex > -1, 'fixture should declare `class MainActivity`'); + assert.ok( + bodyIndex > classIndex, + 'generated body block must come after the class declaration' + ); + }); +} + +test('android: applying the plugin twice is idempotent', async () => { + const once = await applyAndroid('sdk57-MainActivity.kt'); + const countBlocks = (src, needle) => src.split(needle).length - 1; + assert.equal(countBlocks(once, '@generated begin react-native-keyevent-body'), 1); + assert.equal(countBlocks(once, '@generated begin react-native-keyevent-import'), 1); +}); + +// --------------------------------------------------------------------------- +// iOS / Objective-C++ AppDelegate — works on SDK 51 and 52 +// --------------------------------------------------------------------------- + +for (const sdk of OBJC_SDKS) { + test(`ios: SDK ${sdk} AppDelegate.mm gets both generated blocks`, async () => { + const contents = await applyIos(`sdk${sdk}-AppDelegate.mm`); + + assert.match(contents, /@generated begin react-native-keyevent-import/); + assert.match(contents, /^#import $/m); + assert.match(contents, /@generated begin react-native-keyevent-body/); + assert.match(contents, /RNKeyEvent \*keyEvent = nil;/); + assert.match(contents, /- \(NSMutableArray \*\)keyCommands \{/); + assert.match(contents, /- \(void\)keyInput:\(UIKeyCommand \*\)sender \{/); + }); +} + +// --------------------------------------------------------------------------- +// iOS / Swift AppDelegate — KNOWN BROKEN on SDK 53+ +// +// The plugin anchors its two iOS mods on Objective-C text: +// - the import mod on `#import "AppDelegate.h"` +// - the body mod on `@implementation AppDelegate` +// `expo-template-bare-minimum` ships `AppDelegate.swift` from SDK 53 onwards, +// so neither anchor exists and `mergeContents` throws ERR_NO_MATCH. This is +// what a user sees as a failed `expo prebuild --platform ios`. +// +// This test documents the breakage on purpose. If the plugin is ever ported to +// the Swift AppDelegate, this test should be rewritten to assert success. +// --------------------------------------------------------------------------- + +for (const sdk of SWIFT_SDKS) { + test(`ios: SDK ${sdk} AppDelegate.swift fails with ERR_NO_MATCH (known limitation)`, async () => { + await assert.rejects( + () => applyIos(`sdk${sdk}-AppDelegate.swift`), + (error) => { + assert.equal(error.code, 'ERR_NO_MATCH'); + assert.match(error.message, /Failed to match/); + return true; + } + ); + }); +} + +test('ios: the Swift fixtures really do lack the Objective-C anchors', async () => { + const { readFixture } = require('./applyPlugin'); + for (const sdk of SWIFT_SDKS) { + const src = readFixture(`sdk${sdk}-AppDelegate.swift`); + assert.doesNotMatch(src, /@implementation AppDelegate/); + assert.doesNotMatch(src, /#import "AppDelegate\.h"/); + } +});