craft-native 0.0.168 → 0.0.170

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.
@@ -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;
@@ -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,6 +870,8 @@ 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
  };
@@ -0,0 +1,296 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Log
7
+ import androidx.core.content.ContextCompat
8
+ import androidx.javascriptengine.JavaScriptConsoleCallback
9
+ import androidx.javascriptengine.JavaScriptIsolate
10
+ import androidx.javascriptengine.JavaScriptSandbox
11
+ import androidx.work.Constraints
12
+ import androidx.work.CoroutineWorker
13
+ import androidx.work.ExistingPeriodicWorkPolicy
14
+ import androidx.work.ExistingWorkPolicy
15
+ import androidx.work.NetworkType
16
+ import androidx.work.OneTimeWorkRequestBuilder
17
+ import androidx.work.PeriodicWorkRequestBuilder
18
+ import androidx.work.WorkManager
19
+ import androidx.work.WorkerParameters
20
+ import androidx.work.workDataOf
21
+ import com.google.common.util.concurrent.ListenableFuture
22
+ import kotlinx.coroutines.CompletableDeferred
23
+ import kotlinx.coroutines.Dispatchers
24
+ import kotlinx.coroutines.withContext
25
+ import kotlinx.coroutines.withTimeoutOrNull
26
+ import org.json.JSONArray
27
+ import org.json.JSONObject
28
+ import java.util.UUID
29
+ import java.util.concurrent.TimeUnit
30
+
31
+ /**
32
+ * One JavaScriptSandbox for the process: the screens' isolates and a
33
+ * background refresh's all live in it, since an app may hold only one.
34
+ */
35
+ internal object CraftNativeSandbox {
36
+ private val lock = Any()
37
+ private var sandbox: JavaScriptSandbox? = null
38
+ private var connecting: ListenableFuture<JavaScriptSandbox>? = null
39
+
40
+ fun connect(context: Context, done: (JavaScriptSandbox?, Throwable?) -> Unit) {
41
+ val existing = synchronized(lock) { sandbox }
42
+ if (existing != null) return done(existing, null)
43
+ val future = synchronized(lock) {
44
+ connecting ?: JavaScriptSandbox.createConnectedInstanceAsync(context.applicationContext).also { connecting = it }
45
+ }
46
+ future.addListener({
47
+ try {
48
+ val engine = future.get()
49
+ synchronized(lock) { sandbox = engine }
50
+ done(engine, null)
51
+ } catch (error: Exception) {
52
+ synchronized(lock) { connecting = null }
53
+ done(null, error)
54
+ }
55
+ }, ContextCompat.getMainExecutor(context))
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Background refresh, as iOS's BGAppRefreshTask gives it: WorkManager wakes
61
+ * the app every `backgroundRefresh.minimumIntervalMinutes` (15 at the
62
+ * least, with a network) and the app script (`nativeApp`) hears
63
+ * `craftBackgroundRefresh` with an `id`, does its work and answers
64
+ * `craft.background.complete(ok, id)`. With the app open, the running app
65
+ * script hears it; otherwise a script is started for the refresh alone
66
+ * ([CraftNativeHeadlessApp]) and closed when it answers. A silent push
67
+ * (`craftSilentPush`) runs the same way, once.
68
+ */
69
+ internal object CraftNativeBackground {
70
+ private const val PERIODIC = "craft-native-background-refresh"
71
+ private const val ONCE = "craft-native-background-once"
72
+
73
+ /** The running app's script, when the activity has one: delivers an event and hears its answer. */
74
+ @Volatile var foreground: ((event: String, id: String, done: (Boolean) -> Unit) -> Boolean)? = null
75
+
76
+ fun schedule(context: Context, config: JSONObject) {
77
+ val refresh = config.optJSONObject("backgroundRefresh")
78
+ val work = WorkManager.getInstance(context)
79
+ if (refresh?.optBoolean("enabled") != true || config.optString("nativeApp").isBlank()) {
80
+ work.cancelUniqueWork(PERIODIC)
81
+ return
82
+ }
83
+ val minutes = refresh.optLong("minimumIntervalMinutes", 120).coerceAtLeast(15)
84
+ val request = PeriodicWorkRequestBuilder<CraftNativeRefreshWorker>(minutes, TimeUnit.MINUTES)
85
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
86
+ .setInputData(workDataOf("event" to "craftBackgroundRefresh"))
87
+ .build()
88
+ work.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
89
+ }
90
+
91
+ /** One run now (a silent push). */
92
+ fun runOnce(context: Context, event: String) {
93
+ val request = OneTimeWorkRequestBuilder<CraftNativeRefreshWorker>()
94
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
95
+ .setInputData(workDataOf("event" to event))
96
+ .build()
97
+ WorkManager.getInstance(context).enqueueUniqueWork(ONCE, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
98
+ }
99
+ }
100
+
101
+ internal class CraftNativeRefreshWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
102
+ override suspend fun doWork(): Result {
103
+ val event = inputData.getString("event") ?: "craftBackgroundRefresh"
104
+ val id = UUID.randomUUID().toString()
105
+ val answer = CompletableDeferred<Boolean>()
106
+ val running = withContext(Dispatchers.Main) {
107
+ CraftNativeBackground.foreground?.invoke(event, id) { ok -> answer.complete(ok) } == true
108
+ }
109
+ Log.i("CraftNativeAndroid", "$event $id: ${if (running) "the running app script" else "a background app script"}")
110
+ var headless: CraftNativeHeadlessApp? = null
111
+ if (!running) {
112
+ headless = withContext(Dispatchers.Main) { CraftNativeHeadlessApp(applicationContext, event, id) { ok -> answer.complete(ok) } }
113
+ withContext(Dispatchers.Main) { headless.start() }
114
+ }
115
+ // WorkManager stops a worker at ten minutes; answer before that.
116
+ val ok = withTimeoutOrNull(9 * 60 * 1000L) { answer.await() }
117
+ Log.i("CraftNativeAndroid", "$event $id answered ${ok ?: "nothing in time"}")
118
+ headless?.let { withContext(Dispatchers.Main) { it.close() } }
119
+ return if (ok == false) Result.retry() else Result.success()
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The app script with no activity: a background refresh's. It gets the
125
+ * same bridge as a screen's (CraftNativePrelude), Craft's core capabilities
126
+ * and this host's services, minus everything a person would have to see.
127
+ */
128
+ internal class CraftNativeHeadlessApp(
129
+ private val context: Context,
130
+ private val event: String,
131
+ private val id: String,
132
+ private val onDone: (Boolean) -> Unit,
133
+ ) {
134
+ private val config: JSONObject = runCatching {
135
+ context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
136
+ }.getOrDefault(JSONObject())
137
+ private val stores = CraftNativeStores.get(context, config)
138
+ private val capabilities = CraftNativeCapabilities(context, config)
139
+ private val services = CraftNativeServices(context, config) { null }
140
+ private val scope = UUID.randomUUID().toString()
141
+ private val prefix = "CRAFT_STX_$scope:"
142
+ private val main = Handler(Looper.getMainLooper())
143
+ private var isolate: JavaScriptIsolate? = null
144
+ private var closed = false
145
+ private val timers = mutableMapOf<Int, Runnable>()
146
+ private val chunks = CraftNativeChunks()
147
+
148
+ init {
149
+ capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, scope) }
150
+ services.onBackgroundComplete = { ok, answered -> if (answered == null || answered == id) finish(ok) }
151
+ }
152
+
153
+ fun start() {
154
+ val route = config.optString("nativeApp")
155
+ 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)
158
+ CraftNativeSandbox.connect(context) { engine, error ->
159
+ if (closed) return@connect
160
+ if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
161
+ Log.w(TAG, "Background refresh has no JavaScript engine", error)
162
+ return@connect finish(true)
163
+ }
164
+ val created = engine.createIsolate()
165
+ isolate = created
166
+ created.setConsoleCallback(ContextCompat.getMainExecutor(context), JavaScriptConsoleCallback { entry ->
167
+ if (closed) return@JavaScriptConsoleCallback
168
+ if (entry.message.startsWith(prefix)) chunks.accept(entry.message.removePrefix(prefix))?.let(::receive)
169
+ else Log.i(TAG, "[background] ${entry.message}")
170
+ })
171
+ CraftNativeHub.register(scope) { type, payload -> send(type, payload) }
172
+ val names = capabilities.names().apply { services.names().forEach { put(it) } }
173
+ val bridgeFields = """
174
+ platform: 'android',
175
+ mutationProtocolVersion: 1,
176
+ capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
177
+ capabilityTimeoutMs: 30000,
178
+ capabilities: $names,
179
+ initialAppState: 'background',
180
+ colorScheme: 'light',
181
+ appURL: ${JSONObject.quote(config.optString("devServerURL"))},
182
+ appVersion: '',
183
+ appBuild: ''
184
+ """.trimIndent()
185
+ val prelude = CraftNativePrelude.script(
186
+ bridgeFields,
187
+ CraftNativeChunks.consolePost(prefix),
188
+ "globalThis.__stxNativeCallback = deliver;",
189
+ stores.preloadText(),
190
+ "globalThis.__stxNativeRoute = ${JSONObject.quote(route)};\nglobalThis.__stxNativeParams = {};",
191
+ )
192
+ val setup = created.evaluateJavaScriptAsync(prelude)
193
+ setup.addListener({
194
+ if (closed) return@addListener
195
+ try {
196
+ setup.get()
197
+ val load = created.evaluateJavaScriptAsync(bundle)
198
+ load.addListener({
199
+ try {
200
+ load.get()
201
+ send("CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject().put("id", id)))
202
+ } catch (failure: Exception) {
203
+ Log.e(TAG, "Background refresh script failed", failure)
204
+ finish(false)
205
+ }
206
+ }, ContextCompat.getMainExecutor(context))
207
+ } catch (failure: Exception) {
208
+ Log.e(TAG, "Background refresh bridge failed", failure)
209
+ finish(false)
210
+ }
211
+ }, ContextCompat.getMainExecutor(context))
212
+ }
213
+ }
214
+
215
+ private fun receive(raw: String) {
216
+ val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
217
+ val payload = message.optJSONObject("payload") ?: JSONObject()
218
+ when (message.optString("type")) {
219
+ "API_REQUEST" -> answer(message.optString("id"), payload)
220
+ "SYNC_WRITE" -> {
221
+ val key = payload.optString("key")
222
+ val value = if (payload.isNull("value")) null else payload.optString("value")
223
+ val reply: (Boolean) -> Unit = { ok ->
224
+ payload.optString("id").takeIf { it.isNotBlank() }?.let { send("SYNC_RESULT", JSONObject().put("id", it).put("ok", ok)) }
225
+ }
226
+ when (payload.optString("store")) {
227
+ "storage" -> if (key.isNotBlank()) stores.setStorage(key, value, scope)
228
+ "snapshots" -> if (payload.optBoolean("clear")) {
229
+ val keep = payload.optJSONArray("keep") ?: JSONArray()
230
+ stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
231
+ } else stores.setSnapshot(key, value, scope, reply)
232
+ }
233
+ }
234
+ "TIMER" -> {
235
+ val id = payload.optInt("id")
236
+ timers.remove(id)?.let(main::removeCallbacks)
237
+ val fire = Runnable {
238
+ timers.remove(id)
239
+ send("TIMER_FIRE", JSONObject().put("id", id))
240
+ }
241
+ timers[id] = fire
242
+ main.postDelayed(fire, payload.optLong("ms", 0).coerceAtLeast(0))
243
+ }
244
+ "TIMER_CLEAR" -> timers.remove(payload.optInt("id"))?.let(main::removeCallbacks)
245
+ // Nothing is drawn in the background: frames come at a calm pace.
246
+ "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", android.os.SystemClock.uptimeMillis().toDouble())) }, 100)
247
+ // A background refresh shows nothing: navigation waits for the app.
248
+ }
249
+ }
250
+
251
+ private fun answer(requestId: String, payload: JSONObject) {
252
+ if (requestId.isBlank()) return
253
+ val module = payload.optString("module")
254
+ val method = payload.optString("method")
255
+ val args = payload.optJSONArray("args") ?: JSONArray()
256
+ val reply: (CraftNativeCapabilityResult) -> Unit = { result ->
257
+ val response = JSONObject().put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION).put("requestId", requestId)
258
+ if (result.code == null) response.put("data", result.data ?: JSONObject.NULL)
259
+ else response.put("code", result.code).put("message", result.message ?: "Native API failed")
260
+ send(if (result.code == null) "API_RESPONSE" else "API_ERROR", response, requestId)
261
+ }
262
+ if (services.handles(module)) services.perform(module, method, args, reply)
263
+ else capabilities.perform("$scope/$requestId", payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION), module, method, args, reply)
264
+ }
265
+
266
+ private fun send(type: String, payload: JSONObject, correlationId: String? = null) {
267
+ val target = isolate ?: return
268
+ if (closed) return
269
+ val message = JSONObject().put("type", type).put("payload", payload)
270
+ if (correlationId != null) message.put("correlationId", correlationId)
271
+ runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})") }
272
+ }
273
+
274
+ private fun finish(ok: Boolean) {
275
+ main.post { onDone(ok) }
276
+ }
277
+
278
+ fun close() {
279
+ if (closed) return
280
+ closed = true
281
+ CraftNativeHub.unregister(scope)
282
+ timers.values.forEach(main::removeCallbacks)
283
+ timers.clear()
284
+ services.close()
285
+ capabilities.close()
286
+ isolate?.let { target ->
287
+ runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount()") }
288
+ main.postDelayed({ runCatching { target.close() } }, 500)
289
+ }
290
+ isolate = null
291
+ }
292
+
293
+ companion object {
294
+ private const val TAG = "CraftNativeAndroid"
295
+ }
296
+ }