craft-native 0.0.168 → 0.0.169
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 +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1013 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -26,6 +26,24 @@ export interface CraftAndroidConfig {
|
|
|
26
26
|
enableGeolocation?: boolean;
|
|
27
27
|
enableBackgroundLocation?: boolean;
|
|
28
28
|
enableHealthConnect?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The Health Connect data the app reads and writes, by the shared type
|
|
31
|
+
* names (`steps`, `sleep`, `workouts`...). The manifest declares only
|
|
32
|
+
* these, as Play's health permissions review requires; without it, the
|
|
33
|
+
* original set (steps, heart rate, energy, distance, workouts).
|
|
34
|
+
*/
|
|
35
|
+
healthConnect?: {
|
|
36
|
+
read?: HealthConnectType[];
|
|
37
|
+
write?: HealthConnectType[];
|
|
38
|
+
/** Read while the app is in the background (a WorkManager refresh): Android 15's READ_HEALTH_DATA_IN_BACKGROUND. */
|
|
39
|
+
background?: boolean;
|
|
40
|
+
/** Read further back than the 30 days before the first grant: READ_HEALTH_DATA_HISTORY. */
|
|
41
|
+
history?: boolean;
|
|
42
|
+
};
|
|
43
|
+
enableHealthConnectBackground?: boolean;
|
|
44
|
+
enableHealthConnectHistory?: boolean;
|
|
45
|
+
/** The app's privacy policy, which Health Connect links to from its permission screen. */
|
|
46
|
+
privacyPolicyURL?: string;
|
|
29
47
|
enableKeepAwake?: boolean;
|
|
30
48
|
enableDeepLinks?: boolean;
|
|
31
49
|
/** Reach the person's own devices on the local network over plain HTTP (a Mac companion). */
|
|
@@ -40,7 +58,37 @@ export interface CraftAndroidConfig {
|
|
|
40
58
|
minSdk?: number;
|
|
41
59
|
compileSdk?: number;
|
|
42
60
|
targetSdk?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Native renderer: the app's paths and the screen drawing each
|
|
63
|
+
* (`/m/workout/:id` → `Workout`), its tabs, and the bundle route of the
|
|
64
|
+
* script that runs beside the screens. The same keys as the iOS builder.
|
|
65
|
+
*/
|
|
66
|
+
nativeScreens?: Record<string, string>;
|
|
67
|
+
tabs?: Array<{
|
|
68
|
+
id: string;
|
|
69
|
+
title: string;
|
|
70
|
+
symbol?: string;
|
|
71
|
+
}>;
|
|
72
|
+
nativeApp?: string;
|
|
73
|
+
/** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
|
|
74
|
+
backgroundRefresh?: {
|
|
75
|
+
enabled?: boolean;
|
|
76
|
+
minimumIntervalMinutes?: number;
|
|
77
|
+
};
|
|
43
78
|
}
|
|
79
|
+
/** The shared health data types (the iOS builder's HealthKit names). */
|
|
80
|
+
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat';
|
|
81
|
+
/**
|
|
82
|
+
* The Health Connect permissions behind each type, for reading and for
|
|
83
|
+
* writing. CraftNativeHealth.kt asks for the same ones; a permission the
|
|
84
|
+
* manifest does not declare is refused without a prompt.
|
|
85
|
+
*/
|
|
86
|
+
export declare const HEALTH_CONNECT_PERMISSIONS: Record<HealthConnectType, {
|
|
87
|
+
read: string[];
|
|
88
|
+
write: string[];
|
|
89
|
+
}>;
|
|
90
|
+
/** Every `android.permission.health.*` the app's Health Connect types need. */
|
|
91
|
+
export declare function healthConnectPermissions(config: Pick<CraftAndroidConfig, 'healthConnect'>): string[];
|
|
44
92
|
export interface InitOptions {
|
|
45
93
|
name: string;
|
|
46
94
|
packageName?: string;
|
|
@@ -58,9 +106,23 @@ export interface InitOptions {
|
|
|
58
106
|
export interface BuildOptions {
|
|
59
107
|
htmlPath?: string;
|
|
60
108
|
nativeBundlePath?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Native renderer: the app's icons as vectors (`{ viewBox, strokeWidth,
|
|
111
|
+
* icons: { name: svgBody } }`, keyed by Iconify name and SF Symbol), which
|
|
112
|
+
* `<Icon>`, the tab bar and the bar buttons draw. Without it they fall
|
|
113
|
+
* back to the glyphs Android's fonts have.
|
|
114
|
+
*/
|
|
115
|
+
iconsPath?: string;
|
|
61
116
|
devServer?: string;
|
|
62
117
|
output: string;
|
|
63
118
|
release?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* An Android App Bundle (`bundleRelease`, `.aab`), what Google Play takes,
|
|
121
|
+
* rather than an APK. Signed with CRAFT_ANDROID_KEYSTORE,
|
|
122
|
+
* CRAFT_ANDROID_KEYSTORE_PASSWORD, CRAFT_ANDROID_KEY_ALIAS and
|
|
123
|
+
* CRAFT_ANDROID_KEY_PASSWORD when they are set.
|
|
124
|
+
*/
|
|
125
|
+
bundle?: boolean;
|
|
64
126
|
compile?: boolean;
|
|
65
127
|
/** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
|
|
66
128
|
runtimeDir?: string | null;
|
|
@@ -111,6 +111,48 @@ var KOTLIN_KEYWORDS = new Set([
|
|
|
111
111
|
"while"
|
|
112
112
|
]);
|
|
113
113
|
var ANDROID_MIN_SDK = 26;
|
|
114
|
+
var HEALTH_CONNECT_PERMISSIONS = {
|
|
115
|
+
steps: { read: ["READ_STEPS"], write: [] },
|
|
116
|
+
heartRate: { read: ["READ_HEART_RATE"], write: [] },
|
|
117
|
+
activeEnergy: { read: ["READ_ACTIVE_CALORIES_BURNED"], write: ["WRITE_ACTIVE_CALORIES_BURNED"] },
|
|
118
|
+
distance: { read: ["READ_DISTANCE"], write: ["WRITE_DISTANCE"] },
|
|
119
|
+
workouts: {
|
|
120
|
+
read: ["READ_EXERCISE", "READ_HEART_RATE", "READ_ACTIVE_CALORIES_BURNED", "READ_DISTANCE"],
|
|
121
|
+
write: ["WRITE_EXERCISE", "WRITE_ACTIVE_CALORIES_BURNED", "WRITE_DISTANCE"]
|
|
122
|
+
},
|
|
123
|
+
restingHeartRate: { read: ["READ_RESTING_HEART_RATE"], write: [] },
|
|
124
|
+
heartRateVariability: { read: ["READ_HEART_RATE_VARIABILITY"], write: [] },
|
|
125
|
+
bodyMass: { read: ["READ_WEIGHT"], write: [] },
|
|
126
|
+
sleep: { read: ["READ_SLEEP"], write: [] },
|
|
127
|
+
mindful: { read: ["READ_MINDFULNESS"], write: ["WRITE_MINDFULNESS"] },
|
|
128
|
+
dietaryEnergy: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
129
|
+
dietaryProtein: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
130
|
+
dietaryCarbohydrates: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
131
|
+
dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] }
|
|
132
|
+
};
|
|
133
|
+
var DEFAULT_HEALTH_CONNECT = { read: ["steps", "heartRate", "activeEnergy", "distance", "workouts"], write: ["workouts"] };
|
|
134
|
+
function healthConnectPermissions(config) {
|
|
135
|
+
const read = config.healthConnect?.read ?? DEFAULT_HEALTH_CONNECT.read;
|
|
136
|
+
const write = config.healthConnect?.write ?? (config.healthConnect ? [] : DEFAULT_HEALTH_CONNECT.write);
|
|
137
|
+
const permissions = new Set;
|
|
138
|
+
for (const type of read) {
|
|
139
|
+
const known = HEALTH_CONNECT_PERMISSIONS[type];
|
|
140
|
+
if (!known)
|
|
141
|
+
throw new Error(`Unknown Health Connect type: ${type}`);
|
|
142
|
+
known.read.forEach((permission) => permissions.add(`android.permission.health.${permission}`));
|
|
143
|
+
}
|
|
144
|
+
for (const type of write) {
|
|
145
|
+
const known = HEALTH_CONNECT_PERMISSIONS[type];
|
|
146
|
+
if (!known)
|
|
147
|
+
throw new Error(`Unknown Health Connect type: ${type}`);
|
|
148
|
+
known.write.forEach((permission) => permissions.add(`android.permission.health.${permission}`));
|
|
149
|
+
}
|
|
150
|
+
if (config.healthConnect?.background)
|
|
151
|
+
permissions.add("android.permission.health.READ_HEALTH_DATA_IN_BACKGROUND");
|
|
152
|
+
if (config.healthConnect?.history)
|
|
153
|
+
permissions.add("android.permission.health.READ_HEALTH_DATA_HISTORY");
|
|
154
|
+
return [...permissions];
|
|
155
|
+
}
|
|
114
156
|
var DEFAULT_CONFIG = {
|
|
115
157
|
renderer: "web",
|
|
116
158
|
version: "1.0.0",
|
|
@@ -307,17 +349,77 @@ function renderAndroidPermissions(config) {
|
|
|
307
349
|
permissions.add("android.permission.POST_NOTIFICATIONS");
|
|
308
350
|
if (config.enableKeepAwake)
|
|
309
351
|
permissions.add("android.permission.WAKE_LOCK");
|
|
352
|
+
if (config.enableHealthConnect)
|
|
353
|
+
healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
|
|
354
|
+
if (config.renderer === "native" && config.backgroundRefresh?.enabled)
|
|
355
|
+
permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
|
|
356
|
+
return [...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`).join(`
|
|
357
|
+
`);
|
|
358
|
+
}
|
|
359
|
+
function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
360
|
+
const javaDir = join(output, "app/src/main/java", packagePath);
|
|
361
|
+
const write = (template, file, replace = (source) => source) => {
|
|
362
|
+
const source = readFileSync(join(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
|
|
363
|
+
writeFileSync(join(javaDir, file), replace(source));
|
|
364
|
+
};
|
|
365
|
+
write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
|
|
366
|
+
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
367
|
+
write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
|
|
368
|
+
write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_PUSH_REGISTER}}", config.enablePushNotifications ? ` try {
|
|
369
|
+
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
|
370
|
+
val token = if (task.isSuccessful) task.result else null
|
|
371
|
+
if (token.isNullOrBlank()) done(failure("PUSH_UNAVAILABLE", task.exception?.message ?: "Firebase Cloud Messaging gave no token"))
|
|
372
|
+
else {
|
|
373
|
+
context.getSharedPreferences("craft-native-push", Context.MODE_PRIVATE).edit().putString("token", token).apply()
|
|
374
|
+
done(success(token))
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (error: Exception) {
|
|
378
|
+
done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured"))
|
|
379
|
+
}` : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))'));
|
|
380
|
+
if (config.enablePushNotifications)
|
|
381
|
+
write("CraftNativeMessagingService.kt.template", "CraftNativeMessagingService.kt");
|
|
382
|
+
else
|
|
383
|
+
rmSync(join(javaDir, "CraftNativeMessagingService.kt"), { force: true });
|
|
384
|
+
mkdirSync(join(output, "app/src/main/res/xml"), { recursive: true });
|
|
385
|
+
writeFileSync(join(output, "app/src/main/res/xml/craft_file_paths.xml"), `<?xml version="1.0" encoding="utf-8"?>
|
|
386
|
+
<paths>
|
|
387
|
+
<cache-path name="craft-files" path="craft-files/" />
|
|
388
|
+
<files-path name="craft-media" path="craft-media/" />
|
|
389
|
+
</paths>
|
|
390
|
+
`);
|
|
391
|
+
}
|
|
392
|
+
function renderNativeShellManifest(config) {
|
|
393
|
+
const parts = [
|
|
394
|
+
' <receiver android:name=".CraftNativeNotificationReceiver" android:exported="false" />',
|
|
395
|
+
` <provider
|
|
396
|
+
android:name="androidx.core.content.FileProvider"
|
|
397
|
+
android:authorities="${config.packageName}.craftfiles"
|
|
398
|
+
android:exported="false"
|
|
399
|
+
android:grantUriPermissions="true">
|
|
400
|
+
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/craft_file_paths" />
|
|
401
|
+
</provider>`
|
|
402
|
+
];
|
|
403
|
+
if (config.enablePushNotifications) {
|
|
404
|
+
parts.push(` <service android:name=".CraftNativeMessagingService" android:exported="false">
|
|
405
|
+
<intent-filter>
|
|
406
|
+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
407
|
+
</intent-filter>
|
|
408
|
+
</service>`);
|
|
409
|
+
}
|
|
310
410
|
if (config.enableHealthConnect) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
411
|
+
parts.push(` <activity-alias
|
|
412
|
+
android:name="ViewPermissionUsageActivity"
|
|
413
|
+
android:exported="true"
|
|
414
|
+
android:targetActivity=".MainActivity"
|
|
415
|
+
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
|
416
|
+
<intent-filter>
|
|
417
|
+
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
|
418
|
+
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
|
419
|
+
</intent-filter>
|
|
420
|
+
</activity-alias>`);
|
|
319
421
|
}
|
|
320
|
-
return
|
|
422
|
+
return parts.join(`
|
|
321
423
|
`);
|
|
322
424
|
}
|
|
323
425
|
function renderAndroidDeepLinks(config) {
|
|
@@ -382,6 +484,9 @@ async function init(options) {
|
|
|
382
484
|
config.enableGeolocation = true;
|
|
383
485
|
if (config.enableHealthConnect) {
|
|
384
486
|
config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
|
|
487
|
+
config.enableHealthConnectBackground = Boolean(config.healthConnect?.background);
|
|
488
|
+
config.enableHealthConnectHistory = Boolean(config.healthConnect?.history);
|
|
489
|
+
healthConnectPermissions(config);
|
|
385
490
|
}
|
|
386
491
|
normalizeAndroidNetworkConfig(config);
|
|
387
492
|
validateAndroidConfig(config);
|
|
@@ -433,6 +538,7 @@ async function init(options) {
|
|
|
433
538
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
434
539
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
435
540
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
541
|
+
writeNativeShellSources(output, packagePath, finalPackageName, config);
|
|
436
542
|
}
|
|
437
543
|
const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
|
|
438
544
|
const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
|
|
@@ -464,21 +570,24 @@ async function init(options) {
|
|
|
464
570
|
const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
|
|
465
571
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
466
572
|
const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
|
|
467
|
-
const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ?
|
|
573
|
+
const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") || config.enableLocalNetwork ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
|
|
468
574
|
<package android:name="com.google.android.apps.healthdata" />
|
|
469
575
|
</queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
|
|
470
576
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
|
471
|
-
</intent-filter
|
|
577
|
+
</intent-filter>${config.renderer === "native" ? "" : `
|
|
472
578
|
<intent-filter>
|
|
473
579
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
|
474
580
|
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
|
475
|
-
</intent-filter>` : "");
|
|
581
|
+
</intent-filter>`}` : "");
|
|
476
582
|
writeFileSync(join(output, "app/src/main/AndroidManifest.xml"), manifest);
|
|
477
583
|
const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
|
|
478
584
|
writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
|
|
479
585
|
const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
|
|
480
586
|
const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
|
|
481
|
-
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ?
|
|
587
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ` implementation("androidx.javascriptengine:javascriptengine:1.1.0")
|
|
588
|
+
// The in-app browser (Custom Tabs) and background refresh (WorkManager).
|
|
589
|
+
implementation("androidx.browser:browser:1.8.0")
|
|
590
|
+
implementation("androidx.work:work-runtime-ktx:2.9.1")` : "");
|
|
482
591
|
writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
|
|
483
592
|
const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
|
|
484
593
|
writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
@@ -630,7 +739,7 @@ zipStorePath=wrapper/dists
|
|
|
630
739
|
console.log("");
|
|
631
740
|
}
|
|
632
741
|
async function build(options) {
|
|
633
|
-
const { htmlPath, nativeBundlePath, devServer, output, release, compile = true } = options;
|
|
742
|
+
const { htmlPath, nativeBundlePath, devServer, output, release, bundle, compile = true } = options;
|
|
634
743
|
console.log(`
|
|
635
744
|
\uD83D\uDCE6 Building Craft Android project...`);
|
|
636
745
|
const configPath = join(output, "craft.config.json");
|
|
@@ -645,6 +754,12 @@ async function build(options) {
|
|
|
645
754
|
const destination = join(output, "app/src/main/assets/native-screen.js");
|
|
646
755
|
if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
|
|
647
756
|
cpSync(nativeBundlePath, destination);
|
|
757
|
+
const icons = join(output, "app/src/main/assets/craft-icons.json");
|
|
758
|
+
if (options.iconsPath) {
|
|
759
|
+
requireRegularFile(options.iconsPath, "Icon table");
|
|
760
|
+
if (resolve(options.iconsPath) !== resolve(icons))
|
|
761
|
+
cpSync(options.iconsPath, icons);
|
|
762
|
+
}
|
|
648
763
|
if (!existsSync(destination)) {
|
|
649
764
|
throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
650
765
|
}
|
|
@@ -687,12 +802,15 @@ async function build(options) {
|
|
|
687
802
|
throw new Error("Gradle is unavailable. Install Android Studio or Gradle before building Android.", { cause: error });
|
|
688
803
|
}
|
|
689
804
|
}
|
|
690
|
-
const
|
|
691
|
-
|
|
805
|
+
const variant = release ? "Release" : "Debug";
|
|
806
|
+
const buildType = `${bundle ? "bundle" : "assemble"}${variant}`;
|
|
807
|
+
console.log(` Building ${release ? "release" : "debug"} ${bundle ? "app bundle" : "APK"}...`);
|
|
808
|
+
if (release && !process.env.CRAFT_ANDROID_KEYSTORE)
|
|
809
|
+
console.log(" CRAFT_ANDROID_KEYSTORE is not set: the release build is unsigned.");
|
|
692
810
|
try {
|
|
693
811
|
await $`cd ${output} && ./gradlew ${buildType}`;
|
|
694
|
-
const
|
|
695
|
-
console.log(`\u2705 APK built: ${
|
|
812
|
+
const artifact = bundle ? `app/build/outputs/bundle/${variant.toLowerCase()}/app-${variant.toLowerCase()}.aab` : release ? "app/build/outputs/apk/release/app-release.apk" : "app/build/outputs/apk/debug/app-debug.apk";
|
|
813
|
+
console.log(`\u2705 ${bundle ? "App bundle" : "APK"} built: ${artifact}`);
|
|
696
814
|
} catch (error) {
|
|
697
815
|
console.error("Build failed. Open in Android Studio for details.");
|
|
698
816
|
throw error;
|
|
@@ -752,6 +870,8 @@ export {
|
|
|
752
870
|
open,
|
|
753
871
|
installRuntime,
|
|
754
872
|
init,
|
|
873
|
+
healthConnectPermissions,
|
|
755
874
|
build,
|
|
875
|
+
HEALTH_CONNECT_PERMISSIONS,
|
|
756
876
|
ANDROID_MIN_SDK
|
|
757
877
|
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
8
|
+
import androidx.javascriptengine.JavaScriptConsoleCallback
|
|
9
|
+
import androidx.javascriptengine.JavaScriptIsolate
|
|
10
|
+
import androidx.javascriptengine.JavaScriptSandbox
|
|
11
|
+
import androidx.work.Constraints
|
|
12
|
+
import androidx.work.CoroutineWorker
|
|
13
|
+
import androidx.work.ExistingPeriodicWorkPolicy
|
|
14
|
+
import androidx.work.ExistingWorkPolicy
|
|
15
|
+
import androidx.work.NetworkType
|
|
16
|
+
import androidx.work.OneTimeWorkRequestBuilder
|
|
17
|
+
import androidx.work.PeriodicWorkRequestBuilder
|
|
18
|
+
import androidx.work.WorkManager
|
|
19
|
+
import androidx.work.WorkerParameters
|
|
20
|
+
import androidx.work.workDataOf
|
|
21
|
+
import com.google.common.util.concurrent.ListenableFuture
|
|
22
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
23
|
+
import kotlinx.coroutines.Dispatchers
|
|
24
|
+
import kotlinx.coroutines.withContext
|
|
25
|
+
import kotlinx.coroutines.withTimeoutOrNull
|
|
26
|
+
import org.json.JSONArray
|
|
27
|
+
import org.json.JSONObject
|
|
28
|
+
import java.util.UUID
|
|
29
|
+
import java.util.concurrent.TimeUnit
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One JavaScriptSandbox for the process: the screens' isolates and a
|
|
33
|
+
* background refresh's all live in it, since an app may hold only one.
|
|
34
|
+
*/
|
|
35
|
+
internal object CraftNativeSandbox {
|
|
36
|
+
private val lock = Any()
|
|
37
|
+
private var sandbox: JavaScriptSandbox? = null
|
|
38
|
+
private var connecting: ListenableFuture<JavaScriptSandbox>? = null
|
|
39
|
+
|
|
40
|
+
fun connect(context: Context, done: (JavaScriptSandbox?, Throwable?) -> Unit) {
|
|
41
|
+
val existing = synchronized(lock) { sandbox }
|
|
42
|
+
if (existing != null) return done(existing, null)
|
|
43
|
+
val future = synchronized(lock) {
|
|
44
|
+
connecting ?: JavaScriptSandbox.createConnectedInstanceAsync(context.applicationContext).also { connecting = it }
|
|
45
|
+
}
|
|
46
|
+
future.addListener({
|
|
47
|
+
try {
|
|
48
|
+
val engine = future.get()
|
|
49
|
+
synchronized(lock) { sandbox = engine }
|
|
50
|
+
done(engine, null)
|
|
51
|
+
} catch (error: Exception) {
|
|
52
|
+
synchronized(lock) { connecting = null }
|
|
53
|
+
done(null, error)
|
|
54
|
+
}
|
|
55
|
+
}, ContextCompat.getMainExecutor(context))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Background refresh, as iOS's BGAppRefreshTask gives it: WorkManager wakes
|
|
61
|
+
* the app every `backgroundRefresh.minimumIntervalMinutes` (15 at the
|
|
62
|
+
* least, with a network) and the app script (`nativeApp`) hears
|
|
63
|
+
* `craftBackgroundRefresh` with an `id`, does its work and answers
|
|
64
|
+
* `craft.background.complete(ok, id)`. With the app open, the running app
|
|
65
|
+
* script hears it; otherwise a script is started for the refresh alone
|
|
66
|
+
* ([CraftNativeHeadlessApp]) and closed when it answers. A silent push
|
|
67
|
+
* (`craftSilentPush`) runs the same way, once.
|
|
68
|
+
*/
|
|
69
|
+
internal object CraftNativeBackground {
|
|
70
|
+
private const val PERIODIC = "craft-native-background-refresh"
|
|
71
|
+
private const val ONCE = "craft-native-background-once"
|
|
72
|
+
|
|
73
|
+
/** The running app's script, when the activity has one: delivers an event and hears its answer. */
|
|
74
|
+
@Volatile var foreground: ((event: String, id: String, done: (Boolean) -> Unit) -> Boolean)? = null
|
|
75
|
+
|
|
76
|
+
fun schedule(context: Context, config: JSONObject) {
|
|
77
|
+
val refresh = config.optJSONObject("backgroundRefresh")
|
|
78
|
+
val work = WorkManager.getInstance(context)
|
|
79
|
+
if (refresh?.optBoolean("enabled") != true || config.optString("nativeApp").isBlank()) {
|
|
80
|
+
work.cancelUniqueWork(PERIODIC)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
val minutes = refresh.optLong("minimumIntervalMinutes", 120).coerceAtLeast(15)
|
|
84
|
+
val request = PeriodicWorkRequestBuilder<CraftNativeRefreshWorker>(minutes, TimeUnit.MINUTES)
|
|
85
|
+
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
|
86
|
+
.setInputData(workDataOf("event" to "craftBackgroundRefresh"))
|
|
87
|
+
.build()
|
|
88
|
+
work.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One run now (a silent push). */
|
|
92
|
+
fun runOnce(context: Context, event: String) {
|
|
93
|
+
val request = OneTimeWorkRequestBuilder<CraftNativeRefreshWorker>()
|
|
94
|
+
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
|
95
|
+
.setInputData(workDataOf("event" to event))
|
|
96
|
+
.build()
|
|
97
|
+
WorkManager.getInstance(context).enqueueUniqueWork(ONCE, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
internal class CraftNativeRefreshWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
|
102
|
+
override suspend fun doWork(): Result {
|
|
103
|
+
val event = inputData.getString("event") ?: "craftBackgroundRefresh"
|
|
104
|
+
val id = UUID.randomUUID().toString()
|
|
105
|
+
val answer = CompletableDeferred<Boolean>()
|
|
106
|
+
val running = withContext(Dispatchers.Main) {
|
|
107
|
+
CraftNativeBackground.foreground?.invoke(event, id) { ok -> answer.complete(ok) } == true
|
|
108
|
+
}
|
|
109
|
+
Log.i("CraftNativeAndroid", "$event $id: ${if (running) "the running app script" else "a background app script"}")
|
|
110
|
+
var headless: CraftNativeHeadlessApp? = null
|
|
111
|
+
if (!running) {
|
|
112
|
+
headless = withContext(Dispatchers.Main) { CraftNativeHeadlessApp(applicationContext, event, id) { ok -> answer.complete(ok) } }
|
|
113
|
+
withContext(Dispatchers.Main) { headless.start() }
|
|
114
|
+
}
|
|
115
|
+
// WorkManager stops a worker at ten minutes; answer before that.
|
|
116
|
+
val ok = withTimeoutOrNull(9 * 60 * 1000L) { answer.await() }
|
|
117
|
+
Log.i("CraftNativeAndroid", "$event $id answered ${ok ?: "nothing in time"}")
|
|
118
|
+
headless?.let { withContext(Dispatchers.Main) { it.close() } }
|
|
119
|
+
return if (ok == false) Result.retry() else Result.success()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The app script with no activity: a background refresh's. It gets the
|
|
125
|
+
* same bridge as a screen's (CraftNativePrelude), Craft's core capabilities
|
|
126
|
+
* and this host's services, minus everything a person would have to see.
|
|
127
|
+
*/
|
|
128
|
+
internal class CraftNativeHeadlessApp(
|
|
129
|
+
private val context: Context,
|
|
130
|
+
private val event: String,
|
|
131
|
+
private val id: String,
|
|
132
|
+
private val onDone: (Boolean) -> Unit,
|
|
133
|
+
) {
|
|
134
|
+
private val config: JSONObject = runCatching {
|
|
135
|
+
context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
|
|
136
|
+
}.getOrDefault(JSONObject())
|
|
137
|
+
private val stores = CraftNativeStores.get(context, config)
|
|
138
|
+
private val capabilities = CraftNativeCapabilities(context, config)
|
|
139
|
+
private val services = CraftNativeServices(context, config) { null }
|
|
140
|
+
private val scope = UUID.randomUUID().toString()
|
|
141
|
+
private val prefix = "CRAFT_STX_$scope:"
|
|
142
|
+
private val main = Handler(Looper.getMainLooper())
|
|
143
|
+
private var isolate: JavaScriptIsolate? = null
|
|
144
|
+
private var closed = false
|
|
145
|
+
private val timers = mutableMapOf<Int, Runnable>()
|
|
146
|
+
private val chunks = CraftNativeChunks()
|
|
147
|
+
|
|
148
|
+
init {
|
|
149
|
+
capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, scope) }
|
|
150
|
+
services.onBackgroundComplete = { ok, answered -> if (answered == null || answered == id) finish(ok) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fun start() {
|
|
154
|
+
val route = config.optString("nativeApp")
|
|
155
|
+
if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(true)
|
|
156
|
+
val bundle = runCatching { context.assets.open("native-screen.js").bufferedReader().use { it.readText() } }.getOrNull()
|
|
157
|
+
?: return finish(true)
|
|
158
|
+
CraftNativeSandbox.connect(context) { engine, error ->
|
|
159
|
+
if (closed) return@connect
|
|
160
|
+
if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
|
|
161
|
+
Log.w(TAG, "Background refresh has no JavaScript engine", error)
|
|
162
|
+
return@connect finish(true)
|
|
163
|
+
}
|
|
164
|
+
val created = engine.createIsolate()
|
|
165
|
+
isolate = created
|
|
166
|
+
created.setConsoleCallback(ContextCompat.getMainExecutor(context), JavaScriptConsoleCallback { entry ->
|
|
167
|
+
if (closed) return@JavaScriptConsoleCallback
|
|
168
|
+
if (entry.message.startsWith(prefix)) chunks.accept(entry.message.removePrefix(prefix))?.let(::receive)
|
|
169
|
+
else Log.i(TAG, "[background] ${entry.message}")
|
|
170
|
+
})
|
|
171
|
+
CraftNativeHub.register(scope) { type, payload -> send(type, payload) }
|
|
172
|
+
val names = capabilities.names().apply { services.names().forEach { put(it) } }
|
|
173
|
+
val bridgeFields = """
|
|
174
|
+
platform: 'android',
|
|
175
|
+
mutationProtocolVersion: 1,
|
|
176
|
+
capabilityProtocolVersion: $CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION,
|
|
177
|
+
capabilityTimeoutMs: 30000,
|
|
178
|
+
capabilities: $names,
|
|
179
|
+
initialAppState: 'background',
|
|
180
|
+
colorScheme: 'light',
|
|
181
|
+
appURL: ${JSONObject.quote(config.optString("devServerURL"))},
|
|
182
|
+
appVersion: '',
|
|
183
|
+
appBuild: ''
|
|
184
|
+
""".trimIndent()
|
|
185
|
+
val prelude = CraftNativePrelude.script(
|
|
186
|
+
bridgeFields,
|
|
187
|
+
CraftNativeChunks.consolePost(prefix),
|
|
188
|
+
"globalThis.__stxNativeCallback = deliver;",
|
|
189
|
+
stores.preloadText(),
|
|
190
|
+
"globalThis.__stxNativeRoute = ${JSONObject.quote(route)};\nglobalThis.__stxNativeParams = {};",
|
|
191
|
+
)
|
|
192
|
+
val setup = created.evaluateJavaScriptAsync(prelude)
|
|
193
|
+
setup.addListener({
|
|
194
|
+
if (closed) return@addListener
|
|
195
|
+
try {
|
|
196
|
+
setup.get()
|
|
197
|
+
val load = created.evaluateJavaScriptAsync(bundle)
|
|
198
|
+
load.addListener({
|
|
199
|
+
try {
|
|
200
|
+
load.get()
|
|
201
|
+
send("CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject().put("id", id)))
|
|
202
|
+
} catch (failure: Exception) {
|
|
203
|
+
Log.e(TAG, "Background refresh script failed", failure)
|
|
204
|
+
finish(false)
|
|
205
|
+
}
|
|
206
|
+
}, ContextCompat.getMainExecutor(context))
|
|
207
|
+
} catch (failure: Exception) {
|
|
208
|
+
Log.e(TAG, "Background refresh bridge failed", failure)
|
|
209
|
+
finish(false)
|
|
210
|
+
}
|
|
211
|
+
}, ContextCompat.getMainExecutor(context))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fun receive(raw: String) {
|
|
216
|
+
val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
|
217
|
+
val payload = message.optJSONObject("payload") ?: JSONObject()
|
|
218
|
+
when (message.optString("type")) {
|
|
219
|
+
"API_REQUEST" -> answer(message.optString("id"), payload)
|
|
220
|
+
"SYNC_WRITE" -> {
|
|
221
|
+
val key = payload.optString("key")
|
|
222
|
+
val value = if (payload.isNull("value")) null else payload.optString("value")
|
|
223
|
+
val reply: (Boolean) -> Unit = { ok ->
|
|
224
|
+
payload.optString("id").takeIf { it.isNotBlank() }?.let { send("SYNC_RESULT", JSONObject().put("id", it).put("ok", ok)) }
|
|
225
|
+
}
|
|
226
|
+
when (payload.optString("store")) {
|
|
227
|
+
"storage" -> if (key.isNotBlank()) stores.setStorage(key, value, scope)
|
|
228
|
+
"snapshots" -> if (payload.optBoolean("clear")) {
|
|
229
|
+
val keep = payload.optJSONArray("keep") ?: JSONArray()
|
|
230
|
+
stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
|
|
231
|
+
} else stores.setSnapshot(key, value, scope, reply)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
"TIMER" -> {
|
|
235
|
+
val id = payload.optInt("id")
|
|
236
|
+
timers.remove(id)?.let(main::removeCallbacks)
|
|
237
|
+
val fire = Runnable {
|
|
238
|
+
timers.remove(id)
|
|
239
|
+
send("TIMER_FIRE", JSONObject().put("id", id))
|
|
240
|
+
}
|
|
241
|
+
timers[id] = fire
|
|
242
|
+
main.postDelayed(fire, payload.optLong("ms", 0).coerceAtLeast(0))
|
|
243
|
+
}
|
|
244
|
+
"TIMER_CLEAR" -> timers.remove(payload.optInt("id"))?.let(main::removeCallbacks)
|
|
245
|
+
// Nothing is drawn in the background: frames come at a calm pace.
|
|
246
|
+
"FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", android.os.SystemClock.uptimeMillis().toDouble())) }, 100)
|
|
247
|
+
// A background refresh shows nothing: navigation waits for the app.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private fun answer(requestId: String, payload: JSONObject) {
|
|
252
|
+
if (requestId.isBlank()) return
|
|
253
|
+
val module = payload.optString("module")
|
|
254
|
+
val method = payload.optString("method")
|
|
255
|
+
val args = payload.optJSONArray("args") ?: JSONArray()
|
|
256
|
+
val reply: (CraftNativeCapabilityResult) -> Unit = { result ->
|
|
257
|
+
val response = JSONObject().put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION).put("requestId", requestId)
|
|
258
|
+
if (result.code == null) response.put("data", result.data ?: JSONObject.NULL)
|
|
259
|
+
else response.put("code", result.code).put("message", result.message ?: "Native API failed")
|
|
260
|
+
send(if (result.code == null) "API_RESPONSE" else "API_ERROR", response, requestId)
|
|
261
|
+
}
|
|
262
|
+
if (services.handles(module)) services.perform(module, method, args, reply)
|
|
263
|
+
else capabilities.perform("$scope/$requestId", payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION), module, method, args, reply)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun send(type: String, payload: JSONObject, correlationId: String? = null) {
|
|
267
|
+
val target = isolate ?: return
|
|
268
|
+
if (closed) return
|
|
269
|
+
val message = JSONObject().put("type", type).put("payload", payload)
|
|
270
|
+
if (correlationId != null) message.put("correlationId", correlationId)
|
|
271
|
+
runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})") }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private fun finish(ok: Boolean) {
|
|
275
|
+
main.post { onDone(ok) }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fun close() {
|
|
279
|
+
if (closed) return
|
|
280
|
+
closed = true
|
|
281
|
+
CraftNativeHub.unregister(scope)
|
|
282
|
+
timers.values.forEach(main::removeCallbacks)
|
|
283
|
+
timers.clear()
|
|
284
|
+
services.close()
|
|
285
|
+
capabilities.close()
|
|
286
|
+
isolate?.let { target ->
|
|
287
|
+
runCatching { target.evaluateJavaScriptAsync("globalThis.__stxNativeUnmount && globalThis.__stxNativeUnmount()") }
|
|
288
|
+
main.postDelayed({ runCatching { target.close() } }, 500)
|
|
289
|
+
}
|
|
290
|
+
isolate = null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
companion object {
|
|
294
|
+
private const val TAG = "CraftNativeAndroid"
|
|
295
|
+
}
|
|
296
|
+
}
|