craft-native 0.0.238 → 0.0.242

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 (27) hide show
  1. package/dist/android/src/index.d.ts +23 -1
  2. package/dist/android/src/index.js +102 -9
  3. package/dist/android/src/native-map.d.ts +7 -0
  4. package/dist/android/templates/CraftBridge.kt.template +2 -1
  5. package/dist/android/templates/CraftNativeBackground.kt.template +28 -2
  6. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  7. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  8. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  9. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  10. package/dist/android/templates/LocationRecordingService.kt.template +3 -1
  11. package/dist/android/templates/MainActivityNative.kt.template +14 -433
  12. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  13. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/ios/src/index.d.ts +12 -1
  18. package/dist/ios/src/index.js +32 -7
  19. package/dist/ios/src/testing.d.ts +14 -2
  20. package/dist/ios/templates/CraftApp.swift +117 -29
  21. package/dist/ios/templates/CraftHybrid.swift +193 -29
  22. package/dist/ios/templates/CraftNativeActions.swift +26 -27
  23. package/dist/ios/templates/CraftNativeFiles.swift +205 -1
  24. package/dist/ios/templates/CraftNativeScreen.swift +230 -33
  25. package/dist/ios/templates/testing/DriverTests.swift.template +37 -0
  26. package/dist/macos/templates/native_layout.zig +41 -5
  27. package/package.json +1 -1
@@ -3,6 +3,16 @@
3
3
  *
4
4
  * Generates Android apps with native Craft components and optional WebView content.
5
5
  */
6
+ import { type AndroidMapProvider } from './native-map.js';
7
+ export type { AndroidMapProvider } from './native-map.js';
8
+ /**
9
+ * The builder installs a directory of route bundles (stx's `routesDir`) as
10
+ * well as the one-file bundle, and the app runs each screen from its own
11
+ * route's file. Stacks asks for route bundles only when this is true.
12
+ */
13
+ export declare const nativeRouteBundles = true;
14
+ /** Providers this published builder can generate as real Android map views. */
15
+ export declare const nativeMapProviders: readonly ['google', 'maplibre'];
6
16
  export { runKotlinFixture } from './testing.js';
7
17
  export { createAndroidEmulator } from './emulator.js';
8
18
  export type { AndroidEmulatorOptions, AndroidEmulatorSession } from './emulator.js';
@@ -71,8 +81,20 @@ export interface CraftAndroidConfig {
71
81
  }>;
72
82
  trustedOrigins?: string[];
73
83
  appIconPath?: string;
84
+ /**
85
+ * The small icon in the status bar and on every notification the app
86
+ * shows, push or local: an Android vector drawable (`.xml`) or a `.png`,
87
+ * white on transparent, since Android draws only its shape. Without one,
88
+ * a white Craft mark. Never the launcher icon, which shows as a white
89
+ * square.
90
+ */
91
+ notificationIconPath?: string;
92
+ /** The `#RRGGBB` Android tints the notification icon and app name with. The system's own colour without it. */
93
+ notificationColor?: string;
74
94
  googleServicesFile?: string;
75
- /** Google Maps Android SDK key used by native `Map` screens. */
95
+ /** Native map renderer. MapLibre needs no SDK key; Google remains the default for compatibility. */
96
+ mapProvider?: AndroidMapProvider;
97
+ /** Google Maps Android SDK key, used only by the Google native map host. */
76
98
  mapsApiKey?: string;
77
99
  devServerURL?: string;
78
100
  hasBundledFallback?: boolean;
@@ -4,6 +4,48 @@ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSy
4
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) {
@@ -315,6 +357,8 @@ async function createAndroidEmulator(options) {
315
357
  }
316
358
 
317
359
  // ../android/src/index.ts
360
+ var nativeRouteBundles = true;
361
+ var nativeMapProviders = ["google", "maplibre"];
318
362
  function syncNativeFonts(fonts, output) {
319
363
  const destination = join3(output, "app/src/main/assets/NativeFonts");
320
364
  const names = new Set;
@@ -549,6 +593,7 @@ function normalizeAndroidNetworkConfig(config) {
549
593
  config.trustedOrigins = [...new Set(trustedOrigins)];
550
594
  }
551
595
  function validateAndroidConfig(config) {
596
+ androidMapHost(config.mapProvider);
552
597
  if (!config.appName.trim())
553
598
  throw new Error("Android app name must not be empty");
554
599
  if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
@@ -682,6 +727,7 @@ function writeAndroidConfig(output, config) {
682
727
  writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
683
728
  const runtimeConfig = { ...config };
684
729
  delete runtimeConfig.appIconPath;
730
+ delete runtimeConfig.notificationIconPath;
685
731
  delete runtimeConfig.googleServicesFile;
686
732
  writeFileSync(join3(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
687
733
  }
@@ -802,7 +848,9 @@ function renderNativeShellManifest(config) {
802
848
  <intent-filter>
803
849
  <action android:name="com.google.firebase.MESSAGING_EVENT" />
804
850
  </intent-filter>
805
- </service>`);
851
+ </service>
852
+ <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/craft_notification_icon" />${config.notificationColor ? `
853
+ <meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/craft_notification_color" />` : ""}`);
806
854
  }
807
855
  if (config.enableHealthConnect) {
808
856
  parts.push(` <activity-alias
@@ -912,6 +960,18 @@ async function init(options) {
912
960
  if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
913
961
  throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
914
962
  }
963
+ if (config.notificationIconPath)
964
+ requireRegularFile(config.notificationIconPath, "Notification icon");
965
+ const notificationIconExtension = config.notificationIconPath ? extname(config.notificationIconPath).toLowerCase() : undefined;
966
+ if (config.notificationIconPath && ![".png", ".xml"].includes(notificationIconExtension ?? "")) {
967
+ throw new Error(`Unsupported Android notification icon format: ${notificationIconExtension || "(none)"} (use a vector drawable .xml or a white-on-transparent .png)`);
968
+ }
969
+ if (config.notificationIconPath && notificationIconExtension === ".xml" && !/<vector[\s>]/.test(readFileSync2(config.notificationIconPath, "utf8"))) {
970
+ throw new Error("Notification icon .xml must be an Android vector drawable (<vector>)");
971
+ }
972
+ if (config.notificationColor !== undefined && !/^#[0-9a-f]{6}$/i.test(config.notificationColor)) {
973
+ throw new Error(`notificationColor must be #RRGGBB, not ${String(config.notificationColor)}`);
974
+ }
915
975
  const dirs = [
916
976
  output,
917
977
  join3(output, "app/src/main/java", packagePath),
@@ -936,7 +996,7 @@ async function init(options) {
936
996
  cpSync(config.googleServicesFile, join3(output, "app/google-services.json"));
937
997
  }
938
998
  const mainActivityTemplate = readFileSync2(join3(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
939
- const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
999
+ 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);
940
1000
  writeFileSync(join3(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
941
1001
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftAppLinks.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftAppLinks.kt.template"), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
942
1002
  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));
@@ -979,11 +1039,11 @@ async function init(options) {
979
1039
  mkdirSync2(nativeDir, { recursive: true });
980
1040
  writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
981
1041
  const serviceTemplate = readFileSync2(join3(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
982
- writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate);
1042
+ writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
983
1043
  const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
984
1044
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
985
1045
  const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
986
- 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>
1046
+ 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>
987
1047
  <package android:name="com.google.android.apps.healthdata" />
988
1048
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
989
1049
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -996,8 +1056,8 @@ async function init(options) {
996
1056
  const projectGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
997
1057
  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' : ""));
998
1058
  const appGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
999
- 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
1000
- 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")
1059
+ 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
1060
+ implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1001
1061
  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")
1002
1062
  // Native background refresh (WorkManager).
1003
1063
  implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
@@ -1036,6 +1096,8 @@ zipStorePath=wrapper/dists
1036
1096
  <color name="primary_dark">#16162b</color>
1037
1097
  <color name="accent">#4ade80</color>
1038
1098
  <color name="background">${config.backgroundColor}</color>
1099
+ <!-- Transparent is NotificationCompat's COLOR_DEFAULT: the system picks. -->
1100
+ <color name="craft_notification_color">${config.notificationColor ?? "#00000000"}</color>
1039
1101
  </resources>
1040
1102
  `;
1041
1103
  writeFileSync(join3(output, "app/src/main/res/values/colors.xml"), colorsXml);
@@ -1064,6 +1126,21 @@ zipStorePath=wrapper/dists
1064
1126
  } else {
1065
1127
  writeFileSync(join3(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
1066
1128
  }
1129
+ for (const extension of [".png", ".xml"])
1130
+ rmSync2(join3(output, `app/src/main/res/drawable/craft_notification_icon${extension}`), { force: true });
1131
+ if (config.notificationIconPath) {
1132
+ cpSync(config.notificationIconPath, join3(output, `app/src/main/res/drawable/craft_notification_icon${notificationIconExtension}`));
1133
+ } else {
1134
+ writeFileSync(join3(output, "app/src/main/res/drawable/craft_notification_icon.xml"), `<?xml version="1.0" encoding="utf-8"?>
1135
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
1136
+ android:width="24dp"
1137
+ android:height="24dp"
1138
+ android:viewportWidth="108"
1139
+ android:viewportHeight="108">
1140
+ <path android:fillColor="#FFFFFFFF" android:pathData="M18,78L42,42l12,18l12,-12l24,30z" />
1141
+ </vector>
1142
+ `);
1143
+ }
1067
1144
  const themesXml = `<?xml version="1.0" encoding="utf-8"?>
1068
1145
  <resources>
1069
1146
  <style name="Theme.CraftApp" parent="Theme.Material3.DayNight.NoActionBar">
@@ -1167,15 +1244,29 @@ async function build(options) {
1167
1244
  if (htmlPath || devServer)
1168
1245
  throw new Error("A native Android screen cannot use --html-path or --dev-server.");
1169
1246
  const destination = join3(output, "app/src/main/assets/native-screen.js");
1170
- if (nativeBundlePath && resolve2(nativeBundlePath) !== resolve2(destination))
1171
- cpSync(nativeBundlePath, destination);
1247
+ const routeDestination = join3(output, "app/src/main/assets/native-screens");
1248
+ if (nativeBundlePath) {
1249
+ if (!existsSync(nativeBundlePath))
1250
+ throw new Error(`Native bundle not found: ${nativeBundlePath}`);
1251
+ if (statSync(nativeBundlePath).isDirectory()) {
1252
+ if (resolve2(nativeBundlePath) !== resolve2(routeDestination)) {
1253
+ rmSync2(routeDestination, { recursive: true, force: true });
1254
+ cpSync(nativeBundlePath, routeDestination, { recursive: true });
1255
+ }
1256
+ rmSync2(destination, { force: true });
1257
+ } else {
1258
+ if (resolve2(nativeBundlePath) !== resolve2(destination))
1259
+ cpSync(nativeBundlePath, destination);
1260
+ rmSync2(routeDestination, { recursive: true, force: true });
1261
+ }
1262
+ }
1172
1263
  const icons = join3(output, "app/src/main/assets/craft-icons.json");
1173
1264
  if (options.iconsPath) {
1174
1265
  requireRegularFile(options.iconsPath, "Icon table");
1175
1266
  if (resolve2(options.iconsPath) !== resolve2(icons))
1176
1267
  cpSync(options.iconsPath, icons);
1177
1268
  }
1178
- if (!existsSync(destination)) {
1269
+ if (!existsSync(destination) && !existsSync(routeDestination)) {
1179
1270
  throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1180
1271
  }
1181
1272
  } else if (nativeBundlePath) {
@@ -1286,6 +1377,8 @@ export {
1286
1377
  renderAndroidPermissions,
1287
1378
  renderAndroidDeepLinks,
1288
1379
  open,
1380
+ nativeRouteBundles,
1381
+ nativeMapProviders,
1289
1382
  installRuntime,
1290
1383
  init,
1291
1384
  healthConnectPermissions,
@@ -0,0 +1,7 @@
1
+ /** Build-time native map selection. Both providers are native Android views. */
2
+ export type AndroidMapProvider = 'google' | 'maplibre';
3
+ export declare function androidMapHost(provider?: AndroidMapProvider): {
4
+ template: string;
5
+ imports: string;
6
+ dependency: string;
7
+ };
@@ -3475,7 +3475,8 @@ class CraftBridge(
3475
3475
  val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
3476
3476
 
3477
3477
  val builder = androidx.core.app.NotificationCompat.Builder(activity, notificationChannelId)
3478
- .setSmallIcon(android.R.drawable.ic_dialog_info)
3478
+ .setSmallIcon(R.drawable.craft_notification_icon)
3479
+ .setColor(ContextCompat.getColor(activity, R.color.craft_notification_color))
3479
3480
  .setContentTitle(title)
3480
3481
  .setContentText(body)
3481
3482
  .setPriority(androidx.core.app.NotificationCompat.PRIORITY_DEFAULT)
@@ -56,6 +56,33 @@ internal object CraftNativeSandbox {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * The native screens' JavaScript: a route's own file when stx wrote one
61
+ * (`native-screens/<route>.js`, its `routesDir`), else the one-file
62
+ * `native-screen.js`. An isolate evaluates all it is given, so a route file
63
+ * spares each screen parsing (and sending the sandbox) every other screen.
64
+ * Read once and kept while memory allows.
65
+ */
66
+ internal object CraftNativeBundle {
67
+ private val sources = object : android.util.LruCache<String, String>(24 shl 20) {
68
+ override fun sizeOf(key: String, value: String) = value.length
69
+ }
70
+
71
+ private fun read(context: Context, asset: String): String? = sources.get(asset)
72
+ ?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
73
+
74
+ fun source(context: Context, route: String?): String? {
75
+ if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
76
+ read(context, "native-screens/$route.js")?.let { return it }
77
+ }
78
+ return read(context, "native-screen.js")
79
+ }
80
+
81
+ fun isAvailable(context: Context): Boolean =
82
+ runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
83
+ || (context.assets.list("native-screens")?.isNotEmpty() == true)
84
+ }
85
+
59
86
  /**
60
87
  * Background refresh, as iOS's BGAppRefreshTask gives it: WorkManager wakes
61
88
  * the app every `backgroundRefresh.minimumIntervalMinutes` (15 at the
@@ -153,8 +180,7 @@ internal class CraftNativeHeadlessApp(
153
180
  fun start() {
154
181
  val route = config.optString("nativeApp")
155
182
  if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(true)
156
- val bundle = runCatching { context.assets.open("native-screen.js").bufferedReader().use { it.readText() } }.getOrNull()
157
- ?: return finish(true)
183
+ val bundle = CraftNativeBundle.source(context, route) ?: return finish(true)
158
184
  CraftNativeSandbox.connect(context) { engine, error ->
159
185
  if (closed) return@connect
160
186
  if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {