@craft-native/android 0.0.167 → 0.0.169

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.
package/README.md CHANGED
@@ -204,6 +204,45 @@ Support depends on the installed WebView provider, so validate on each device
204
204
  class you plan to ship. The [Android JavaScriptEngine guide](https://developer.android.com/develop/ui/views/layout/webapps/jsengine)
205
205
  documents the sandbox and device-support check.
206
206
 
207
+ ### A whole app of native screens
208
+
209
+ With `nativeScreens` (paths to the screens that draw them), `tabs` and
210
+ `nativeApp` in craft.config.json, the same keys as the iOS builder, the
211
+ native renderer is a whole app rather than one bundle route:
212
+
213
+ - a tab bar and a navigation bar (`craft.navigation.setOptions`: title, large
214
+ title, right buttons with badges, `tabBarHidden`), a stack per tab, Android's
215
+ Back key popping it;
216
+ - `craft.navigation.open(path)` matched in the iOS shell's order (fixed segments
217
+ before parameters), a tab's path selecting its tab, a path no screen draws
218
+ opening the site in a Custom Tab; deep links and tapped notifications route
219
+ the same way;
220
+ - the app script (`nativeApp`) in its own isolate for the app's life, and in a
221
+ WorkManager job for `backgroundRefresh` and silent pushes
222
+ (`craftBackgroundRefresh`, `craftSilentPush`, answered with
223
+ `craft.background.complete`);
224
+ - `craft.storage.getSync`, `craft.secureStorage.getSync` and `craft.snapshots`:
225
+ JavaScriptSandbox cannot call the host synchronously, so every isolate starts
226
+ with a copy of these stores and the host keeps each copy current. Timers and
227
+ `requestAnimationFrame` (vsync, for the screen on display) are the host's;
228
+ long messages on the console bridge travel in pieces;
229
+ - the services the iOS host answers by the same module names: Dialog, Browser
230
+ (Custom Tabs, `mode: 'auth'` callbacks), Linking, Share, Push (FCM),
231
+ Location (with a foreground-service route recording), Media (videos kept
232
+ for offline, played in place of their address), KeepAwake, Brightness,
233
+ StatusBar, Speech and Health;
234
+ - `iconsPath` (a `{ viewBox, strokeWidth, icons }` table of SVG bodies keyed by
235
+ icon name and SF Symbol) gives `<Icon>`, tabs and bar buttons the app's own
236
+ vectors in place of Android's glyphs.
237
+
238
+ Health is Health Connect with the HealthKit contract (`getWorkouts`,
239
+ `getDailyStatistics`, `getHeartRateSamples`, `getSleepIntervals`,
240
+ `saveWorkout`, `saveMindfulSession`...), plus `availability`, `grantedTypes`
241
+ and `openSettings` (`healthAvailability`). `healthConnect: { read, write,
242
+ background, history }` lists the types the manifest declares; Play reviews
243
+ each one. `bun scripts/test-native-health.ts` runs it against an Android 14+
244
+ emulator's Health Connect.
245
+
207
246
  ### 3. Open in Android Studio
208
247
 
209
248
  ```bash
@@ -754,6 +793,12 @@ of bundled HTML.
754
793
 
755
794
  ## Publishing
756
795
 
796
+ `build({ release: true, bundle: true })` runs `bundleRelease` and writes
797
+ `app/build/outputs/bundle/release/app-release.aab`, signed with the upload key
798
+ from `CRAFT_ANDROID_KEYSTORE` (the keystore's path), `CRAFT_ANDROID_KEYSTORE_PASSWORD`,
799
+ `CRAFT_ANDROID_KEY_ALIAS` and `CRAFT_ANDROID_KEY_PASSWORD` (the keystore password
800
+ when unset). Without them the release build is unsigned.
801
+
757
802
  Build release APK/AAB:
758
803
 
759
804
  ```bash
package/dist/index.d.ts CHANGED
@@ -26,6 +26,24 @@ export interface CraftAndroidConfig {
26
26
  enableGeolocation?: boolean;
27
27
  enableBackgroundLocation?: boolean;
28
28
  enableHealthConnect?: boolean;
29
+ /**
30
+ * The Health Connect data the app reads and writes, by the shared type
31
+ * names (`steps`, `sleep`, `workouts`...). The manifest declares only
32
+ * these, as Play's health permissions review requires; without it, the
33
+ * original set (steps, heart rate, energy, distance, workouts).
34
+ */
35
+ healthConnect?: {
36
+ read?: HealthConnectType[];
37
+ write?: HealthConnectType[];
38
+ /** Read while the app is in the background (a WorkManager refresh): Android 15's READ_HEALTH_DATA_IN_BACKGROUND. */
39
+ background?: boolean;
40
+ /** Read further back than the 30 days before the first grant: READ_HEALTH_DATA_HISTORY. */
41
+ history?: boolean;
42
+ };
43
+ enableHealthConnectBackground?: boolean;
44
+ enableHealthConnectHistory?: boolean;
45
+ /** The app's privacy policy, which Health Connect links to from its permission screen. */
46
+ privacyPolicyURL?: string;
29
47
  enableKeepAwake?: boolean;
30
48
  enableDeepLinks?: boolean;
31
49
  /** Reach the person's own devices on the local network over plain HTTP (a Mac companion). */
@@ -40,7 +58,37 @@ export interface CraftAndroidConfig {
40
58
  minSdk?: number;
41
59
  compileSdk?: number;
42
60
  targetSdk?: number;
61
+ /**
62
+ * Native renderer: the app's paths and the screen drawing each
63
+ * (`/m/workout/:id` → `Workout`), its tabs, and the bundle route of the
64
+ * script that runs beside the screens. The same keys as the iOS builder.
65
+ */
66
+ nativeScreens?: Record<string, string>;
67
+ tabs?: Array<{
68
+ id: string;
69
+ title: string;
70
+ symbol?: string;
71
+ }>;
72
+ nativeApp?: string;
73
+ /** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
74
+ backgroundRefresh?: {
75
+ enabled?: boolean;
76
+ minimumIntervalMinutes?: number;
77
+ };
43
78
  }
79
+ /** The shared health data types (the iOS builder's HealthKit names). */
80
+ export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat';
81
+ /**
82
+ * The Health Connect permissions behind each type, for reading and for
83
+ * writing. CraftNativeHealth.kt asks for the same ones; a permission the
84
+ * manifest does not declare is refused without a prompt.
85
+ */
86
+ export declare const HEALTH_CONNECT_PERMISSIONS: Record<HealthConnectType, {
87
+ read: string[];
88
+ write: string[];
89
+ }>;
90
+ /** Every `android.permission.health.*` the app's Health Connect types need. */
91
+ export declare function healthConnectPermissions(config: Pick<CraftAndroidConfig, 'healthConnect'>): string[];
44
92
  export interface InitOptions {
45
93
  name: string;
46
94
  packageName?: string;
@@ -58,9 +106,23 @@ export interface InitOptions {
58
106
  export interface BuildOptions {
59
107
  htmlPath?: string;
60
108
  nativeBundlePath?: string;
109
+ /**
110
+ * Native renderer: the app's icons as vectors (`{ viewBox, strokeWidth,
111
+ * icons: { name: svgBody } }`, keyed by Iconify name and SF Symbol), which
112
+ * `<Icon>`, the tab bar and the bar buttons draw. Without it they fall
113
+ * back to the glyphs Android's fonts have.
114
+ */
115
+ iconsPath?: string;
61
116
  devServer?: string;
62
117
  output: string;
63
118
  release?: boolean;
119
+ /**
120
+ * An Android App Bundle (`bundleRelease`, `.aab`), what Google Play takes,
121
+ * rather than an APK. Signed with CRAFT_ANDROID_KEYSTORE,
122
+ * CRAFT_ANDROID_KEYSTORE_PASSWORD, CRAFT_ANDROID_KEY_ALIAS and
123
+ * CRAFT_ANDROID_KEY_PASSWORD when they are set.
124
+ */
125
+ bundle?: boolean;
64
126
  compile?: boolean;
65
127
  /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
66
128
  runtimeDir?: string | null;
package/dist/index.js CHANGED
@@ -111,6 +111,48 @@ var KOTLIN_KEYWORDS = new Set([
111
111
  "while"
112
112
  ]);
113
113
  var ANDROID_MIN_SDK = 26;
114
+ var HEALTH_CONNECT_PERMISSIONS = {
115
+ steps: { read: ["READ_STEPS"], write: [] },
116
+ heartRate: { read: ["READ_HEART_RATE"], write: [] },
117
+ activeEnergy: { read: ["READ_ACTIVE_CALORIES_BURNED"], write: ["WRITE_ACTIVE_CALORIES_BURNED"] },
118
+ distance: { read: ["READ_DISTANCE"], write: ["WRITE_DISTANCE"] },
119
+ workouts: {
120
+ read: ["READ_EXERCISE", "READ_HEART_RATE", "READ_ACTIVE_CALORIES_BURNED", "READ_DISTANCE"],
121
+ write: ["WRITE_EXERCISE", "WRITE_ACTIVE_CALORIES_BURNED", "WRITE_DISTANCE"]
122
+ },
123
+ restingHeartRate: { read: ["READ_RESTING_HEART_RATE"], write: [] },
124
+ heartRateVariability: { read: ["READ_HEART_RATE_VARIABILITY"], write: [] },
125
+ bodyMass: { read: ["READ_WEIGHT"], write: [] },
126
+ sleep: { read: ["READ_SLEEP"], write: [] },
127
+ mindful: { read: ["READ_MINDFULNESS"], write: ["WRITE_MINDFULNESS"] },
128
+ dietaryEnergy: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
129
+ dietaryProtein: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
130
+ dietaryCarbohydrates: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
131
+ dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] }
132
+ };
133
+ var DEFAULT_HEALTH_CONNECT = { read: ["steps", "heartRate", "activeEnergy", "distance", "workouts"], write: ["workouts"] };
134
+ function healthConnectPermissions(config) {
135
+ const read = config.healthConnect?.read ?? DEFAULT_HEALTH_CONNECT.read;
136
+ const write = config.healthConnect?.write ?? (config.healthConnect ? [] : DEFAULT_HEALTH_CONNECT.write);
137
+ const permissions = new Set;
138
+ for (const type of read) {
139
+ const known = HEALTH_CONNECT_PERMISSIONS[type];
140
+ if (!known)
141
+ throw new Error(`Unknown Health Connect type: ${type}`);
142
+ known.read.forEach((permission) => permissions.add(`android.permission.health.${permission}`));
143
+ }
144
+ for (const type of write) {
145
+ const known = HEALTH_CONNECT_PERMISSIONS[type];
146
+ if (!known)
147
+ throw new Error(`Unknown Health Connect type: ${type}`);
148
+ known.write.forEach((permission) => permissions.add(`android.permission.health.${permission}`));
149
+ }
150
+ if (config.healthConnect?.background)
151
+ permissions.add("android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND");
152
+ if (config.healthConnect?.history)
153
+ permissions.add("android.permission.health.READ_HEALTH_DATA_HISTORY");
154
+ return [...permissions];
155
+ }
114
156
  var DEFAULT_CONFIG = {
115
157
  renderer: "web",
116
158
  version: "1.0.0",
@@ -307,17 +349,77 @@ function renderAndroidPermissions(config) {
307
349
  permissions.add("android.permission.POST_NOTIFICATIONS");
308
350
  if (config.enableKeepAwake)
309
351
  permissions.add("android.permission.WAKE_LOCK");
352
+ if (config.enableHealthConnect)
353
+ healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
354
+ if (config.renderer === "native" && config.backgroundRefresh?.enabled)
355
+ permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
356
+ return [...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`).join(`
357
+ `);
358
+ }
359
+ function writeNativeShellSources(output, packagePath, packageName, config) {
360
+ const javaDir = join(output, "app/src/main/java", packagePath);
361
+ const write = (template, file, replace = (source) => source) => {
362
+ const source = readFileSync(join(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
363
+ writeFileSync(join(javaDir, file), replace(source));
364
+ };
365
+ write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
366
+ write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
367
+ write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
368
+ write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
369
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
370
+ val token = if (task.isSuccessful) task.result else null
371
+ if (token.isNullOrBlank()) done(failure("PUSH_UNAVAILABLE", task.exception?.message ?: "Firebase Cloud Messaging gave no token"))
372
+ else {
373
+ context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).edit().putString("token", token).apply()
374
+ done(success(token))
375
+ }
376
+ }
377
+ } catch (error: Exception) {
378
+ done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured"))
379
+ }` : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))'));
380
+ if (config.enablePushNotifications)
381
+ write("CraftNativeMessagingService.kt.template", "CraftNativeMessagingService.kt");
382
+ else
383
+ rmSync(join(javaDir, "CraftNativeMessagingService.kt"), { force: true });
384
+ mkdirSync(join(output, "app/src/main/res/xml"), { recursive: true });
385
+ writeFileSync(join(output, "app/src/main/res/xml/craft_file_paths.xml"), `<?xml version="1.0" encoding="utf-8"?>
386
+ <paths>
387
+ <cache-path name="craft-files" path="craft-files/" />
388
+ <files-path name="craft-media" path="craft-media/" />
389
+ </paths>
390
+ `);
391
+ }
392
+ function renderNativeShellManifest(config) {
393
+ const parts = [
394
+ ' <receiver android:name=".CraftNativeNotificationReceiver" android:exported="false" />',
395
+ ` <provider
396
+ android:name="androidx.core.content.FileProvider"
397
+ android:authorities="${config.packageName}.craftfiles"
398
+ android:exported="false"
399
+ android:grantUriPermissions="true">
400
+ <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/craft_file_paths" />
401
+ </provider>`
402
+ ];
403
+ if (config.enablePushNotifications) {
404
+ parts.push(` <service android:name=".CraftNativeMessagingService" android:exported="false">
405
+ <intent-filter>
406
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
407
+ </intent-filter>
408
+ </service>`);
409
+ }
310
410
  if (config.enableHealthConnect) {
311
- permissions.add("android.permission.health.READ_STEPS");
312
- permissions.add("android.permission.health.READ_HEART_RATE");
313
- permissions.add("android.permission.health.READ_ACTIVE_CALORIES_BURNED");
314
- permissions.add("android.permission.health.READ_DISTANCE");
315
- permissions.add("android.permission.health.READ_EXERCISE");
316
- permissions.add("android.permission.health.WRITE_EXERCISE");
317
- permissions.add("android.permission.health.WRITE_ACTIVE_CALORIES_BURNED");
318
- permissions.add("android.permission.health.WRITE_DISTANCE");
411
+ parts.push(` <activity-alias
412
+ android:name="ViewPermissionUsageActivity"
413
+ android:exported="true"
414
+ android:targetActivity=".MainActivity"
415
+ android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
416
+ <intent-filter>
417
+ <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
418
+ <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
419
+ </intent-filter>
420
+ </activity-alias>`);
319
421
  }
320
- return [...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`).join(`
422
+ return parts.join(`
321
423
  `);
322
424
  }
323
425
  function renderAndroidDeepLinks(config) {
@@ -382,6 +484,9 @@ async function init(options) {
382
484
  config.enableGeolocation = true;
383
485
  if (config.enableHealthConnect) {
384
486
  config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
487
+ config.enableHealthConnectBackground = Boolean(config.healthConnect?.background);
488
+ config.enableHealthConnectHistory = Boolean(config.healthConnect?.history);
489
+ healthConnectPermissions(config);
385
490
  }
386
491
  normalizeAndroidNetworkConfig(config);
387
492
  validateAndroidConfig(config);
@@ -433,6 +538,7 @@ async function init(options) {
433
538
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
434
539
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
435
540
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
541
+ writeNativeShellSources(output, packagePath, finalPackageName, config);
436
542
  }
437
543
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
438
544
  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 {
@@ -464,21 +570,24 @@ async function init(options) {
464
570
  const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
465
571
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
466
572
  const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
467
- 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" ? ' <receiver android:name=".CraftNativeNotificationReceiver" android:exported="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
573
+ 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(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
468
574
  <package android:name="com.google.android.apps.healthdata" />
469
575
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
470
576
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
471
- </intent-filter>
577
+ </intent-filter>${config.renderer === "native" ? "" : `
472
578
  <intent-filter>
473
579
  <action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
474
580
  <category android:name="android.intent.category.HEALTH_PERMISSIONS" />
475
- </intent-filter>` : "");
581
+ </intent-filter>`}` : "");
476
582
  writeFileSync(join(output, "app/src/main/AndroidManifest.xml"), manifest);
477
583
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
478
584
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
479
585
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
480
586
  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(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
481
- 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")' : "");
587
+ 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")
588
+ // The in-app browser (Custom Tabs) and background refresh (WorkManager).
589
+ implementation("androidx.browser:browser:1.8.0")
590
+ implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
482
591
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
483
592
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
484
593
  writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
@@ -630,7 +739,7 @@ zipStorePath=wrapper/dists
630
739
  console.log("");
631
740
  }
632
741
  async function build(options) {
633
- const { htmlPath, nativeBundlePath, devServer, output, release, compile = true } = options;
742
+ const { htmlPath, nativeBundlePath, devServer, output, release, bundle, compile = true } = options;
634
743
  console.log(`
635
744
  \uD83D\uDCE6 Building Craft Android project...`);
636
745
  const configPath = join(output, "craft.config.json");
@@ -645,6 +754,12 @@ async function build(options) {
645
754
  const destination = join(output, "app/src/main/assets/native-screen.js");
646
755
  if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
647
756
  cpSync(nativeBundlePath, destination);
757
+ const icons = join(output, "app/src/main/assets/craft-icons.json");
758
+ if (options.iconsPath) {
759
+ requireRegularFile(options.iconsPath, "Icon table");
760
+ if (resolve(options.iconsPath) !== resolve(icons))
761
+ cpSync(options.iconsPath, icons);
762
+ }
648
763
  if (!existsSync(destination)) {
649
764
  throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
650
765
  }
@@ -687,12 +802,15 @@ async function build(options) {
687
802
  throw new Error("Gradle is unavailable. Install Android Studio or Gradle before building Android.", { cause: error });
688
803
  }
689
804
  }
690
- const buildType = release ? "assembleRelease" : "assembleDebug";
691
- console.log(` Building ${release ? "release" : "debug"} APK...`);
805
+ const variant = release ? "Release" : "Debug";
806
+ const buildType = `${bundle ? "bundle" : "assemble"}${variant}`;
807
+ console.log(` Building ${release ? "release" : "debug"} ${bundle ? "app bundle" : "APK"}...`);
808
+ if (release && !process.env.CRAFT_ANDROID_KEYSTORE)
809
+ console.log(" CRAFT_ANDROID_KEYSTORE is not set: the release build is unsigned.");
692
810
  try {
693
811
  await $`cd ${output} && ./gradlew ${buildType}`;
694
- const apkPath = release ? "app/build/outputs/apk/release/app-release.apk" : "app/build/outputs/apk/debug/app-debug.apk";
695
- console.log(`\u2705 APK built: ${apkPath}`);
812
+ const artifact = bundle ? `app/build/outputs/bundle/${variant.toLowerCase()}/app-${variant.toLowerCase()}.aab` : release ? "app/build/outputs/apk/release/app-release.apk" : "app/build/outputs/apk/debug/app-debug.apk";
813
+ console.log(`\u2705 ${bundle ? "App bundle" : "APK"} built: ${artifact}`);
696
814
  } catch (error) {
697
815
  console.error("Build failed. Open in Android Studio for details.");
698
816
  throw error;
@@ -752,8 +870,10 @@ export {
752
870
  open,
753
871
  installRuntime,
754
872
  init,
873
+ healthConnectPermissions,
755
874
  build,
875
+ HEALTH_CONNECT_PERMISSIONS,
756
876
  ANDROID_MIN_SDK
757
877
  };
758
878
 
759
- //# debugId=37340FB9307E589364756E2164756E21
879
+ //# debugId=695695A5D869A12C64756E2164756E21