craft-native 0.0.304 → 0.0.328

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/android/src/index.d.ts +3 -5
  2. package/dist/android/src/index.js +50 -7
  3. package/dist/android/templates/CraftBridge.kt.template +0 -90
  4. package/dist/android/templates/CraftNative.kt.template +3 -0
  5. package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
  6. package/dist/android/templates/CraftNativeServices.kt.template +100 -1
  7. package/dist/android/templates/CraftNativeShell.kt.template +26 -8
  8. package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
  9. package/dist/android/templates/MainActivityNative.kt.template +120 -6
  10. package/dist/android/templates/build.gradle.kts.app.template +1 -5
  11. package/dist/api/mobile.d.ts +9 -0
  12. package/dist/cli.js +63 -20
  13. package/dist/index.cjs +67 -20
  14. package/dist/index.d.cts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +67 -20
  17. package/dist/ios/src/index.d.ts +2 -5
  18. package/dist/ios/src/index.js +26 -1
  19. package/dist/ios/src/testing.d.ts +6 -0
  20. package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
  21. package/dist/ios/templates/CraftApp.swift +45 -7
  22. package/dist/ios/templates/CraftHealth.swift +14 -0
  23. package/dist/ios/templates/CraftHybrid.swift +6 -0
  24. package/dist/ios/templates/CraftLiveActivities.swift +106 -2
  25. package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
  26. package/dist/ios/templates/CraftNativeActions.swift +10 -1
  27. package/dist/ios/templates/CraftNativeScreen.swift +170 -9
  28. package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
  29. package/dist/ios/templates/project.yml.template +6 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
  31. package/dist/package.d.ts +15 -4
  32. package/dist/types.d.ts +11 -4
  33. package/package.json +1 -1
@@ -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
 
@@ -286,12 +287,12 @@ async function createAndroidEmulator(options) {
286
287
  if (options.acceptLicenses)
287
288
  await run([options.sdkmanager, `--sdk_root=${sdk}`, "--licenses"], `y
288
289
  `.repeat(100));
289
- await run([options.sdkmanager, `--sdk_root=${sdk}`, "platform-tools", "emulator"], options.acceptLicenses ? `y
290
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "--verbose", "platform-tools", "emulator"], options.acceptLicenses ? `y
290
291
  `.repeat(100) : undefined);
291
292
  const acceleration = await run([executable, "-accel-check"], undefined, 30000, true);
292
293
  if (options.acceleration === "hardware" && acceleration.code !== 0)
293
294
  throw new Error(`Android hardware acceleration is unavailable; inspect ${artifacts}`);
294
- await run([options.sdkmanager, `--sdk_root=${sdk}`, options.image], options.acceptLicenses ? `y
295
+ await run([options.sdkmanager, `--sdk_root=${sdk}`, "--verbose", options.image], options.acceptLicenses ? `y
295
296
  `.repeat(100) : undefined);
296
297
  const devices = (await run([adb, "devices"], undefined, 1e4)).stdout;
297
298
  if (devices.split(`
@@ -357,6 +358,8 @@ async function createAndroidEmulator(options) {
357
358
  }
358
359
  await Bun.sleep(Math.min(2000, Math.max(0, deadline - Date.now())));
359
360
  }
361
+ if (child.exitCode !== null)
362
+ throw new Error(`The owned Android emulator exited with code ${child.exitCode} before boot; inspect ${join2(artifacts, "emulator.log")}`);
360
363
  throw new Error(`The owned Android emulator did not boot within ${bootTimeoutMs}ms; inspect ${artifacts}`);
361
364
  } catch (error) {
362
365
  if (child) {
@@ -381,6 +384,21 @@ async function createAndroidEmulator(options) {
381
384
  }
382
385
 
383
386
  // ../android/src/index.ts
387
+ function compressNativeSources(target) {
388
+ if (statSync(target).isDirectory()) {
389
+ for (const entry of readdirSync(target))
390
+ compressNativeSources(join3(target, entry));
391
+ return;
392
+ }
393
+ if (!/\.(?:js|json)$/.test(target))
394
+ return;
395
+ const bytes = readFileSync2(target);
396
+ if (bytes.length < 1024 || bytes[0] === 31 && bytes[1] === 139)
397
+ return;
398
+ const packed = gzipSync(bytes, { level: 9 });
399
+ if (packed.length < bytes.length)
400
+ writeFileSync(target, packed);
401
+ }
384
402
  var nativeRouteBundles = true;
385
403
  var nativeRouteRuntime = true;
386
404
  var nativeMessages = true;
@@ -538,6 +556,7 @@ var DEFAULT_CONFIG = {
538
556
  darkMode: true,
539
557
  backgroundColor: "#0b1712",
540
558
  enableSpeechRecognition: false,
559
+ enableAudioRecording: false,
541
560
  enableHaptics: false,
542
561
  enableShare: false,
543
562
  enableCamera: false,
@@ -825,7 +844,7 @@ function syncAndroidWebAssets(source, output) {
825
844
  }
826
845
  function renderAndroidPermissions(config) {
827
846
  const permissions = new Set(["android.permission.INTERNET", "android.permission.ACCESS_NETWORK_STATE"]);
828
- if (config.enableSpeechRecognition)
847
+ if (config.enableSpeechRecognition || config.enableAudioRecording)
829
848
  permissions.add("android.permission.RECORD_AUDIO");
830
849
  if (config.enableCamera)
831
850
  permissions.add("android.permission.CAMERA");
@@ -1138,6 +1157,8 @@ async function init(options) {
1138
1157
  writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1139
1158
  writeNativeShellSources(output, packagePath, finalPackageName, config);
1140
1159
  }
1160
+ const permissionPolicy = readFileSync2(join3(TEMPLATES_DIR, "CraftPermissionPolicy.kt.template"), "utf8");
1161
+ writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftPermissionPolicy.kt"), permissionPolicy.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
1141
1162
  const craftBridgeTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
1142
1163
  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
1164
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@@ -1158,8 +1179,21 @@ async function init(options) {
1158
1179
  }` : `evaluatePromiseJavascript(
1159
1180
  "window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
1160
1181
  )`);
1161
- writeFileSync(join3(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
1162
- const craftNative = readFileSync2(join3(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
1182
+ const bridgeFile = join3(output, "app/src/main/java", packagePath, "CraftBridge.kt");
1183
+ if (config.renderer === "native")
1184
+ rmSync2(bridgeFile, { force: true });
1185
+ else
1186
+ writeFileSync(bridgeFile, craftBridge);
1187
+ let craftNative = readFileSync2(join3(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
1188
+ if (config.renderer === "native") {
1189
+ 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
1190
+ fun ${name}(imageBase64: String) {
1191
+ val (_, reject) = createMlSettlement()
1192
+ reject("Machine learning is unavailable in the native renderer")
1193
+ }`).join(`
1194
+
1195
+ `));
1196
+ }
1163
1197
  const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
1164
1198
  mkdirSync2(nativeDir, { recursive: true });
1165
1199
  writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
@@ -1184,7 +1218,13 @@ async function init(options) {
1184
1218
  const projectGradleTemplate = readFileSync2(join3(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
1185
1219
  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
1220
  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
1221
+ const appGradle = appGradleTemplate.replace(/\{\{MACHINE_LEARNING_DEPENDENCIES\}\}/g, config.renderer === "native" ? "" : [
1222
+ "barcode-scanning:17.3.0",
1223
+ "image-labeling:17.0.9",
1224
+ "object-detection:17.0.2",
1225
+ "text-recognition:16.0.1"
1226
+ ].map((module) => ` implementation("com.google.mlkit:${module}")`).join(`
1227
+ `)).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
1228
  implementation("${androidMapHost(config.mapProvider).dependency}")` : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
1189
1229
  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
1230
  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 +1421,13 @@ async function build(options) {
1381
1421
  if (resolve2(nativeBundlePath) !== resolve2(routeDestination)) {
1382
1422
  rmSync2(routeDestination, { recursive: true, force: true });
1383
1423
  cpSync(nativeBundlePath, routeDestination, { recursive: true });
1424
+ compressNativeSources(routeDestination);
1384
1425
  }
1385
1426
  rmSync2(destination, { force: true });
1386
1427
  } else {
1387
1428
  if (resolve2(nativeBundlePath) !== resolve2(destination))
1388
1429
  cpSync(nativeBundlePath, destination);
1430
+ compressNativeSources(destination);
1389
1431
  rmSync2(routeDestination, { recursive: true, force: true });
1390
1432
  }
1391
1433
  }
@@ -1502,6 +1544,7 @@ export {
1502
1544
  ANDROID_MIN_SDK,
1503
1545
  HEALTH_CONNECT_PERMISSIONS,
1504
1546
  build,
1547
+ compressNativeSources,
1505
1548
  createAndroidEmulator,
1506
1549
  healthConnectPermissions,
1507
1550
  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
@@ -806,6 +806,10 @@ internal class CraftNativeChrome(
806
806
  private val density = resources.displayMetrics.density
807
807
  private var screenBackgroundColor: Int? = null
808
808
  private var selectedTab = 0
809
+ private var tabBarHiddenByScreen = false
810
+ private var visiblePath: String? = null
811
+ private val accessorySelected: Boolean
812
+ get() = accessorySpec?.optString("path")?.let { path -> visiblePath == path || visiblePath?.startsWith(path + "/") == true } == true
809
813
  private var swipeTracking = false
810
814
  private var swipeIntercepted = false
811
815
  private var horizontalScrollDeferred = false
@@ -969,7 +973,8 @@ internal class CraftNativeChrome(
969
973
  }
970
974
 
971
975
  /** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
972
- fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
976
+ fun apply(options: JSONObject, depth: Int, fallbackTitle: String?, path: String? = null) {
977
+ visiblePath = path
973
978
  screenBackgroundColor = options.opt("backgroundColor")
974
979
  ?.takeUnless { it == JSONObject.NULL }
975
980
  ?.let { craftNativeOptionalString(it) }
@@ -1004,7 +1009,19 @@ internal class CraftNativeChrome(
1004
1009
  renderedButtonSpecs = buttonSpecs
1005
1010
  }
1006
1011
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
1007
- tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
1012
+ if (tabBarHiddenByScreen != tabBarHidden) {
1013
+ tabBarHiddenByScreen = tabBarHidden
1014
+ tabRow.animate().cancel()
1015
+ val duration = if (android.animation.ValueAnimator.areAnimatorsEnabled()) 240L else 0L
1016
+ if (tabBarHidden) {
1017
+ tabRow.animate().translationY(dp(80).toFloat()).alpha(0f).setDuration(duration).withEndAction {
1018
+ if (tabBarHiddenByScreen) tabRow.visibility = View.GONE
1019
+ }.start()
1020
+ } else if (tabs.isNotEmpty()) {
1021
+ tabRow.visibility = View.VISIBLE
1022
+ tabRow.animate().translationY(0f).alpha(1f).setDuration(duration).start()
1023
+ }
1024
+ } else if (tabs.isEmpty()) tabRow.visibility = View.GONE
1008
1025
  accessorySpec = options.optJSONObject("tabAccessory")?.takeIf {
1009
1026
  val path = it.optString("path")
1010
1027
  path.startsWith("/") && !path.startsWith("//") && it.has("title")
@@ -1091,7 +1108,7 @@ internal class CraftNativeChrome(
1091
1108
  /** Selection belongs to the native tab stack, including retained pushed screens. */
1092
1109
  internal fun tabIconAppearance(index: Int, motionAllowed: Boolean): CraftTabIconAppearance {
1093
1110
  val tab = tabs[index]
1094
- val selected = index == selectedTab
1111
+ val selected = index == selectedTab && !accessorySelected
1095
1112
  return CraftTabIconAppearance(
1096
1113
  if (selected) selectedSymbols[tab.id] ?: tabSymbols[tab.id] ?: tab.symbol else tabSymbols[tab.id] ?: tab.symbol,
1097
1114
  if (selected) selectedIconTints[tab.id] ?: tabIconTints[tab.id] else tabIconTints[tab.id],
@@ -1114,12 +1131,13 @@ internal class CraftNativeChrome(
1114
1131
  backButton.imageTintList = ColorStateList.valueOf(accent)
1115
1132
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1116
1133
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1117
- accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1118
- accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
1134
+ accessory.isSelected = accessorySelected
1135
+ accessory.background = GradientDrawable().apply { setColor(if (accessorySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1136
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (accessorySelected) accent else text)) }
1119
1137
  tabViews.forEachIndexed { index, (icon, label) ->
1120
- tabBar.getChildAt(index).isSelected = index == selectedTab
1138
+ tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
1121
1139
  tabBar.getChildAt(index).background = tabCellBackground(index, dark)
1122
- val color = if (index == selectedTab) accent else muted
1140
+ val color = if (index == selectedTab && !accessorySelected) accent else muted
1123
1141
  val id = tabs[index].id
1124
1142
  val number = tabIconText[id]
1125
1143
  val appearance = tabIconAppearance(index, hasWindowFocus() && tabRow.isShown && android.animation.ValueAnimator.areAnimatorsEnabled())
@@ -1209,7 +1227,7 @@ internal class CraftNativeChrome(
1209
1227
  private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1210
1228
 
1211
1229
  private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212
- if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1230
+ if (accessorySelected || index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213
1231
  val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214
1232
  ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215
1233
  val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
@@ -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
+ }