Environment
System:
OS: macOS 26.6.2
JDK: Temurin 21.0.11
Binaries:
Node: 22.23.1
Gradle wrapper: 8.14.3
AGP: 8.13.0
npmPackages:
react-native: 0.81.6
@react-native-community/cli: 20.2.0
@react-native-community/cli-platform-android: 20.2.0
Description
run-android fails at the install step for a product flavor whose name is camelCase, while the
Gradle build itself succeeds:
BUILD SUCCESSFUL in 10s
error Failed to install the app on the device.
Error: Could not find the correct install APK file.
at getInstallApkName (.../runAndroid/tryInstallAppOnDevice.js:86:9)
at tryInstallAppOnDevice (.../runAndroid/tryInstallAppOnDevice.js:51:23)
at installAndLaunchOnDevice (.../runAndroid/index.js:246:38)
at runOnSpecificDevice (.../runAndroid/index.js:236:7)
at buildAndRun (.../runAndroid/index.js:177:14)
This only happens on the runOnSpecificDevice path, i.e. when --device, --list-devices or
--interactive is passed. Without those flags runOnAllDevices runs the Gradle install<Variant>
task, which never has to know the APK filename, so the same project installs fine. That asymmetry
makes the bug easy to miss.
Cause
tryInstallAppOnDevice.ts
derives both the output directory and the APK filename from --mode by splitting the string on
capital letters:
const variantFromSelectedTask = (selectedTask ?? args.mode)
?.replace('install', '')
.split(/(?=[A-Z])/);
// joined with '' -> `stagingInternal/debug`
const variantPath = variantFromSelectedTask
? `${variantFromSelectedTask
.slice(0, -1)
.join('')}/${variantFromSelectedTask.at(-1)!.toLocaleLowerCase()}`
: defaultVariant;
// joined with '-' -> `staging-internal-debug`
const variantAppName =
variantFromSelectedTask?.join('-')?.toLowerCase() ?? defaultVariant;
The two lines disagree with each other. The directory treats the flavor as a single token
(join('')), the filename treats it as several (join('-')). Only one of them can be right for
a given project, and for a camelCase flavor name it is the directory.
Example that fails
android {
flavorDimensions 'env'
productFlavors {
stagingInternal { dimension 'env' }
}
}
npx react-native run-android --mode stagingInternalDebug --list-devices
|
path |
| Gradle writes |
app/build/outputs/apk/stagingInternal/debug/app-stagingInternal-debug.apk |
| CLI looks for |
app/build/outputs/apk/stagingInternal/debug/app-staging-internal-debug.apk |
The directory is resolved correctly and only the filename is wrong, so the install fails on a build
that is sitting right there. output-metadata.json, which AGP writes into that same directory,
confirms the real name:
{
"variantName": "stagingInternalDebug",
"elements": [
{
"type": "SINGLE",
"filters": [],
"outputFile": "app-stagingInternal-debug.apk"
}
]
}
Example that works
android {
flavorDimensions 'client', 'env'
productFlavors {
acme { dimension 'client' }
staging { dimension 'env' }
}
}
--mode acmeStagingDebug → Gradle writes apk/acmeStaging/debug/app-acme-staging-debug.apk, and
the split-on-capitals guess matches.
Why this cannot be fixed by adjusting the split
acmeStagingDebug is ambiguous on its own: it could be one flavor named acmeStaging, or the
flavors acme and staging from two dimensions. Both are valid Gradle configurations and they
produce different filenames (app-acmeStaging-debug.apk vs app-acme-staging-debug.apk) inside
the same directory. No amount of string splitting can tell the two apart, so any heuristic will
keep breaking for one group of projects or the other.
Related history: #2323 reported the same error, #2324 introduced the current split to fix the
directory for multi-dimension flavors, and #2709 was closed as fixed in v20.0.1. The directory part
is indeed fixed; the filename part still guesses. As of main today the code above is unchanged and
20.2.0 is the latest published version, so there is no release to upgrade to.
Reproducible Demo
npx @react-native-community/cli init FlavorRepro
- Add a single camelCase product flavor to
android/app/build.gradle:
android {
flavorDimensions 'env'
productFlavors {
stagingInternal { dimension 'env' }
}
}
- Connect a device or start an emulator.
npx react-native run-android --mode stagingInternalDebug --list-devices
The Gradle build succeeds and the install step fails with Could not find the correct install APK file. Running the same command without --list-devices succeeds, since it takes the
runOnAllDevices path instead.
Proposal
AGP already writes the authoritative answer next to the APKs, in output-metadata.json. Reading it
removes the guesswork entirely, works for every flavor-dimension layout, and keeps working if the
Gradle naming convention changes. The existing heuristic can stay as a fallback for builds where the
file is absent, so nothing regresses.
import path from 'path';
const OUTPUT_METADATA_FILE = 'output-metadata.json';
const ABI_FILTER_TYPE = 'ABI';
interface ApkOutputFilter {
filterType: string;
value: string;
}
interface ApkOutputElement {
outputFile: string;
filters?: ApkOutputFilter[];
}
function isApkOutputElement(value: unknown): value is ApkOutputElement {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as ApkOutputElement).outputFile === 'string'
);
}
function readApkOutputElements(buildDirectory: string): ApkOutputElement[] {
const metadataPath = path.join(buildDirectory, OUTPUT_METADATA_FILE);
try {
const metadata: unknown = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const elements =
typeof metadata === 'object' && metadata !== null
? (metadata as {elements?: unknown}).elements
: undefined;
return Array.isArray(elements) ? elements.filter(isApkOutputElement) : [];
} catch (error) {
logger.debug(`Could not read ${metadataPath}: ${String(error)}`);
return [];
}
}
function getAbiFilter(element: ApkOutputElement): string | undefined {
return element.filters?.find(({filterType}) => filterType === ABI_FILTER_TYPE)
?.value;
}
function getApkNameFromMetadata(
buildDirectory: string,
availableCPUs: string[],
): string | undefined {
const elements = readApkOutputElements(buildDirectory);
// an ABI split matching the device wins, then the universal/unfiltered output
const matchingSplit = elements.find((element) => {
const abi = getAbiFilter(element);
return abi !== undefined && availableCPUs.includes(abi);
});
const universalOutput = elements.find(
(element) => getAbiFilter(element) === undefined,
);
return (matchingSplit ?? universalOutput)?.outputFile;
}
getInstallApkName then consults it first and otherwise behaves exactly as it does today:
function getInstallApkName(
appName: string,
adbPath: string,
variant: string,
device: string,
buildDirectory: string,
) {
const availableCPUs = adb.getAvailableCPUs(adbPath, device);
const apkNameFromMetadata = getApkNameFromMetadata(
buildDirectory,
availableCPUs,
);
if (
apkNameFromMetadata !== undefined &&
fs.existsSync(path.join(buildDirectory, apkNameFromMetadata))
) {
return apkNameFromMetadata;
}
// unchanged fallback: check for an apk file like app-armeabi-v7a-debug.apk
for (const availableCPU of availableCPUs.concat('universal')) {
const apkName = `${appName}-${availableCPU}-${variant}.apk`;
if (fs.existsSync(`${buildDirectory}/${apkName}`)) {
return apkName;
}
}
// unchanged fallback: check for a default file like app-debug.apk
const apkName = `${appName}-${variant}.apk`;
if (fs.existsSync(`${buildDirectory}/${apkName}`)) {
return apkName;
}
throw new Error('Could not find the correct install APK file.');
}
If reading the metadata file is considered too large a change, a much smaller fix is to try the
un-split flavor name as a second candidate — build ${flavorTokens.join('')}-${buildType} alongside
the current ${tokens.join('-')} and look for both, preferring the existing one so current
behaviour is unaffected. That covers this bug, though it stays a heuristic.
Why I am asking for this
I usually have an emulator and a physical device connected at the same time, so I rely on
--list-devices / --device to choose where a build lands. On the default path everything works,
but as soon as I pick a device the install fails, even though the APK has just been built
successfully. The error message also points at the wrong thing: the build is fine and the file is
there, it is simply being looked for under a name Gradle never produced.
My workaround for now is to assemble with Gradle myself and then pass --binary-path to
run-android so the filename is never guessed. That works, but it means duplicating Gradle's output
path and naming convention in my own tooling, which is exactly the fragile part I would rather not
own.
Happy to open a PR with either approach if you can confirm which direction you would prefer.
Environment
Description
run-androidfails at the install step for a product flavor whose name is camelCase, while theGradle build itself succeeds:
This only happens on the
runOnSpecificDevicepath, i.e. when--device,--list-devicesor--interactiveis passed. Without those flagsrunOnAllDevicesruns the Gradleinstall<Variant>task, which never has to know the APK filename, so the same project installs fine. That asymmetry
makes the bug easy to miss.
Cause
tryInstallAppOnDevice.tsderives both the output directory and the APK filename from
--modeby splitting the string oncapital letters:
The two lines disagree with each other. The directory treats the flavor as a single token
(
join('')), the filename treats it as several (join('-')). Only one of them can be right fora given project, and for a camelCase flavor name it is the directory.
Example that fails
app/build/outputs/apk/stagingInternal/debug/app-stagingInternal-debug.apkapp/build/outputs/apk/stagingInternal/debug/app-staging-internal-debug.apkThe directory is resolved correctly and only the filename is wrong, so the install fails on a build
that is sitting right there.
output-metadata.json, which AGP writes into that same directory,confirms the real name:
{ "variantName": "stagingInternalDebug", "elements": [ { "type": "SINGLE", "filters": [], "outputFile": "app-stagingInternal-debug.apk" } ] }Example that works
--mode acmeStagingDebug→ Gradle writesapk/acmeStaging/debug/app-acme-staging-debug.apk, andthe split-on-capitals guess matches.
Why this cannot be fixed by adjusting the split
acmeStagingDebugis ambiguous on its own: it could be one flavor namedacmeStaging, or theflavors
acmeandstagingfrom two dimensions. Both are valid Gradle configurations and theyproduce different filenames (
app-acmeStaging-debug.apkvsapp-acme-staging-debug.apk) insidethe same directory. No amount of string splitting can tell the two apart, so any heuristic will
keep breaking for one group of projects or the other.
Related history: #2323 reported the same error, #2324 introduced the current split to fix the
directory for multi-dimension flavors, and #2709 was closed as fixed in v20.0.1. The directory part
is indeed fixed; the filename part still guesses. As of
maintoday the code above is unchanged and20.2.0is the latest published version, so there is no release to upgrade to.Reproducible Demo
npx @react-native-community/cli init FlavorReproandroid/app/build.gradle:npx react-native run-android --mode stagingInternalDebug --list-devicesThe Gradle build succeeds and the install step fails with
Could not find the correct install APK file.Running the same command without--list-devicessucceeds, since it takes therunOnAllDevicespath instead.Proposal
AGP already writes the authoritative answer next to the APKs, in
output-metadata.json. Reading itremoves the guesswork entirely, works for every flavor-dimension layout, and keeps working if the
Gradle naming convention changes. The existing heuristic can stay as a fallback for builds where the
file is absent, so nothing regresses.
getInstallApkNamethen consults it first and otherwise behaves exactly as it does today:If reading the metadata file is considered too large a change, a much smaller fix is to try the
un-split flavor name as a second candidate — build
${flavorTokens.join('')}-${buildType}alongsidethe current
${tokens.join('-')}and look for both, preferring the existing one so currentbehaviour is unaffected. That covers this bug, though it stays a heuristic.
Why I am asking for this
I usually have an emulator and a physical device connected at the same time, so I rely on
--list-devices/--deviceto choose where a build lands. On the default path everything works,but as soon as I pick a device the install fails, even though the APK has just been built
successfully. The error message also points at the wrong thing: the build is fine and the file is
there, it is simply being looked for under a name Gradle never produced.
My workaround for now is to assemble with Gradle myself and then pass
--binary-pathtorun-androidso the filename is never guessed. That works, but it means duplicating Gradle's outputpath and naming convention in my own tooling, which is exactly the fragile part I would rather not
own.
Happy to open a PR with either approach if you can confirm which direction you would prefer.