craft-native 0.0.319 → 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.
- package/dist/android/src/index.d.ts +3 -5
- package/dist/android/src/index.js +48 -5
- package/dist/android/templates/CraftBridge.kt.template +0 -90
- package/dist/android/templates/CraftNative.kt.template +3 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +8 -2
- package/dist/android/templates/CraftNativeServices.kt.template +100 -1
- package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
- package/dist/android/templates/MainActivityNative.kt.template +1 -0
- package/dist/android/templates/build.gradle.kts.app.template +1 -5
- package/dist/api/mobile.d.ts +9 -0
- package/dist/cli.js +37 -19
- package/dist/index.cjs +40 -19
- package/dist/index.js +40 -19
- package/dist/ios/src/index.d.ts +2 -5
- package/dist/ios/src/index.js +20 -0
- package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
- package/dist/ios/templates/CraftApp.swift +1 -1
- package/dist/ios/templates/CraftHealth.swift +14 -0
- package/dist/ios/templates/CraftLiveActivities.swift +106 -2
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +9 -1
- package/dist/ios/templates/CraftNativeScreen.swift +51 -1
- package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
- package/dist/ios/templates/project.yml.template +6 -0
- package/dist/types.d.ts +3 -2
- package/package.json +1 -1
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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 {
|
|
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
|
|
|
@@ -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
|
-
|
|
1162
|
-
|
|
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(/\{\{
|
|
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).
|
|
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
|
-
|
|
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")
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
2991
|
+
var version = "0.0.328";
|
|
2974
2992
|
|
|
2975
2993
|
// bin/cli.ts
|
|
2976
2994
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|