@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.
@@ -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,2 @@
1
+ /** Uses the Android builder's Kotlin version, including its transitive compiler dependencies. */
2
+ export declare function runKotlinFixture(source: string, libraries?: readonly string[]): Promise<void>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.451",
3
+ "version": "0.0.464",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- /** One run now (a silent push). */
158
- fun runOnce(context: Context, event: String) {
159
- val request = OneTimeWorkRequestBuilder<CraftNativeRefreshWorker>()
160
- .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
161
- .setInputData(workDataOf("event" to event))
162
- .build()
163
- WorkManager.getInstance(context).enqueueUniqueWork(ONCE, ExistingWorkPolicy.APPEND_OR_REPLACE, request)
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 id = UUID.randomUUID().toString()
171
- val answer = CompletableDeferred<Boolean>()
172
- val running = withContext(Dispatchers.Main) {
173
- CraftNativeBackground.foreground?.invoke(event, id) { ok -> answer.complete(ok) } == true
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
- Log.i("CraftNativeAndroid", "$event $id: ${if (running) "the running app script" else "a background app script"}")
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
- if (!running) {
178
- headless = withContext(Dispatchers.Main) { CraftNativeHeadlessApp(applicationContext, event, id) { ok -> answer.complete(ok) } }
179
- withContext(Dispatchers.Main) { headless.start() }
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(true)
222
- val bundle = CraftNativeBundle.source(context, route) ?: return finish(true)
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(true)
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
- (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancelAll()
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 manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
53
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && manager.getNotificationChannel(CHANNEL) == null) {
54
- manager.createNotificationChannel(NotificationChannel(CHANNEL, applicationInfo.loadLabel(packageManager), NotificationManager.IMPORTANCE_DEFAULT))
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 {