From 85c958dc7cdbc7e94d4c04e446aad77a462a95a6 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 22:13:59 +0200 Subject: [PATCH 1/4] 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 bc7375b506e7c78c90bfb3b42066a49a5995b512 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 22:21:22 +0200 Subject: [PATCH 2/4] feat(android): per-plugin build options, with aarSuffix to break name clashes The name of the `.aar` built for a plugin comes from `getShortPluginName`, which drops the npm scope. `@foo/plugin-x` and `@bar/plugin-x` therefore both build a `plugin_x.aar` into their own platforms folder, and the one that gradle picks up depends on which was built last. A project can now give one of them a suffix: ```js export default { android: { plugins: { "@bar/plugin-x": { aarSuffix: "-bar" }, }, }, } satisfies NativeScriptConfig; ``` `android.plugins` is a map keyed by npm package name, spread into the options `buildAar` receives, so it is the place to put future per-plugin build settings too. The suffix is appended to the plugin name before it is shortened, and the resulting name is used consistently - for the temp build directory, the produced `.aar` and the namespace fallback, which `setupGradle` used to recompute without it. Co-Authored-By: Claude Opus 5 --- lib/definitions/android-plugin-migrator.d.ts | 7 ++++ lib/definitions/project.d.ts | 16 ++++++++ lib/services/android-plugin-build-service.ts | 10 +++-- lib/services/android-project-service.ts | 3 ++ test/services/android-plugin-build-service.ts | 37 +++++++++++++++++-- 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/lib/definitions/android-plugin-migrator.d.ts b/lib/definitions/android-plugin-migrator.d.ts index f5ad873307..c5b81a3213 100644 --- a/lib/definitions/android-plugin-migrator.d.ts +++ b/lib/definitions/android-plugin-migrator.d.ts @@ -12,6 +12,13 @@ interface IAndroidBuildOptions { tempPluginDirPath: string; gradlePath?: string; gradleArgs?: string; + /** + * Appended to the plugin name before it is shortened into the name of the + * produced `.aar`. The npm scope is dropped when shortening, so two plugins + * from different scopes can end up with the same `.aar` - a suffix tells + * them apart. + */ + aarSuffix?: string; } interface IAndroidPluginBuildService { diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 7ab75c5b0f..82794dded0 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -8,6 +8,7 @@ import { import { ICheckEnvironmentRequirementsOutput, IPlatformData } from "./platform"; import { IPluginData, IBasePluginData } from "./plugins"; import { + IDictionary, IStringDictionary, IProjectDir, IDeviceIdentifier, @@ -179,6 +180,21 @@ interface INsConfigAndroid extends INsConfigPlaform { * Custom runtime package name */ runtimePackageName?: string; + + /** + * Per plugin build options, keyed by the plugin's npm package name. + */ + plugins?: IDictionary; +} + +interface INsConfigAndroidPlugin { + /** + * Appended to the plugin name before it is shortened into the name of the + * produced `.aar`. The npm scope is dropped when shortening, so + * `@foo/plugin` and `@bar/plugin` both build a `plugin.aar` and overwrite + * each other - a suffix tells them apart. + */ + aarSuffix?: string; } interface INsConfigHooks { diff --git a/lib/services/android-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 88098d55b0..34bd48a919 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -226,7 +226,11 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { const androidSourceDirectories = this.getAndroidSourceDirectories( options.platformsAndroidDirPath, ); - const shortPluginName = getShortPluginName(options.pluginName); + // the npm scope is dropped when shortening, so an optional suffix is what + // keeps two same-named plugins from overwriting each other's `.aar` + const shortPluginName = getShortPluginName( + `${options.pluginName}${options.aarSuffix || ""}`, + ); const pluginTempDir = path.join(options.tempPluginDirPath, shortPluginName); const pluginSourceFileHashesInfo = await this.getSourceFilesHashes( options.platformsAndroidDirPath, @@ -260,6 +264,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { options.platformsAndroidDirPath, options.projectDir, options.pluginName, + shortPluginName, ); await this.buildPlugin({ gradlePath: options.gradlePath, @@ -401,6 +406,7 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { platformsAndroidDirPath: string, projectDir: string, pluginName: string, + shortPluginName: string, ): Promise { const gradleTemplatePath = path.resolve( path.join(__dirname, "../../vendor/gradle-plugin"), @@ -425,8 +431,6 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { this.replaceFileContent(settingsGradlePath, "{{pluginName}}", pluginName); // gets the package from the AndroidManifest to use as the namespace or fallback to the `org.nativescript.${shortPluginName}` - const shortPluginName = getShortPluginName(pluginName); - const manifestPath = path.join( pluginTempDir, "src", diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index b0e53a53e5..b07a0d55e9 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -688,6 +688,8 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject AndroidProjectService.ANDROID_PLATFORM_NAME ); if (this.$fs.exists(pluginPlatformsFolderPath)) { + const pluginConfig = + (projectData.nsConfig?.android?.plugins || {})[pluginData.name] || {}; const options: IPluginBuildOptions = { gradlePath: this.$options.gradlePath, gradleArgs: this.$options.gradleArgs, @@ -696,6 +698,7 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject platformsAndroidDirPath: pluginPlatformsFolderPath, aarOutputDir: pluginPlatformsFolderPath, tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), + ...pluginConfig, }; if (await this.$androidPluginBuildService.buildAar(options)) { diff --git a/test/services/android-plugin-build-service.ts b/test/services/android-plugin-build-service.ts index c2107cf4c1..16621473c1 100644 --- a/test/services/android-plugin-build-service.ts +++ b/test/services/android-plugin-build-service.ts @@ -24,6 +24,7 @@ describe("androidPluginBuildService", () => { const pluginName = "my-plugin"; const shortPluginName = getShortPluginName(pluginName); let spawnFromEventCalled = false; + let builtPluginDirName: string = null; let fs: IFileSystem; let androidBuildPluginService: AndroidPluginBuildService; let tempFolder: string; @@ -46,6 +47,7 @@ describe("androidPluginBuildService", () => { }): IPluginBuildOptions { options = options || {}; spawnFromEventCalled = false; + builtPluginDirName = null; tempFolder = mkdtempSync( path.join(tmpdir(), "androidPluginBuildService-temp-"), ); @@ -75,11 +77,16 @@ describe("androidPluginBuildService", () => { const testInjector: IInjector = new stubs.InjectorStub(); testInjector.register("fs", FsLib.FileSystem); testInjector.register("childProcess", { - spawnFromEvent: async (command: string): Promise => { - const finalAarName = `${shortPluginName}-release.aar`; + spawnFromEvent: async ( + command: string, + args: string[], + ): Promise => { + // the plugin dir gradle was pointed at is what names the built aar + const pluginDir = args[args.indexOf("-p") + 1]; + builtPluginDirName = path.basename(pluginDir); + const finalAarName = `${builtPluginDirName}-release.aar`; const aar = path.join( - tempFolder, - shortPluginName, + pluginDir, "build", "outputs", "aar", @@ -269,6 +276,28 @@ dependencies { assert.isTrue(spawnFromEventCalled); }); + it("builds an aar named after the plugin", async () => { + const config: IPluginBuildOptions = setup({ addManifest: true }); + + await androidBuildPluginService.buildAar(config); + + assert.deepStrictEqual(builtPluginDirName, shortPluginName); + assert.isTrue( + fs.exists(path.join(pluginFolder, `${shortPluginName}.aar`)), + ); + }); + + it("appends aarSuffix to the name of the built aar", async () => { + const config: IPluginBuildOptions = setup({ addManifest: true }); + config.aarSuffix = "-v2"; + + await androidBuildPluginService.buildAar(config); + + const expectedName = getShortPluginName(`${pluginName}-v2`); + assert.deepStrictEqual(builtPluginDirName, expectedName); + assert.isTrue(fs.exists(path.join(pluginFolder, `${expectedName}.aar`))); + }); + it("does not build aar when there are no supported files in the plugin", async () => { const config: IPluginBuildOptions = setup(); From 5ecb7c07a50374623dc4fea3a5277715ed8e0a8f Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Wed, 19 Aug 2026 14:26:09 +0200 Subject: [PATCH 3/4] 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); + }); +}); From ee7089f286ed0528bc720332acbe763a6923fd1b Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Wed, 19 Aug 2026 15:38:02 +0200 Subject: [PATCH 4/4] feat(android): per-plugin build options reaching the plugin gradle build A plugin's `android.plugins.` entry already reaches `buildAar`; this makes it reach the gradle build itself, and adds `abiFilters` as the first option that does: ```js android: { plugins: { "@foo/plugin-x": { abiFilters: ["arm64-v8a"] }, }, } ``` Nothing here is specific to `abiFilters`. What a plugin has native code for does not depend on what is plugged in, so the list wins over the ABIs `--filter-plugins-devices-arch` derives from the connected devices and applies whether or not that flag is set; an empty list passes nothing, opting a single plugin out of the narrowing. Every other key of the entry reaches the build as it is written. The plugin build data now records the options gradle was asked for, under a single `__buildOptions` entry rather than the `abiFilters`-specific one, since the plugin sources do not change when an option does: any per-plugin option added later takes part in the rebuild decision by being listed there. Options that only change the artifact's name, such as `aarSuffix`, produce a different file rather than a stale one and stay out of it. Co-Authored-By: Claude Opus 5 (1M context) --- lib/definitions/project.d.ts | 9 ++++ lib/services/android-plugin-build-service.ts | 41 ++++++++++++--- lib/services/android-project-service.ts | 26 +++++++--- test/services/android-plugin-build-service.ts | 26 ++++++++++ test/services/android-project-service.ts | 50 ++++++++++++++++++- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 82794dded0..cb3e284531 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -195,6 +195,15 @@ interface INsConfigAndroidPlugin { * each other - a suffix tells them apart. */ aarSuffix?: string; + + /** + * The ABIs passed to this plugin's gradle build as `-PabiFilters`, which a + * plugin acts on in its own `include.gradle`. Wins over the ABIs + * `--filter-plugins-devices-arch` derives from the connected devices, and + * applies whether or not that flag is set. An empty array passes nothing, + * which opts this plugin out of the narrowing. + */ + abiFilters?: string[]; } interface INsConfigHooks { diff --git a/lib/services/android-plugin-build-service.ts b/lib/services/android-plugin-build-service.ts index 626c00b98d..6343b2de8b 100644 --- a/lib/services/android-plugin-build-service.ts +++ b/lib/services/android-plugin-build-service.ts @@ -61,7 +61,13 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { private $watchIgnoreListService: IWatchIgnoreListService, ) {} - private static ABI_FILTERS_BUILD_DATA_KEY = "__abiFilters"; + /** + * The plugin build data entry recording the build options gradle was last + * asked for. The plugin sources do not change when an option does, so every + * per-plugin option that changes what gradle produces belongs in here - + * otherwise the aar built with the old one is kept. + */ + private static BUILD_OPTIONS_DATA_KEY = "__buildOptions"; private static MANIFEST_ROOT = { $: { @@ -239,14 +245,11 @@ 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) { + const buildOptions = this.getArtifactAffectingOptions(options); + if (buildOptions) { pluginSourceFileHashesInfo[ - AndroidPluginBuildService.ABI_FILTERS_BUILD_DATA_KEY - ] = options.abiFilters.join(","); + AndroidPluginBuildService.BUILD_OPTIONS_DATA_KEY + ] = buildOptions; } const shouldBuildAar = await this.shouldBuildAar({ @@ -296,6 +299,28 @@ export class AndroidPluginBuildService implements IAndroidPluginBuildService { return shouldBuildAar; } + /** + * The build options that change what gradle produces for this plugin, in + * the form they are recorded in the plugin build data. `null` when none of + * them is set, so a project that uses none of them keeps the build data it + * already has. + * + * `abiFilters` is the only one today: the aar of a plugin built for a + * subset of the ABIs is not the aar of the same sources built for another + * subset. Options that only change the *name* of the artifact - `aarSuffix` + * - do not belong here, they produce a different file rather than a stale + * one. + */ + private getArtifactAffectingOptions(options: IPluginBuildOptions): string { + const affectingOptions: { [key: string]: any } = {}; + + if (options.abiFilters && options.abiFilters.length) { + affectingOptions.abiFilters = options.abiFilters; + } + + return _.isEmpty(affectingOptions) ? null : JSON.stringify(affectingOptions); + } + private cleanPluginDir(pluginTempDir: string): void { // In case plugin was already built in the current process, we need to clean the old sources as they may break the new build. this.$fs.deleteDirectory(pluginTempDir); diff --git a/lib/services/android-project-service.ts b/lib/services/android-project-service.ts index 7edd506225..e194878dce 100644 --- a/lib/services/android-project-service.ts +++ b/lib/services/android-project-service.ts @@ -9,6 +9,7 @@ import { Configurations, LiveSyncPaths } from "../common/constants"; import { hook } from "../common/helpers"; import { performanceLog } from ".././common/decorators"; import { + INsConfigAndroidPlugin, IProjectData, IProjectDataService, IValidatePlatformOutput, @@ -698,12 +699,15 @@ export class AndroidProjectService extends projectServiceBaseLib.PlatformProject const options: IPluginBuildOptions = { gradlePath: this.$options.gradlePath, gradleArgs: this.$options.gradleArgs, - abiFilters: this.getPluginsAbiFilters(), + abiFilters: this.getPluginsAbiFilters(pluginConfig), projectDir: projectData.projectDir, pluginName: pluginData.name, platformsAndroidDirPath: pluginPlatformsFolderPath, aarOutputDir: pluginPlatformsFolderPath, tempPluginDirPath: path.join(projectData.platformsDir, "tempPlugin"), + // the rest of the plugin's config entry reaches the build as it is + // written - `abiFilters` is resolved above only because it falls + // back to the devices when the entry does not set it ...pluginConfig, }; @@ -716,13 +720,21 @@ 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. + * The ABIs passed to the gradle build of a plugin built from source. 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 it is not asked for. + * + * A list in the plugin's config entry always wins: what a plugin has native + * code for does not depend on what is plugged in. Otherwise the ABIs of the + * devices this run is about to deploy to are used, and only when + * `--filter-plugins-devices-arch` asks for it. */ - private getPluginsAbiFilters(): string[] { + private getPluginsAbiFilters(pluginConfig: INsConfigAndroidPlugin): string[] { + if (pluginConfig.abiFilters) { + return pluginConfig.abiFilters; + } + if (!this.$options.filterPluginsDevicesArch) { return null; } diff --git a/test/services/android-plugin-build-service.ts b/test/services/android-plugin-build-service.ts index 16621473c1..e15a9b3b1e 100644 --- a/test/services/android-plugin-build-service.ts +++ b/test/services/android-plugin-build-service.ts @@ -350,6 +350,32 @@ dependencies { assert.isFalse(spawnFromEventCalled); }); + it("records the options that changed what gradle produced", async () => { + const config: IPluginBuildOptions = setup({ addManifest: true }); + config.abiFilters = ["arm64-v8a"]; + + await androidBuildPluginService.buildAar(config); + + const buildData = fs.readJson( + path.join(tempFolder, shortPluginName, PLUGIN_BUILD_DATA_FILENAME), + ); + assert.deepStrictEqual( + buildData["__buildOptions"], + JSON.stringify({ abiFilters: ["arm64-v8a"] }), + ); + }); + + it("records no build options when none of them is set", async () => { + const config: IPluginBuildOptions = setup({ addManifest: true }); + + await androidBuildPluginService.buildAar(config); + + const buildData = fs.readJson( + path.join(tempFolder, shortPluginName, PLUGIN_BUILD_DATA_FILENAME), + ); + assert.isUndefined(buildData["__buildOptions"]); + }); + it("builds aar with the latest runtime gradle versions when no project dir is specified", async () => { const expectedGradleVersion = "4.4"; const expectedAndroidVersion = "4.5.6"; diff --git a/test/services/android-project-service.ts b/test/services/android-project-service.ts index f84e413103..363a29854d 100644 --- a/test/services/android-project-service.ts +++ b/test/services/android-project-service.ts @@ -20,6 +20,7 @@ import { IProjectDir, } from "../../lib/common/declarations"; import { IPluginBuildOptions } from "../../lib/definitions/android-plugin-migrator"; +import { INsConfigAndroidPlugin } from "../../lib/definitions/project"; const createTestInjector = (): IInjector => { const testInjector = new Yok(); @@ -424,7 +425,8 @@ describe("androidProjectService plugins abi filtering", () => { const preparePluginNativeCode = async ( options: any, - devices: any[] + devices: any[], + pluginsConfig?: IDictionary ): Promise => { const testInjector = createTestInjector(); let pluginBuildOptions: IPluginBuildOptions = null; @@ -452,7 +454,11 @@ describe("androidProjectService plugins abi filtering", () => { name: "my-plugin", pluginPlatformsFolderPath: (): string => "pluginPlatformsDir", }, - { projectDir: "projectDir", platformsDir: "platformsDir" } + { + projectDir: "projectDir", + platformsDir: "platformsDir", + nsConfig: { android: { plugins: pluginsConfig } }, + } ); return pluginBuildOptions; @@ -490,6 +496,46 @@ describe("androidProjectService plugins abi filtering", () => { assert.isNull(options.abiFilters); }); + it("passes the abis from the plugin's config entry", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true }, + [createDevice("device1", ["x86_64"], true)], + { "my-plugin": { abiFilters: ["arm64-v8a"] } } + ); + + assert.deepStrictEqual(options.abiFilters, ["arm64-v8a"]); + }); + + it("passes the abis from the config entry without the option", async () => { + const options = await preparePluginNativeCode( + {}, + [createDevice("device1", ["x86_64"], true)], + { "my-plugin": { abiFilters: ["arm64-v8a"] } } + ); + + assert.deepStrictEqual(options.abiFilters, ["arm64-v8a"]); + }); + + it("passes no abis when the config entry is an empty list", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true }, + [createDevice("device1", ["x86_64"], true)], + { "my-plugin": { abiFilters: [] } } + ); + + assert.deepStrictEqual(options.abiFilters, []); + }); + + it("ignores the config entry of another plugin", async () => { + const options = await preparePluginNativeCode( + { filterPluginsDevicesArch: true }, + [createDevice("device1", ["x86_64"], true)], + { "other-plugin": { abiFilters: ["arm64-v8a"] } } + ); + + assert.deepStrictEqual(options.abiFilters, ["x86_64"]); + }); + it("passes no abis when no device reports its abis", async () => { const options = await preparePluginNativeCode( { filterPluginsDevicesArch: true },