diff --git a/docs/man_pages/project/testing/debug-android.md b/docs/man_pages/project/testing/debug-android.md index cd34d57c66..2d51cae53d 100644 --- a/docs/man_pages/project/testing/debug-android.md +++ b/docs/man_pages/project/testing/debug-android.md @@ -38,6 +38,7 @@ Attach the debug tools to a running app in the native emulator | `$ ns debug and * `--env.sourceMap` - creates inline source maps. * `--env.hiddenSourceMap` - creates sources maps in the root folder (useful for Crashlytics usage with bundled app in release). * `--aab` - Specifies that the command will produce and deploy an Android App Bundle. +* `--no-filter-devices-arch` - If set, builds every ABI instead of only the ones the connected devices report. The narrowing only applies when the app's gradle configuration acts on the `abiFilters` property, and `ns build` never narrows. * `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`. <% if(isHtml) { %> diff --git a/docs/man_pages/project/testing/run-android.md b/docs/man_pages/project/testing/run-android.md index c895cd8103..e1ee430a8c 100644 --- a/docs/man_pages/project/testing/run-android.md +++ b/docs/man_pages/project/testing/run-android.md @@ -43,6 +43,7 @@ Start a default emulator if none are running, or run application on all connecte * `--env.sourceMap` - creates inline source maps. * `--env.hiddenSourceMap` - creates sources maps in the root folder (useful for Crashlytics usage with bundled app in release). * `--aab` - Specifies that the command will produce and deploy an Android App Bundle. +* `--no-filter-devices-arch` - If set, builds every ABI instead of only the ones the connected devices report. The narrowing only applies when the app's gradle configuration acts on the `abiFilters` property, and `ns build` never narrows. * `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`. <% if(isHtml) { %> diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 7216e8a3fc..4ce6c0ef72 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -53,7 +53,12 @@ export abstract class BuildCommandBase extends ValidatePlatformCommandBase { const buildData = this.$buildDataService.getBuildData( this.$projectData.projectDir, platform, - this.$options, + { + ...this.$options.argv, + // `ns build` produces an artifact meant to be shipped, so it must + // not be narrowed down to the ABIs of whatever is plugged in + filterDevicesArch: false, + }, ); const outputPath = await this.$buildController.prepareAndBuild(buildData); diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index a658dd0c17..c6d3c27b3a 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -100,6 +100,11 @@ declare global { * For iOS simulators - same as the identifier. */ imageIdentifier?: string; + /** + * Optional property listing the ABIs the device supports, most + * preferred first. Available for Android only. + */ + abis?: string[]; } interface IDeviceError extends Error, IDeviceIdentifier {} diff --git a/lib/common/mobile/android/android-device.ts b/lib/common/mobile/android/android-device.ts index b230e02827..ec17423c29 100644 --- a/lib/common/mobile/android/android-device.ts +++ b/lib/common/mobile/android/android-device.ts @@ -13,6 +13,9 @@ interface IAndroidDeviceDetails { name: string; release: string; brand: string; + "cpu.abi"?: string; + "cpu.abilist32"?: string; + "cpu.abilist64"?: string; } interface IAdbDeviceStatusInfo { @@ -96,6 +99,7 @@ export class AndroidDevice implements Mobile.IAndroidDevice { identifier: this.identifier, displayName: details.name, model: details.model, + abis: this.getAbis(details), version, vendor: details.brand, platform: this.$devicePlatformsConstants.Android, @@ -179,6 +183,25 @@ export class AndroidDevice implements Mobile.IAndroidDevice { return parsedDetails; } + // `ro.product.cpu.abilist64`/`abilist32` list every ABI the device supports, + // most preferred first. Old devices report neither and only have the single + // `ro.product.cpu.abi`. + private getAbis(details: IAndroidDeviceDetails): string[] { + const abis = [ + ...(details["cpu.abilist64"] || "").split(","), + ...(details["cpu.abilist32"] || "").split(",") + ] + .map((abi) => abi.trim()) + .filter((abi) => !!abi); + + if (abis.length) { + return abis; + } + + const abi = (details["cpu.abi"] || "").trim(); + return abi ? [abi] : []; + } + private getIsTablet(details: any): boolean { //version 3.x.x (also known as Honeycomb) is a tablet only version return ( diff --git a/lib/controllers/build-controller.ts b/lib/controllers/build-controller.ts index f5ebb1666c..291ed88f12 100644 --- a/lib/controllers/build-controller.ts +++ b/lib/controllers/build-controller.ts @@ -116,7 +116,7 @@ export class BuildController extends EventEmitter implements IBuildController { ); if (buildData.copyTo) { - this.$buildArtifactsService.copyLatestAppPackage( + this.$buildArtifactsService.copyAppPackages( buildData.copyTo, platformData, buildData diff --git a/lib/data/build-data.ts b/lib/data/build-data.ts index f6b2734174..d95f1e356a 100644 --- a/lib/data/build-data.ts +++ b/lib/data/build-data.ts @@ -51,6 +51,7 @@ export class AndroidBuildData extends BuildData { public keyStoreAliasPassword: string; public keyStorePassword: string; public androidBundle: boolean; + public buildFilterDevicesArch: boolean; public gradlePath: string; public gradleArgs: string; public hostProjectPath: string; @@ -63,6 +64,9 @@ export class AndroidBuildData extends BuildData { this.keyStoreAliasPassword = data.keyStoreAliasPassword; this.keyStorePassword = data.keyStorePassword; this.androidBundle = data.androidBundle || data.aab; + // an app bundle already carries every ABI, so there is nothing to filter + this.buildFilterDevicesArch = + !this.androidBundle && data.filterDevicesArch !== false; this.gradlePath = data.gradlePath; this.gradleArgs = data.gradleArgs; this.hostProjectPath = data.hostProjectPath; diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 8e58d23875..1a494560d6 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -571,6 +571,12 @@ interface IEmbedOptions { } interface IAndroidOptions extends IEmbedOptions { + /** + * When true (the default) `ns run`/`ns debug` restrict the native build to + * the ABIs of the devices it is about to deploy to. Pass + * `--no-filter-devices-arch` to always build every ABI. + */ + filterDevicesArch: boolean; gradlePath: string; gradleArgs: string; } diff --git a/lib/definitions/build.d.ts b/lib/definitions/build.d.ts index e64a318c6d..ab9c5bf350 100644 --- a/lib/definitions/build.d.ts +++ b/lib/definitions/build.d.ts @@ -31,6 +31,7 @@ interface IAndroidBuildData extends IBuildData, IAndroidSigningData, IHasAndroidBundle { + buildFilterDevicesArch?: boolean; gradlePath?: string; gradleArgs?: string; } @@ -62,7 +63,7 @@ interface IBuildArtifactsService { platformData: IPlatformData, buildOutputOptions: IBuildOutputOptions ): Promise; - copyLatestAppPackage( + copyAppPackages( targetPath: string, platformData: IPlatformData, buildOutputOptions: IBuildOutputOptions diff --git a/lib/options.ts b/lib/options.ts index 64a4e644e7..1e95653dd2 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -219,6 +219,11 @@ export class Options { default: false, hasSensitiveValue: false, }, + filterDevicesArch: { + type: OptionType.Boolean, + default: true, + hasSensitiveValue: false, + }, gradlePath: { type: OptionType.String, hasSensitiveValue: false }, gradleArgs: { type: OptionType.String, hasSensitiveValue: false }, hostProjectPath: { type: OptionType.String, hasSensitiveValue: false }, diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index b0e53a53e5..851c305816 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -47,6 +47,8 @@ import { import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import { INotConfiguredEnvOptions } from "../common/definitions/commands"; +import { AndroidPrepareData } from "../data/prepare-data"; +import { IProjectChangesInfo } from "../definitions/project-changes"; interface NativeDependency { name: string; @@ -148,7 +150,9 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject private $androidPluginBuildService: IAndroidPluginBuildService, private $platformEnvironmentRequirements: IPlatformEnvironmentRequirements, private $androidResourcesMigrationService: IAndroidResourcesMigrationService, + private $devicesService: Mobile.IDevicesService, private $filesHashService: IFilesHashService, + private $liveSyncProcessDataService: ILiveSyncProcessDataService, private $gradleCommandService: IGradleCommandService, private $gradleBuildService: IGradleBuildService, private $analyticsService: IAnalyticsService @@ -835,8 +839,61 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject await adb.executeShellCommand(["rm", "-rf", deviceRootPath]); } - public async checkForChanges(): Promise { - // Nothing android specific to check yet. + /** + * When the native build is narrowed down to the ABIs of the connected + * devices, a device that joins later has no package of its own in the build + * output. Nothing else would trigger a native rebuild for it - the sources + * did not change - so flag it here. + */ + public async checkForChanges( + changesInfo: IProjectChangesInfo, + prepareData: AndroidPrepareData, + projectData: IProjectData + ): Promise { + if (changesInfo.nativeChanged) { + return; + } + + const platformData = this.getPlatformData(projectData); + const deviceDescriptors = this.$liveSyncProcessDataService.getDeviceDescriptors( + projectData.projectDir + ); + + for (const deviceDescriptor of deviceDescriptors) { + const buildData = deviceDescriptor.buildData; + if (!buildData || !buildData.buildFilterDevicesArch) { + continue; + } + + const packagesOutputPath = platformData.getBuildOutputPath(buildData); + if (!this.$fs.exists(packagesOutputPath)) { + continue; + } + + const builtPackages = this.$fs.readDirectory(packagesOutputPath); + // a universal package runs on every device, nothing to rebuild + if (_.some(builtPackages, (f) => f.indexOf("universal") !== -1)) { + continue; + } + + const device = _.find( + this.$devicesService.getDevicesForPlatform(buildData.platform), + (d) => d.deviceInfo.identifier === deviceDescriptor.identifier + ); + const abi = device && (device.deviceInfo.abis || [])[0]; + if (!abi) { + continue; + } + + const abiRegex = new RegExp(`${abi}.*\\.apk$`); + if (!_.some(builtPackages, (entry) => abiRegex.test(entry))) { + this.$logger.trace( + `No package was built for '${abi}', marking the native project as changed.` + ); + changesInfo.nativeChanged = true; + return; + } + } } public getDeploymentTarget(projectData: IProjectData): semver.SemVer { diff --git a/lib/services/android/gradle-build-service.ts b/lib/services/android/gradle-build-service.ts index 4ce97ab89a..820740e133 100644 --- a/lib/services/android/gradle-build-service.ts +++ b/lib/services/android/gradle-build-service.ts @@ -9,12 +9,14 @@ import { import { IAndroidBuildData } from "../../definitions/build"; import { IChildProcess } from "../../common/declarations"; import { injector } from "../../common/yok"; +import * as _ from "lodash"; export class GradleBuildService extends EventEmitter implements IGradleBuildService { constructor( private $childProcess: IChildProcess, + private $devicesService: Mobile.IDevicesService, private $gradleBuildArgsService: IGradleBuildArgsService, private $gradleCommandService: IGradleCommandService ) { @@ -28,6 +30,9 @@ export class GradleBuildService const buildTaskArgs = await this.$gradleBuildArgsService.getBuildTaskArgs( buildData ); + + this.applyDevicesAbiFilter(buildTaskArgs, buildData); + const spawnOptions = { emitOptions: { eventName: constants.BUILD_OUTPUT_EVENT_NAME }, throwError: true, @@ -51,6 +56,47 @@ export class GradleBuildService ); } + /** + * Narrows the native build down to the ABIs of the devices this build is + * about to be deployed to. The app's gradle configuration decides what to do + * with `abiFilters` - typically an `ndk.abiFilters`/`splits` block in + * `App_Resources/Android/app.gradle`. An explicitly passed `-PabiFilters` + * always wins. + */ + private applyDevicesAbiFilter( + buildTaskArgs: string[], + buildData: IAndroidBuildData + ): void { + if (!buildData.buildFilterDevicesArch) { + return; + } + + if (_.some(buildTaskArgs, (arg) => arg.startsWith("-PabiFilters"))) { + return; + } + + let devices = this.$devicesService.getDevicesForPlatform( + buildData.platform + ); + if (buildData.device) { + devices = devices.filter( + (d) => d.deviceInfo.identifier === buildData.device + ); + } else if (buildData.emulator) { + devices = devices.filter((d) => d.isEmulator); + } + + const abis = _.uniq( + devices + .map((d) => (d.deviceInfo.abis || [])[0]) + .filter((abi) => !!abi) + ); + + if (abis.length) { + buildTaskArgs.push(`-PabiFilters=${abis.join(",")}`); + } + } + public async cleanProject( projectRoot: string, buildData: IAndroidBuildData diff --git a/lib/services/build-artifacts-service.ts b/lib/services/build-artifacts-service.ts index 1a2bfc94af..04df64f8cf 100644 --- a/lib/services/build-artifacts-service.ts +++ b/lib/services/build-artifacts-service.ts @@ -75,7 +75,12 @@ export class BuildArtifactsService implements IBuildArtifactsService { return []; } - public copyLatestAppPackage( + /** + * Copies what the build produced to `targetPath`. A build can produce more + * than one package - an app split per ABI - so a directory target receives + * all of them, while a single file target receives the universal one. + */ + public copyAppPackages( targetPath: string, platformData: IPlatformData, buildOutputOptions: IBuildOutputOptions @@ -85,26 +90,36 @@ export class BuildArtifactsService implements IBuildArtifactsService { const outputPath = buildOutputOptions.outputPath || platformData.getBuildOutputPath(buildOutputOptions); - const applicationPackage = this.getLatestApplicationPackage( + const applicationPackages = this.getAllAppPackages( outputPath, platformData.getValidBuildOutputData(buildOutputOptions) ); - const packageFile = applicationPackage.packageName; this.$fs.ensureDirectoryExists(path.dirname(targetPath)); - if ( - this.$fs.exists(targetPath) && - this.$fs.getFsStats(targetPath).isDirectory() - ) { - const sourceFileName = path.basename(packageFile); + const targetIsDirectory = + (this.$fs.exists(targetPath) && + this.$fs.getFsStats(targetPath).isDirectory()) || + !path.extname(targetPath); + + let packagesToCopy = applicationPackages; + if (!targetIsDirectory && applicationPackages.length > 1) { this.$logger.trace( - `Specified target path: '${targetPath}' is directory. Same filename will be used: '${sourceFileName}'.` + `Specified target path: '${targetPath}' is a single file, but the build produced ${applicationPackages.length} packages. Only the universal one will be copied.` + ); + packagesToCopy = applicationPackages.filter((pack) => + path.basename(pack.packageName).includes("universal") ); - targetPath = path.join(targetPath, sourceFileName); } - this.$fs.copyFile(packageFile, targetPath); - this.$logger.info(`Copied file '${packageFile}' to '${targetPath}'.`); + + _.each(packagesToCopy, (pack) => { + const packageFile = pack.packageName; + const targetFilePath = targetIsDirectory + ? path.join(targetPath, path.basename(packageFile)) + : targetPath; + this.$fs.copyFile(packageFile, targetFilePath); + this.$logger.info(`Copied file '${packageFile}' to '${targetFilePath}'.`); + }); } private getLatestApplicationPackage( diff --git a/lib/services/device/device-install-app-service.ts b/lib/services/device/device-install-app-service.ts index 4c5d16b6f3..798917e892 100644 --- a/lib/services/device/device-install-app-service.ts +++ b/lib/services/device/device-install-app-service.ts @@ -51,7 +51,8 @@ export class DeviceInstallAppService { }); if (!packageFile) { - packageFile = await this.$buildArtifactsService.getLatestAppPackagePath( + packageFile = await this.getPackageForDevice( + device, platformData, buildData ); @@ -92,6 +93,49 @@ export class DeviceInstallAppService { ); } + /** + * A build narrowed down to the connected devices' ABIs produces one package + * per ABI, so the one this device can run has to be picked. Falls back to + * the universal package and then to the newest one, which is what a build + * that was not split produces anyway. + */ + private async getPackageForDevice( + device: Mobile.IDevice, + platformData: IPlatformData, + buildData: IBuildData + ): Promise { + const outputPath = + buildData.outputPath || platformData.getBuildOutputPath(buildData); + const packages = this.$buildArtifactsService.getAllAppPackages( + outputPath, + platformData.getValidBuildOutputData(buildData) + ); + + if (packages.length > 1) { + const abis = device.deviceInfo.abis || []; + for (const abi of abis) { + const match = packages.find((p) => + path.basename(p.packageName).includes(abi) + ); + if (match) { + return match.packageName; + } + } + + const universalPackage = packages.find((p) => + path.basename(p.packageName).includes("universal") + ); + if (universalPackage) { + return universalPackage.packageName; + } + } + + return this.$buildArtifactsService.getLatestAppPackagePath( + platformData, + buildData + ); + } + public async installOnDeviceIfNeeded( device: Mobile.IDevice, buildData: IBuildData, diff --git a/test/plugins-service.ts b/test/plugins-service.ts index 85bc40641d..4393a66817 100644 --- a/test/plugins-service.ts +++ b/test/plugins-service.ts @@ -185,6 +185,12 @@ function createTestInjector() { ); testInjector.register("platformEnvironmentRequirements", {}); + testInjector.register("devicesService", { + getDevicesForPlatform: (): Mobile.IDevice[] => [], + }); + testInjector.register("liveSyncProcessDataService", { + getDeviceDescriptors: (): ILiveSyncDeviceDescriptor[] => [], + }); testInjector.register("filesHashService", { hasChangesInShasums: ( oldPluginNativeHashes: IStringDictionary, diff --git a/test/services/android-project-service.ts b/test/services/android-project-service.ts index 1961548012..f775907e38 100644 --- a/test/services/android-project-service.ts +++ b/test/services/android-project-service.ts @@ -38,6 +38,12 @@ const createTestInjector = (): IInjector => { testInjector.register("filesHashService", { saveHashesForProject: () => ({}), }); + testInjector.register("devicesService", { + getDevicesForPlatform: (): Mobile.IDevice[] => [], + }); + testInjector.register("liveSyncProcessDataService", { + getDeviceDescriptors: (): ILiveSyncDeviceDescriptor[] => [], + }); testInjector.register("androidPluginBuildService", {}); testInjector.register("errors", stubs.ErrorsStub); testInjector.register("logger", stubs.LoggerStub); diff --git a/test/services/android/gradle-build-service.ts b/test/services/android/gradle-build-service.ts new file mode 100644 index 0000000000..d3185d9ba2 --- /dev/null +++ b/test/services/android/gradle-build-service.ts @@ -0,0 +1,125 @@ +import { Yok } from "../../../lib/common/yok"; +import { GradleBuildService } from "../../../lib/services/android/gradle-build-service"; +import { assert } from "chai"; +import { IInjector } from "../../../lib/common/definitions/yok"; +import { IAndroidBuildData } from "../../../lib/definitions/build"; + +const createDevice = ( + identifier: string, + abis: string[], + isEmulator = false, +): any => ({ + deviceInfo: { identifier, abis, platform: "android" }, + isEmulator, +}); + +function createTestInjector(devices: any[]): IInjector { + const injector = new Yok(); + injector.register("childProcess", { + on: (): void => undefined, + removeListener: (): void => undefined, + }); + injector.register("devicesService", { + getDevicesForPlatform: () => devices, + }); + injector.register("gradleBuildArgsService", { + getBuildTaskArgs: async () => ["assembleDebug"], + getCleanTaskArgs: () => ["clean"], + getBuildLoggingArgs: (): string[] => [], + }); + injector.register("gradleCommandService", { + executeCommand: async (args: string[]): Promise => { + executedArgs = args; + return null; + }, + }); + injector.register("gradleBuildService", GradleBuildService); + + return injector; +} + +let executedArgs: string[] = null; + +const buildProject = async ( + devices: any[], + buildData: Partial, +): Promise => { + executedArgs = null; + const injector = createTestInjector(devices); + const gradleBuildService = injector.resolve("gradleBuildService"); + await gradleBuildService.buildProject("projectRoot", { + platform: "android", + ...buildData, + }); + + return executedArgs; +}; + +describe("GradleBuildService", () => { + describe("abi filtering", () => { + it("passes the abis of the connected devices", async () => { + const args = await buildProject( + [ + createDevice("device1", ["arm64-v8a", "armeabi-v7a"]), + createDevice("emulator1", ["x86_64", "x86"], true), + ], + { buildFilterDevicesArch: true }, + ); + + assert.include(args, "-PabiFilters=arm64-v8a,x86_64"); + }); + + it("passes the abi of the selected device only", async () => { + const args = await buildProject( + [ + createDevice("device1", ["arm64-v8a"]), + createDevice("emulator1", ["x86_64"], true), + ], + { buildFilterDevicesArch: true, device: "device1" }, + ); + + assert.include(args, "-PabiFilters=arm64-v8a"); + }); + + it("passes the abis of the emulators only when --emulator is used", async () => { + const args = await buildProject( + [ + createDevice("device1", ["arm64-v8a"]), + createDevice("emulator1", ["x86_64"], true), + ], + { buildFilterDevicesArch: true, emulator: true }, + ); + + assert.include(args, "-PabiFilters=x86_64"); + }); + + it("deduplicates the abis", async () => { + const args = await buildProject( + [ + createDevice("device1", ["arm64-v8a"]), + createDevice("device2", ["arm64-v8a"]), + ], + { buildFilterDevicesArch: true }, + ); + + assert.include(args, "-PabiFilters=arm64-v8a"); + }); + + it("passes nothing when the filtering is off", async () => { + const args = await buildProject( + [createDevice("device1", ["arm64-v8a"])], + { buildFilterDevicesArch: false }, + ); + + assert.isUndefined(args.find((a) => a.startsWith("-PabiFilters"))); + }); + + it("passes nothing when no device reports its abis", async () => { + const args = await buildProject([createDevice("device1", [])], { + buildFilterDevicesArch: true, + }); + + assert.isUndefined(args.find((a) => a.startsWith("-PabiFilters"))); + }); + }); +});