@craft-native/android 0.0.168 → 0.0.170
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/README.md +45 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +139 -19
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/templates/CraftNativeBackground.kt.template +296 -0
- package/templates/CraftNativeCapabilities.kt.template +59 -37
- package/templates/CraftNativeHealth.kt.template +1046 -0
- package/templates/CraftNativeHealthStub.kt.template +32 -0
- package/templates/CraftNativeMessagingService.kt.template +75 -0
- package/templates/CraftNativeMutation.kt.template +14 -2
- package/templates/CraftNativeServices.kt.template +561 -0
- package/templates/CraftNativeShell.kt.template +1028 -0
- package/templates/LocationRecordingService.kt.template +7 -0
- package/templates/MainActivityNative.kt.template +738 -132
- package/templates/build.gradle.kts.app.template +16 -0
package/README.md
CHANGED
|
@@ -204,6 +204,45 @@ Support depends on the installed WebView provider, so validate on each device
|
|
|
204
204
|
class you plan to ship. The [Android JavaScriptEngine guide](https://developer.android.com/develop/ui/views/layout/webapps/jsengine)
|
|
205
205
|
documents the sandbox and device-support check.
|
|
206
206
|
|
|
207
|
+
### A whole app of native screens
|
|
208
|
+
|
|
209
|
+
With `nativeScreens` (paths to the screens that draw them), `tabs` and
|
|
210
|
+
`nativeApp` in craft.config.json, the same keys as the iOS builder, the
|
|
211
|
+
native renderer is a whole app rather than one bundle route:
|
|
212
|
+
|
|
213
|
+
- a tab bar and a navigation bar (`craft.navigation.setOptions`: title, large
|
|
214
|
+
title, right buttons with badges, `tabBarHidden`), a stack per tab, Android's
|
|
215
|
+
Back key popping it;
|
|
216
|
+
- `craft.navigation.open(path)` matched in the iOS shell's order (fixed segments
|
|
217
|
+
before parameters), a tab's path selecting its tab, a path no screen draws
|
|
218
|
+
opening the site in a Custom Tab; deep links and tapped notifications route
|
|
219
|
+
the same way;
|
|
220
|
+
- the app script (`nativeApp`) in its own isolate for the app's life, and in a
|
|
221
|
+
WorkManager job for `backgroundRefresh` and silent pushes
|
|
222
|
+
(`craftBackgroundRefresh`, `craftSilentPush`, answered with
|
|
223
|
+
`craft.background.complete`);
|
|
224
|
+
- `craft.storage.getSync`, `craft.secureStorage.getSync` and `craft.snapshots`:
|
|
225
|
+
JavaScriptSandbox cannot call the host synchronously, so every isolate starts
|
|
226
|
+
with a copy of these stores and the host keeps each copy current. Timers and
|
|
227
|
+
`requestAnimationFrame` (vsync, for the screen on display) are the host's;
|
|
228
|
+
long messages on the console bridge travel in pieces;
|
|
229
|
+
- the services the iOS host answers by the same module names: Dialog, Browser
|
|
230
|
+
(Custom Tabs, `mode: 'auth'` callbacks), Linking, Share, Push (FCM),
|
|
231
|
+
Location (with a foreground-service route recording), Media (videos kept
|
|
232
|
+
for offline, played in place of their address), KeepAwake, Brightness,
|
|
233
|
+
StatusBar, Speech and Health;
|
|
234
|
+
- `iconsPath` (a `{ viewBox, strokeWidth, icons }` table of SVG bodies keyed by
|
|
235
|
+
icon name and SF Symbol) gives `<Icon>`, tabs and bar buttons the app's own
|
|
236
|
+
vectors in place of Android's glyphs.
|
|
237
|
+
|
|
238
|
+
Health is Health Connect with the HealthKit contract (`getWorkouts`,
|
|
239
|
+
`getDailyStatistics`, `getHeartRateSamples`, `getSleepIntervals`,
|
|
240
|
+
`saveWorkout`, `saveMindfulSession`...), plus `availability`, `grantedTypes`
|
|
241
|
+
and `openSettings` (`healthAvailability`). `healthConnect: { read, write,
|
|
242
|
+
background, history }` lists the types the manifest declares; Play reviews
|
|
243
|
+
each one. `bun scripts/test-native-health.ts` runs it against an Android 14+
|
|
244
|
+
emulator's Health Connect.
|
|
245
|
+
|
|
207
246
|
### 3. Open in Android Studio
|
|
208
247
|
|
|
209
248
|
```bash
|
|
@@ -754,6 +793,12 @@ of bundled HTML.
|
|
|
754
793
|
|
|
755
794
|
## Publishing
|
|
756
795
|
|
|
796
|
+
`build({ release: true, bundle: true })` runs `bundleRelease` and writes
|
|
797
|
+
`app/build/outputs/bundle/release/app-release.aab`, signed with the upload key
|
|
798
|
+
from `CRAFT_ANDROID_KEYSTORE` (the keystore's path), `CRAFT_ANDROID_KEYSTORE_PASSWORD`,
|
|
799
|
+
`CRAFT_ANDROID_KEY_ALIAS` and `CRAFT_ANDROID_KEY_PASSWORD` (the keystore password
|
|
800
|
+
when unset). Without them the release build is unsigned.
|
|
801
|
+
|
|
757
802
|
Build release APK/AAB:
|
|
758
803
|
|
|
759
804
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/index.js
CHANGED
|
@@ -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,8 +870,10 @@ 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
|
};
|
|
758
878
|
|
|
759
|
-
//# debugId=
|
|
879
|
+
//# debugId=695695A5D869A12C64756E2164756E21
|