#2851·cli

run-android: "Could not find the correct install APK file" for single-dimension camelCase product flavors when targeting a specific device

Author: ivaniuk7531Created Sep 4, 2026Updated Sep 4, 2026

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

  1. npx @react-native-community/cli init FlavorRepro
  2. Add a single camelCase product flavor to android/app/build.gradle:
    android {
        flavorDimensions 'env'
        productFlavors {
            stagingInternal { dimension 'env' }
        }
    }
    
  3. Connect a device or start an emulator.
  4. 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.

Source: react-native-community/cli