Skip to content

Commit bf19f23

Browse files
committed
feat(android): Gradle facts file, unique plugin aar names and opt-in read-only node_modules
- `ns prepare android` writes platforms/android/nativescript-gradle.json next to dependencies.json (project dirs and every native plugin's package dir, native dir and aar, paths relative to the file, plus the hash of dependencies.json) and passes -Pnativescript.factsFile to app and plugin Gradle builds. org.nativescript:gradle-api reads it. - Plugins whose short names clash (@foo/plugin-x and @bar/plugin-x) get scope-qualified aar names, temp build dirs and namespace fallbacks. Names that don't clash are unchanged. An aar left under a plugin's previous name is deleted when it is the CLI's own output, otherwise prepare stops. - `android.readOnlyNodeModules` in nativescript.config: plugins' Android files and package.json are mirrored into platforms/android/.ns-plugins, aars are built and include.gradle is migrated there, dependencies.json points at the mirrors, and plugin aar builds also apply the plugin's own buildscript.gradle and the gradle-api library.
1 parent a34f381 commit bf19f23

23 files changed

Lines changed: 2148 additions & 77 deletions

‎lib/bootstrap.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ injector.require(
4848
"gradleBuildArgsService",
4949
"./services/android/gradle-build-args-service",
5050
);
51+
injector.require(
52+
"androidPluginMirrorService",
53+
"./services/android/android-plugin-mirror-service",
54+
);
5155
injector.require(
5256
"androidBundleToolService",
5357
"./services/android/android-bundle-tool-service",

‎lib/common/helpers.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,71 @@ function sanitizePluginName(pluginName: string): string {
125125
return pluginName.split("/").pop();
126126
}
127127

128+
function getScopeQualifiedPluginName(pluginName: string): string | null {
129+
const parts = pluginName.split("/");
130+
if (parts.length < 2 || !parts[0].startsWith("@")) {
131+
return null;
132+
}
133+
134+
const scope = parts[0].slice(1).replace(/[^A-Za-z0-9_]/g, "_");
135+
return `${scope}_${getShortPluginName(pluginName)}`;
136+
}
137+
138+
// The aar name doubles as a Java package segment (the `org.nativescript.<name>`
139+
// namespace fallback), which cannot start with a digit.
140+
function withoutLeadingDigit(name: string): string {
141+
return /^[0-9]/.test(name) ? `_${name}` : name;
142+
}
143+
144+
/**
145+
* Maps each plugin name to a file-system and Java-package safe name for its aar
146+
* that is unique among `pluginNames`. A plugin whose short name is not shared
147+
* keeps `getShortPluginName(name)`, so projects without clashes keep their names.
148+
* Throws if two plugins still end up with the same name.
149+
*/
150+
export function getUniqueShortPluginNames(
151+
pluginNames: string[],
152+
): Map<string, string> {
153+
const sortedNames = _.sortBy(_.uniq(pluginNames));
154+
const namesByShortName = _.groupBy(sortedNames, getShortPluginName);
155+
const result = new Map<string, string>();
156+
const owners = new Map<string, string>();
157+
158+
for (const pluginName of sortedNames) {
159+
const shortName = getShortPluginName(pluginName);
160+
const aarName =
161+
namesByShortName[shortName].length === 1
162+
? shortName
163+
: withoutLeadingDigit(
164+
getScopeQualifiedPluginName(pluginName) ?? shortName,
165+
);
166+
167+
const owner = owners.get(aarName);
168+
if (owner !== undefined) {
169+
throw new Error(
170+
`The plugins "${owner}" and "${pluginName}" both need the Android aar name "${aarName}". Remove one of them from the project.`,
171+
);
172+
}
173+
174+
owners.set(aarName, pluginName);
175+
result.set(pluginName, aarName);
176+
}
177+
178+
return result;
179+
}
180+
181+
/**
182+
* Every aar name `getUniqueShortPluginNames` can give this plugin, depending on
183+
* which other plugins are installed.
184+
*/
185+
export function getCandidateAarNames(pluginName: string): string[] {
186+
const shortName = getShortPluginName(pluginName);
187+
return _.uniq([
188+
shortName,
189+
withoutLeadingDigit(getScopeQualifiedPluginName(pluginName) ?? shortName),
190+
]);
191+
}
192+
128193
export async function executeActionByChunks<T>(
129194
initialData: T[] | IDictionary<T>,
130195
chunkSize: number,

‎lib/common/test/unit-tests/helpers.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,4 +1111,129 @@ const test = require("./test");`,
11111111
assert.deepStrictEqual(seen.sink, ["kept"]);
11121112
});
11131113
});
1114+
1115+
describe("getUniqueShortPluginNames", () => {
1116+
const toObject = (names: string[]) =>
1117+
Object.fromEntries(helpers.getUniqueShortPluginNames(names));
1118+
1119+
it("keeps the short name of plugins without a clash", () => {
1120+
assert.deepStrictEqual(
1121+
toObject([
1122+
"@nativescript/core",
1123+
"nativescript-plugin",
1124+
"@scope/my-plugin",
1125+
"9-lives",
1126+
]),
1127+
{
1128+
"@nativescript/core": "core",
1129+
"nativescript-plugin": "nativescript_plugin",
1130+
"@scope/my-plugin": "my_plugin",
1131+
"9-lives": "9_lives",
1132+
},
1133+
);
1134+
});
1135+
1136+
it("qualifies clashing scoped plugins with their scope", () => {
1137+
assert.deepStrictEqual(
1138+
toObject(["@foo/plugin-x", "@bar/plugin-x", "@nativescript/core"]),
1139+
{
1140+
"@foo/plugin-x": "foo_plugin_x",
1141+
"@bar/plugin-x": "bar_plugin_x",
1142+
"@nativescript/core": "core",
1143+
},
1144+
);
1145+
});
1146+
1147+
it("lets an unscoped plugin keep its name when it clashes with a scoped one", () => {
1148+
assert.deepStrictEqual(toObject(["plugin-x", "@foo/plugin-x"]), {
1149+
"plugin-x": "plugin_x",
1150+
"@foo/plugin-x": "foo_plugin_x",
1151+
});
1152+
});
1153+
1154+
it("replaces characters a scope may contain but a name may not", () => {
1155+
assert.deepStrictEqual(toObject(["@my.org~x/plugin", "@a/plugin"]), {
1156+
"@my.org~x/plugin": "my_org_x_plugin",
1157+
"@a/plugin": "a_plugin",
1158+
});
1159+
});
1160+
1161+
it("prefixes clashing names that would start with a digit", () => {
1162+
assert.deepStrictEqual(toObject(["@1password/plugin", "@a/plugin"]), {
1163+
"@1password/plugin": "_1password_plugin",
1164+
"@a/plugin": "a_plugin",
1165+
});
1166+
assert.deepStrictEqual(toObject(["9-lives", "@a/9-lives"]), {
1167+
"9-lives": "_9_lives",
1168+
"@a/9-lives": "a_9_lives",
1169+
});
1170+
});
1171+
1172+
it("fails when two plugins still share a name", () => {
1173+
assert.throws(
1174+
() =>
1175+
helpers.getUniqueShortPluginNames(["@foo/plugin-x", "@foo/plugin_x"]),
1176+
/"@foo\/plugin-x" and "@foo\/plugin_x"/,
1177+
);
1178+
assert.throws(
1179+
() =>
1180+
helpers.getUniqueShortPluginNames([
1181+
"foo-plugin-x",
1182+
"@foo/plugin-x",
1183+
"@bar/plugin-x",
1184+
]),
1185+
/"@foo\/plugin-x" and "foo-plugin-x".*"foo_plugin_x"/,
1186+
);
1187+
});
1188+
1189+
it("does not depend on the order of the plugins", () => {
1190+
const names = ["@foo/plugin-x", "plugin-x", "@bar/plugin-x", "other"];
1191+
const expected = toObject(names);
1192+
assert.deepStrictEqual(toObject([...names].reverse()), expected);
1193+
assert.deepStrictEqual(
1194+
toObject([names[2], names[0], names[3], names[1]]),
1195+
expected,
1196+
);
1197+
assert.throws(
1198+
() =>
1199+
helpers.getUniqueShortPluginNames(["@foo/plugin_x", "@foo/plugin-x"]),
1200+
/"@foo\/plugin-x" and "@foo\/plugin_x"/,
1201+
);
1202+
});
1203+
});
1204+
1205+
describe("getCandidateAarNames", () => {
1206+
it("lists the short and the scope-qualified name of a scoped plugin", () => {
1207+
assert.deepStrictEqual(helpers.getCandidateAarNames("@foo/plugin-x"), [
1208+
"plugin_x",
1209+
"foo_plugin_x",
1210+
]);
1211+
});
1212+
1213+
it("lists the short name of an unscoped plugin", () => {
1214+
assert.deepStrictEqual(helpers.getCandidateAarNames("plugin-x"), [
1215+
"plugin_x",
1216+
]);
1217+
assert.deepStrictEqual(helpers.getCandidateAarNames("9-lives"), [
1218+
"9_lives",
1219+
"_9_lives",
1220+
]);
1221+
});
1222+
1223+
it("covers every name getUniqueShortPluginNames gives", () => {
1224+
const projects = [
1225+
["@foo/plugin-x"],
1226+
["@foo/plugin-x", "@bar/plugin-x"],
1227+
["plugin-x", "@foo/plugin-x"],
1228+
["@1a/plugin", "@b/plugin", "plugin", "9-lives", "@c/9-lives"],
1229+
];
1230+
for (const project of projects) {
1231+
for (const [name, aarName] of helpers.getUniqueShortPluginNames(
1232+
project,
1233+
)) {
1234+
assert.include(helpers.getCandidateAarNames(name), aarName);
1235+
}
1236+
}
1237+
});
1238+
});
11141239
});

‎lib/constants.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export const CONFIG_FILE_NAME_DISPLAY = "nativescript.config.(js|ts)";
7373
export const CONFIG_FILE_NAME_JS = "nativescript.config.js";
7474
export const CONFIG_FILE_NAME_TS = "nativescript.config.ts";
7575
export const DEPENDENCIES_JSON_NAME = "dependencies.json";
76+
export const GRADLE_FACTS_FILE_NAME = "nativescript-gradle.json";
77+
export const ANDROID_PLUGIN_MIRRORS_DIR_NAME = ".ns-plugins";
7678
export const APK_EXTENSION_NAME = ".apk";
7779
export const AAB_EXTENSION_NAME = ".aab";
7880
export const APKS_EXTENSION_NAME = ".apks";

‎lib/definitions/android-plugin-migrator.d.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@ import { IProjectDir } from "../common/declarations";
33

44
interface IPluginBuildOptions extends IAndroidBuildOptions {
55
projectDir?: string;
6+
/**
7+
* File name (without extension) of the built aar and of its temp build dir.
8+
* Defaults to the plugin's short name.
9+
*/
10+
aarName?: string;
11+
/**
12+
* The project keeps node_modules read-only, so `aarOutputDir` is a CLI-owned
13+
* copy of the plugin's platforms/android.
14+
*/
15+
readOnlyNodeModules?: boolean;
16+
/**
17+
* The project's nativescript-gradle.json, passed to the plugin's Gradle build when it exists.
18+
*/
19+
gradleFactsFile?: string;
620
}
721

822
interface IAndroidBuildOptions {
@@ -17,6 +31,33 @@ interface IAndroidBuildOptions {
1731
interface IAndroidPluginBuildService {
1832
buildAar(options: IPluginBuildOptions): Promise<boolean>;
1933
migrateIncludeGradle(options: IPluginBuildOptions): boolean;
34+
/**
35+
* Whether the plugin has sources to build but `aarOutputDir` lacks its aar
36+
* or still holds one under another of its candidate names.
37+
*/
38+
hasOutdatedAar(options: IPluginBuildOptions): boolean;
39+
}
40+
41+
interface IAndroidPluginMirror {
42+
pluginName: string;
43+
/**
44+
* The installed npm package.
45+
*/
46+
packageDir: string;
47+
mirrorDir: string;
48+
/**
49+
* Paths relative to the package's platforms/android that are not copied.
50+
*/
51+
excludedNativeFiles?: string[];
52+
}
53+
54+
interface IAndroidPluginMirrorService {
55+
/**
56+
* Brings `<mirrorsRoot>/<dir>` in line with each plugin's package.json and
57+
* platforms/android, and removes mirrors of plugins not in `mirrors`.
58+
* @returns The names of the plugins whose mirror changed.
59+
*/
60+
syncMirrors(mirrorsRoot: string, mirrors: IAndroidPluginMirror[]): string[];
2061
}
2162

2263
/**
@@ -49,4 +90,14 @@ interface IBuildAndroidPluginData extends Partial<IProjectDir> {
4990
* Optional custom Gradle arguments.
5091
*/
5192
gradleArgs?: string;
93+
94+
/**
95+
* The project keeps node_modules read-only (`android.readOnlyNodeModules`).
96+
*/
97+
readOnlyNodeModules?: boolean;
98+
99+
/**
100+
* Absolute path of the project's nativescript-gradle.json, when it exists.
101+
*/
102+
gradleFactsFile?: string;
52103
}

‎lib/definitions/gradle-facts.d.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Contents of platforms/android/nativescript-gradle.json, read by
3+
* org.nativescript:gradle-api. Mirrors gradle-api/schema/nativescript-gradle.schema.json:
4+
* fields are only ever added, and every path is relative to the file's
5+
* directory with forward slashes.
6+
*/
7+
export interface INativeScriptGradleFacts {
8+
schemaVersion: number;
9+
cli?: { version?: string };
10+
runtime?: { name?: string; version?: string };
11+
capabilities?: string[];
12+
/**
13+
* Identifies the dependencies.json written together with these facts, so a
14+
* reader can tell when a CLI that does not write facts has replaced it since.
15+
*/
16+
dependenciesJson: { sha256: string };
17+
project: INativeScriptGradleFactsProject;
18+
plugins: INativeScriptGradleFactsPlugin[];
19+
}
20+
21+
export interface INativeScriptGradleFactsProject {
22+
root: string;
23+
appDir: string;
24+
appResourcesDir: string;
25+
}
26+
27+
export interface INativeScriptGradleFactsPlugin {
28+
name: string;
29+
packageDir: string;
30+
nativeDir: string;
31+
aarName: string;
32+
aar: string;
33+
dependencies: string[];
34+
options?: Record<string, unknown>;
35+
}

‎lib/definitions/project.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ interface INsConfigAndroid extends INsConfigPlaform {
179179
* Custom runtime package name
180180
*/
181181
runtimePackageName?: string;
182+
183+
/**
184+
* Never write into node_modules: each plugin's Android files are copied to
185+
* `platforms/android/.ns-plugins/` and its aar is built there.
186+
*/
187+
readOnlyNodeModules?: boolean;
182188
}
183189

184190
interface INsConfigHooks {

0 commit comments

Comments
 (0)