Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import execa from 'execa';
import fs from 'fs';
import type {AndroidProject, Flags} from '..';
import adb from '../adb';
import tryInstallAppOnDevice from '../tryInstallAppOnDevice';

jest.mock('execa');
jest.mock('fs', () => ({
existsSync: jest.fn(),
readFileSync: jest.fn(),
}));
jest.mock('../adb');
jest.mock('../getAdbPath');
jest.mock('../tryLaunchEmulator');

const adbPath = 'path/to/adb';
const device = 'emulator-5554';

const androidProject: AndroidProject = {
sourceDir: '/android',
appName: 'app',
packageName: 'com.myapp',
applicationId: 'com.myapp',
mainActivity: '.MainActivity',
assets: [],
};

function flags(mode: string): Flags {
return {
activeArchOnly: false,
packager: true,
port: 8081,
terminal: 'iTerm.app',
appId: '',
appIdSuffix: '',
listDevices: true,
mode,
};
}

function apkDir(variantPath: string) {
return `/android/app/build/outputs/apk/${variantPath}`;
}

function mockMetadata(
buildDirectory: string,
elements: Array<{
outputFile: string;
filters?: Array<{filterType: string; value: string}>;
}>,
) {
(fs.readFileSync as jest.Mock).mockImplementation((filePath: string) => {
if (filePath === `${buildDirectory}/output-metadata.json`) {
return JSON.stringify({elements});
}
throw new Error('ENOENT');
});
}

beforeEach(() => {
jest.clearAllMocks();
(fs.existsSync as jest.Mock).mockReturnValue(false);
(fs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('ENOENT');
});
(adb.getAvailableCPUs as jest.Mock).mockReturnValue(['arm64-v8a']);
});

test('installs camelCase flavor APK using AGP output-metadata.json', () => {
const buildDirectory = apkDir('stagingInternal/debug');
const apkName = 'app-stagingInternal-debug.apk';

mockMetadata(buildDirectory, [{outputFile: apkName, filters: []}]);
(fs.existsSync as jest.Mock).mockImplementation(
(filePath: string) => filePath === `${buildDirectory}/${apkName}`,
);

tryInstallAppOnDevice(
flags('stagingInternalDebug'),
adbPath,
device,
androidProject,
);

expect(execa.sync).toHaveBeenCalledWith(
adbPath,
['-s', device, 'install', '-r', '-d', `${buildDirectory}/${apkName}`],
{stdio: 'inherit'},
);
});

test('prefers an ABI split matching the device over metadata order', () => {
const buildDirectory = apkDir('stagingInternal/debug');
const preferred = 'app-arm64-v8a-stagingInternal-debug.apk';
const other = 'app-armeabi-v7a-stagingInternal-debug.apk';

(adb.getAvailableCPUs as jest.Mock).mockReturnValue([
'arm64-v8a',
'armeabi-v7a',
]);
mockMetadata(buildDirectory, [
{
outputFile: other,
filters: [{filterType: 'ABI', value: 'armeabi-v7a'}],
},
{
outputFile: preferred,
filters: [{filterType: 'ABI', value: 'arm64-v8a'}],
},
]);
(fs.existsSync as jest.Mock).mockImplementation(
(filePath: string) =>
filePath === `${buildDirectory}/${preferred}` ||
filePath === `${buildDirectory}/${other}`,
);

tryInstallAppOnDevice(
flags('stagingInternalDebug'),
adbPath,
device,
androidProject,
);

expect(execa.sync).toHaveBeenCalledWith(
adbPath,
['-s', device, 'install', '-r', '-d', `${buildDirectory}/${preferred}`],
{stdio: 'inherit'},
);
});

test('falls back to the hyphenated filename when metadata is absent', () => {
const buildDirectory = apkDir('acmeStaging/debug');
const apkName = 'app-acme-staging-debug.apk';

(fs.existsSync as jest.Mock).mockImplementation(
(filePath: string) => filePath === `${buildDirectory}/${apkName}`,
);

tryInstallAppOnDevice(
flags('acmeStagingDebug'),
adbPath,
device,
androidProject,
);

expect(execa.sync).toHaveBeenCalledWith(
adbPath,
['-s', device, 'install', '-r', '-d', `${buildDirectory}/${apkName}`],
{stdio: 'inherit'},
);
});

test('throws when no matching APK is present', () => {
expect(() =>
tryInstallAppOnDevice(flags('debug'), adbPath, device, androidProject),
).toThrow('Failed to install the app on the device.');
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,70 @@ function tryInstallAppOnDevice(
}
}

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 = `${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);

// 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: string,
adbPath: string,
Expand All @@ -74,6 +138,19 @@ function getInstallApkName(
) {
const availableCPUs = adb.getAvailableCPUs(adbPath, device);

// AGP records the real output names next to the APKs, so prefer them
// over guessing from --mode
const apkNameFromMetadata = getApkNameFromMetadata(
buildDirectory,
availableCPUs,
);
if (
apkNameFromMetadata !== undefined &&
fs.existsSync(`${buildDirectory}/${apkNameFromMetadata}`)
) {
return apkNameFromMetadata;
}

// check if there is an apk file like app-armeabi-v7a-debug.apk
for (const availableCPU of availableCPUs.concat('universal')) {
const apkName = `${appName}-${availableCPU}-${variant}.apk`;
Expand Down
Loading