craft-native 0.0.436 → 0.0.443
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/LICENSE.md +30 -0
- package/README.md +35 -0
- package/dist/android/src/index.d.ts +44 -0
- package/dist/android/src/index.js +82 -3
- package/dist/android/templates/AndroidManifest.xml.template +2 -1
- package/dist/android/templates/CraftHealthServices.kt.template +2 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
- package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
- package/dist/android/templates/CraftNativeServices.kt.template +6 -1
- package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
- package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
- package/dist/android/templates/LocationRecordingService.kt.template +77 -9
- package/dist/android/templates/MainActivity.kt.template +3 -0
- package/dist/android/templates/MainActivityNative.kt.template +132 -1
- package/dist/cli.js +289 -108
- package/dist/components/index.d.ts +25 -0
- package/dist/index.cjs +207 -27
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +321 -140
- package/dist/installer-background.d.ts +2 -0
- package/dist/installer-layout.d.ts +1 -0
- package/dist/ios/src/index.d.ts +23 -1
- package/dist/ios/src/index.js +49 -9
- package/dist/ios/templates/CraftApp.swift +14 -0
- package/dist/ios/templates/CraftHealth.swift +2 -1
- package/dist/ios/templates/CraftHealthBackground.swift +308 -0
- package/dist/ios/templates/CraftNativeActions.swift +153 -0
- package/dist/ios/templates/CraftNativePedometer.swift +181 -0
- package/dist/ios/templates/CraftNativeScreen.swift +76 -0
- package/dist/macos/src/index.js +1 -1
- package/dist/macos/src/toolchain.d.ts +1 -1
- package/dist/macos/templates/CraftMacApplication.swift +124 -39
- package/dist/macos/templates/CraftMacRenderer.swift +111 -33
- package/dist/macos/templates/CraftMacRuntime.swift +270 -81
- package/dist/package.d.ts +40 -0
- package/dist/types.d.ts +26 -0
- package/package.json +1 -1
package/LICENSE.md
CHANGED
|
@@ -19,3 +19,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
21
|
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
## Installer layout notices
|
|
24
|
+
|
|
25
|
+
The Finder store and Carbon Alias format implementation follows the MIT-licensed
|
|
26
|
+
references [gwend/dsstore](https://github.com/gwend/dsstore),
|
|
27
|
+
[rigsmith/rigsmith](https://github.com/rigsmith/rigsmith/tree/main/core/dsstore), and
|
|
28
|
+
[al45tair/mac_alias](https://github.com/al45tair/mac_alias).
|
|
29
|
+
|
|
30
|
+
Copyright (c) 2020 Kenty Gwend
|
|
31
|
+
Copyright (c) 2026 John Campion Jr
|
|
32
|
+
Copyright (c) 2014 Alastair Houghton
|
|
33
|
+
Copyright (c) 2022 Russell Keith-Magee
|
|
34
|
+
|
|
35
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
36
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
37
|
+
in the Software without restriction, including without limitation the rights
|
|
38
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
39
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
40
|
+
furnished to do so, subject to the following conditions:
|
|
41
|
+
|
|
42
|
+
The above copyright notice and this permission notice shall be included in all
|
|
43
|
+
copies or substantial portions of the Software.
|
|
44
|
+
|
|
45
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
46
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
47
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
48
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
49
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
50
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
51
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -740,3 +740,38 @@ Craft's bundled-backend host reads `backend: { executable: 'stx-backend' }` from
|
|
|
740
740
|
the helper with `--serve --managed`, and consumes its authenticated loopback URL.
|
|
741
741
|
The helper's standard input is a lifetime lease: it must stop when that pipe
|
|
742
742
|
closes. `craft --capabilities` advertises `bundleBackend` on macOS.
|
|
743
|
+
|
|
744
|
+
## Drag-to-Applications disk images
|
|
745
|
+
|
|
746
|
+
`createDiskImage` packages an existing macOS app bundle with an Applications
|
|
747
|
+
shortcut, installation artwork and a Finder icon layout. The layout is written
|
|
748
|
+
without launching Finder or requesting Automation permission.
|
|
749
|
+
|
|
750
|
+
```ts
|
|
751
|
+
import { createDiskImage } from 'craft-native'
|
|
752
|
+
|
|
753
|
+
const image = await createDiskImage({
|
|
754
|
+
appBundlePath: './dist/Example.app',
|
|
755
|
+
outputPath: './dist/Example.dmg',
|
|
756
|
+
volumeName: 'Example',
|
|
757
|
+
})
|
|
758
|
+
if (!image.success) throw new Error(image.error)
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
Signing and notarization belong to the publishing pipeline. Stacks applications
|
|
762
|
+
use Buddy and Pantry's native workflow to sign, notarize and staple the image
|
|
763
|
+
before attaching its ZIP archive. Craft's updater accepts both app ZIPs and ZIPs
|
|
764
|
+
containing a single DMG, and verifies the extracted app before installation.
|
|
765
|
+
|
|
766
|
+
The shipped LICENSE file includes the installer layout's third-party notices.
|
|
767
|
+
|
|
768
|
+
## Framework bridge types
|
|
769
|
+
|
|
770
|
+
Core owns the optional `Window.craft` declaration. React, Svelte and Vue bindings
|
|
771
|
+
depend on core for types, so importing a binding alongside `craft-native` does
|
|
772
|
+
not redeclare that global with an incompatible shape.
|
|
773
|
+
|
|
774
|
+
`CraftBindingsAPI` describes the bindings' existing flat host API (`getPlatform`,
|
|
775
|
+
`invoke`, and related methods). Their hooks and stores retain that view and their
|
|
776
|
+
runtime behavior. This type does not install a bridge, add flat methods to a
|
|
777
|
+
desktop host, or replace the structured `CraftBridgeAPI` and typed event APIs.
|
|
@@ -7,6 +7,8 @@ export type { AndroidMapProvider } from './native-map.js';
|
|
|
7
7
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
8
8
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
9
9
|
*/
|
|
10
|
+
/** Build-time proof for Stacks capability mappings, including unreleased source builds. */
|
|
11
|
+
export declare const nativeSensorCapabilities: readonly ['motionSensors', 'barometer', 'pedometer'];
|
|
10
12
|
export declare const nativeRouteBundles = true;
|
|
11
13
|
/**
|
|
12
14
|
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
@@ -65,6 +67,12 @@ export interface CraftAndroidConfig {
|
|
|
65
67
|
enableSecureStorage?: boolean;
|
|
66
68
|
enableGeolocation?: boolean;
|
|
67
69
|
enableBackgroundLocation?: boolean;
|
|
70
|
+
/** Foreground raw Android sensors on native screens; separate from iOS fused motion. */
|
|
71
|
+
enableMotionSensors?: boolean;
|
|
72
|
+
/** Foreground pressure and session-relative altitude; does not enable background recording. */
|
|
73
|
+
enableBarometer?: boolean;
|
|
74
|
+
/** Foreground hardware step counters/detectors; requires activity recognition on Android 10+. */
|
|
75
|
+
enablePedometer?: boolean;
|
|
68
76
|
enableBluetooth?: boolean;
|
|
69
77
|
/**
|
|
70
78
|
* A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
|
|
@@ -114,6 +122,18 @@ export interface CraftAndroidConfig {
|
|
|
114
122
|
}>;
|
|
115
123
|
trustedOrigins?: string[];
|
|
116
124
|
appIconPath?: string;
|
|
125
|
+
/**
|
|
126
|
+
* The launch splash: a directory laid out as Android resources, holding
|
|
127
|
+
* `drawable-<density>/splash_icon.png` (Android 12's splash icon, 288dp
|
|
128
|
+
* with its content inside the 192dp circle) and `splash_logo.png` (the
|
|
129
|
+
* logo the window background centres before Android 12), with Dark Mode
|
|
130
|
+
* ones in `drawable-night-<density>`. ts-images' `generateSplashScreens`
|
|
131
|
+
* writes exactly this (`android.resources`). Both sit on `backgroundColor`
|
|
132
|
+
* (`backgroundColorDark` at night), the colour the first frame continues.
|
|
133
|
+
* Without it, Android 12 shows the launcher icon and older versions the
|
|
134
|
+
* background colour.
|
|
135
|
+
*/
|
|
136
|
+
splashResources?: string;
|
|
117
137
|
/**
|
|
118
138
|
* The small icon in the status bar and on every notification the app
|
|
119
139
|
* shows, push or local: an Android vector drawable (`.xml`) or a `.png`,
|
|
@@ -260,6 +280,30 @@ export interface RunOptions {
|
|
|
260
280
|
device?: string;
|
|
261
281
|
output: string;
|
|
262
282
|
}
|
|
283
|
+
/** A splash file, by its place under `res/` (`drawable-night-xxhdpi/splash_icon.png`). */
|
|
284
|
+
export interface AndroidSplashFile {
|
|
285
|
+
folder: string;
|
|
286
|
+
name: 'splash_icon.png' | 'splash_logo.png';
|
|
287
|
+
path: string;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The splash files in a resources directory: `splash_icon.png` and
|
|
291
|
+
* `splash_logo.png` in each `drawable[-night][-<density>]` folder. A directory
|
|
292
|
+
* with neither is a mistake, not an app without a splash.
|
|
293
|
+
*/
|
|
294
|
+
export declare function androidSplashFiles(resources: string): AndroidSplashFile[];
|
|
295
|
+
/**
|
|
296
|
+
* The launch theme the activity starts in, and the theme it swaps to before
|
|
297
|
+
* its first frame. Android 12 and later draw their splash from
|
|
298
|
+
* `windowSplashScreen*`; earlier versions draw the window background, here
|
|
299
|
+
* the logo centred on the background colour. Either way the colour is the
|
|
300
|
+
* app's own `background`, so nothing flashes when the first frame replaces it.
|
|
301
|
+
*/
|
|
302
|
+
export declare function androidLaunchThemes(splash: Pick<AndroidSplashFile, 'name'>[]): {
|
|
303
|
+
base: string;
|
|
304
|
+
v31: string;
|
|
305
|
+
background?: string;
|
|
306
|
+
};
|
|
263
307
|
export declare function syncAndroidWebAssets(source: string, output: string): void;
|
|
264
308
|
export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
|
|
265
309
|
export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
|
|
@@ -549,6 +549,7 @@ function compressNativeSources(target) {
|
|
|
549
549
|
if (packed.length < bytes.length)
|
|
550
550
|
writeFileSync(target, packed);
|
|
551
551
|
}
|
|
552
|
+
var nativeSensorCapabilities = ["motionSensors", "barometer", "pedometer"];
|
|
552
553
|
var nativeRouteBundles = true;
|
|
553
554
|
var nativeRouteRuntime = true;
|
|
554
555
|
var nativeMessages = true;
|
|
@@ -715,6 +716,9 @@ var DEFAULT_CONFIG = {
|
|
|
715
716
|
enableSecureStorage: false,
|
|
716
717
|
enableGeolocation: false,
|
|
717
718
|
enableBackgroundLocation: false,
|
|
719
|
+
enableMotionSensors: false,
|
|
720
|
+
enableBarometer: false,
|
|
721
|
+
enablePedometer: false,
|
|
718
722
|
enableBluetooth: false,
|
|
719
723
|
enableHeartRate: false,
|
|
720
724
|
enableHealthConnect: false,
|
|
@@ -753,6 +757,49 @@ function requireRegularFile(path, label) {
|
|
|
753
757
|
if (!statSync(path).isFile())
|
|
754
758
|
throw new Error(`${label} must be a file: ${path}`);
|
|
755
759
|
}
|
|
760
|
+
function androidSplashFiles(resources) {
|
|
761
|
+
if (!existsSync(resources) || !statSync(resources).isDirectory())
|
|
762
|
+
throw new Error(`Splash resources must be a directory: ${resources}`);
|
|
763
|
+
const files = [];
|
|
764
|
+
for (const folder of readdirSync(resources).sort()) {
|
|
765
|
+
if (!/^drawable(?:-night)?(?:-(?:l|m|tv|h|xh|xxh|xxxh)dpi)?$/.test(folder))
|
|
766
|
+
continue;
|
|
767
|
+
for (const name of ["splash_icon.png", "splash_logo.png"]) {
|
|
768
|
+
const path = join3(resources, folder, name);
|
|
769
|
+
if (existsSync(path) && statSync(path).isFile())
|
|
770
|
+
files.push({ folder, name, path });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (!files.length)
|
|
774
|
+
throw new Error(`Splash resources hold no drawable*/splash_icon.png or splash_logo.png: ${resources}`);
|
|
775
|
+
return files;
|
|
776
|
+
}
|
|
777
|
+
function androidLaunchThemes(splash) {
|
|
778
|
+
const icon = splash.some((file) => file.name === "splash_icon.png");
|
|
779
|
+
const logo = splash.some((file) => file.name === "splash_logo.png");
|
|
780
|
+
const base = logo ? ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
|
|
781
|
+
<item name="android:windowBackground">@drawable/craft_launch_background</item>
|
|
782
|
+
</style>
|
|
783
|
+
` : ` <style name="Theme.CraftApp.Launch" parent="Theme.CraftApp" />
|
|
784
|
+
`;
|
|
785
|
+
const v31 = `<?xml version="1.0" encoding="utf-8"?>
|
|
786
|
+
<resources>
|
|
787
|
+
<style name="Theme.CraftApp.Launch" parent="Theme.CraftApp">
|
|
788
|
+
<item name="android:windowSplashScreenBackground">@color/background</item>${icon ? `
|
|
789
|
+
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>` : ""}
|
|
790
|
+
</style>
|
|
791
|
+
</resources>
|
|
792
|
+
`;
|
|
793
|
+
const background = logo ? `<?xml version="1.0" encoding="utf-8"?>
|
|
794
|
+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
|
|
795
|
+
<item android:drawable="@color/background" />
|
|
796
|
+
<item>
|
|
797
|
+
<bitmap android:gravity="center" android:src="@drawable/splash_logo" />
|
|
798
|
+
</item>
|
|
799
|
+
</layer-list>
|
|
800
|
+
` : undefined;
|
|
801
|
+
return { base, v31, background };
|
|
802
|
+
}
|
|
756
803
|
function containsPath(parent, candidate) {
|
|
757
804
|
const relativePath = relative(parent, candidate);
|
|
758
805
|
return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute2(relativePath);
|
|
@@ -960,6 +1007,7 @@ function writeAndroidConfig(output, config) {
|
|
|
960
1007
|
writeFileSync(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
961
1008
|
const runtimeConfig = { ...config };
|
|
962
1009
|
delete runtimeConfig.appIconPath;
|
|
1010
|
+
delete runtimeConfig.splashResources;
|
|
963
1011
|
delete runtimeConfig.notificationIconPath;
|
|
964
1012
|
delete runtimeConfig.googleServicesFile;
|
|
965
1013
|
writeFileSync(join3(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
|
|
@@ -1013,10 +1061,14 @@ function renderAndroidPermissions(config) {
|
|
|
1013
1061
|
}
|
|
1014
1062
|
if (config.enablePushNotifications)
|
|
1015
1063
|
permissions.add("android.permission.POST_NOTIFICATIONS");
|
|
1016
|
-
if (config.enableKeepAwake)
|
|
1064
|
+
if (config.enableKeepAwake || config.enableBarometer && config.enableBackgroundLocation)
|
|
1017
1065
|
permissions.add("android.permission.WAKE_LOCK");
|
|
1018
1066
|
if (config.enableHealthConnect)
|
|
1019
1067
|
healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
|
|
1068
|
+
if (config.enablePedometer)
|
|
1069
|
+
permissions.add("android.permission.ACTIVITY_RECOGNITION");
|
|
1070
|
+
if (config.enablePedometer && config.enableHealthServices)
|
|
1071
|
+
permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
|
|
1020
1072
|
if (config.renderer === "native" && config.backgroundRefresh?.enabled)
|
|
1021
1073
|
permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
|
|
1022
1074
|
if (config.enableHeartRate) {
|
|
@@ -1056,13 +1108,16 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
|
1056
1108
|
write("CraftSnapshotFile.kt.template", "CraftSnapshotFile.kt");
|
|
1057
1109
|
write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
|
|
1058
1110
|
write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
|
|
1111
|
+
write("CraftNativeMotion.kt.template", "CraftNativeMotion.kt");
|
|
1059
1112
|
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
1060
1113
|
write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
|
|
1061
1114
|
write(config.enableHealthServices ? "CraftHealthServices.kt.template" : "CraftHealthServicesStub.kt.template", "CraftHealthServices.kt");
|
|
1062
1115
|
if (config.enableHealthServices) {
|
|
1063
1116
|
write("CraftHealthServicesService.kt.template", "CraftHealthServicesService.kt");
|
|
1117
|
+
write("CraftPassiveSteps.kt.template", "CraftPassiveSteps.kt");
|
|
1064
1118
|
} else {
|
|
1065
1119
|
rmSync2(join3(javaDir, "CraftHealthServicesService.kt"), { force: true });
|
|
1120
|
+
rmSync2(join3(javaDir, "CraftPassiveSteps.kt"), { force: true });
|
|
1066
1121
|
}
|
|
1067
1122
|
write("CraftNativeServices.kt.template", "CraftNativeServices.kt", (source) => source.replace("{{NATIVE_PUSH_IMPORTS}}", config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace("{{NATIVE_WALLET_IMPORTS}}", config.enableWallet ? `import com.google.android.gms.pay.Pay
|
|
1068
1123
|
import com.google.android.gms.pay.PayApiAvailabilityStatus
|
|
@@ -1255,6 +1310,7 @@ async function init(options) {
|
|
|
1255
1310
|
if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
|
|
1256
1311
|
throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
|
|
1257
1312
|
}
|
|
1313
|
+
const splash = config.splashResources ? androidSplashFiles(config.splashResources) : [];
|
|
1258
1314
|
if (config.notificationIconPath)
|
|
1259
1315
|
requireRegularFile(config.notificationIconPath, "Notification icon");
|
|
1260
1316
|
const notificationIconExtension = config.notificationIconPath ? extname(config.notificationIconPath).toLowerCase() : undefined;
|
|
@@ -1348,6 +1404,7 @@ async function init(options) {
|
|
|
1348
1404
|
const nativeDir = join3(output, "app/src/main/java/com/craft/runtime");
|
|
1349
1405
|
mkdirSync2(nativeDir, { recursive: true });
|
|
1350
1406
|
writeFileSync(join3(nativeDir, "CraftNative.kt"), craftNative);
|
|
1407
|
+
writeFileSync(join3(nativeDir, "CraftRecordingPressure.kt"), readFileSync2(join3(TEMPLATES_DIR, "CraftRecordingPressure.kt.template"), "utf-8"));
|
|
1351
1408
|
const serviceTemplate = readFileSync2(join3(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
|
|
1352
1409
|
writeFileSync(join3(nativeDir, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
1353
1410
|
const healthTemplate = readFileSync2(join3(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
|
|
@@ -1356,7 +1413,9 @@ async function init(options) {
|
|
|
1356
1413
|
writeFileSync(join3(output, "app/src/main/java", packagePath, file), readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
1357
1414
|
}
|
|
1358
1415
|
const manifestTemplate = readFileSync2(join3(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
|
|
1359
|
-
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(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />' : "").replace(/\{\{HEALTH_SERVICES_SERVICE\}\}/g, config.enableHealthServices ? ' <service android:name=".CraftHealthServicesService" android:exported="false" android:foregroundServiceType="health" android:stopWithTask="false" />'
|
|
1416
|
+
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(/\{\{HEART_RATE_SERVICE\}\}/g, config.enableHeartRate ? ' <service android:name=".CraftHeartRateService" android:exported="false" android:foregroundServiceType="connectedDevice" />' : "").replace(/\{\{HEALTH_SERVICES_SERVICE\}\}/g, config.enableHealthServices ? ' <service android:name=".CraftHealthServicesService" android:exported="false" android:foregroundServiceType="health" android:stopWithTask="false" />' + (config.enablePedometer ? `
|
|
1417
|
+
<service android:name=".CraftPassiveStepsService" android:exported="true" android:permission="com.google.android.wearable.healthservices.permission.PASSIVE_DATA_BINDING" />
|
|
1418
|
+
<receiver android:name=".CraftPassiveStepsBootReceiver" android:exported="true"><intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter></receiver>` : "") : "").replace(/\{\{NATIVE_NOTIFICATION_RECEIVER\}\}/g, config.renderer === "native" ? renderNativeShellManifest(config) : "").replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`).replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== "maplibre" && config.mapsApiKey ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
|
|
1360
1419
|
<package android:name="com.google.android.apps.healthdata" />
|
|
1361
1420
|
</queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
|
|
1362
1421
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
|
@@ -1461,6 +1520,17 @@ zipStorePath=wrapper/dists
|
|
|
1461
1520
|
</vector>
|
|
1462
1521
|
`);
|
|
1463
1522
|
}
|
|
1523
|
+
const resDir = join3(output, "app/src/main/res");
|
|
1524
|
+
for (const folder of existsSync(resDir) ? readdirSync(resDir) : []) {
|
|
1525
|
+
if (folder.startsWith("drawable"))
|
|
1526
|
+
for (const name of ["splash_icon.png", "splash_logo.png"])
|
|
1527
|
+
rmSync2(join3(resDir, folder, name), { force: true });
|
|
1528
|
+
}
|
|
1529
|
+
for (const file of splash) {
|
|
1530
|
+
mkdirSync2(join3(resDir, file.folder), { recursive: true });
|
|
1531
|
+
cpSync(file.path, join3(resDir, file.folder, file.name));
|
|
1532
|
+
}
|
|
1533
|
+
const launch = androidLaunchThemes(splash);
|
|
1464
1534
|
const themesXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
1465
1535
|
<resources>
|
|
1466
1536
|
<style name="Theme.CraftApp" parent="Theme.Material3.DayNight.NoActionBar">
|
|
@@ -1468,9 +1538,15 @@ zipStorePath=wrapper/dists
|
|
|
1468
1538
|
<item name="android:navigationBarColor">@color/primary</item>
|
|
1469
1539
|
<item name="android:windowBackground">@color/background</item>
|
|
1470
1540
|
</style>
|
|
1471
|
-
</resources>
|
|
1541
|
+
${launch.base}</resources>
|
|
1472
1542
|
`;
|
|
1473
1543
|
writeFileSync(join3(output, "app/src/main/res/values/themes.xml"), themesXml);
|
|
1544
|
+
mkdirSync2(join3(output, "app/src/main/res/values-v31"), { recursive: true });
|
|
1545
|
+
writeFileSync(join3(output, "app/src/main/res/values-v31/themes.xml"), launch.v31);
|
|
1546
|
+
if (launch.background)
|
|
1547
|
+
writeFileSync(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), launch.background);
|
|
1548
|
+
else
|
|
1549
|
+
rmSync2(join3(output, "app/src/main/res/drawable/craft_launch_background.xml"), { force: true });
|
|
1474
1550
|
const activityMainXml = config.renderer === "native" ? `<?xml version="1.0" encoding="utf-8"?>
|
|
1475
1551
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
1476
1552
|
android:id="@+id/native_root"
|
|
@@ -1694,6 +1770,8 @@ async function run(options) {
|
|
|
1694
1770
|
export {
|
|
1695
1771
|
ANDROID_MIN_SDK,
|
|
1696
1772
|
HEALTH_CONNECT_PERMISSIONS,
|
|
1773
|
+
androidLaunchThemes,
|
|
1774
|
+
androidSplashFiles,
|
|
1697
1775
|
build,
|
|
1698
1776
|
compressNativeSources,
|
|
1699
1777
|
createAndroidEmulator,
|
|
@@ -1704,6 +1782,7 @@ export {
|
|
|
1704
1782
|
nativeMessages,
|
|
1705
1783
|
nativeRouteBundles,
|
|
1706
1784
|
nativeRouteRuntime,
|
|
1785
|
+
nativeSensorCapabilities,
|
|
1707
1786
|
open,
|
|
1708
1787
|
renderAndroidDeepLinks,
|
|
1709
1788
|
renderAndroidPermissions,
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
android:windowSoftInputMode="adjustResize"
|
|
46
46
|
android:supportsPictureInPicture="true"
|
|
47
47
|
android:resizeableActivity="true"
|
|
48
|
-
android:launchMode="singleTask"
|
|
48
|
+
android:launchMode="singleTask"
|
|
49
|
+
android:theme="@style/Theme.CraftApp.Launch">
|
|
49
50
|
<intent-filter>
|
|
50
51
|
<action android:name="android.intent.action.MAIN" />
|
|
51
52
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
@@ -20,9 +20,11 @@ internal class CraftHealthServices(
|
|
|
20
20
|
private val activityProvider: () -> Activity?,
|
|
21
21
|
private val fallback: ((String, JSONArray, (CraftNativeCapabilityResult) -> Unit) -> Unit)? = null,
|
|
22
22
|
) {
|
|
23
|
+
private val passiveSteps by lazy { CraftPassiveSteps(context) }
|
|
23
24
|
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
24
25
|
|
|
25
26
|
fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
27
|
+
if (method.startsWith("passive:")) { passiveSteps.perform(method.removePrefix("passive:"), done); return }
|
|
26
28
|
when (method) {
|
|
27
29
|
"startWorkout" -> start(args.optJSONObject(0) ?: JSONObject(), done)
|
|
28
30
|
"pauseWorkout" -> command(CraftHealthServicesService.ACTION_PAUSE, done)
|
|
@@ -22,6 +22,7 @@ import android.net.NetworkCapabilities
|
|
|
22
22
|
import android.provider.MediaStore
|
|
23
23
|
import android.util.Base64
|
|
24
24
|
import androidx.core.content.ContextCompat
|
|
25
|
+
import androidx.core.app.ActivityCompat
|
|
25
26
|
import androidx.biometric.BiometricManager
|
|
26
27
|
import androidx.biometric.BiometricPrompt
|
|
27
28
|
import androidx.fragment.app.FragmentActivity
|
|
@@ -50,6 +51,7 @@ private const val CRAFT_NATIVE_FILE_PICK_FIRST = 0x4000
|
|
|
50
51
|
private const val CRAFT_NATIVE_FILE_PICK_LAST = 0xffff
|
|
51
52
|
private const val CRAFT_NATIVE_FILE_PICK_STATE = "craft.filePicker.nextCode"
|
|
52
53
|
private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
|
|
54
|
+
private const val CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST = 4358
|
|
53
55
|
private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
|
|
54
56
|
private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
|
|
55
57
|
private const val CRAFT_NATIVE_FILE_DEFAULT_QUALITY = 0.85
|
|
@@ -88,6 +90,8 @@ internal class CraftNativeCapabilities(
|
|
|
88
90
|
override fun dismiss(requestCode: Int) { host.finishActivity(requestCode) }
|
|
89
91
|
}
|
|
90
92
|
},
|
|
93
|
+
private val requestPedometerPermission: ((Int) -> Unit)? = null,
|
|
94
|
+
private val checkPedometerPermission: (() -> Boolean)? = null,
|
|
91
95
|
) {
|
|
92
96
|
companion object {
|
|
93
97
|
private val pickerCodes = CraftNativePickerCodes()
|
|
@@ -127,6 +131,8 @@ internal class CraftNativeCapabilities(
|
|
|
127
131
|
private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
|
|
128
132
|
private var pendingFilePick: PendingFilePick? = null
|
|
129
133
|
private var pendingNotificationPermission: ((Boolean) -> Unit)? = null
|
|
134
|
+
private var pendingPedometerPermission: Pair<String, (CraftNativeCapabilityResult) -> Unit>? = null
|
|
135
|
+
private var pedometerPermissionInFlight = false
|
|
130
136
|
private var database: SQLiteDatabase? = null
|
|
131
137
|
private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
|
|
132
138
|
|
|
@@ -214,6 +220,29 @@ internal class CraftNativeCapabilities(
|
|
|
214
220
|
if (method == "randomUUID") complete(requestToken, success(UUID.randomUUID().toString()), completion)
|
|
215
221
|
else complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Crypto method"), completion)
|
|
216
222
|
}
|
|
223
|
+
"Pedometer" -> {
|
|
224
|
+
if (!config.optBoolean("enablePedometer")) complete(requestToken, failure("CAPABILITY_DISABLED", "Pedometer is disabled"), completion)
|
|
225
|
+
else if (method != "requestPermission") complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Pedometer permission method"), completion)
|
|
226
|
+
else onMain {
|
|
227
|
+
if (cancelled.remove(requestToken) || closed.get()) return@onMain
|
|
228
|
+
val host = activity
|
|
229
|
+
if (host == null && requestPedometerPermission == null) complete(requestToken, failure("NEEDS_ACTIVITY", "Pedometer permission needs a screen"), completion)
|
|
230
|
+
else if (pedometerPermissionGranted()) complete(requestToken, success("granted"), completion)
|
|
231
|
+
else if (pedometerPermissionInFlight) complete(requestToken, failure("PERMISSION_BUSY", "An activity recognition request is already open"), completion)
|
|
232
|
+
else {
|
|
233
|
+
pendingPedometerPermission = requestToken to completion
|
|
234
|
+
pedometerPermissionInFlight = true
|
|
235
|
+
try {
|
|
236
|
+
if (requestPedometerPermission != null) requestPedometerPermission.invoke(CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST)
|
|
237
|
+
else ActivityCompat.requestPermissions(requireNotNull(host), arrayOf(Manifest.permission.ACTIVITY_RECOGNITION), CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST)
|
|
238
|
+
} catch (error: Exception) {
|
|
239
|
+
pendingPedometerPermission = null
|
|
240
|
+
pedometerPermissionInFlight = false
|
|
241
|
+
complete(requestToken, failure("PERMISSION_ERROR", error.message ?: "Permission could not be requested"), completion)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
217
246
|
"Bluetooth" -> {
|
|
218
247
|
if (!config.optBoolean("enableBluetooth")) complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
|
|
219
248
|
else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
|
|
@@ -268,6 +297,7 @@ internal class CraftNativeCapabilities(
|
|
|
268
297
|
cancelled.add(requestToken)
|
|
269
298
|
fetchConnections.remove(requestToken)?.disconnect()
|
|
270
299
|
onMain {
|
|
300
|
+
if (pendingPedometerPermission?.first == requestToken) pendingPedometerPermission = null
|
|
271
301
|
if (pendingFilePick?.requestToken == requestToken) {
|
|
272
302
|
dismissFilePick()
|
|
273
303
|
cancelled.remove(requestToken)
|
|
@@ -285,13 +315,24 @@ internal class CraftNativeCapabilities(
|
|
|
285
315
|
networkExecutor.shutdownNow()
|
|
286
316
|
fetchConnections.values.forEach(HttpURLConnection::disconnect)
|
|
287
317
|
fetchConnections.clear()
|
|
288
|
-
onMain { dismissFilePick() }
|
|
318
|
+
onMain { dismissFilePick(); pendingPedometerPermission = null; pedometerPermissionInFlight = false }
|
|
289
319
|
database?.close()
|
|
290
320
|
database = null
|
|
291
321
|
biometricPrompts.values.forEach { it.cancelAuthentication() }
|
|
292
322
|
biometricPrompts.clear()
|
|
293
323
|
}
|
|
294
324
|
|
|
325
|
+
fun pedometerPermissionGranted(): Boolean = checkPedometerPermission?.invoke() ?: (Build.VERSION.SDK_INT < 29 ||
|
|
326
|
+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED)
|
|
327
|
+
|
|
328
|
+
fun onPedometerPermissions(requestCode: Int) {
|
|
329
|
+
if (requestCode != CRAFT_NATIVE_PEDOMETER_PERMISSION_REQUEST) return
|
|
330
|
+
pedometerPermissionInFlight = false
|
|
331
|
+
val pending = pendingPedometerPermission
|
|
332
|
+
pendingPedometerPermission = null
|
|
333
|
+
pending?.let { (token, completion) -> complete(token, success(if (pedometerPermissionGranted()) "granted" else "denied"), completion) }
|
|
334
|
+
}
|
|
335
|
+
|
|
295
336
|
private fun onMain(work: () -> Unit) {
|
|
296
337
|
if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) work()
|
|
297
338
|
else mainHandler.post { work() }
|