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

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