Skip to content

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

Description

@ivaniuk7531

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.

Activity

  1. ivaniuk7531 commented on Sep 4, 2026

    @ivaniuk7531
    Author

    A small update from my side, in case it is useful to anyone else hitting this.

    I first worked around it without touching the CLI at all, by assembling with Gradle myself and then
    pointing run-android at the result so the filename is never guessed:

    ./gradlew app:assemble<Variant> -x lint
    
    npx react-native run-android --mode <variant> --list-devices \
      --binary-path android/app/build/outputs/apk/<flavor>/<buildType>/app-<flavor>-<buildType>.apk

    That does work, and --binary-path is a clean escape hatch since it skips the name derivation
    entirely. The downside is that it moves Gradle's output path and naming convention into my own
    tooling, which is the part I would rather not have to maintain.

    I have since switched to a small local patch-package patch implementing the output-metadata.json
    approach described in the issue, and it has been working well: both --list-devices and --device
    now install the correct APK, and because the existing name derivation is kept as a fallback, nothing
    changes for projects that already work today.

    For anyone who wants it before a fix lands, this is the patch against the published build output:

    @@ -25,6 +25,13 @@ function _cliTools() {
       };
       return data;
     }
    +function _path() {
    +  const data = _interopRequireDefault(require("path"));
    +  _path = function () {
    +    return data;
    +  };
    +  return data;
    +}
     var _adb = _interopRequireDefault(require("./adb"));
    @@ -67,9 +74,45 @@
    +const OUTPUT_METADATA_FILE = 'output-metadata.json';
    +const ABI_FILTER_TYPE = 'ABI';
    +function isApkOutputElement(value) {
    +  return typeof value === 'object' && value !== null && typeof value.outputFile === 'string';
    +}
    +function readApkOutputElements(buildDirectory) {
    +  const metadataPath = _path().default.join(buildDirectory, OUTPUT_METADATA_FILE);
    +  try {
    +    const metadata = JSON.parse(_fs().default.readFileSync(metadataPath, 'utf8'));
    +    const elements = typeof metadata === 'object' && metadata !== null ? metadata.elements : undefined;
    +    return Array.isArray(elements) ? elements.filter(isApkOutputElement) : [];
    +  } catch (error) {
    +    _cliTools().logger.debug(`Could not read ${metadataPath}: ${String(error)}`);
    +    return [];
    +  }
    +}
    +function getAbiFilter(element) {
    +  return element.filters?.find(({filterType}) => filterType === ABI_FILTER_TYPE)?.value;
    +}
    +function getApkNameFromMetadata(buildDirectory, availableCPUs) {
    +  const elements = readApkOutputElements(buildDirectory);
    +
    +  // availableCPUs is in device preference order, so follow it rather than the metadata order
    +  for (const availableCPU of availableCPUs) {
    +    const split = elements.find(element => getAbiFilter(element) === availableCPU);
    +    if (split !== undefined) {
    +      return split.outputFile;
    +    }
    +  }
    +  return elements.find(element => getAbiFilter(element) === undefined)?.outputFile;
    +}
     function getInstallApkName(appName, adbPath, variant, device, buildDirectory) {
       const availableCPUs = _adb.default.getAvailableCPUs(adbPath, device);
     
    +  // AGP records the real output names next to the APKs, so prefer them over guessing
    +  const apkNameFromMetadata = getApkNameFromMetadata(buildDirectory, availableCPUs);
    +  if (apkNameFromMetadata !== undefined && _fs().default.existsSync(_path().default.join(buildDirectory, apkNameFromMetadata))) {
    +    return apkNameFromMetadata;
    +  }
    +
       // check if there is an apk file like app-armeabi-v7a-debug.apk

    The signature of getInstallApkName is unchanged, so this is purely additive and there is no call
    site to update. Happy to raise it as a proper PR against src/ if this is the direction you would
    prefer.

  2. added a commit that references this issue on Oct 5, 2026
    3358636
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions