craft-native 0.0.237 → 0.0.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +71 -0
  2. package/dist/android/src/emulator.d.ts +23 -0
  3. package/dist/android/src/index.d.ts +24 -2
  4. package/dist/android/src/index.js +362 -103
  5. package/dist/android/src/native-map.d.ts +7 -0
  6. package/dist/android/templates/CraftBridge.kt.template +67 -55
  7. package/dist/android/templates/CraftNative.kt.template +27 -0
  8. package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  11. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  12. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  13. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  14. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  15. package/dist/android/templates/LocationRecordingService.kt.template +255 -47
  16. package/dist/android/templates/MainActivityNative.kt.template +7 -428
  17. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  18. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  19. package/dist/android-adb.d.ts +2 -0
  20. package/dist/api/index.d.ts +1 -1
  21. package/dist/api/mobile.d.ts +49 -14
  22. package/dist/browser/storage.d.ts +3 -0
  23. package/dist/browser.js +163 -51
  24. package/dist/cli.js +1 -1
  25. package/dist/index.cjs +126 -12
  26. package/dist/index.js +126 -12
  27. package/dist/ios/src/index.d.ts +5 -0
  28. package/dist/ios/src/index.js +17 -1
  29. package/dist/ios/src/testing.d.ts +16 -2
  30. package/dist/ios/templates/CraftApp.swift +418 -93
  31. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  32. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  33. package/dist/ios/templates/CraftNativeScreen.swift +82 -17
  34. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  35. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  36. package/dist/ios/templates/testing/DriverTests.swift.template +45 -1
  37. package/dist/macos/templates/native_layout.zig +41 -5
  38. package/dist/mobile-testing.js +30 -4
  39. package/dist/mobile.js +169 -17
  40. package/package.json +1 -1
@@ -1,9 +1,51 @@
1
1
  // @bun
2
2
  // ../android/src/index.ts
3
- import { cpSync, existsSync, mkdirSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
4
- import { dirname as dirname2, extname, isAbsolute, join as join2, relative, resolve, sep } from "path";
3
+ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync, writeFileSync } from "fs";
4
+ import { dirname as dirname2, extname, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2, sep } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
 
7
+ // ../android/src/native-map.ts
8
+ function androidMapHost(provider = "google") {
9
+ if (provider === "google")
10
+ return {
11
+ template: "CraftNativeMapGoogle.kt.template",
12
+ imports: `import com.google.android.gms.maps.CameraUpdateFactory
13
+ import com.google.android.gms.maps.GoogleMap
14
+ import com.google.android.gms.maps.MapView
15
+ import com.google.android.gms.maps.OnMapReadyCallback
16
+ import com.google.android.gms.maps.model.BitmapDescriptorFactory
17
+ import com.google.android.gms.maps.model.LatLng
18
+ import com.google.android.gms.maps.model.LatLngBounds
19
+ import com.google.android.gms.maps.model.Marker
20
+ import com.google.android.gms.maps.model.MarkerOptions
21
+ import com.google.android.gms.maps.model.Polyline
22
+ import com.google.android.gms.maps.model.PolylineOptions`,
23
+ dependency: "com.google.android.gms:play-services-maps:18.2.0"
24
+ };
25
+ if (provider === "maplibre")
26
+ return {
27
+ template: "CraftNativeMapLibre.kt.template",
28
+ imports: `import org.maplibre.android.MapLibre
29
+ import org.maplibre.android.camera.CameraPosition
30
+ import org.maplibre.android.camera.CameraUpdateFactory
31
+ import org.maplibre.android.geometry.LatLng
32
+ import org.maplibre.android.geometry.LatLngBounds
33
+ import org.maplibre.android.location.LocationComponentActivationOptions
34
+ import org.maplibre.android.maps.MapLibreMap
35
+ import org.maplibre.android.maps.MapView
36
+ import org.maplibre.android.maps.OnMapReadyCallback
37
+ import org.maplibre.android.maps.Style
38
+ import org.maplibre.android.style.expressions.Expression
39
+ import org.maplibre.android.style.layers.CircleLayer
40
+ import org.maplibre.android.style.layers.LineLayer
41
+ import org.maplibre.android.style.layers.SymbolLayer
42
+ import org.maplibre.android.style.layers.PropertyFactory.*
43
+ import org.maplibre.android.style.sources.GeoJsonSource`,
44
+ dependency: "org.maplibre.gl:android-sdk:13.7.1"
45
+ };
46
+ throw new Error(`Unknown Android native map provider: ${provider}. Expected google or maplibre.`);
47
+ }
48
+
7
49
  // ../android/src/promise-runtime.ts
8
50
  var ANDROID_PROMISE_RUNTIME = `
9
51
  if (window.__craftRejectPendingPromises) {
@@ -132,10 +174,192 @@ tasks.register('runFixture', JavaExec) {
132
174
  await rm(directory, { recursive: true, force: true });
133
175
  }
134
176
  }
177
+ // ../android/src/emulator.ts
178
+ import { appendFileSync, closeSync, mkdirSync, mkdtempSync, openSync, rmSync } from "fs";
179
+ import { availableParallelism, cpus, tmpdir as tmpdir2, totalmem } from "os";
180
+ import { basename, isAbsolute, join as join2, resolve } from "path";
181
+ async function createAndroidEmulator(options) {
182
+ if (!["darwin", "linux"].includes(process.platform))
183
+ throw new Error("Android emulator sessions require macOS or Linux");
184
+ if (!["hardware", "software"].includes(options.acceleration))
185
+ throw new Error("Select hardware or software Android emulation explicitly");
186
+ if (![options.sdkmanager, options.avdmanager].every(isAbsolute))
187
+ throw new Error("Select absolute Android command-line tool paths");
188
+ const abi = process.arch === "arm64" ? "arm64-v8a" : process.arch === "x64" ? "x86_64" : undefined;
189
+ if (!abi || !/^system-images;android-\d+;[^;\s]+;[^;\s]+$/.test(options.image) || !options.image.endsWith(`;${abi}`))
190
+ throw new Error("The Android system image must match the host architecture");
191
+ if (options.acceleration === "software" && process.arch !== "x64")
192
+ throw new Error("Android software emulation requires an x86_64 host and system image");
193
+ const bootTimeoutMs = options.bootTimeoutMs ?? 600000;
194
+ const maximumBootTimeoutMs = options.acceleration === "software" ? 1200000 : 600000;
195
+ if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
196
+ throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`);
197
+ const port = options.port ?? 5558;
198
+ if (!Number.isInteger(port) || port < 5554 || port > 5682 || port % 2 !== 0)
199
+ throw new Error("Android console ports must be even and between 5554 and 5682");
200
+ const sdk = resolve(options.sdkRoot);
201
+ const artifacts = resolve(options.artifactsDirectory);
202
+ const temporary = options.temporaryDirectory ?? tmpdir2();
203
+ mkdirSync(artifacts, { recursive: true });
204
+ mkdirSync(temporary, { recursive: true });
205
+ const owned = mkdtempSync(join2(temporary, "craft-android-emulator-"));
206
+ const name = basename(owned);
207
+ const serial = `emulator-${port}`;
208
+ const env = {
209
+ ...process.env,
210
+ ...options.env,
211
+ ANDROID_HOME: sdk,
212
+ ANDROID_SDK_ROOT: sdk,
213
+ ANDROID_SDK_HOME: owned,
214
+ ANDROID_USER_HOME: join2(owned, ".android"),
215
+ ANDROID_EMULATOR_HOME: join2(owned, ".android"),
216
+ ANDROID_AVD_HOME: join2(owned, "avd"),
217
+ ANDROID_SERIAL: serial,
218
+ AVDMANAGER_OPTS: `'-Dcom.android.sdkmanager.toolsdir=${join2(sdk, "cmdline-tools/latest")}'`
219
+ };
220
+ const adb = join2(sdk, "platform-tools/adb");
221
+ const executable = join2(sdk, "emulator/emulator");
222
+ let child;
223
+ let log;
224
+ let created = false;
225
+ let sequence = 0;
226
+ let disposal;
227
+ let bootStartedAt = 0;
228
+ let cpuStart = 0;
229
+ let idleStart = 0;
230
+ let identity;
231
+ async function recordBootResult(status, endedAt = Date.now()) {
232
+ if (!identity)
233
+ return;
234
+ const cpuEnd = cpus();
235
+ const totalDeltaMs = cpuEnd.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0) - cpuStart;
236
+ const idleDeltaMs = cpuEnd.reduce((total, cpu) => total + cpu.times.idle, 0) - idleStart;
237
+ await Bun.write(join2(artifacts, "android-emulator.json"), JSON.stringify({
238
+ ...identity,
239
+ status,
240
+ [status === "booted" ? "bootedAt" : "failedAt"]: new Date(endedAt).toISOString(),
241
+ bootElapsedMs: endedAt - bootStartedAt,
242
+ hostCpuInterval: { totalDeltaMs, idleDeltaMs, busyDeltaMs: totalDeltaMs - idleDeltaMs }
243
+ }, null, 2) + `
244
+ `);
245
+ }
246
+ async function run(args, input, timeout = 900000, allowFailure = false) {
247
+ const startedAt = new Date().toISOString();
248
+ const command = Bun.spawn(args, { env, stdin: input === undefined ? "ignore" : new TextEncoder().encode(input), stdout: "pipe", stderr: "pipe", signal: AbortSignal.timeout(timeout) });
249
+ const [code, stdout, stderr] = await Promise.all([command.exited, new Response(command.stdout).text(), new Response(command.stderr).text()]);
250
+ appendFileSync(join2(artifacts, "device-commands.jsonl"), JSON.stringify({ sequence: ++sequence, startedAt, finishedAt: new Date().toISOString(), args, code, stdout, stderr }) + `
251
+ `);
252
+ if (code !== 0 && !allowFailure)
253
+ throw new Error(`${basename(args[0])} failed with exit ${code}; inspect ${artifacts}`);
254
+ return { code, stdout };
255
+ }
256
+ const dispose = () => disposal ??= (async () => {
257
+ try {
258
+ if (child && child.exitCode === null) {
259
+ child.kill("SIGTERM");
260
+ await Promise.race([child.exited, Bun.sleep(1e4)]);
261
+ if (child.exitCode === null)
262
+ child.kill("SIGKILL");
263
+ await child.exited;
264
+ }
265
+ if (created)
266
+ await run([options.avdmanager, "delete", "avd", "--name", name], undefined, 30000);
267
+ } finally {
268
+ if (log !== undefined)
269
+ closeSync(log);
270
+ rmSync(owned, { recursive: true, force: true });
271
+ }
272
+ })();
273
+ try {
274
+ mkdirSync(env.ANDROID_AVD_HOME, { recursive: true });
275
+ mkdirSync(env.ANDROID_USER_HOME, { recursive: true });
276
+ if (options.acceptLicenses)
277
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "--licenses"], `y
278
+ `.repeat(100));
279
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "platform-tools", "emulator"], options.acceptLicenses ? `y
280
+ `.repeat(100) : undefined);
281
+ const acceleration = await run([executable, "-accel-check"], undefined, 30000, true);
282
+ if (options.acceleration === "hardware" && acceleration.code !== 0)
283
+ throw new Error(`Android hardware acceleration is unavailable; inspect ${artifacts}`);
284
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, options.image], options.acceptLicenses ? `y
285
+ `.repeat(100) : undefined);
286
+ const devices = (await run([adb, "devices"], undefined, 1e4)).stdout;
287
+ if (devices.split(`
288
+ `).some((line) => line.startsWith(`${serial} `)))
289
+ throw new Error(`${serial} is already in use`);
290
+ await run([options.avdmanager, "create", "avd", "--name", name, "--package", options.image, "--device", "pixel_6"], `no
291
+ `);
292
+ created = true;
293
+ log = openSync(join2(artifacts, "emulator.log"), "w");
294
+ bootStartedAt = Date.now();
295
+ const hostCpus = cpus();
296
+ cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0);
297
+ idleStart = hostCpus.reduce((total, cpu) => total + cpu.times.idle, 0);
298
+ child = Bun.spawn([executable, "-avd", name, "-port", String(port), "-no-window", "-no-snapshot", "-no-audio", "-no-boot-anim", "-gpu", "software", "-memory", "2048", "-cores", "2", "-accel", options.acceleration === "software" ? "off" : "on"], { env, stdout: log, stderr: log });
299
+ identity = {
300
+ image: options.image,
301
+ acceleration: options.acceleration,
302
+ accelerationProbeExitCode: acceleration.code,
303
+ bootTimeoutMs,
304
+ bootStartedAt: new Date(bootStartedAt).toISOString(),
305
+ host: { cpuModel: hostCpus[0]?.model, logicalCpus: hostCpus.length, availableParallelism: availableParallelism(), memoryBytes: totalmem() },
306
+ virtualCpus: 2,
307
+ memoryMb: 2048,
308
+ serial,
309
+ avdDirectory: env.ANDROID_AVD_HOME,
310
+ emulatorPid: child.pid
311
+ };
312
+ await Bun.write(join2(artifacts, "android-emulator.json"), JSON.stringify(identity, null, 2) + `
313
+ `);
314
+ const deadline = bootStartedAt + bootTimeoutMs;
315
+ const probeTimeout = () => Math.max(1, Math.min(1e4, deadline - Date.now()));
316
+ while (Date.now() < deadline && child.exitCode === null) {
317
+ let booted = false;
318
+ const connection = await run([adb, "devices"], undefined, probeTimeout(), true);
319
+ if (connection.code === 0 && connection.stdout.includes(`${serial} device`)) {
320
+ const completion = await run([adb, "-s", serial, "shell", "getprop", "sys.boot_completed"], undefined, probeTimeout(), true);
321
+ if (completion.code === 0 && completion.stdout.trim() === "1") {
322
+ const settings = await run([adb, "-s", serial, "shell", "settings", "get", "global", "animator_duration_scale"], undefined, probeTimeout(), true);
323
+ const packages = await run([adb, "-s", serial, "shell", "pm", "path", "android"], undefined, probeTimeout(), true);
324
+ const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
325
+ const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-t", "1"], undefined, probeTimeout(), true);
326
+ booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim());
327
+ }
328
+ }
329
+ const checkedAt = Date.now();
330
+ if (booted && checkedAt <= deadline) {
331
+ await recordBootResult("booted", checkedAt);
332
+ return { serial, adb, env, dispose };
333
+ }
334
+ await Bun.sleep(Math.min(2000, Math.max(0, deadline - Date.now())));
335
+ }
336
+ throw new Error(`The owned Android emulator did not boot within ${bootTimeoutMs}ms; inspect ${artifacts}`);
337
+ } catch (error) {
338
+ if (child) {
339
+ try {
340
+ await recordBootResult("failed");
341
+ } catch (diagnostic) {
342
+ console.error(`Could not retain emulator boot timing: ${diagnostic}`);
343
+ }
344
+ for (const args of [["logcat", "-d", "-b", "all"], ["shell", "getprop"], ["shell", "dumpsys", "activity", "processes"]]) {
345
+ try {
346
+ await run([adb, "-s", serial, ...args], undefined, 1e4, true);
347
+ } catch {}
348
+ }
349
+ }
350
+ try {
351
+ await dispose();
352
+ } catch (cleanup) {
353
+ throw new AggregateError([error, cleanup], "Android emulator setup and cleanup failed");
354
+ }
355
+ throw error;
356
+ }
357
+ }
135
358
 
136
359
  // ../android/src/index.ts
360
+ var nativeMapProviders = ["google", "maplibre"];
137
361
  function syncNativeFonts(fonts, output) {
138
- const destination = join2(output, "app/src/main/assets/NativeFonts");
362
+ const destination = join3(output, "app/src/main/assets/NativeFonts");
139
363
  const names = new Set;
140
364
  const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
141
365
  if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
@@ -143,7 +367,7 @@ function syncNativeFonts(fonts, output) {
143
367
  if (names.has(family.toLowerCase()))
144
368
  throw new Error(`Duplicate native font name: ${family}`);
145
369
  names.add(family.toLowerCase());
146
- if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep)))
370
+ if ([entry.src, entry.license].some((file) => file && resolve2(file).startsWith(resolve2(destination) + sep)))
147
371
  throw new Error("Native font sources must be outside the generated NativeFonts directory");
148
372
  const extension = extname(entry.src).toLowerCase();
149
373
  if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
@@ -152,23 +376,23 @@ function syncNativeFonts(fonts, output) {
152
376
  throw new Error(`Native font license not found: ${entry.license}`);
153
377
  return { family, ...entry, filename: `${family}${extension}` };
154
378
  });
155
- rmSync(destination, { recursive: true, force: true });
379
+ rmSync2(destination, { recursive: true, force: true });
156
380
  if (!files.length)
157
381
  return [];
158
- mkdirSync(destination, { recursive: true });
382
+ mkdirSync2(destination, { recursive: true });
159
383
  for (const file of files) {
160
- cpSync(file.src, join2(destination, file.filename));
384
+ cpSync(file.src, join3(destination, file.filename));
161
385
  if (file.license)
162
- cpSync(file.license, join2(destination, `${file.family}.LICENSE.txt`));
386
+ cpSync(file.license, join3(destination, `${file.family}.LICENSE.txt`));
163
387
  }
164
388
  return files.map((file) => `NativeFonts/${file.filename}`);
165
389
  }
166
- var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
390
+ var TEMPLATES_DIR = join3(dirname2(import.meta.dir), "templates");
167
391
  function resolveCraftAndroidVersion(startDir = import.meta.dir) {
168
- let directory = resolve(startDir);
392
+ let directory = resolve2(startDir);
169
393
  while (true) {
170
394
  try {
171
- const pkg = JSON.parse(readFileSync2(join2(directory, "package.json"), "utf8"));
395
+ const pkg = JSON.parse(readFileSync2(join3(directory, "package.json"), "utf8"));
172
396
  if ((pkg.name === "@craft-native/android" || pkg.name === "craft-native") && typeof pkg.version === "string" && pkg.version)
173
397
  return pkg.version;
174
398
  } catch {}
@@ -305,7 +529,7 @@ function requireRegularFile(path, label) {
305
529
  }
306
530
  function containsPath(parent, candidate) {
307
531
  const relativePath = relative(parent, candidate);
308
- return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
532
+ return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute2(relativePath);
309
533
  }
310
534
  function validateGoogleServicesFile(path, packageName) {
311
535
  requireRegularFile(path, "Google services file");
@@ -368,6 +592,7 @@ function normalizeAndroidNetworkConfig(config) {
368
592
  config.trustedOrigins = [...new Set(trustedOrigins)];
369
593
  }
370
594
  function validateAndroidConfig(config) {
595
+ androidMapHost(config.mapProvider);
371
596
  if (!config.appName.trim())
372
597
  throw new Error("Android app name must not be empty");
373
598
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
@@ -498,36 +723,37 @@ function validateNativeRouteConfig(config) {
498
723
  }
499
724
  }
500
725
  function writeAndroidConfig(output, config) {
501
- writeFileSync(join2(output, "craft.config.json"), JSON.stringify(config, null, 2));
726
+ writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
502
727
  const runtimeConfig = { ...config };
503
728
  delete runtimeConfig.appIconPath;
729
+ delete runtimeConfig.notificationIconPath;
504
730
  delete runtimeConfig.googleServicesFile;
505
- writeFileSync(join2(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
731
+ writeFileSync(join3(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
506
732
  }
507
733
  function syncAndroidWebAssets(source, output) {
508
- const sourcePath = resolve(source);
734
+ const sourcePath = resolve2(source);
509
735
  if (!existsSync(sourcePath))
510
736
  throw new Error(`Web asset path not found: ${source}`);
511
737
  const sourceStat = statSync(sourcePath);
512
738
  if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
513
739
  throw new Error(`Web asset path must be a file or directory: ${source}`);
514
740
  }
515
- const assetsDir = resolve(output, "app/src/main/assets");
741
+ const assetsDir = resolve2(output, "app/src/main/assets");
516
742
  if (containsPath(sourcePath, assetsDir) || containsPath(assetsDir, sourcePath)) {
517
743
  throw new Error(`Web asset source must not overlap generated asset directory: ${source}`);
518
744
  }
519
745
  if (sourceStat.isDirectory()) {
520
- requireRegularFile(join2(sourcePath, "index.html"), "Web asset directory entry point");
746
+ requireRegularFile(join3(sourcePath, "index.html"), "Web asset directory entry point");
521
747
  }
522
- const configPath = join2(assetsDir, "craft.config.json");
748
+ const configPath = join3(assetsDir, "craft.config.json");
523
749
  const config = existsSync(configPath) ? readFileSync2(configPath) : undefined;
524
- rmSync(assetsDir, { recursive: true, force: true });
525
- mkdirSync(assetsDir, { recursive: true });
750
+ rmSync2(assetsDir, { recursive: true, force: true });
751
+ mkdirSync2(assetsDir, { recursive: true });
526
752
  if (sourceStat.isDirectory())
527
753
  cpSync(sourcePath, assetsDir, { recursive: true });
528
754
  else
529
- cpSync(sourcePath, join2(assetsDir, "index.html"));
530
- if (!existsSync(join2(assetsDir, "index.html")))
755
+ cpSync(sourcePath, join3(assetsDir, "index.html"));
756
+ if (!existsSync(join3(assetsDir, "index.html")))
531
757
  throw new Error(`Web asset directory must contain index.html: ${source}`);
532
758
  if (config)
533
759
  writeFileSync(configPath, config);
@@ -571,10 +797,10 @@ function renderAndroidPermissions(config) {
571
797
  `);
572
798
  }
573
799
  function writeNativeShellSources(output, packagePath, packageName, config) {
574
- const javaDir = join2(output, "app/src/main/java", packagePath);
800
+ const javaDir = join3(output, "app/src/main/java", packagePath);
575
801
  const write = (template, file, replace = (source) => source) => {
576
- const source = readFileSync2(join2(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
577
- writeFileSync(join2(javaDir, file), replace(source));
802
+ const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
803
+ writeFileSync(join3(javaDir, file), replace(source));
578
804
  };
579
805
  write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
580
806
  write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
@@ -596,9 +822,9 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
596
822
  if (config.enablePushNotifications)
597
823
  write("CraftNativeMessagingService.kt.template", "CraftNativeMessagingService.kt");
598
824
  else
599
- rmSync(join2(javaDir, "CraftNativeMessagingService.kt"), { force: true });
600
- mkdirSync(join2(output, "app/src/main/res/xml"), { recursive: true });
601
- writeFileSync(join2(output, "app/src/main/res/xml/craft_file_paths.xml"), `<?xml version="1.0" encoding="utf-8"?>
825
+ rmSync2(join3(javaDir, "CraftNativeMessagingService.kt"), { force: true });
826
+ mkdirSync2(join3(output, "app/src/main/res/xml"), { recursive: true });
827
+ writeFileSync(join3(output, "app/src/main/res/xml/craft_file_paths.xml"), `<?xml version="1.0" encoding="utf-8"?>
602
828
  <paths>
603
829
  <cache-path name="craft-files" path="craft-files/" />
604
830
  <files-path name="craft-media" path="craft-media/" />
@@ -621,7 +847,9 @@ function renderNativeShellManifest(config) {
621
847
  <intent-filter>
622
848
  <action android:name="com.google.firebase.MESSAGING_EVENT" />
623
849
  </intent-filter>
624
- </service>`);
850
+ </service>
851
+ <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/craft_notification_icon" />${config.notificationColor ? `
852
+ <meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/craft_notification_color" />` : ""}`);
625
853
  }
626
854
  if (config.enableHealthConnect) {
627
855
  parts.push(` <activity-alias
@@ -677,7 +905,7 @@ function resolveRuntimeDir(override) {
677
905
  return dir;
678
906
  }
679
907
  function installRuntime(output, runtimeDir) {
680
- const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join2(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync(entry.source));
908
+ const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join3(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync(entry.source));
681
909
  if (found.length === 0) {
682
910
  throw new Error(`${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(", ")}. ` + "Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.");
683
911
  }
@@ -685,12 +913,12 @@ function installRuntime(output, runtimeDir) {
685
913
  const missing = RUNTIME_ABIS.filter((abi) => !found.some((entry) => entry.abi === abi));
686
914
  console.warn(` \u26A0 only ${found.map((entry) => entry.abi).join(", ")} was found; ${missing.join(", ")} is missing. ` + "The app will fall back to the Kotlin shim on those devices.");
687
915
  }
688
- const dest = join2(output, "app/src/main/jniLibs");
689
- rmSync(dest, { force: true, recursive: true });
916
+ const dest = join3(output, "app/src/main/jniLibs");
917
+ rmSync2(dest, { force: true, recursive: true });
690
918
  for (const { abi, source } of found) {
691
- const abiDir = join2(dest, abi);
692
- mkdirSync(abiDir, { recursive: true });
693
- cpSync(source, join2(abiDir, "libcraft.so"));
919
+ const abiDir = join3(dest, abi);
920
+ mkdirSync2(abiDir, { recursive: true });
921
+ cpSync(source, join3(abiDir, "libcraft.so"));
694
922
  }
695
923
  return true;
696
924
  }
@@ -731,48 +959,60 @@ async function init(options) {
731
959
  if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
732
960
  throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
733
961
  }
962
+ if (config.notificationIconPath)
963
+ requireRegularFile(config.notificationIconPath, "Notification icon");
964
+ const notificationIconExtension = config.notificationIconPath ? extname(config.notificationIconPath).toLowerCase() : undefined;
965
+ if (config.notificationIconPath && ![".png", ".xml"].includes(notificationIconExtension ?? "")) {
966
+ throw new Error(`Unsupported Android notification icon format: ${notificationIconExtension || "(none)"} (use a vector drawable .xml or a white-on-transparent .png)`);
967
+ }
968
+ if (config.notificationIconPath && notificationIconExtension === ".xml" && !/<vector[\s>]/.test(readFileSync2(config.notificationIconPath, "utf8"))) {
969
+ throw new Error("Notification icon .xml must be an Android vector drawable (<vector>)");
970
+ }
971
+ if (config.notificationColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.notificationColor)) {
972
+ throw new Error(`notificationColor must be #RRGGBB, not ${String(config.notificationColor)}`);
973
+ }
734
974
  const dirs = [
735
975
  output,
736
- join2(output, "app/src/main/java", packagePath),
737
- join2(output, "app/src/main/res/layout"),
738
- join2(output, "app/src/main/res/values"),
739
- join2(output, "app/src/main/res/values-night"),
740
- join2(output, "app/src/main/res/drawable"),
741
- join2(output, "app/src/main/assets"),
742
- join2(output, "gradle/wrapper")
976
+ join3(output, "app/src/main/java", packagePath),
977
+ join3(output, "app/src/main/res/layout"),
978
+ join3(output, "app/src/main/res/values"),
979
+ join3(output, "app/src/main/res/values-night"),
980
+ join3(output, "app/src/main/res/drawable"),
981
+ join3(output, "app/src/main/assets"),
982
+ join3(output, "gradle/wrapper")
743
983
  ];
744
984
  if (existsSync(output) && !statSync(output).isDirectory()) {
745
985
  throw new Error(`Android project output must be a directory: ${output}`);
746
986
  }
747
987
  for (const dir of dirs) {
748
988
  if (!existsSync(dir)) {
749
- mkdirSync(dir, { recursive: true });
989
+ mkdirSync2(dir, { recursive: true });
750
990
  }
751
991
  }
752
992
  writeAndroidConfig(output, config);
753
993
  const hasGoogleServices = Boolean(config.googleServicesFile);
754
994
  if (config.googleServicesFile) {
755
- cpSync(config.googleServicesFile, join2(output, "app/google-services.json"));
995
+ cpSync(config.googleServicesFile, join3(output, "app/google-services.json"));
756
996
  }
757
- const mainActivityTemplate = readFileSync2(join2(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
758
- const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
759
- writeFileSync(join2(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
760
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftAppLinks.kt"), readFileSync2(join2(TEMPLATES_DIR, "CraftAppLinks.kt.template"), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
761
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftCrashReporter.kt"), readFileSync2(join2(TEMPLATES_DIR, "CraftCrashReporter.kt.template"), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_ANDROID_VERSION));
997
+ const mainActivityTemplate = readFileSync2(join3(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
998
+ const mainActivity = mainActivityTemplate.replace(/\{\{NATIVE_MAP_IMPORTS\}\}/g, () => androidMapHost(config.mapProvider).imports).replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync2(join3(TEMPLATES_DIR, androidMapHost(config.mapProvider).template), "utf8")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
999
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
1000
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftAppLinks.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftAppLinks.kt.template"), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1001
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftCrashReporter.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.kt.template"), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_ANDROID_VERSION));
762
1002
  if (config.renderer === "native") {
763
- const mutationTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
764
- const flatListTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
765
- const refreshableFlatListTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftNativeRefreshableFlatList.kt.template"), "utf-8");
766
- const capabilityTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
767
- const notificationTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
768
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
769
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
770
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
771
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
772
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1003
+ const mutationTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
1004
+ const flatListTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
1005
+ const refreshableFlatListTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftNativeRefreshableFlatList.kt.template"), "utf-8");
1006
+ const capabilityTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
1007
+ const notificationTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
1008
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1009
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1010
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1011
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1012
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
773
1013
  writeNativeShellSources(output, packagePath, finalPackageName, config);
774
1014
  }
775
- const craftBridgeTemplate = readFileSync2(join2(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
1015
+ const craftBridgeTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
776
1016
  const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
777
1017
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
778
1018
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
@@ -792,17 +1032,17 @@ async function init(options) {
792
1032
  }` : `evaluatePromiseJavascript(
793
1033
  "window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
794
1034
  )`);
795
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
796
- const craftNative = readFileSync2(join2(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
797
- const nativeDir = join2(output, "app/src/main/java/com/craft/runtime");
798
- mkdirSync(nativeDir, { recursive: true });
799
- writeFileSync(join2(nativeDir, "CraftNative.kt"), craftNative);
800
- const serviceTemplate = readFileSync2(join2(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
801
- writeFileSync(join2(nativeDir, "LocationRecordingService.kt"), serviceTemplate);
802
- const healthTemplate = readFileSync2(join2(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
803
- writeFileSync(join2(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
804
- const manifestTemplate = readFileSync2(join2(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
805
- const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`).replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapsApiKey ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
1035
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
1036
+ const craftNative = readFileSync2(join3(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
1037
+ const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
1038
+ mkdirSync2(nativeDir, { recursive: true });
1039
+ writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
1040
+ const serviceTemplate = readFileSync2(join3(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
1041
+ writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1042
+ const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
1043
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1044
+ const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
1045
+ const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`).replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== "maplibre" && config.mapsApiKey ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
806
1046
  <package android:name="com.google.android.apps.healthdata" />
807
1047
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
808
1048
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -811,28 +1051,28 @@ async function init(options) {
811
1051
  <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
812
1052
  <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
813
1053
  </intent-filter>`}` : "");
814
- writeFileSync(join2(output, "app/src/main/AndroidManifest.xml"), manifest);
815
- const projectGradleTemplate = readFileSync2(join2(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
816
- writeFileSync(join2(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
817
- const appGradleTemplate = readFileSync2(join2(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
818
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{GOOGLE_MAPS_DEPENDENCY\}\}/g, config.renderer === "native" ? ` // Native STX Map overlays and trail routes
819
- implementation("com.google.android.gms:play-services-maps:18.2.0")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1054
+ writeFileSync(join3(output, "app/src/main/AndroidManifest.xml"), manifest);
1055
+ const projectGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
1056
+ writeFileSync(join3(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
1057
+ const appGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
1058
+ const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{NATIVE_MAP_DEPENDENCY\}\}/g, config.renderer === "native" ? ` // Native STX Map overlays and trail routes
1059
+ implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
820
1060
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ` implementation("androidx.javascriptengine:javascriptengine:1.1.0")
821
1061
  // Native background refresh (WorkManager).
822
1062
  implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
823
- writeFileSync(join2(output, "app/build.gradle.kts"), appGradle);
824
- const proguardTemplate = readFileSync2(join2(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
825
- writeFileSync(join2(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
826
- const settingsTemplate = readFileSync2(join2(TEMPLATES_DIR, "settings.gradle.kts.template"), "utf-8");
1063
+ writeFileSync(join3(output, "app/build.gradle.kts"), appGradle);
1064
+ const proguardTemplate = readFileSync2(join3(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
1065
+ writeFileSync(join3(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1066
+ const settingsTemplate = readFileSync2(join3(TEMPLATES_DIR, "settings.gradle.kts.template"), "utf-8");
827
1067
  const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, escapeKotlinString(generatedGradleProjectName(name)));
828
- writeFileSync(join2(output, "settings.gradle.kts"), settings);
1068
+ writeFileSync(join3(output, "settings.gradle.kts"), settings);
829
1069
  const gradleProperties = `org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
830
1070
  android.useAndroidX=true
831
1071
  kotlin.code.style=official
832
1072
  android.nonTransitiveRClass=true
833
1073
  `;
834
- writeFileSync(join2(output, "gradle.properties"), gradleProperties);
835
- writeFileSync(join2(output, "local.properties"), `# SDK location will be set by Android Studio
1074
+ writeFileSync(join3(output, "gradle.properties"), gradleProperties);
1075
+ writeFileSync(join3(output, "local.properties"), `# SDK location will be set by Android Studio
836
1076
  `);
837
1077
  const gradleWrapperProps = `distributionBase=GRADLE_USER_HOME
838
1078
  distributionPath=wrapper/dists
@@ -842,23 +1082,25 @@ validateDistributionUrl=true
842
1082
  zipStoreBase=GRADLE_USER_HOME
843
1083
  zipStorePath=wrapper/dists
844
1084
  `;
845
- writeFileSync(join2(output, "gradle/wrapper/gradle-wrapper.properties"), gradleWrapperProps);
1085
+ writeFileSync(join3(output, "gradle/wrapper/gradle-wrapper.properties"), gradleWrapperProps);
846
1086
  const stringsXml = `<?xml version="1.0" encoding="utf-8"?>
847
1087
  <resources>
848
1088
  <string name="app_name">${escapeXml(name)}</string>
849
1089
  </resources>
850
1090
  `;
851
- writeFileSync(join2(output, "app/src/main/res/values/strings.xml"), stringsXml);
1091
+ writeFileSync(join3(output, "app/src/main/res/values/strings.xml"), stringsXml);
852
1092
  const colorsXml = `<?xml version="1.0" encoding="utf-8"?>
853
1093
  <resources>
854
1094
  <color name="primary">#1a1a2e</color>
855
1095
  <color name="primary_dark">#16162b</color>
856
1096
  <color name="accent">#4ade80</color>
857
1097
  <color name="background">${config.backgroundColor}</color>
1098
+ <!-- Transparent is NotificationCompat's COLOR_DEFAULT: the system picks. -->
1099
+ <color name="craft_notification_color">${config.notificationColor ?? "#00000000"}</color>
858
1100
  </resources>
859
1101
  `;
860
- writeFileSync(join2(output, "app/src/main/res/values/colors.xml"), colorsXml);
861
- const darkColorsPath = join2(output, "app/src/main/res/values-night/colors.xml");
1102
+ writeFileSync(join3(output, "app/src/main/res/values/colors.xml"), colorsXml);
1103
+ const darkColorsPath = join3(output, "app/src/main/res/values-night/colors.xml");
862
1104
  if (config.backgroundColorDark) {
863
1105
  writeFileSync(darkColorsPath, `<?xml version="1.0" encoding="utf-8"?>
864
1106
  <resources>
@@ -866,7 +1108,7 @@ zipStorePath=wrapper/dists
866
1108
  </resources>
867
1109
  `);
868
1110
  } else {
869
- rmSync(darkColorsPath, { force: true });
1111
+ rmSync2(darkColorsPath, { force: true });
870
1112
  }
871
1113
  const appIconXml = `<?xml version="1.0" encoding="utf-8"?>
872
1114
  <vector xmlns:android="http://schemas.android.com/apk/res/android"
@@ -879,9 +1121,24 @@ zipStorePath=wrapper/dists
879
1121
  </vector>
880
1122
  `;
881
1123
  if (config.appIconPath) {
882
- cpSync(config.appIconPath, join2(output, `app/src/main/res/drawable/craft_app_icon${appIconExtension}`));
1124
+ cpSync(config.appIconPath, join3(output, `app/src/main/res/drawable/craft_app_icon${appIconExtension}`));
1125
+ } else {
1126
+ writeFileSync(join3(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
1127
+ }
1128
+ for (const extension of [".png", ".xml"])
1129
+ rmSync2(join3(output, `app/src/main/res/drawable/craft_notification_icon${extension}`), { force: true });
1130
+ if (config.notificationIconPath) {
1131
+ cpSync(config.notificationIconPath, join3(output, `app/src/main/res/drawable/craft_notification_icon${notificationIconExtension}`));
883
1132
  } else {
884
- writeFileSync(join2(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
1133
+ writeFileSync(join3(output, "app/src/main/res/drawable/craft_notification_icon.xml"), `<?xml version="1.0" encoding="utf-8"?>
1134
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
1135
+ android:width="24dp"
1136
+ android:height="24dp"
1137
+ android:viewportWidth="108"
1138
+ android:viewportHeight="108">
1139
+ <path android:fillColor="#FFFFFFFF" android:pathData="M18,78L42,42l12,18l12,-12l24,30z" />
1140
+ </vector>
1141
+ `);
885
1142
  }
886
1143
  const themesXml = `<?xml version="1.0" encoding="utf-8"?>
887
1144
  <resources>
@@ -892,7 +1149,7 @@ zipStorePath=wrapper/dists
892
1149
  </style>
893
1150
  </resources>
894
1151
  `;
895
- writeFileSync(join2(output, "app/src/main/res/values/themes.xml"), themesXml);
1152
+ writeFileSync(join3(output, "app/src/main/res/values/themes.xml"), themesXml);
896
1153
  const activityMainXml = config.renderer === "native" ? `<?xml version="1.0" encoding="utf-8"?>
897
1154
  <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
898
1155
  android:id="@+id/native_root"
@@ -912,7 +1169,7 @@ zipStorePath=wrapper/dists
912
1169
 
913
1170
  </androidx.coordinatorlayout.widget.CoordinatorLayout>
914
1171
  `;
915
- writeFileSync(join2(output, "app/src/main/res/layout/activity_main.xml"), activityMainXml);
1172
+ writeFileSync(join3(output, "app/src/main/res/layout/activity_main.xml"), activityMainXml);
916
1173
  syncNativeFonts(config.nativeFonts, output);
917
1174
  const placeholderHtml = `<!DOCTYPE html>
918
1175
  <html>
@@ -951,7 +1208,7 @@ zipStorePath=wrapper/dists
951
1208
  </script>
952
1209
  </body>
953
1210
  </html>`;
954
- writeFileSync(join2(output, "app/src/main/assets/index.html"), placeholderHtml);
1211
+ writeFileSync(join3(output, "app/src/main/assets/index.html"), placeholderHtml);
955
1212
  const runtimeDir = resolveRuntimeDir(options.runtimeDir);
956
1213
  if (runtimeDir) {
957
1214
  installRuntime(output, runtimeDir);
@@ -975,7 +1232,7 @@ async function build(options) {
975
1232
  const { htmlPath, nativeBundlePath, devServer, output, release, bundle, compile = true } = options;
976
1233
  console.log(`
977
1234
  \uD83D\uDCE6 Building Craft Android project...`);
978
- const configPath = join2(output, "craft.config.json");
1235
+ const configPath = join3(output, "craft.config.json");
979
1236
  if (!existsSync(configPath)) {
980
1237
  throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
981
1238
  }
@@ -985,13 +1242,13 @@ async function build(options) {
985
1242
  if (config.renderer === "native") {
986
1243
  if (htmlPath || devServer)
987
1244
  throw new Error("A native Android screen cannot use --html-path or --dev-server.");
988
- const destination = join2(output, "app/src/main/assets/native-screen.js");
989
- if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
1245
+ const destination = join3(output, "app/src/main/assets/native-screen.js");
1246
+ if (nativeBundlePath && resolve2(nativeBundlePath) !== resolve2(destination))
990
1247
  cpSync(nativeBundlePath, destination);
991
- const icons = join2(output, "app/src/main/assets/craft-icons.json");
1248
+ const icons = join3(output, "app/src/main/assets/craft-icons.json");
992
1249
  if (options.iconsPath) {
993
1250
  requireRegularFile(options.iconsPath, "Icon table");
994
- if (resolve(options.iconsPath) !== resolve(icons))
1251
+ if (resolve2(options.iconsPath) !== resolve2(icons))
995
1252
  cpSync(options.iconsPath, icons);
996
1253
  }
997
1254
  if (!existsSync(destination)) {
@@ -1000,7 +1257,7 @@ async function build(options) {
1000
1257
  } else if (nativeBundlePath) {
1001
1258
  throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
1002
1259
  }
1003
- if (existsSync(join2(output, "app/src/main/jniLibs"))) {
1260
+ if (existsSync(join3(output, "app/src/main/jniLibs"))) {
1004
1261
  const runtimeDir = resolveRuntimeDir(options.runtimeDir);
1005
1262
  if (runtimeDir) {
1006
1263
  installRuntime(output, runtimeDir);
@@ -1028,7 +1285,7 @@ async function build(options) {
1028
1285
  syncNativeFonts(config.nativeFonts, output);
1029
1286
  if (!compile)
1030
1287
  return;
1031
- const gradlewPath = join2(output, "gradlew");
1288
+ const gradlewPath = join3(output, "gradlew");
1032
1289
  if (!existsSync(gradlewPath)) {
1033
1290
  console.log(" Generating Gradle wrapper...");
1034
1291
  try {
@@ -1075,13 +1332,13 @@ async function run(options) {
1075
1332
  await build({ output });
1076
1333
  console.log("\uD83D\uDCF1 Installing and running on device...");
1077
1334
  try {
1078
- const apkPath = join2(output, "app/build/outputs/apk/debug/app-debug.apk");
1335
+ const apkPath = join3(output, "app/build/outputs/apk/debug/app-debug.apk");
1079
1336
  if (device) {
1080
1337
  await $`adb -s ${device} install -r ${apkPath}`;
1081
1338
  } else {
1082
1339
  await $`adb install -r ${apkPath}`;
1083
1340
  }
1084
- const config = JSON.parse(readFileSync2(join2(output, "craft.config.json"), "utf-8"));
1341
+ const config = JSON.parse(readFileSync2(join3(output, "craft.config.json"), "utf-8"));
1085
1342
  const launchCmd = `${config.packageName}/.MainActivity`;
1086
1343
  if (device) {
1087
1344
  await $`adb -s ${device} shell am start -n ${launchCmd}`;
@@ -1105,9 +1362,11 @@ export {
1105
1362
  renderAndroidPermissions,
1106
1363
  renderAndroidDeepLinks,
1107
1364
  open,
1365
+ nativeMapProviders,
1108
1366
  installRuntime,
1109
1367
  init,
1110
1368
  healthConnectPermissions,
1369
+ createAndroidEmulator,
1111
1370
  build,
1112
1371
  HEALTH_CONNECT_PERMISSIONS,
1113
1372
  ANDROID_MIN_SDK