From 85c958dc7cdbc7e94d4c04e446aad77a462a95a6 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 22:13:59 +0200 Subject: [PATCH 1/2] feat(android): build only the ABIs of the devices being deployed to A `ns run android` with a single arm64 device still builds every ABI. This narrows the native build down to the ABIs of the devices it is about to deploy to, and picks the matching package when installing. - `GradleBuildService` passes `-PabiFilters=` built from the devices the build targets (honouring `--device`/`--emulator`). The app's gradle configuration decides what to do with it - typically an `ndk.abiFilters` or `splits` block in `App_Resources/Android/app.gradle`. An explicit `-PabiFilters` in `--gradleArgs` always wins. - `--no-filter-devices-arch` turns the narrowing off. `ns build` never narrows, since its artifact is meant to be shipped, and neither does an app bundle build, which carries every ABI anyway. - `Mobile.IDeviceInfo` gained `abis`, read on android from `ro.product.cpu.abilist64`/`abilist32`, falling back to `ro.product.cpu.abi` on old devices. - `AndroidProjectService.checkForChanges` marks the native project as changed when a connected device has no package of its own in the build output - a device that joins later would otherwise never get one, as the sources did not change. - `DeviceInstallAppService` installs the package matching the device's ABIs, falling back to the universal one and then to the newest package. - `copyLatestAppPackage` became `copyAppPackages`: a directory `--copy-to` target receives every package the build produced, a single file target receives the universal one. Co-Authored-By: Claude Opus 5 --- .../project/testing/debug-android.md | 1 + docs/man_pages/project/testing/run-android.md | 1 + lib/commands/build.ts | 7 +- lib/common/definitions/mobile.d.ts | 5 + lib/common/mobile/android/android-device.ts | 23 ++++ lib/controllers/build-controller.ts | 2 +- lib/data/build-data.ts | 4 + lib/declarations.d.ts | 6 + lib/definitions/build.d.ts | 3 +- lib/options.ts | 5 + lib/services/android-project-service.ts | 61 ++++++++- lib/services/android/gradle-build-service.ts | 46 +++++++ lib/services/build-artifacts-service.ts | 39 ++++-- .../device/device-install-app-service.ts | 46 ++++++- test/plugins-service.ts | 6 + test/services/android-project-service.ts | 6 + test/services/android/gradle-build-service.ts | 125 ++++++++++++++++++ 17 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 test/services/android/gradle-build-service.ts 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"))); + }); + }); +}); From 5ecb7c07a50374623dc4fea3a5277715ed8e0a8f Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Wed, 19 Aug 2026 14:26:09 +0200 Subject: [PATCH 2/2] feat(android): optionally pass the device ABIs to plugin builds `--filter-plugins-devices-arch` passes the same `-PabiFilters` the app build gets to the gradle build of every plugin built from source. Nothing in the gradle files the CLI generates for a plugin acts on the property, and this deliberately does not add such a block: what a plugin's native sources need per ABI is the plugin's business. It is there for a plugin whose own `include.gradle` reads `abiFilters` - a plugin with a long native build (an NDK/CMake one, say) can then build only the ABIs this run is about to deploy to instead of all four. Off by default. A narrowed aar is a partial artifact and the aar cache is keyed by the plugin sources, which do not change when a device with another ABI joins, so the ABIs are now part of the plugin build data the rebuild decision reads. Co-Authored-By: Claude Opus 5 (1M context) --- .../project/testing/debug-android.md | 1 + docs/man_pages/project/testing/run-android.md | 1 + lib/declarations.d.ts | 8 ++ lib/definitions/android-plugin-migrator.d.ts | 10 +++ lib/options.ts | 5 ++ lib/services/android-plugin-build-service.ts | 26 ++++++ lib/services/android-project-service.ts | 23 +++++ lib/services/android/devices-abis.ts | 23 +++++ lib/services/android/gradle-build-service.ts | 21 ++--- test/services/android-project-service.ts | 89 +++++++++++++++++++ 10 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 lib/services/android/devices-abis.ts diff --git a/docs/man_pages/project/testing/debug-android.md b/docs/man_pages/project/testing/debug-android.md index 2d51cae53d..34f4bb201c 100644 --- a/docs/man_pages/project/testing/debug-android.md +++ b/docs/man_pages/project/testing/debug-android.md @@ -39,6 +39,7 @@ Attach the debug tools to a running app in the native emulator | `$ ns debug and * `--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. +* `--filter-plugins-devices-arch` - If set, the ABIs of the connected devices are also passed to the gradle build of every plugin built from source. Nothing in the gradle files the CLI generates for a plugin acts on them - this is for a plugin whose own `include.gradle` reads the `abiFilters` property to shorten a long native build. * `--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 e1ee430a8c..7d674dc96f 100644 --- a/docs/man_pages/project/testing/run-android.md +++ b/docs/man_pages/project/testing/run-android.md @@ -44,6 +44,7 @@ Start a default emulator if none are running, or run application on all connecte * `--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. +* `--filter-plugins-devices-arch` - If set, the ABIs of the connected devices are also passed to the gradle build of every plugin built from source. Nothing in the gradle files the CLI generates for a plugin acts on them - this is for a plugin whose own `include.gradle` reads the `abiFilters` property to shorten a long native build. * `--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/declarations.d.ts b/lib/declarations.d.ts index 1a494560d6..819e1f5eb0 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -577,6 +577,14 @@ interface IAndroidOptions extends IEmbedOptions { * `--no-filter-devices-arch` to always build every ABI. */ filterDevicesArch: boolean; + + /** + * When true, the same ABIs are passed to the gradle build of every plugin + * that is built from source. Off by default - the CLI's own plugin gradle + * files ignore the property, only a plugin acting on it in its + * `include.gradle` gains anything from it. + */ + filterPluginsDevicesArch: boolean; gradlePath: string; gradleArgs: string; } diff --git a/lib/definitions/android-plugin-migrator.d.ts b/lib/definitions/android-plugin-migrator.d.ts index f5ad873307..646e8930a5 100644 --- a/lib/definitions/android-plugin-migrator.d.ts +++ b/lib/definitions/android-plugin-migrator.d.ts @@ -12,6 +12,7 @@ interface IAndroidBuildOptions { tempPluginDirPath: string; gradlePath?: string; gradleArgs?: string; + abiFilters?: string[]; } interface IAndroidPluginBuildService { @@ -49,4 +50,13 @@ interface IBuildAndroidPluginData extends Partial { * Optional custom Gradle arguments. */ gradleArgs?: string; + + /** + * The ABIs the build this plugin is prepared for is about to deploy to, + * passed to the plugin build as `-PabiFilters`. Nothing in the gradle files + * the CLI generates for a plugin acts on it - it is there for a plugin whose + * own `include.gradle` reads the property to skip the ABIs the build does + * not need. + */ + abiFilters?: string[]; } diff --git a/lib/options.ts b/lib/options.ts index 1e95653dd2..9a6669eee2 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -224,6 +224,11 @@ export class Options { default: true, hasSensitiveValue: false, }, + filterPluginsDevicesArch: { + type: OptionType.Boolean, + default: false, + 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-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 88098d55b0..33ab22e8a1 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -61,6 +61,8 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { private $watchIgnoreListService: IWatchIgnoreListService, ) {} + private static ABI_FILTERS_BUILD_DATA_KEY = "__abiFilters"; + private static MANIFEST_ROOT = { $: { "xmlns:android": "http://schemas.android.com/apk/res/android", @@ -233,6 +235,16 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { shortPluginName, ); + // the aar of a plugin built for a subset of the ABIs is not the aar of the + // same sources built for another subset, so the ABIs take part in the + // decision to rebuild - the sources alone would not change when a device + // with another ABI joins the run. + if (options.abiFilters && options.abiFilters.length) { + pluginSourceFileHashesInfo[ + AndroidPluginBuildService.ABI_FILTERS_BUILD_DATA_KEY + ] = options.abiFilters.join(","); + } + const shouldBuildAar = await this.shouldBuildAar({ manifestFilePath, androidSourceDirectories, @@ -264,6 +276,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { await this.buildPlugin({ gradlePath: options.gradlePath, gradleArgs: options.gradleArgs, + abiFilters: options.abiFilters, pluginDir: pluginTempDir, pluginName: options.pluginName, projectDir: options.projectDir, @@ -821,6 +834,19 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { localArgs.push(pluginBuildSettings.gradleArgs); } + // nothing in the gradle files generated here acts on `abiFilters` - it is + // passed for a plugin whose own include.gradle reads it to narrow a long + // native build down. An explicit `-PabiFilters` in the gradle args wins. + if ( + pluginBuildSettings.abiFilters && + pluginBuildSettings.abiFilters.length && + (pluginBuildSettings.gradleArgs || "").indexOf("-PabiFilters") === -1 + ) { + localArgs.push( + `-PabiFilters=${pluginBuildSettings.abiFilters.join(",")}` + ); + } + if (this.$logger.getLevel() === "INFO") { localArgs.push("--quiet"); } diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index 851c305816..b4218ea66b 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -49,6 +49,7 @@ import { injector } from "../common/yok"; import { INotConfiguredEnvOptions } from "../common/definitions/commands"; import { AndroidPrepareData } from "../data/prepare-data"; import { IProjectChangesInfo } from "../definitions/project-changes"; +import { getDevicesAbis } from "./android/devices-abis"; interface NativeDependency { name: string; @@ -695,6 +696,7 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject const options: IPluginBuildOptions = { gradlePath: this.$options.gradlePath, gradleArgs: this.$options.gradleArgs, + abiFilters: this.getPluginsAbiFilters(), projectDir: projectData.projectDir, pluginName: pluginData.name, platformsAndroidDirPath: pluginPlatformsFolderPath, @@ -710,6 +712,27 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject } } + /** + * The ABIs passed to the gradle build of a plugin built from source. Opt-in + * (`--filter-plugins-devices-arch`): nothing in the gradle files the CLI + * generates for a plugin acts on `abiFilters`, so this is only useful for a + * plugin whose own `include.gradle` reads the property - a long native build + * can then skip the ABIs this run is not going to deploy to. + */ + private getPluginsAbiFilters(): string[] { + if (!this.$options.filterPluginsDevicesArch) { + return null; + } + + const abis = getDevicesAbis( + this.$devicesService, + this.$devicePlatformsConstants.Android, + { device: this.$options.device, emulator: this.$options.emulator } + ); + + return abis.length ? abis : null; + } + public async processConfigurationFilesFromAppResources(): Promise { return; } diff --git a/lib/services/android/devices-abis.ts b/lib/services/android/devices-abis.ts new file mode 100644 index 0000000000..f26f7ef4cc --- /dev/null +++ b/lib/services/android/devices-abis.ts @@ -0,0 +1,23 @@ +import * as _ from "lodash"; + +/** + * The ABIs of the devices a build is about to be deployed to - the first (most + * preferred) ABI of every device, deduplicated. `device`/`emulator` narrow the + * set down the same way they narrow the run itself. + */ +export function getDevicesAbis( + $devicesService: Mobile.IDevicesService, + platform: string, + filter: { device?: string; emulator?: boolean } = {} +): string[] { + let devices = $devicesService.getDevicesForPlatform(platform); + if (filter.device) { + devices = devices.filter((d) => d.deviceInfo.identifier === filter.device); + } else if (filter.emulator) { + devices = devices.filter((d) => d.isEmulator); + } + + return _.uniq( + devices.map((d) => (d.deviceInfo.abis || [])[0]).filter((abi) => !!abi) + ); +} diff --git a/lib/services/android/gradle-build-service.ts b/lib/services/android/gradle-build-service.ts index 820740e133..93b57c8e72 100644 --- a/lib/services/android/gradle-build-service.ts +++ b/lib/services/android/gradle-build-service.ts @@ -9,6 +9,7 @@ import { import { IAndroidBuildData } from "../../definitions/build"; import { IChildProcess } from "../../common/declarations"; import { injector } from "../../common/yok"; +import { getDevicesAbis } from "./devices-abis"; import * as _ from "lodash"; export class GradleBuildService @@ -75,22 +76,10 @@ export class GradleBuildService 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) - ); + const abis = getDevicesAbis(this.$devicesService, buildData.platform, { + device: buildData.device, + emulator: buildData.emulator, + }); if (abis.length) { buildTaskArgs.push(`-PabiFilters=${abis.join(",")}`); diff --git a/test/services/android-project-service.ts b/test/services/android-project-service.ts index f775907e38..f84e413103 100644 --- a/test/services/android-project-service.ts +++ b/test/services/android-project-service.ts @@ -19,6 +19,7 @@ import { IFileSystem, IProjectDir, } from "../../lib/common/declarations"; +import { IPluginBuildOptions } from "../../lib/definitions/android-plugin-migrator"; const createTestInjector = (): IInjector => { const testInjector = new Yok(); @@ -410,3 +411,91 @@ describe("androidProjectService", () => { }); }); }); + +describe("androidProjectService plugins abi filtering", () => { + const createDevice = ( + identifier: string, + abis: string[], + isEmulator = false + ): any => ({ + deviceInfo: { identifier, abis, platform: "android" }, + isEmulator, + }); + + const preparePluginNativeCode = async ( + options: any, + devices: any[] + ): Promise => { + const testInjector = createTestInjector(); + let pluginBuildOptions: IPluginBuildOptions = null; + testInjector.register("androidPluginBuildService", { + buildAar: async (opts: IPluginBuildOptions): Promise => { + pluginBuildOptions = opts; + return false; + }, + migrateIncludeGradle: (): boolean => false, + }); + testInjector.register("options", { + hostProjectModuleName: "app", + ...options, + }); + testInjector.register("devicesService", { + getDevicesForPlatform: (): any[] => devices, + }); + testInjector.register("devicePlatformsConstants", { Android: "Android" }); + + const androidProjectService: IPlatformProjectService = testInjector.resolve( + "androidProjectService" + ); + await androidProjectService.preparePluginNativeCode( + { + name: "my-plugin", + pluginPlatformsFolderPath: (): string => "pluginPlatformsDir", + }, + { projectDir: "projectDir", platformsDir: "platformsDir" } + ); + + return pluginBuildOptions; + }; + + it("passes the abis of the connected devices when the option is set", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true }, + [ + createDevice("device1", ["arm64-v8a", "armeabi-v7a"]), + createDevice("emulator1", ["x86_64", "x86"], true), + ] + ); + + assert.deepStrictEqual(options.abiFilters, ["arm64-v8a", "x86_64"]); + }); + + it("passes the abi of the selected device only", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true, device: "device1" }, + [ + createDevice("device1", ["arm64-v8a"]), + createDevice("emulator1", ["x86_64"], true), + ] + ); + + assert.deepStrictEqual(options.abiFilters, ["arm64-v8a"]); + }); + + it("passes no abis when the option is not set", async () => { + const options = await preparePluginNativeCode({}, [ + createDevice("device1", ["arm64-v8a"]), + ]); + + assert.isNull(options.abiFilters); + }); + + it("passes no abis when no device reports its abis", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true }, + [createDevice("device1", [])] + ); + + assert.isNull(options.abiFilters); + }); +});