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.
- package/dist/android/src/index.d.ts +3 -5
- package/dist/android/src/index.js +50 -7
- 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/CraftNativeShell.kt.template +26 -8
- package/dist/android/templates/CraftPermissionPolicy.kt.template +96 -0
- package/dist/android/templates/MainActivityNative.kt.template +120 -6
- package/dist/android/templates/build.gradle.kts.app.template +1 -5
- package/dist/api/mobile.d.ts +9 -0
- package/dist/cli.js +63 -20
- package/dist/index.cjs +67 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +67 -20
- package/dist/ios/src/index.d.ts +2 -5
- package/dist/ios/src/index.js +26 -1
- package/dist/ios/src/testing.d.ts +6 -0
- package/dist/ios/templates/CraftActivityAttributes.swift +29 -2
- package/dist/ios/templates/CraftApp.swift +45 -7
- package/dist/ios/templates/CraftHealth.swift +14 -0
- package/dist/ios/templates/CraftHybrid.swift +6 -0
- package/dist/ios/templates/CraftLiveActivities.swift +106 -2
- package/dist/ios/templates/CraftLiveActivityWidget.swift.template +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +10 -1
- package/dist/ios/templates/CraftNativeScreen.swift +170 -9
- package/dist/ios/templates/CraftVoiceRecording.swift +75 -0
- package/dist/ios/templates/project.yml.template +6 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +38 -0
- package/dist/package.d.ts +15 -4
- package/dist/types.d.ts +11 -4
- 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
|
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
1118
|
-
|
|
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
|
+
}
|