Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -41,7 +42,7 @@
"expo-module-scripts": "^3.5.2"
},
"peerDependencies": {
"expo": "^51.0.9"
"expo": ">=51"
},
"dependencies": {
"@expo/config-plugins": "~8.0.0"
Expand Down
74 changes: 74 additions & 0 deletions test/applyPlugin.js
Original file line number Diff line number Diff line change
@@ -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.<platform>.<mod>`. 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 };
62 changes: 62 additions & 0 deletions test/fixtures/sdk51-AppDelegate.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#import "AppDelegate.h"

#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>

@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<UIApplicationOpenURLOptionsKey,id> *)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<id<UIUserActivityRestoring>> * _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
61 changes: 61 additions & 0 deletions test/fixtures/sdk51-MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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 <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
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()
}
}
Loading
Loading