Every time I moved between React Native projects built with Expo, I found myself repeating a familiar sequence: connect an Android phone over USB, check ADB authorization, make Metro reachable, and figure out whether the installed development APK still matched the project.
None of these steps is particularly difficult. Repeating them across projects is the annoying part.
I wanted one command that handled the routine checks, reused a compatible debug build when possible, and left JavaScript and TypeScript changes to Metro and Fast Refresh.
So I put a small wrapper around the tools Expo and Android already provide.
View the script and setup instructions on GitHub Gist
This is an Expo SDK 58 example, not a replacement for Expo CLI or a new React Native framework.
The repetitive part of Android development
When working with a physical Android device, a development session can involve several separate decisions:
- Which USB-connected device should receive the app?
- Is the phone authorized for debugging?
- Is a compatible development APK already installed?
- Has anything affecting the native build changed?
- Is Metro running for the correct project and configuration?
- Does the phone have an
adb reversemapping to Metro's port?
You can handle each question manually. But I would rather answer them consistently, especially when switching between apps with different Android package IDs and native dependencies.
The goal was simple: reduce repeated setup, not reinvent the underlying tools.
What Expo already does
Expo CLI already knows how to prebuild and run Android projects, launch Metro, and work with development builds. React Native provides Fast Refresh. Android's ADB handles device discovery and USB port forwarding.
Expo SDK 58 also exposes the official expo/fingerprint API. It can calculate a fingerprint of inputs relevant to the native application. That makes it useful when deciding whether an existing development build is compatible with the current project.
The wrapper adds coordination around these existing capabilities:
- Load the development environment and resolve Expo configuration.
- Select one authorized USB-connected Android device.
- Calculate an Android-focused native fingerprint.
- Inspect the local debug APK and the installed application.
- Reuse a verified install, install a matching APK, or build when required.
- Connect the selected device to Metro and launch the development client.
No claim of a faster Fast Refresh implementation is involved. Fast Refresh remains React Native's feature.
The workflow in one command
After copying the Gist's android-usb.cjs into the root of an Expo project, add a script to package.json:
{
"scripts": {
"android:usb": "node android-usb.cjs"
}
}
Then use:
# Automatically reuse, install, or build as needed
npm run android:usb
# Force a native rebuild
npm run android:usb -- --build
# Reuse only: no Gradle build or APK installation
npm run android:usb -- --start
# Select a specific connected phone and Metro port
npm run android:usb -- --device SERIAL --port 8083
The default Metro port is 8081. The script supports an explicit device serial because silently choosing between multiple connected phones is not a good idea.
In automatic mode, the wrapper checks whether a compatible debug app is already installed. If so, it skips Gradle and installation, prepares the USB connection, and opens the development client with Metro. Otherwise, it can use a matching local artifact or build and install a new one.
Why a fingerprint isn't enough on its own
A native fingerprint answers one question: do the relevant native inputs look compatible?
It does not prove that a particular APK exists, has the expected package ID, was built as a debuggable app, or matches what is installed on the phone.
That's why the workflow treats native compatibility and APK identity as separate checks.
The script combines Expo's Android-only balanced fingerprint with local native sources and relevant project files. It also verifies Gradle's single debug APK output and checks the APK's package and debuggable flag with Android build tools.
For reuse, the wrapper stores local evidence, including the fingerprint and APK SHA-256. It checks the installed APK against that evidence before deciding to skip a build or installation. Missing or stale evidence is not treated as proof of compatibility.
The cache lives under .expo/, which should remain ignored by Git.
This isn't a guarantee that every conceivable native change will be detected. For example, manually patching files inside an installed dependency or changing external Gradle inputs may require an explicit --build.
Keep the development app separate
One thing I didn't want was a convenience script accidentally replacing an installed release application.
The Gist expects a separate Android application ID and URL scheme for USB development. A simplified dynamic Expo configuration looks like this:
module.exports = ({ config }) => {
const usb = process.env.APP_VARIANT === 'usb';
return {
...config,
scheme: usb ? 'example-usb' : config.scheme,
android: {
...config.android,
package: usb ? 'com.example.app.dev' : config.android?.package,
},
};
};
Then configure the matching values in your shell:
export APP_VARIANT=usb
export ANDROID_USB_DEV_ID=com.example.app.dev
export ANDROID_USB_RELEASE_ID=com.example.app
export ANDROID_HOME="$HOME/Library/Android/sdk"
# Replace the version with the build-tools version installed locally.
export ANDROID_USB_AAPT="$ANDROID_HOME/build-tools/37.0.0/aapt"
The script validates the expected development package and refuses to replace a non-debuggable app at that identity. You'll still need to adapt the IDs, Android SDK location, and native build conventions for your own project.
Metro, USB, and Fast Refresh
Once a compatible debug build is available, the wrapper can start Expo CLI's Metro server or reuse a compatible existing session. It uses ADB reverse so the phone can reach the Metro port over USB, and launches the development client.
It also checks important Metro compatibility details, including the project path, Android package, scheme, and loopback bundle URL. If a different project's server is occupying the port, it shouldn't be reused just because something happens to be listening there.
The wrapper consistently selects Expo's development environment for its child processes. That's especially important for projects with dynamic app.config.js logic or development .env files.
A practical limitation: changing environment variables that affect the JavaScript bundle may require restarting Metro. A matching native fingerprint does not mean an old Metro process has the right environment.
After the session is ready, ordinary JS, TS, and TSX edits continue through Metro and Fast Refresh. They do not require a new native APK simply because a component changed.
What I actually tested
I tested the workflow with a physical Android phone connected to a Mac, using Expo SDK 58, Node 24, and an isolated development package in my Falnameh app.
The checks covered:
- A fresh debug APK build, installation, and launch.
- A second run that reused the installed APK without Gradle or reinstalling.
- Automatic selection of reuse when the existing build remained compatible.
- Metro startup and reuse over USB.
- Fingerprint behavior for JavaScript-only versus native-input changes.
- Visible Fast Refresh after reconnecting the development client's HMR session.
That last point deserves context: the first UI edits didn't appear until I restarted Metro and reloaded once. After reconnecting HMR, the UI change and its reversal appeared without another rebuild or reinstall. It's a useful reminder that native-build compatibility and HMR connectivity are different concerns.
I also ran a USB smoke test of the standalone Gist script in the same project. I have not yet tested the standalone version end to end in a second Expo app.
Where this example stops
I deliberately kept the public version narrow. It currently assumes:
- Expo SDK 58 and an Expo development client.
- A physical Android device connected over USB.
- A single
:app:assembleDebugAPK output at the standard debug path. - A local
node_modules-based installation and conventional Android tooling. - A project configuration that can provide a separate development ID and scheme.
It has not been fully validated with Linux, other Expo SDK versions, or end-to-end pnpm, Yarn, and Bun workflows. Projects with split APKs, product flavors, unusual signing, or workspace lockfiles outside the app directory need additional adaptation.
That's fine for a Gist. I wanted a transparent, usable starting point rather than a general-purpose Android build system.
Take it into your own project
The GitHub Gist contains just two files: the script and a README with prerequisites, configuration, commands, and troubleshooting notes.
The takeaway isn't that a one-command wrapper is technically groundbreaking. It's that small, repeatable development tasks are worth automating when you keep doing them across projects.
If this saves you from reassembling the same USB, APK, and Metro workflow in every Expo app, it has done its job.
Top comments (1)
The other half of "does the installed build still match": an old build that keeps working when a fresh one wouldn't.
expo-audio asks for expo-asset as "*", and npm hoisted a newer SDK's version of it to the top. A fresh iOS build lost the native module and never started, while my older simulator build still had it and worked fine.
An npm override pinning expo-asset to my SDK's version fixed it.