craft-native 0.0.319 → 0.0.329

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.
@@ -1,8 +1,5 @@
1
- /**
2
- * Craft Android Builder
3
- *
4
- * Generates Android apps with native Craft components and optional WebView content.
5
- */
1
+ /** Store large native source assets compressed; the host decodes each once on demand. */
2
+ export declare function compressNativeSources(target: string): void;
6
3
  import { type AndroidMapProvider } from './native-map.js';
7
4
  export type { AndroidMapProvider } from './native-map.js';
8
5
  /**
@@ -55,6 +52,7 @@ export interface CraftAndroidConfig {
55
52
  backgroundColor?: string;
56
53
  backgroundColorDark?: string;
57
54
  enableSpeechRecognition?: boolean;
55
+ enableAudioRecording?: boolean;
58
56
  enableHaptics?: boolean;
59
57
  enableShare?: boolean;
60
58
  enableCamera?: boolean;
@@ -1,6 +1,7 @@
1
1
  // @bun
2
2
  // ../android/src/index.ts
3
- import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync, writeFileSync } from "fs";
3
+ import { gzipSync } from "zlib";
4
+ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, rmSync as rmSync2, statSync, writeFileSync } from "fs";
4
5
  import { dirname as dirname2, extname, isAbsolute as isAbsolute2, join as join3, relative, resolve as resolve2, sep } from "path";
5
6
  var {$ } = globalThis.Bun;
6
7
 
@@ -342,6 +343,8 @@ async function createAndroidEmulator(options) {
342
343
  const playStore = requiresPlayStore ? await run([adb, "-s", serial, "shell", "pm", "path", "com.android.vending"], undefined, probeTimeout(), true) : undefined;
343
344
  const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
344
345
  const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-v", "threadtime"], undefined, probeTimeout(), true);
346
+ if (events.code === 0 && /^\S+\s+\S+\s+\d+\s+\d+\s+[A-Z]\s+am_(?:anr|crash)\s*:/m.test(events.stdout))
347
+ throw new Error(`Android emulator reported a platform ANR or crash during boot; inspect ${artifacts}`);
345
348
  const home = await run([adb, "-s", serial, "shell", "cmd", "package", "resolve-activity", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.HOME"], undefined, probeTimeout(), true);
346
349
  const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? "";
347
350
  const temporaryHome = homeActivity.startsWith("com.android.sdksetup/") || homeActivity.startsWith("com.google.android.googlesdksetup/") || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity);
@@ -357,6 +360,8 @@ async function createAndroidEmulator(options) {
357
360
  }
358
361
  await Bun.sleep(Math.min(2000, Math.max(0, deadline - Date.now())));
359
362
  }
363
+ if (child.exitCode !== null)
364
+ throw new Error(`The owned Android emulator exited with code ${child.exitCode} before boot; inspect ${join2(artifacts, "emulator.log")}`);
360
365
  throw new Error(`The owned Android emulator did not boot within ${bootTimeoutMs}ms; inspect ${artifacts}`);
361
366
  } catch (error) {
362
367
  if (child) {
@@ -381,6 +386,21 @@ async function createAndroidEmulator(options) {
381
386
  }
382
387
 
383
388
  // ../android/src/index.ts
389
+ function compressNativeSources(target) {
390
+ if (statSync(target).isDirectory()) {
391
+ for (const entry of readdirSync(target))
392
+ compressNativeSources(join3(target, entry));
393
+ return;
394
+ }
395
+ if (!/\.(?:js|json)$/.test(target))
396
+ return;
397
+ const bytes = readFileSync2(target);
398
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
399
+ return;
400
+ const packed = gzipSync(bytes, { level: 9 });
401
+ if (packed.length < bytes.length)
402
+ writeFileSync(target, packed);
403
+ }
384
404
  var nativeRouteBundles = true;
385
405
  var nativeRouteRuntime = true;
386
406
  var nativeMessages = true;
@@ -538,6 +558,7 @@ var DEFAULT_CONFIG = {
538
558
  darkMode: true,
539
559
  backgroundColor: "#0b1712",
540
560
  enableSpeechRecognition: false,
561
+ enableAudioRecording: false,
541
562
  enableHaptics: false,
542
563
  enableShare: false,
543
564
  enableCamera: false,
@@ -825,7 +846,7 @@ function syncAndroidWebAssets(source, output) {
825
846
  }
826
847
  function renderAndroidPermissions(config) {
827
848
  const permissions = new Set(["android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE"]);
828
- if (config.enableSpeechRecognition)
849
+ if (config.enableSpeechRecognition || config.enableAudioRecording)
829
850
  permissions.add("android.permission.RECORD_AUDIO");
830
851
  if (config.enableCamera)
831
852
  permissions.add("android.permission.CAMERA");
@@ -1138,6 +1159,8 @@ async function init(options) {
1138
1159
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1139
1160
  writeNativeShellSources(output, packagePath, finalPackageName, config);
1140
1161
  }
1162
+ const permissionPolicy = readFileSync2(join3(TEMPLATES_DIR, "CraftPermissionPolicy.kt.template"), "utf8");
1163
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftPermissionPolicy.kt"), permissionPolicy.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1141
1164
  const craftBridgeTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
1142
1165
  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(/\{\{ENABLE_HEART_RATE\}\}/g, String(Boolean(config.enableHeartRate))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
1143
1166
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@@ -1158,8 +1181,21 @@ async function init(options) {
1158
1181
  }` : `evaluatePromiseJavascript(
1159
1182
  "window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
1160
1183
  )`);
1161
- writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
1162
- const craftNative = readFileSync2(join3(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
1184
+ const bridgeFile = join3(output, "app/src/main/java", packagePath, "CraftBridge.kt");
1185
+ if (config.renderer === "native")
1186
+ rmSync2(bridgeFile, { force: true });
1187
+ else
1188
+ writeFileSync(bridgeFile, craftBridge);
1189
+ let craftNative = readFileSync2(join3(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
1190
+ if (config.renderer === "native") {
1191
+ craftNative = craftNative.replace(/^import com\.google\.mlkit\..*\n/gm, "").replace(/ \/\/ CRAFT_WEB_ML_START[\s\S]*? \/\/ CRAFT_WEB_ML_END/, () => ["runImageClassification", "runObjectDetection", "runTextRecognition"].map((name) => ` @JvmStatic
1192
+ fun ${name}(imageBase64: String) {
1193
+ val (_, reject) = createMlSettlement()
1194
+ reject("Machine learning is unavailable in the native renderer")
1195
+ }`).join(`
1196
+
1197
+ `));
1198
+ }
1163
1199
  const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
1164
1200
  mkdirSync2(nativeDir, { recursive: true });
1165
1201
  writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
@@ -1184,7 +1220,13 @@ async function init(options) {
1184
1220
  const projectGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
1185
1221
  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' : ""));
1186
1222
  const appGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
1187
- 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
1223
+ const appGradle = appGradleTemplate.replace(/\{\{MACHINE_LEARNING_DEPENDENCIES\}\}/g, config.renderer === "native" ? "" : [
1224
+ "barcode-scanning:17.3.0",
1225
+ "image-labeling:17.0.9",
1226
+ "object-detection:17.0.2",
1227
+ "text-recognition:16.0.1"
1228
+ ].map((module) => ` implementation("com.google.mlkit:${module}")`).join(`
1229
+ `)).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
1188
1230
  implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1189
1231
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{HEALTH_SERVICES_DEPENDENCIES\}\}/g, config.enableHealthServices ? ' implementation("androidx.health:health-services-client:1.1.0")' : "").replace(/\{\{WALLET_DEPENDENCY\}\}/g, config.enableWallet ? ` // Native Google Wallet save flow for signed pass JWTs
1190
1232
  implementation("com.google.android.gms:play-services-pay:16.5.0")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ` implementation("androidx.javascriptengine:javascriptengine:1.1.0")
@@ -1381,11 +1423,13 @@ async function build(options) {
1381
1423
  if (resolve2(nativeBundlePath) !== resolve2(routeDestination)) {
1382
1424
  rmSync2(routeDestination, { recursive: true, force: true });
1383
1425
  cpSync(nativeBundlePath, routeDestination, { recursive: true });
1426
+ compressNativeSources(routeDestination);
1384
1427
  }
1385
1428
  rmSync2(destination, { force: true });
1386
1429
  } else {
1387
1430
  if (resolve2(nativeBundlePath) !== resolve2(destination))
1388
1431
  cpSync(nativeBundlePath, destination);
1432
+ compressNativeSources(destination);
1389
1433
  rmSync2(routeDestination, { recursive: true, force: true });
1390
1434
  }
1391
1435
  }
@@ -1502,6 +1546,7 @@ export {
1502
1546
  ANDROID_MIN_SDK,
1503
1547
  HEALTH_CONNECT_PERMISSIONS,
1504
1548
  build,
1549
+ compressNativeSources,
1505
1550
  createAndroidEmulator,
1506
1551
  healthConnectPermissions,
1507
1552
  init,
@@ -112,97 +112,7 @@ import java.io.File
112
112
  import java.util.concurrent.TimeUnit
113
113
  import java.util.UUID
114
114
 
115
- internal object CraftPermissionPolicy {
116
- data class Request(
117
- val groupIndex: Int,
118
- val permissions: Array<String>
119
- )
120
-
121
- private fun permissionGroups(name: String, sdkInt: Int): List<List<String>>? = when (name) {
122
- "camera" -> listOf(listOf(Manifest.permission.CAMERA))
123
- "microphone" -> listOf(listOf(Manifest.permission.RECORD_AUDIO))
124
- "photos" -> listOf(listOf(
125
- if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
126
- Manifest.permission.READ_MEDIA_IMAGES
127
- } else {
128
- Manifest.permission.READ_EXTERNAL_STORAGE
129
- }
130
- ))
131
- // Android presents precise and approximate location as one choice. A
132
- // coarse grant is usable foreground location access even when the
133
- // corresponding fine result is denied.
134
- "location" -> listOf(listOf(
135
- Manifest.permission.ACCESS_FINE_LOCATION,
136
- Manifest.permission.ACCESS_COARSE_LOCATION
137
- ))
138
- "locationAlways" -> if (sdkInt >= Build.VERSION_CODES.Q) {
139
- listOf(
140
- listOf(
141
- Manifest.permission.ACCESS_FINE_LOCATION,
142
- Manifest.permission.ACCESS_COARSE_LOCATION
143
- ),
144
- listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
145
- )
146
- } else {
147
- listOf(listOf(
148
- Manifest.permission.ACCESS_FINE_LOCATION,
149
- Manifest.permission.ACCESS_COARSE_LOCATION
150
- ))
151
- }
152
- "notifications" -> if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
153
- listOf(listOf(Manifest.permission.POST_NOTIFICATIONS))
154
- } else {
155
- emptyList()
156
- }
157
- "contacts" -> listOf(listOf(Manifest.permission.READ_CONTACTS))
158
- "calendar" -> listOf(listOf(Manifest.permission.READ_CALENDAR))
159
- "bluetooth" -> if (sdkInt >= Build.VERSION_CODES.S) {
160
- listOf(listOf(Manifest.permission.BLUETOOTH_SCAN))
161
- } else {
162
- emptyList()
163
- }
164
- "motion" -> if (sdkInt >= Build.VERSION_CODES.Q) {
165
- listOf(listOf(Manifest.permission.ACTIVITY_RECOGNITION))
166
- } else {
167
- emptyList()
168
- }
169
- else -> null
170
- }
171
-
172
- fun requiredPermissions(name: String, sdkInt: Int): Array<String>? =
173
- permissionGroups(name, sdkInt)?.flatten()?.distinct()?.toTypedArray()
174
-
175
- fun status(name: String, sdkInt: Int, isGranted: (String) -> Boolean): String {
176
- val groups = permissionGroups(name, sdkInt) ?: return "undetermined"
177
- return if (groups.all { group -> group.any(isGranted) }) "granted" else "denied"
178
- }
179
115
 
180
- fun nextRequest(
181
- name: String,
182
- sdkInt: Int,
183
- startingAt: Int,
184
- isGranted: (String) -> Boolean
185
- ): Request? {
186
- val groups = permissionGroups(name, sdkInt) ?: return null
187
- val groupIndex = (startingAt until groups.size).firstOrNull { index ->
188
- groups[index].none(isGranted)
189
- } ?: return null
190
- return Request(groupIndex, groups[groupIndex].toTypedArray())
191
- }
192
-
193
- fun groupIsGranted(
194
- name: String,
195
- sdkInt: Int,
196
- groupIndex: Int,
197
- isGranted: (String) -> Boolean
198
- ): Boolean = permissionGroups(name, sdkInt)
199
- ?.getOrNull(groupIndex)
200
- ?.any(isGranted) == true
201
-
202
- fun foregroundLocationIsGranted(isGranted: (String) -> Boolean): Boolean =
203
- isGranted(Manifest.permission.ACCESS_FINE_LOCATION) ||
204
- isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
205
- }
206
116
 
207
117
  private data class SpeechRequest(
208
118
  val text: String,
@@ -1245,6 +1245,7 @@ object CraftNative {
1245
1245
  return resolve to reject
1246
1246
  }
1247
1247
 
1248
+ // CRAFT_WEB_ML_START
1248
1249
  @JvmStatic
1249
1250
  fun runImageClassification(imageBase64: String) {
1250
1251
  val (resolve, reject) = createMlSettlement()
@@ -1366,6 +1367,8 @@ object CraftNative {
1366
1367
  }
1367
1368
  }
1368
1369
 
1370
+ // CRAFT_WEB_ML_END
1371
+
1369
1372
  fun classifyImage(imageBase64: String): Boolean {
1370
1373
  if (!isAvailable) return false
1371
1374
  return try { nativeClassifyImage(imageBase64) } catch (e: UnsatisfiedLinkError) { false }
@@ -65,11 +65,17 @@ internal object CraftNativeSandbox {
65
65
  */
66
66
  internal object CraftNativeBundle {
67
67
  private val sources = object : android.util.LruCache<String, String>(24 shl 20) {
68
- override fun sizeOf(key: String, value: String) = value.length
68
+ override fun sizeOf(key: String, value: String) = value.length * 2
69
69
  }
70
70
 
71
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) }
72
+ ?: runCatching { context.assets.open(asset).buffered().use { input ->
73
+ input.mark(2)
74
+ val packed = input.read() == 0x1f && input.read() == 0x8b
75
+ input.reset()
76
+ val decoded = if (packed) java.util.zip.GZIPInputStream(input) else input
77
+ decoded.bufferedReader().use { it.readText() }
78
+ } }.getOrNull()?.also { sources.put(asset, it) }
73
79
 
74
80
  /**
75
81
  * A route's file runs on the runtime stx writes beside it
@@ -9,6 +9,8 @@ import android.content.Context
9
9
  import android.content.Intent
10
10
  import android.content.pm.PackageManager
11
11
  import android.net.Uri
12
+ import android.media.MediaRecorder
13
+ import android.media.MediaMetadataRetriever
12
14
  import android.os.Build
13
15
  import android.os.Handler
14
16
  import android.os.Looper
@@ -113,6 +115,7 @@ internal class CraftNativeServices(
113
115
  add("brightness")
114
116
  add("statusBar")
115
117
  add("speech")
118
+ if (config.optBoolean("enableAudioRecording")) add("audioRecording")
116
119
  // Videos kept on the phone for offline (CraftNativeMedia).
117
120
  add("media")
118
121
  if (config.optBoolean("enableShare")) add("share")
@@ -142,7 +145,7 @@ internal class CraftNativeServices(
142
145
 
143
146
  fun handles(module: String): Boolean = module in setOf(
144
147
  "Dialog", "Browser", "Linking", "Share", "Push", "Health", "KeepAwake", "Brightness", "StatusBar", "Speech", "Background",
145
- "Location", "Media", "CrashReporter", "HeartRate",
148
+ "Location", "Media", "CrashReporter", "HeartRate", "AudioRecording",
146
149
  "Wallet",
147
150
  )
148
151
 
@@ -165,6 +168,7 @@ internal class CraftNativeServices(
165
168
  "Brightness" -> brightness(method, args, done)
166
169
  "StatusBar" -> statusBar(args.optString(0, "default"), done)
167
170
  "Speech" -> speech(method, args, done)
171
+ "AudioRecording" -> main.post { voiceRecording(method, args, done) }
168
172
  "Location" -> location(method, args, done)
169
173
  "HeartRate" -> heartRate(method, args, done)
170
174
  "Media" -> when (method) {
@@ -422,6 +426,12 @@ internal class CraftNativeServices(
422
426
  }
423
427
 
424
428
  fun onRequestPermissionsResult(requestCode: Int): Boolean {
429
+ if (requestCode == 7134) {
430
+ val pending = pendingVoicePermission
431
+ pendingVoicePermission = null
432
+ pending?.invoke(ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
433
+ return true
434
+ }
425
435
  if (heartRateEnabled && CraftHeartRate.get(context).onPermissions(requestCode)) return true
426
436
  if (requestCode == LOCATION_PERMISSION_REQUEST) {
427
437
  val pending = pendingLocationPermission
@@ -612,6 +622,94 @@ internal class CraftNativeServices(
612
622
  done(success(true))
613
623
  }
614
624
 
625
+ // A microphone belongs to its requesting screen/account, including the
626
+ // permission prompt. Files use the same private upload root as Files.
627
+ private var voiceOwner: String? = null
628
+ private var voiceRecorder: MediaRecorder? = null
629
+ private var voiceFile: File? = null
630
+ private var pendingVoicePermission: ((Boolean) -> Unit)? = null
631
+
632
+ fun pauseVoice() { if (voiceRecorder != null) cancelVoice() }
633
+ private fun cancelVoice() {
634
+ voiceOwner = null
635
+ val recorder = voiceRecorder
636
+ voiceRecorder = null
637
+ try { recorder?.stop() } catch (_: Exception) {}
638
+ recorder?.release()
639
+ voiceFile?.parentFile?.deleteRecursively()
640
+ voiceFile = null
641
+ val pending = pendingVoicePermission
642
+ pendingVoicePermission = null
643
+ pending?.invoke(false)
644
+ }
645
+
646
+ private fun voiceRecording(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
647
+ if (!config.optBoolean("enableAudioRecording")) { done(failure("CAPABILITY_DISABLED", "Audio recording is disabled")); return }
648
+ val key = args.optString(0)
649
+ if (!key.matches(Regex("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"))) { done(failure("INVALID_ARGUMENT", "A recording owner UUID is required")); return }
650
+ if (method == "cancel") {
651
+ if (voiceOwner == key) cancelVoice()
652
+ done(success(true)); return
653
+ }
654
+ if (method == "stop") {
655
+ val file = voiceFile
656
+ if (voiceOwner != key || file == null) { done(failure("RECORDING_NOT_FOUND", "This recording is no longer available")); return }
657
+ try {
658
+ voiceRecorder?.stop()
659
+ voiceRecorder?.release()
660
+ voiceRecorder = null
661
+ val metadata = MediaMetadataRetriever()
662
+ val duration = try { metadata.setDataSource(file.absolutePath); (metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0) / 1000 } finally { metadata.release() }
663
+ if (!file.exists() || file.length() == 0L || duration <= 0) throw IllegalStateException("Empty recording")
664
+ done(success(JSONObject().put("path", file.absolutePath).put("name", file.name).put("type", "audio/mp4").put("size", file.length()).put("durationSeconds", duration)))
665
+ } catch (_: Exception) { cancelVoice(); done(failure("RECORDING_FAILED", "The recording could not be read")) }
666
+ return
667
+ }
668
+ if (method != "start") { done(failure("UNKNOWN_ACTION", "Unknown recording action")); return }
669
+ if (voiceOwner != null) { done(failure("RECORDING_BUSY", "Another microphone session is active")); return }
670
+ val host = activity()
671
+ if (host == null) { done(failure("NEEDS_ACTIVITY", "Recording needs the foreground app")); return }
672
+ val seconds = args.optJSONObject(1)?.optInt("maxSeconds", 300) ?: 300
673
+ if (seconds !in 1..300) { done(failure("INVALID_ARGUMENT", "Recordings are limited to five minutes")); return }
674
+ voiceOwner = key
675
+ val start: (Boolean) -> Unit = start@ { allowed ->
676
+ if (voiceOwner != key) { done(failure("RECORDING_CANCELLED", "Recording was cancelled")); return@start }
677
+ if (!allowed) { cancelVoice(); done(failure("PERMISSION_DENIED", "Microphone permission was refused")); return@start }
678
+ try {
679
+ val folder = File(context.cacheDir, "craft-files/" + UUID.randomUUID()).apply { mkdirs() }
680
+ val file = File(folder, "voice-feedback.m4a")
681
+ voiceFile = file
682
+ @Suppress("DEPRECATION")
683
+ val recorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(context) else MediaRecorder()
684
+ voiceRecorder = recorder
685
+ recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
686
+ recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
687
+ recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
688
+ recorder.setAudioChannels(1)
689
+ recorder.setAudioSamplingRate(16000)
690
+ recorder.setAudioEncodingBitRate(32000)
691
+ recorder.setMaxDuration(seconds * 1000 - 250)
692
+ recorder.setMaxFileSize(12L * 1024 * 1024)
693
+ recorder.setOutputFile(file.absolutePath)
694
+ recorder.setOnInfoListener { current, what, _ ->
695
+ if (voiceOwner == key && what in setOf(MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED, MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED)) {
696
+ try { current.stop() } catch (_: Exception) {}
697
+ current.release()
698
+ voiceRecorder = null
699
+ }
700
+ }
701
+ recorder.prepare()
702
+ recorder.start()
703
+ done(success(true))
704
+ } catch (_: Exception) { cancelVoice(); done(failure("RECORDING_FAILED", "The microphone could not start")) }
705
+ }
706
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) start(true)
707
+ else {
708
+ pendingVoicePermission = start
709
+ ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.RECORD_AUDIO), 7134)
710
+ }
711
+ }
712
+
615
713
  // MARK: Speech
616
714
 
617
715
  private fun speech(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
@@ -703,6 +801,7 @@ internal class CraftNativeServices(
703
801
  }
704
802
 
705
803
  fun close() {
804
+ cancelVoice()
706
805
  watchCallback?.let(locationClient::removeLocationUpdates)
707
806
  watchCallback = null
708
807
  pendingLocationPermission = null
@@ -0,0 +1,96 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.os.Build
5
+
6
+ internal object CraftPermissionPolicy {
7
+ data class Request(
8
+ val groupIndex: Int,
9
+ val permissions: Array<String>
10
+ )
11
+
12
+ private fun permissionGroups(name: String, sdkInt: Int): List<List<String>>? = when (name) {
13
+ "camera" -> listOf(listOf(Manifest.permission.CAMERA))
14
+ "microphone" -> listOf(listOf(Manifest.permission.RECORD_AUDIO))
15
+ "photos" -> listOf(listOf(
16
+ if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
17
+ Manifest.permission.READ_MEDIA_IMAGES
18
+ } else {
19
+ Manifest.permission.READ_EXTERNAL_STORAGE
20
+ }
21
+ ))
22
+ // Android presents precise and approximate location as one choice. A
23
+ // coarse grant is usable foreground location access even when the
24
+ // corresponding fine result is denied.
25
+ "location" -> listOf(listOf(
26
+ Manifest.permission.ACCESS_FINE_LOCATION,
27
+ Manifest.permission.ACCESS_COARSE_LOCATION
28
+ ))
29
+ "locationAlways" -> if (sdkInt >= Build.VERSION_CODES.Q) {
30
+ listOf(
31
+ listOf(
32
+ Manifest.permission.ACCESS_FINE_LOCATION,
33
+ Manifest.permission.ACCESS_COARSE_LOCATION
34
+ ),
35
+ listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
36
+ )
37
+ } else {
38
+ listOf(listOf(
39
+ Manifest.permission.ACCESS_FINE_LOCATION,
40
+ Manifest.permission.ACCESS_COARSE_LOCATION
41
+ ))
42
+ }
43
+ "notifications" -> if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
44
+ listOf(listOf(Manifest.permission.POST_NOTIFICATIONS))
45
+ } else {
46
+ emptyList()
47
+ }
48
+ "contacts" -> listOf(listOf(Manifest.permission.READ_CONTACTS))
49
+ "calendar" -> listOf(listOf(Manifest.permission.READ_CALENDAR))
50
+ "bluetooth" -> if (sdkInt >= Build.VERSION_CODES.S) {
51
+ listOf(listOf(Manifest.permission.BLUETOOTH_SCAN))
52
+ } else {
53
+ emptyList()
54
+ }
55
+ "motion" -> if (sdkInt >= Build.VERSION_CODES.Q) {
56
+ listOf(listOf(Manifest.permission.ACTIVITY_RECOGNITION))
57
+ } else {
58
+ emptyList()
59
+ }
60
+ else -> null
61
+ }
62
+
63
+ fun requiredPermissions(name: String, sdkInt: Int): Array<String>? =
64
+ permissionGroups(name, sdkInt)?.flatten()?.distinct()?.toTypedArray()
65
+
66
+ fun status(name: String, sdkInt: Int, isGranted: (String) -> Boolean): String {
67
+ val groups = permissionGroups(name, sdkInt) ?: return "undetermined"
68
+ return if (groups.all { group -> group.any(isGranted) }) "granted" else "denied"
69
+ }
70
+
71
+ fun nextRequest(
72
+ name: String,
73
+ sdkInt: Int,
74
+ startingAt: Int,
75
+ isGranted: (String) -> Boolean
76
+ ): Request? {
77
+ val groups = permissionGroups(name, sdkInt) ?: return null
78
+ val groupIndex = (startingAt until groups.size).firstOrNull { index ->
79
+ groups[index].none(isGranted)
80
+ } ?: return null
81
+ return Request(groupIndex, groups[groupIndex].toTypedArray())
82
+ }
83
+
84
+ fun groupIsGranted(
85
+ name: String,
86
+ sdkInt: Int,
87
+ groupIndex: Int,
88
+ isGranted: (String) -> Boolean
89
+ ): Boolean = permissionGroups(name, sdkInt)
90
+ ?.getOrNull(groupIndex)
91
+ ?.any(isGranted) == true
92
+
93
+ fun foregroundLocationIsGranted(isGranted: (String) -> Boolean): Boolean =
94
+ isGranted(Manifest.permission.ACCESS_FINE_LOCATION) ||
95
+ isGranted(Manifest.permission.ACCESS_COARSE_LOCATION)
96
+ }
@@ -7668,6 +7668,7 @@ class MainActivity : AppCompatActivity() {
7668
7668
  }
7669
7669
 
7670
7670
  override fun onPause() {
7671
+ if (::services.isInitialized) services.pauseVoice()
7671
7672
  nativeAppState = "inactive"
7672
7673
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
7673
7674
  super.onPause()
@@ -116,11 +116,7 @@ dependencies {
116
116
 
117
117
  {{WALLET_DEPENDENCY}}
118
118
 
119
- // On-device computer vision APIs exposed by CraftBridge
120
- implementation("com.google.mlkit:barcode-scanning:17.3.0")
121
- implementation("com.google.mlkit:image-labeling:17.0.9")
122
- implementation("com.google.mlkit:object-detection:17.0.2")
123
- implementation("com.google.mlkit:text-recognition:16.0.1")
119
+ {{MACHINE_LEARNING_DEPENDENCIES}}
124
120
 
125
121
  // Google Play Billing (In-App Purchase)
126
122
  implementation("com.android.billingclient:billing:6.1.0")
@@ -1844,6 +1844,15 @@ export declare const health: {
1844
1844
  onHeartRate(callback: (samples: HealthHeartRateSample[]) => void): () => void;
1845
1845
  };
1846
1846
  export interface LiveActivityState {
1847
+ /** Native step boundaries retained on device; at most 1000, with detail limited to 96 characters. Only the current state is sent to the widget. */
1848
+ transitions?: Array<{
1849
+ at: number;
1850
+ detail: string;
1851
+ duration?: number | null;
1852
+ }>;
1853
+ /** An actual HealthKit workout (iOS 26+, already authorised) owns background updates. */
1854
+ workoutType?: string;
1855
+ sessionStartedAt?: number;
1847
1856
  /** What to show once staleAt passes without fresh app state. */
1848
1857
  staleStatus?: string;
1849
1858
  /** Current step, interval or session detail. */
package/dist/cli.js CHANGED
@@ -311,6 +311,12 @@ async function packageMacOS(config, outDir) {
311
311
  } catch (error) {
312
312
  return [{ success: false, platform: "macos", format: "app", error: error.message }];
313
313
  }
314
+ if (opts.notarize && !opts.appStore) {
315
+ const notarized = await notarizeIfRequested(opts, appBundlePath, "app");
316
+ results.push(...notarized);
317
+ if (notarized.some((result) => !result.success))
318
+ return results;
319
+ }
314
320
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
315
321
  if (opts.dmg !== false) {
316
322
  const outputPath = join2(outDir, `${name}-${version}.dmg`);
@@ -359,24 +365,36 @@ async function notarizeIfRequested(opts, artifactPath, format) {
359
365
  error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
360
366
  }];
361
367
  }
362
- const submitted = await runTool("xcrun", notarytoolArguments({
363
- artifactPath,
364
- appleId: opts.appleId,
365
- applePassword: opts.applePassword,
366
- teamId: opts.teamId,
367
- keychainProfile: opts.notaryKeychainProfile,
368
- apiKey: opts.notaryApiKey
369
- }));
370
- if (!submitted.success)
371
- return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
372
- const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
373
- return [{
374
- success: stapled.success,
375
- platform: "macos",
376
- format: `${format} (notarize)`,
377
- outputPath: stapled.success ? artifactPath : undefined,
378
- error: stapled.success ? undefined : stapled.error
379
- }];
368
+ const scratch = format === "app" ? createTempDir("craft-notarize-") : undefined;
369
+ try {
370
+ const submission = scratch ? join2(scratch, "app.zip") : artifactPath;
371
+ if (scratch) {
372
+ const zipped = await runTool("ditto", ["-c", "-k", "--keepParent", artifactPath, submission]);
373
+ if (!zipped.success)
374
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: zipped.error }];
375
+ }
376
+ const submitted = await runTool("xcrun", notarytoolArguments({
377
+ artifactPath: submission,
378
+ appleId: opts.appleId,
379
+ applePassword: opts.applePassword,
380
+ teamId: opts.teamId,
381
+ keychainProfile: opts.notaryKeychainProfile,
382
+ apiKey: opts.notaryApiKey
383
+ }));
384
+ if (!submitted.success)
385
+ return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
386
+ const stapled = await runTool("xcrun", ["stapler", "staple", artifactPath]);
387
+ return [{
388
+ success: stapled.success,
389
+ platform: "macos",
390
+ format: `${format} (notarize)`,
391
+ outputPath: stapled.success ? artifactPath : undefined,
392
+ error: stapled.success ? undefined : stapled.error
393
+ }];
394
+ } finally {
395
+ if (scratch)
396
+ removeTempDir(scratch);
397
+ }
380
398
  }
381
399
  async function packageWindows(config, outDir) {
382
400
  const results = [];
@@ -2970,7 +2988,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2970
2988
  `);
2971
2989
  }
2972
2990
  // package.json
2973
- var version = "0.0.319";
2991
+ var version = "0.0.329";
2974
2992
 
2975
2993
  // bin/cli.ts
2976
2994
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];