@craft-native/android 0.0.451 → 0.0.464
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 +19 -1
- package/dist/index.d.ts +1 -350
- package/dist/index.js +228 -31
- package/dist/index.js.map +7 -5
- package/dist/internal/android/src/index.d.ts +358 -0
- package/dist/internal/android/src/testing.d.ts +2 -0
- package/dist/internal/typescript/src/native-notifications.d.ts +17 -0
- package/dist/internal/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
- package/templates/CraftNativeBackground.kt.template +65 -25
- package/templates/CraftNativeCapabilities.kt.template +3 -1
- package/templates/CraftNativeMessagingService.kt.template +8 -28
- package/templates/CraftNativeNotificationReceiver.kt.template +213 -15
- package/templates/CraftNativeServices.kt.template +6 -4
- package/templates/CraftNativeShell.kt.template +72 -21
- package/templates/MainActivityNative.kt.template +227 -12
- package/dist/testing.d.ts +0 -2
- /package/dist/{emulator.d.ts → internal/android/src/emulator.d.ts} +0 -0
- /package/dist/{native-map.d.ts → internal/android/src/native-map.d.ts} +0 -0
- /package/dist/{promise-runtime.d.ts → internal/android/src/promise-runtime.d.ts} +0 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { type NativeNotificationCategory } from '../../typescript/src/native-notifications.js';
|
|
2
|
+
export type { NativeNotificationCategory } from '../../typescript/src/native-notifications.js';
|
|
3
|
+
import { type NativeRouteConstraints } from '../../typescript/src/native-routing.js';
|
|
4
|
+
/** Store large native source assets compressed; the host decodes each once on demand. */
|
|
5
|
+
export declare function compressNativeSources(target: string): void;
|
|
6
|
+
import { type AndroidMapProvider } from './native-map.js';
|
|
7
|
+
export type { AndroidMapProvider } from './native-map.js';
|
|
8
|
+
/**
|
|
9
|
+
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
10
|
+
* well as the one-file bundle, and the app runs each screen from its own
|
|
11
|
+
* route's file. Stacks asks for route bundles only when this is true.
|
|
12
|
+
*/
|
|
13
|
+
/** Build-time proof for Stacks capability mappings, including unreleased source builds. */
|
|
14
|
+
export declare const nativeSensorCapabilities: readonly ['motionSensors', 'barometer', 'pedometer'];
|
|
15
|
+
export declare const nativeRouteBundles = true;
|
|
16
|
+
/**
|
|
17
|
+
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
18
|
+
* `sharedRuntime`) in each screen's context before the route's own file.
|
|
19
|
+
* Stacks asks stx for it only when this is true: a route file built so
|
|
20
|
+
* refuses to run without it.
|
|
21
|
+
*/
|
|
22
|
+
export declare const nativeRouteRuntime = true;
|
|
23
|
+
/**
|
|
24
|
+
* The app gives a screen its messages a language at a time (stx's
|
|
25
|
+
* `messages`, `native-screens/__messages/`): the areas its route uses,
|
|
26
|
+
* evaluated ahead of it (an isolate cannot read a file when asked). Stacks leaves the languages
|
|
27
|
+
* out of the screens' code only when this is true.
|
|
28
|
+
*/
|
|
29
|
+
export declare const nativeMessages = true;
|
|
30
|
+
/** Providers this published builder can generate as real Android map views. */
|
|
31
|
+
export declare const nativeMapProviders: readonly ['google', 'maplibre'];
|
|
32
|
+
export { runKotlinFixture } from './testing.js';
|
|
33
|
+
export { createAndroidEmulator } from './emulator.js';
|
|
34
|
+
export type { AndroidEmulatorOptions, AndroidEmulatorSession } from './emulator.js';
|
|
35
|
+
/** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
|
|
36
|
+
export declare function syncNativeFonts(fonts: CraftAndroidConfig['nativeFonts'], output: string): string[];
|
|
37
|
+
/** Oldest Android API level supported by the generated Kotlin runtime. */
|
|
38
|
+
export declare const ANDROID_MIN_SDK = 26;
|
|
39
|
+
export interface CraftAndroidConfig {
|
|
40
|
+
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
41
|
+
nativeFonts?: Record<string, {
|
|
42
|
+
src: string;
|
|
43
|
+
license?: string;
|
|
44
|
+
}>;
|
|
45
|
+
appName: string;
|
|
46
|
+
packageName: string;
|
|
47
|
+
renderer?: 'web' | 'native';
|
|
48
|
+
/** "system" follows the device; light/dark pin the generated host. */
|
|
49
|
+
appearance?: 'light' | 'dark' | 'system';
|
|
50
|
+
/** Static native tab-bar styling applied before the page's own tabBar.set call. */
|
|
51
|
+
tabBar?: CraftTabBarConfig;
|
|
52
|
+
/** Runs from the generated project root before Gradle compiles the app. */
|
|
53
|
+
postGenerate?: CraftPostGenerateHook;
|
|
54
|
+
version?: string;
|
|
55
|
+
versionCode?: number;
|
|
56
|
+
darkMode?: boolean;
|
|
57
|
+
backgroundColor?: string;
|
|
58
|
+
backgroundColorDark?: string;
|
|
59
|
+
enableSpeechRecognition?: boolean;
|
|
60
|
+
enableAudioRecording?: boolean;
|
|
61
|
+
enableHaptics?: boolean;
|
|
62
|
+
enableShare?: boolean;
|
|
63
|
+
enableCamera?: boolean;
|
|
64
|
+
enableBiometric?: boolean;
|
|
65
|
+
enablePushNotifications?: boolean;
|
|
66
|
+
/** Enable native SQLite queries and transactions. Disabled unless explicitly true. */
|
|
67
|
+
enableLocalDatabase?: boolean;
|
|
68
|
+
/** Enable native AlarmManager local notification scheduling. Disabled unless explicitly true. */
|
|
69
|
+
enableLocalNotifications?: boolean;
|
|
70
|
+
/** Notification buttons and inline replies registered at launch. */
|
|
71
|
+
notificationCategories?: NativeNotificationCategory[];
|
|
72
|
+
enableSecureStorage?: boolean;
|
|
73
|
+
enableGeolocation?: boolean;
|
|
74
|
+
enableBackgroundLocation?: boolean;
|
|
75
|
+
/** Foreground raw Android sensors on native screens; separate from iOS fused motion. */
|
|
76
|
+
enableMotionSensors?: boolean;
|
|
77
|
+
/** Foreground pressure and session-relative altitude; does not enable background recording. */
|
|
78
|
+
enableBarometer?: boolean;
|
|
79
|
+
/** Foreground hardware step counters/detectors; requires activity recognition on Android 10+. */
|
|
80
|
+
enablePedometer?: boolean;
|
|
81
|
+
enableBluetooth?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A Bluetooth heart-rate strap, or a watch broadcasting its heart rate (the
|
|
84
|
+
* standard Heart Rate Service), through `craft.heartRate` on a page and the
|
|
85
|
+
* `HeartRate` module on a native screen (CraftHeartRate.kt): scan, connect,
|
|
86
|
+
* live readings as `craftHeartRate` events, and a recording kept natively,
|
|
87
|
+
* in the foreground (a connectedDevice service) while it runs. The iOS
|
|
88
|
+
* builder's flag of the same name.
|
|
89
|
+
*/
|
|
90
|
+
enableHeartRate?: boolean;
|
|
91
|
+
enableHealthConnect?: boolean;
|
|
92
|
+
/** Android Health Services live exercise tracking for native screens. Requires minSdk 30. */
|
|
93
|
+
enableHealthServices?: boolean;
|
|
94
|
+
/** Google Wallet save-link or native SDK handoff for native screens. */
|
|
95
|
+
enableWallet?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The Health Connect data the app reads and writes, by the shared type
|
|
98
|
+
* names (`steps`, `sleep`, `workouts`...). The manifest declares only
|
|
99
|
+
* these, as Play's health permissions review requires; without it, the
|
|
100
|
+
* original set (steps, heart rate, energy, distance, workouts).
|
|
101
|
+
*/
|
|
102
|
+
healthConnect?: {
|
|
103
|
+
read?: HealthConnectType[];
|
|
104
|
+
write?: HealthConnectType[];
|
|
105
|
+
/** Read while the app is in the background (a WorkManager refresh): Android 15's READ_HEALTH_DATA_IN_BACKGROUND. */
|
|
106
|
+
background?: boolean;
|
|
107
|
+
/** Read further back than the 30 days before the first grant: READ_HEALTH_DATA_HISTORY. */
|
|
108
|
+
history?: boolean;
|
|
109
|
+
};
|
|
110
|
+
enableHealthConnectBackground?: boolean;
|
|
111
|
+
enableHealthConnectHistory?: boolean;
|
|
112
|
+
/** The app's privacy policy, which Health Connect links to from its permission screen. */
|
|
113
|
+
privacyPolicyURL?: string;
|
|
114
|
+
enableKeepAwake?: boolean;
|
|
115
|
+
enableDeepLinks?: boolean;
|
|
116
|
+
/** Reach the person's own devices on the local network over plain HTTP (a Mac companion). */
|
|
117
|
+
enableLocalNetwork?: boolean;
|
|
118
|
+
urlSchemes?: string[];
|
|
119
|
+
/** HTTPS hosts owned by the app; path rules are ordered, with `*` matching any path text. */
|
|
120
|
+
/** Ordered path rules: * matches zero or more characters, ? one, and ?* one or more. */
|
|
121
|
+
appLinks?: Array<{
|
|
122
|
+
host: string;
|
|
123
|
+
paths?: Array<{
|
|
124
|
+
pattern: string;
|
|
125
|
+
exclude?: boolean;
|
|
126
|
+
}>;
|
|
127
|
+
}>;
|
|
128
|
+
trustedOrigins?: string[];
|
|
129
|
+
appIconPath?: string;
|
|
130
|
+
/**
|
|
131
|
+
* The launch splash: a directory laid out as Android resources, holding
|
|
132
|
+
* `drawable-<density>/splash_icon.png` (Android 12's splash icon, 288dp
|
|
133
|
+
* with its content inside the 192dp circle) and `splash_logo.png` (the
|
|
134
|
+
* logo the window background centres before Android 12), with Dark Mode
|
|
135
|
+
* ones in `drawable-night-<density>`. ts-images' `generateSplashScreens`
|
|
136
|
+
* writes exactly this (`android.resources`). Both sit on `backgroundColor`
|
|
137
|
+
* (`backgroundColorDark` at night), the colour the first frame continues.
|
|
138
|
+
* Without it, Android 12 shows the launcher icon and older versions the
|
|
139
|
+
* background colour.
|
|
140
|
+
*/
|
|
141
|
+
splashResources?: string;
|
|
142
|
+
/**
|
|
143
|
+
* The small icon in the status bar and on every notification the app
|
|
144
|
+
* shows, push or local: an Android vector drawable (`.xml`) or a `.png`,
|
|
145
|
+
* white on transparent, since Android draws only its shape. Without one,
|
|
146
|
+
* a white Craft mark. Never the launcher icon, which shows as a white
|
|
147
|
+
* square.
|
|
148
|
+
*/
|
|
149
|
+
notificationIconPath?: string;
|
|
150
|
+
/** The `#RRGGBB` Android tints the notification icon and app name with. The system's own colour without it. */
|
|
151
|
+
notificationColor?: string;
|
|
152
|
+
googleServicesFile?: string;
|
|
153
|
+
/** Native map renderer. MapLibre needs no SDK key; Google remains the default for compatibility. */
|
|
154
|
+
mapProvider?: AndroidMapProvider;
|
|
155
|
+
/** Google Maps Android SDK key, used only by the Google native map host. */
|
|
156
|
+
mapsApiKey?: string;
|
|
157
|
+
devServerURL?: string;
|
|
158
|
+
hasBundledFallback?: boolean;
|
|
159
|
+
/** Android API level, at least ANDROID_MIN_SDK (26), or 30 with Health Services. */
|
|
160
|
+
minSdk?: number;
|
|
161
|
+
compileSdk?: number;
|
|
162
|
+
targetSdk?: number;
|
|
163
|
+
/**
|
|
164
|
+
* Native renderer: the app's paths and the screen drawing each
|
|
165
|
+
* (`/m/workout/:id` → `Workout`), its tabs, and the bundle route of the
|
|
166
|
+
* script that runs beside the screens. The same keys as the iOS builder.
|
|
167
|
+
*/
|
|
168
|
+
nativeScreens?: Record<string, string>;
|
|
169
|
+
nativeScreenConstraints?: NativeRouteConstraints;
|
|
170
|
+
tabs?: Array<{
|
|
171
|
+
id: string;
|
|
172
|
+
title: string;
|
|
173
|
+
symbol?: string;
|
|
174
|
+
/** Native selected-tab icon overrides, also supported by navigation.setOptions({ tabItem }). */
|
|
175
|
+
selectedSymbol?: string;
|
|
176
|
+
selectedIconTint?: string;
|
|
177
|
+
selectedIconPulse?: boolean;
|
|
178
|
+
}>;
|
|
179
|
+
nativeApp?: string;
|
|
180
|
+
/**
|
|
181
|
+
* The secure-storage key the signed-in account's token lives under
|
|
182
|
+
* (default `auth.token`). `craft.session` follows it on every native
|
|
183
|
+
* screen, and a change of it is `SESSION_CHANGED { key, token, previous }`.
|
|
184
|
+
*/
|
|
185
|
+
sessionTokenKey?: string;
|
|
186
|
+
/** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
|
|
187
|
+
backgroundRefresh?: {
|
|
188
|
+
enabled?: boolean;
|
|
189
|
+
minimumIntervalMinutes?: number;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Crash and error reporting to bughq (CraftCrashReporter.kt): uncaught JVM
|
|
193
|
+
* exceptions, ANRs and native crashes (ApplicationExitInfo), uncaught
|
|
194
|
+
* JavaScript errors, and what the page sends through `craft.crashReporter`.
|
|
195
|
+
* Queued on disk and sent when online. Release builds are minified: upload
|
|
196
|
+
* `app/build/outputs/mapping/release/mapping.txt` so bughq can retrace.
|
|
197
|
+
*/
|
|
198
|
+
crashReporting?: {
|
|
199
|
+
/** The bughq project's public ingest key. */
|
|
200
|
+
key: string;
|
|
201
|
+
/** Default https://bughq.org. */
|
|
202
|
+
host?: string;
|
|
203
|
+
/** Default `production`. */
|
|
204
|
+
environment?: string;
|
|
205
|
+
/** Default `<versionName>+<versionCode>`, e.g. `1.0.0+41`. */
|
|
206
|
+
release?: string;
|
|
207
|
+
/** One session per launch, for crash-free sessions. Default on. */
|
|
208
|
+
sessions?: boolean;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export interface CraftTabBarConfig {
|
|
212
|
+
selection?: {
|
|
213
|
+
/** The selected tab indicator's corner radius in dp; omitted keeps the default. */
|
|
214
|
+
cornerRadius?: number;
|
|
215
|
+
/** The selected tab indicator fill as #RRGGBB. */
|
|
216
|
+
color?: string;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** A declared command Craft runs after the native project has been generated. */
|
|
220
|
+
export interface CraftPostGenerateHook {
|
|
221
|
+
command: string;
|
|
222
|
+
args?: string[];
|
|
223
|
+
}
|
|
224
|
+
/** The shared health data types (the iOS builder's HealthKit names). */
|
|
225
|
+
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
|
|
226
|
+
/**
|
|
227
|
+
* The Health Connect permissions behind each type, for reading and for
|
|
228
|
+
* writing. CraftNativeHealth.kt asks for the same ones; a permission the
|
|
229
|
+
* manifest does not declare is refused without a prompt.
|
|
230
|
+
*/
|
|
231
|
+
export declare const HEALTH_CONNECT_PERMISSIONS: Record<HealthConnectType, {
|
|
232
|
+
read: string[];
|
|
233
|
+
write: string[];
|
|
234
|
+
}>;
|
|
235
|
+
/** Every `android.permission.health.*` the app's Health Connect types need. */
|
|
236
|
+
export declare function healthConnectPermissions(config: Pick<CraftAndroidConfig, 'healthConnect'>): string[];
|
|
237
|
+
export interface InitOptions {
|
|
238
|
+
name: string;
|
|
239
|
+
packageName?: string;
|
|
240
|
+
output: string;
|
|
241
|
+
config?: Partial<CraftAndroidConfig>;
|
|
242
|
+
/**
|
|
243
|
+
* Where to find the Zig runtime's `<abi>/libcraft.so`.
|
|
244
|
+
*
|
|
245
|
+
* `null` means no runtime regardless of the environment; `undefined` falls
|
|
246
|
+
* through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's
|
|
247
|
+
* `runtimeDir`.
|
|
248
|
+
*/
|
|
249
|
+
runtimeDir?: string | null;
|
|
250
|
+
}
|
|
251
|
+
export interface BuildOptions {
|
|
252
|
+
htmlPath?: string;
|
|
253
|
+
nativeBundlePath?: string;
|
|
254
|
+
/**
|
|
255
|
+
* Native renderer: the app's icons as vectors (`{ viewBox, strokeWidth,
|
|
256
|
+
* icons: { name: svgBody } }`, keyed by Iconify name and SF Symbol), which
|
|
257
|
+
* `<Icon>`, the tab bar and the bar buttons draw. Without it they fall
|
|
258
|
+
* back to the glyphs Android's fonts have.
|
|
259
|
+
*/
|
|
260
|
+
iconsPath?: string;
|
|
261
|
+
devServer?: string;
|
|
262
|
+
output: string;
|
|
263
|
+
release?: boolean;
|
|
264
|
+
/**
|
|
265
|
+
* An Android App Bundle (`bundleRelease`, `.aab`), what Google Play takes,
|
|
266
|
+
* rather than an APK. Signed with CRAFT_ANDROID_KEYSTORE,
|
|
267
|
+
* CRAFT_ANDROID_KEYSTORE_PASSWORD, CRAFT_ANDROID_KEY_ALIAS and
|
|
268
|
+
* CRAFT_ANDROID_KEY_PASSWORD when they are set.
|
|
269
|
+
*/
|
|
270
|
+
bundle?: boolean;
|
|
271
|
+
compile?: boolean;
|
|
272
|
+
/** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
|
|
273
|
+
runtimeDir?: string | null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Run the app's declared generator hook without invoking a shell.
|
|
277
|
+
*
|
|
278
|
+
* A hook gets the generated project as its cwd and explicit metadata so a
|
|
279
|
+
* patch can target the right platform and phase without guessing from paths.
|
|
280
|
+
*/
|
|
281
|
+
export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
|
|
282
|
+
export interface OpenOptions {
|
|
283
|
+
output: string;
|
|
284
|
+
}
|
|
285
|
+
export interface RunOptions {
|
|
286
|
+
device?: string;
|
|
287
|
+
output: string;
|
|
288
|
+
}
|
|
289
|
+
/** A splash file, by its place under `res/` (`drawable-night-xxhdpi/splash_icon.png`). */
|
|
290
|
+
export interface AndroidSplashFile {
|
|
291
|
+
folder: string;
|
|
292
|
+
name: 'splash_icon.png' | 'splash_logo.png';
|
|
293
|
+
path: string;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The splash files in a resources directory: `splash_icon.png` and
|
|
297
|
+
* `splash_logo.png` in each `drawable[-night][-<density>]` folder. A directory
|
|
298
|
+
* with neither is a mistake, not an app without a splash.
|
|
299
|
+
*/
|
|
300
|
+
export declare function androidSplashFiles(resources: string): AndroidSplashFile[];
|
|
301
|
+
/**
|
|
302
|
+
* The launch theme the activity starts in, and the theme it swaps to before
|
|
303
|
+
* its first frame. Android 12 and later draw their splash from
|
|
304
|
+
* `windowSplashScreen*`; earlier versions draw the window background, here
|
|
305
|
+
* the logo centred on the background colour. Either way the colour is the
|
|
306
|
+
* app's own `background`, so nothing flashes when the first frame replaces it.
|
|
307
|
+
*/
|
|
308
|
+
export declare function androidLaunchThemes(splash: Pick<AndroidSplashFile, 'name'>[]): {
|
|
309
|
+
base: string;
|
|
310
|
+
v31: string;
|
|
311
|
+
background?: string;
|
|
312
|
+
};
|
|
313
|
+
export declare function syncAndroidWebAssets(source: string, output: string): void;
|
|
314
|
+
export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
|
|
315
|
+
export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
|
|
316
|
+
/**
|
|
317
|
+
* Where the runtime directory comes from when the caller does not say.
|
|
318
|
+
*
|
|
319
|
+
* The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an
|
|
320
|
+
* explicit override for the monorepo dev loop, not a lookup path. Shipping the
|
|
321
|
+
* runtime to real apps means putting these libraries in the pantry package
|
|
322
|
+
* beside the `craft` binary, which is a distribution decision this function
|
|
323
|
+
* does not make.
|
|
324
|
+
*/
|
|
325
|
+
export declare function resolveRuntimeDir(override?: string | null): string | null;
|
|
326
|
+
/**
|
|
327
|
+
* Copy the Zig runtime into the generated project as
|
|
328
|
+
* `app/src/main/jniLibs/<abi>/libcraft.so`.
|
|
329
|
+
*
|
|
330
|
+
* That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle
|
|
331
|
+
* templates has to know about it — the library is packaged into the APK and
|
|
332
|
+
* `System.loadLibrary("craft")` finds it because the file is named for the
|
|
333
|
+
* `craft` it asks for.
|
|
334
|
+
*
|
|
335
|
+
* Until this existed, nothing put the library anywhere. `CraftNative` caught
|
|
336
|
+
* the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell
|
|
337
|
+
* through to the Kotlin shim — by design, so an app with no runtime still
|
|
338
|
+
* works, which is also why nobody noticed that *every* generated app was in
|
|
339
|
+
* that state and the whole Android half of the Zig bridge had never run.
|
|
340
|
+
*
|
|
341
|
+
* Returns true when a runtime was installed.
|
|
342
|
+
*/
|
|
343
|
+
export declare function installRuntime(output: string, runtimeDir: string): boolean;
|
|
344
|
+
export declare function init(options: InitOptions): Promise<void>;
|
|
345
|
+
/**
|
|
346
|
+
* Build Android project
|
|
347
|
+
*/
|
|
348
|
+
export declare function build(options: BuildOptions): Promise<void>;
|
|
349
|
+
/**
|
|
350
|
+
* Open Android project in Android Studio
|
|
351
|
+
*/
|
|
352
|
+
export declare function open(options: OpenOptions): Promise<void>;
|
|
353
|
+
/**
|
|
354
|
+
* Run on Android device or emulator
|
|
355
|
+
*/
|
|
356
|
+
export declare function run(options: RunOptions): Promise<void>;
|
|
357
|
+
/** The native host enforces declared named-parameter constraints. */
|
|
358
|
+
export declare const nativeRouteConstraints = true;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Registered before notification delivery; payloads reference category ids. */
|
|
2
|
+
export interface NativeNotificationCategory {
|
|
3
|
+
id: string;
|
|
4
|
+
actions: {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
destructive?: boolean;
|
|
8
|
+
/** Open the app. Otherwise nativeApp handles the action in the background. */
|
|
9
|
+
foreground?: boolean;
|
|
10
|
+
authenticationRequired?: boolean;
|
|
11
|
+
textInput?: {
|
|
12
|
+
buttonTitle: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
};
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
export declare function nativeNotificationProblems(categories: unknown, nativeApp?: string, renderer?: string): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Constraints on decoded named path parameters; query items cannot override them. */
|
|
2
|
+
export interface NativeParameterConstraint {
|
|
3
|
+
/** A case-sensitive regular expression for the complete normalized value. */
|
|
4
|
+
pattern?: string;
|
|
5
|
+
exclude?: readonly string[];
|
|
6
|
+
/** Normalize decoded values and exclusions before case-sensitive grammar validation. */
|
|
7
|
+
normalize?: 'lowercase';
|
|
8
|
+
}
|
|
9
|
+
export type NativeRouteConstraints = Record<string, Record<string, NativeParameterConstraint>>;
|
|
10
|
+
/** Validate and normalize the decoded parameter map before query binding. */
|
|
11
|
+
export declare function applyNativeParameterConstraints(params: Record<string, string>, rules?: Record<string, NativeParameterConstraint>): boolean;
|
|
12
|
+
export declare function nativeRouteConstraintProblems(screens?: Record<string, string>, constraints?: NativeRouteConstraints): string[];
|
|
13
|
+
/** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
|
|
14
|
+
export declare function normalizeNativePath(raw: string): string | null;
|
|
15
|
+
export interface NativePathMatch {
|
|
16
|
+
path: string;
|
|
17
|
+
screen: string;
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export declare function routeSegments(path: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
|
|
23
|
+
* and the page script apply: static segments win over `:params`, which win
|
|
24
|
+
* over a trailing `*`; query items become params under the path's own.
|
|
25
|
+
*/
|
|
26
|
+
export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string, constraints?: NativeRouteConstraints): NativePathMatch | null;
|
package/package.json
CHANGED
|
@@ -132,12 +132,23 @@ internal object CraftNativeBundle {
|
|
|
132
132
|
* ([CraftNativeHeadlessApp]) and closed when it answers. A silent push
|
|
133
133
|
* (`craftSilentPush`) runs the same way, once.
|
|
134
134
|
*/
|
|
135
|
+
/** WorkManager Data is capped at 10 KiB; keep replies in app-private storage. */
|
|
136
|
+
internal object CraftNativeBackgroundPayload {
|
|
137
|
+
private fun storage(context: Context) = context.getSharedPreferences("craft-native-background-payloads", Context.MODE_PRIVATE)
|
|
138
|
+
fun write(context: Context, text: String): String? {
|
|
139
|
+
val id = UUID.randomUUID().toString()
|
|
140
|
+
return if (storage(context).edit().putString(id, text).commit()) id else null
|
|
141
|
+
}
|
|
142
|
+
fun read(context: Context, id: String): String? = storage(context).getString(id, null)
|
|
143
|
+
fun remove(context: Context, id: String) { storage(context).edit().remove(id).commit() }
|
|
144
|
+
}
|
|
145
|
+
|
|
135
146
|
internal object CraftNativeBackground {
|
|
136
147
|
private const val PERIODIC = "craft-native-background-refresh"
|
|
137
148
|
private const val ONCE = "craft-native-background-once"
|
|
138
149
|
|
|
139
150
|
/** The running app's script, when the activity has one: delivers an event and hears its answer. */
|
|
140
|
-
@Volatile var foreground: ((event: String, id: String, done: (Boolean) -> Unit) -> Boolean)? = null
|
|
151
|
+
@Volatile var foreground: ((event: String, id: String, detail: JSONObject, done: (Boolean) -> Unit) -> Boolean)? = null
|
|
141
152
|
|
|
142
153
|
fun schedule(context: Context, config: JSONObject) {
|
|
143
154
|
val refresh = config.optJSONObject("backgroundRefresh")
|
|
@@ -154,35 +165,63 @@ internal object CraftNativeBackground {
|
|
|
154
165
|
work.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
/**
|
|
158
|
-
fun runOnce(context: Context, event: String) {
|
|
159
|
-
val
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
/** Persist action payloads before scheduling; enqueue completion releases a receiver's goAsync hold. */
|
|
169
|
+
fun runOnce(context: Context, event: String, detail: JSONObject? = null, queued: (() -> Unit)? = null) {
|
|
170
|
+
val payload = detail?.let { CraftNativeBackgroundPayload.write(context, it.toString()) }
|
|
171
|
+
if (detail != null && payload == null) {
|
|
172
|
+
Log.e("CraftNativeAndroid", "Could not persist $event")
|
|
173
|
+
queued?.invoke()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
val builder = OneTimeWorkRequestBuilder<CraftNativeRefreshWorker>()
|
|
177
|
+
.setInputData(workDataOf("event" to event, "payload" to payload))
|
|
178
|
+
// A local action may work without a network connection. Silent push
|
|
179
|
+
// keeps its existing connected-network constraint.
|
|
180
|
+
if (detail == null) builder.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
|
181
|
+
try {
|
|
182
|
+
val operation = WorkManager.getInstance(context).enqueueUniqueWork(ONCE, ExistingWorkPolicy.APPEND_OR_REPLACE, builder.build())
|
|
183
|
+
operation.result.addListener({
|
|
184
|
+
try { operation.result.get() }
|
|
185
|
+
catch (failure: Exception) { Log.e("CraftNativeAndroid", "Could not enqueue $event", failure) }
|
|
186
|
+
finally { queued?.invoke() }
|
|
187
|
+
}, ContextCompat.getMainExecutor(context))
|
|
188
|
+
} catch (failure: Exception) {
|
|
189
|
+
Log.e("CraftNativeAndroid", "Could not enqueue $event", failure)
|
|
190
|
+
queued?.invoke()
|
|
191
|
+
}
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
internal class CraftNativeRefreshWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
|
168
196
|
override suspend fun doWork(): Result {
|
|
169
197
|
val event = inputData.getString("event") ?: "craftBackgroundRefresh"
|
|
170
|
-
val
|
|
171
|
-
val
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
val payload = inputData.getString("payload")
|
|
199
|
+
val detail = if (payload == null) JSONObject() else {
|
|
200
|
+
val raw = CraftNativeBackgroundPayload.read(applicationContext, payload)
|
|
201
|
+
?: return Result.failure()
|
|
202
|
+
runCatching { JSONObject(raw) }.getOrElse { return Result.failure() }
|
|
174
203
|
}
|
|
175
|
-
|
|
204
|
+
// Stable across retries so an app can deduplicate a side effect.
|
|
205
|
+
val id = payload ?: UUID.randomUUID().toString()
|
|
206
|
+
val answer = CompletableDeferred<Boolean>()
|
|
176
207
|
var headless: CraftNativeHeadlessApp? = null
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
208
|
+
try {
|
|
209
|
+
val running = withContext(Dispatchers.Main) {
|
|
210
|
+
CraftNativeBackground.foreground?.invoke(event, id, detail) { ok -> answer.complete(ok) } == true
|
|
211
|
+
}
|
|
212
|
+
if (!running) {
|
|
213
|
+
headless = withContext(Dispatchers.Main) { CraftNativeHeadlessApp(applicationContext, event, id, detail) { ok -> answer.complete(ok) } }
|
|
214
|
+
withContext(Dispatchers.Main) { headless?.start() }
|
|
215
|
+
}
|
|
216
|
+
val ok = withTimeoutOrNull(9 * 60 * 1000L) { answer.await() }
|
|
217
|
+
// An unacknowledged action keeps its payload for retry; refresh
|
|
218
|
+
// retains its original timeout behavior.
|
|
219
|
+
if (ok == false || (payload != null && ok == null)) return Result.retry()
|
|
220
|
+
if (payload != null) CraftNativeBackgroundPayload.remove(applicationContext, payload)
|
|
221
|
+
return Result.success()
|
|
222
|
+
} finally {
|
|
223
|
+
withContext(kotlinx.coroutines.NonCancellable + Dispatchers.Main) { headless?.close() }
|
|
180
224
|
}
|
|
181
|
-
// WorkManager stops a worker at ten minutes; answer before that.
|
|
182
|
-
val ok = withTimeoutOrNull(9 * 60 * 1000L) { answer.await() }
|
|
183
|
-
Log.i("CraftNativeAndroid", "$event $id answered ${ok ?: "nothing in time"}")
|
|
184
|
-
headless?.let { withContext(Dispatchers.Main) { it.close() } }
|
|
185
|
-
return if (ok == false) Result.retry() else Result.success()
|
|
186
225
|
}
|
|
187
226
|
}
|
|
188
227
|
|
|
@@ -195,6 +234,7 @@ internal class CraftNativeHeadlessApp(
|
|
|
195
234
|
private val context: Context,
|
|
196
235
|
private val event: String,
|
|
197
236
|
private val id: String,
|
|
237
|
+
private val detail: JSONObject,
|
|
198
238
|
private val onDone: (Boolean) -> Unit,
|
|
199
239
|
) {
|
|
200
240
|
private val config: JSONObject = runCatching {
|
|
@@ -218,13 +258,13 @@ internal class CraftNativeHeadlessApp(
|
|
|
218
258
|
|
|
219
259
|
fun start() {
|
|
220
260
|
val route = config.optString("nativeApp")
|
|
221
|
-
if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(
|
|
222
|
-
val bundle = CraftNativeBundle.source(context, route) ?: return finish(
|
|
261
|
+
if (route.isBlank() || !JavaScriptSandbox.isSupported()) return finish(detail.length() == 0)
|
|
262
|
+
val bundle = CraftNativeBundle.source(context, route) ?: return finish(detail.length() == 0)
|
|
223
263
|
CraftNativeSandbox.connect(context) { engine, error ->
|
|
224
264
|
if (closed) return@connect
|
|
225
265
|
if (engine == null || !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
|
|
226
266
|
Log.w(TAG, "Background refresh has no JavaScript engine", error)
|
|
227
|
-
return@connect finish(
|
|
267
|
+
return@connect finish(detail.length() == 0)
|
|
228
268
|
}
|
|
229
269
|
val created = engine.createIsolate()
|
|
230
270
|
isolate = created
|
|
@@ -264,7 +304,7 @@ internal class CraftNativeHeadlessApp(
|
|
|
264
304
|
load.addListener({
|
|
265
305
|
try {
|
|
266
306
|
load.get()
|
|
267
|
-
send("CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject().put("id", id)))
|
|
307
|
+
send("CRAFT_EVENT", JSONObject().put("event", event).put("detail", JSONObject(detail.toString()).put("id", id)))
|
|
268
308
|
} catch (failure: Exception) {
|
|
269
309
|
Log.e(TAG, "Background refresh script failed", failure)
|
|
270
310
|
finish(false)
|
|
@@ -1297,7 +1297,7 @@ internal class CraftNativeCapabilities(
|
|
|
1297
1297
|
}
|
|
1298
1298
|
"cancelAll" -> {
|
|
1299
1299
|
notificationStorage.all.keys.forEach(::cancelNotification)
|
|
1300
|
-
|
|
1300
|
+
CraftNativeNotifications.cancelAll(context)
|
|
1301
1301
|
complete(requestToken, success(true), completion)
|
|
1302
1302
|
}
|
|
1303
1303
|
"pending" -> complete(requestToken, success(JSONArray(notificationStorage.all.values.mapNotNull {
|
|
@@ -1317,6 +1317,7 @@ internal class CraftNativeCapabilities(
|
|
|
1317
1317
|
private fun schedule(value: JSONObject?): CraftNativeCapabilityResult {
|
|
1318
1318
|
val title = value?.optString("title").orEmpty()
|
|
1319
1319
|
if (title.isEmpty()) return failure("INVALID_ARGUMENT", "Notifications.schedule needs a title")
|
|
1320
|
+
CraftNativeNotifications.invalidOptions(value!!)?.let { return failure("INVALID_ARGUMENT", it) }
|
|
1320
1321
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
1321
1322
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
1322
1323
|
return failure("PERMISSION_DENIED", "Notification permission denied")
|
|
@@ -1347,6 +1348,7 @@ internal class CraftNativeCapabilities(
|
|
|
1347
1348
|
}
|
|
1348
1349
|
|
|
1349
1350
|
private fun cancelNotification(id: String) {
|
|
1351
|
+
CraftNativeNotifications.cancel(context, id)
|
|
1350
1352
|
val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
1351
1353
|
val intent = notificationIntent(id)
|
|
1352
1354
|
alarm.cancel(intent)
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
package {{PACKAGE_NAME}}
|
|
2
2
|
|
|
3
|
-
import android.app.NotificationChannel
|
|
4
|
-
import android.app.NotificationManager
|
|
5
|
-
import android.app.PendingIntent
|
|
6
3
|
import android.content.Context
|
|
7
|
-
import android.content.Intent
|
|
8
|
-
import android.os.Build
|
|
9
|
-
import androidx.core.app.NotificationCompat
|
|
10
|
-
import androidx.core.app.NotificationManagerCompat
|
|
11
|
-
import androidx.core.content.ContextCompat
|
|
12
4
|
import com.google.firebase.messaging.FirebaseMessagingService
|
|
13
5
|
import com.google.firebase.messaging.RemoteMessage
|
|
14
6
|
import org.json.JSONObject
|
|
@@ -41,6 +33,10 @@ class CraftNativeMessagingService : FirebaseMessagingService() {
|
|
|
41
33
|
if (notification == null) {
|
|
42
34
|
if (message.data["content-available"] == "1" || message.data["contentAvailable"] == "true") {
|
|
43
35
|
CraftNativeBackground.runOnce(applicationContext, "craftSilentPush")
|
|
36
|
+
} else if (!message.data["categoryId"].isNullOrBlank()) {
|
|
37
|
+
// Use a data-only FCM payload for background action buttons;
|
|
38
|
+
// FCM owns notifications with a notification block itself.
|
|
39
|
+
show(message.data["title"].orEmpty(), message.data["body"].orEmpty(), message.data)
|
|
44
40
|
}
|
|
45
41
|
return
|
|
46
42
|
}
|
|
@@ -49,26 +45,10 @@ class CraftNativeMessagingService : FirebaseMessagingService() {
|
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
private fun show(title: String, body: String, data: Map<String, String>) {
|
|
52
|
-
val
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
val open = Intent(this, MainActivity::class.java)
|
|
57
|
-
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
58
|
-
.putExtra("craft.notification", true)
|
|
59
|
-
data.forEach { (key, value) -> open.putExtra(key, value) }
|
|
60
|
-
val id = (data["notification_id"]?.toIntOrNull() ?: System.currentTimeMillis().toInt())
|
|
61
|
-
val tap = PendingIntent.getActivity(this, id, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
62
|
-
val built = NotificationCompat.Builder(this, CHANNEL)
|
|
63
|
-
.setSmallIcon(R.drawable.craft_notification_icon)
|
|
64
|
-
.setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
|
|
65
|
-
.setContentTitle(title)
|
|
66
|
-
.setContentText(body)
|
|
67
|
-
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
|
68
|
-
.setAutoCancel(true)
|
|
69
|
-
.setContentIntent(tap)
|
|
70
|
-
.build()
|
|
71
|
-
runCatching { NotificationManagerCompat.from(this).notify(id, built) }
|
|
48
|
+
val value = JSONObject().put("title", title).put("body", body).put("data", JSONObject(data))
|
|
49
|
+
for (key in listOf("categoryId", "threadIdentifier", "interruptionLevel")) data[key]?.let { value.put(key, it) }
|
|
50
|
+
val id = data["notification_id"] ?: java.util.UUID.randomUUID().toString()
|
|
51
|
+
CraftNativeNotifications.show(this, value, id, CHANNEL)
|
|
72
52
|
}
|
|
73
53
|
|
|
74
54
|
companion object {
|