craft-native 0.0.451 → 0.0.467

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.
Files changed (35) hide show
  1. package/dist/android/src/index.d.ts +8 -0
  2. package/dist/android/src/index.js +227 -30
  3. package/dist/android/src/testing.d.ts +1 -1
  4. package/dist/android/templates/CraftNativeBackground.kt.template +65 -25
  5. package/dist/android/templates/CraftNativeCapabilities.kt.template +3 -1
  6. package/dist/android/templates/CraftNativeMessagingService.kt.template +8 -28
  7. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +213 -15
  8. package/dist/android/templates/CraftNativeServices.kt.template +23 -5
  9. package/dist/android/templates/CraftNativeShell.kt.template +201 -50
  10. package/dist/android/templates/MainActivityNative.kt.template +227 -12
  11. package/dist/cli.js +1 -1
  12. package/dist/components/index.d.ts +18 -0
  13. package/dist/index.cjs +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/ios/src/index.d.ts +13 -14
  16. package/dist/ios/src/index.js +293 -81
  17. package/dist/ios/templates/CraftApp.swift +118 -16
  18. package/dist/ios/templates/CraftHybrid.swift +75 -5
  19. package/dist/ios/templates/CraftNativeActions.swift +105 -14
  20. package/dist/ios/templates/CraftNativeFiles.swift +230 -18
  21. package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
  22. package/dist/ios/templates/CraftNativeScreen.swift +136 -6
  23. package/dist/ios/templates/CraftNotificationService.swift +199 -0
  24. package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
  25. package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
  26. package/dist/mobile-testing.js +12 -3
  27. package/dist/mobile.d.ts +2 -0
  28. package/dist/mobile.js +120 -0
  29. package/dist/native-notifications.d.ts +17 -0
  30. package/dist/native-routing.d.ts +26 -0
  31. package/dist/testing/media-cache/media-cache.kt +74 -5
  32. package/dist/testing/media-cache/media-cache.swift.fixture +60 -4
  33. package/dist/typescript/src/native-notifications.d.ts +17 -0
  34. package/dist/typescript/src/native-routing.d.ts +26 -0
  35. package/package.json +1 -1
@@ -1,3 +1,6 @@
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';
1
4
  /** Store large native source assets compressed; the host decodes each once on demand. */
2
5
  export declare function compressNativeSources(target: string): void;
3
6
  import { type AndroidMapProvider } from './native-map.js';
@@ -64,6 +67,8 @@ export interface CraftAndroidConfig {
64
67
  enableLocalDatabase?: boolean;
65
68
  /** Enable native AlarmManager local notification scheduling. Disabled unless explicitly true. */
66
69
  enableLocalNotifications?: boolean;
70
+ /** Notification buttons and inline replies registered at launch. */
71
+ notificationCategories?: NativeNotificationCategory[];
67
72
  enableSecureStorage?: boolean;
68
73
  enableGeolocation?: boolean;
69
74
  enableBackgroundLocation?: boolean;
@@ -161,6 +166,7 @@ export interface CraftAndroidConfig {
161
166
  * script that runs beside the screens. The same keys as the iOS builder.
162
167
  */
163
168
  nativeScreens?: Record<string, string>;
169
+ nativeScreenConstraints?: NativeRouteConstraints;
164
170
  tabs?: Array<{
165
171
  id: string;
166
172
  title: string;
@@ -348,3 +354,5 @@ export declare function open(options: OpenOptions): Promise<void>;
348
354
  * Run on Android device or emulator
349
355
  */
350
356
  export declare function run(options: RunOptions): Promise<void>;
357
+ /** The native host enforces declared named-parameter constraints. */
358
+ export declare const nativeRouteConstraints = true;
@@ -1,4 +1,212 @@
1
1
  // @bun
2
+ // src/native-notifications.ts
3
+ function nativeNotificationProblems(categories, nativeApp, renderer) {
4
+ if (categories === undefined)
5
+ return [];
6
+ if (!Array.isArray(categories))
7
+ return ["notificationCategories must be an array."];
8
+ const problems = [];
9
+ const categoryIds = new Set;
10
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
11
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
12
+ for (const [index, category] of categories.entries()) {
13
+ const field = `notificationCategories[${index}]`;
14
+ if (!object(category) || !text(category.id)) {
15
+ problems.push(`${field} needs a nonempty id.`);
16
+ continue;
17
+ }
18
+ if (categoryIds.has(category.id))
19
+ problems.push(`${field}.id must be unique.`);
20
+ categoryIds.add(category.id);
21
+ if (!Array.isArray(category.actions) || category.actions.length < 1 || category.actions.length > 3) {
22
+ problems.push(`${field}.actions must contain 1\u20133 actions for both platforms.`);
23
+ continue;
24
+ }
25
+ const actionIds = new Set;
26
+ for (const [position, action] of category.actions.entries()) {
27
+ const path = `${field}.actions[${position}]`;
28
+ if (!object(action) || !text(action.id) || !text(action.title)) {
29
+ problems.push(`${path} needs a nonempty id and title.`);
30
+ continue;
31
+ }
32
+ if (action.id.startsWith("com.apple.") || actionIds.has(action.id))
33
+ problems.push(`${path}.id must be unique and must not use Apple's reserved prefix.`);
34
+ actionIds.add(action.id);
35
+ for (const flag of ["destructive", "foreground", "authenticationRequired"]) {
36
+ if (action[flag] !== undefined && typeof action[flag] !== "boolean")
37
+ problems.push(`${path}.${flag} must be a boolean.`);
38
+ }
39
+ if (!nativeApp?.trim() && (action.foreground !== true || renderer === "native"))
40
+ problems.push(`${path} requires nativeApp to receive native/background action events.`);
41
+ if (action.textInput !== undefined && (!object(action.textInput) || !text(action.textInput.buttonTitle) || action.textInput.placeholder !== undefined && typeof action.textInput.placeholder !== "string")) {
42
+ problems.push(`${path}.textInput needs a buttonTitle and an optional string placeholder.`);
43
+ }
44
+ }
45
+ }
46
+ return problems;
47
+ }
48
+
49
+ // src/native-routing.ts
50
+ function applyNativeParameterConstraints(params, rules) {
51
+ if (rules === undefined)
52
+ return true;
53
+ if (!rules || typeof rules !== "object" || Array.isArray(rules))
54
+ return false;
55
+ for (const [name, rule] of Object.entries(rules)) {
56
+ let value = params[name];
57
+ if (typeof value !== "string" || !rule || typeof rule !== "object" || Array.isArray(rule))
58
+ return false;
59
+ if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
60
+ return false;
61
+ if (rule.normalize !== undefined && rule.normalize !== "lowercase")
62
+ return false;
63
+ if (rule.normalize === "lowercase")
64
+ value = value.toLowerCase();
65
+ if (rule.exclude !== undefined) {
66
+ if (!Array.isArray(rule.exclude) || rule.exclude.some((item) => typeof item !== "string"))
67
+ return false;
68
+ if (rule.exclude.some((item) => (rule.normalize === "lowercase" ? item.toLowerCase() : item) === value))
69
+ return false;
70
+ }
71
+ if (rule.pattern !== undefined) {
72
+ if (typeof rule.pattern !== "string")
73
+ return false;
74
+ try {
75
+ const matched = new RegExp(`^(?:${rule.pattern})$`).exec(value);
76
+ if (!matched || matched[0] !== value)
77
+ return false;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+ params[name] = value;
83
+ }
84
+ return true;
85
+ }
86
+ function nativeRouteConstraintProblems(screens, constraints) {
87
+ const problems = [];
88
+ if (constraints === undefined)
89
+ return problems;
90
+ if (!constraints || typeof constraints !== "object" || Array.isArray(constraints))
91
+ return ["nativeScreenConstraints must name route parameter constraints."];
92
+ for (const [route, rules] of Object.entries(constraints)) {
93
+ const prefix = `nativeScreenConstraints[${JSON.stringify(route)}]`;
94
+ if (!Object.prototype.hasOwnProperty.call(screens ?? {}, route))
95
+ problems.push(`${prefix} names no native screen route.`);
96
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) {
97
+ problems.push(`${prefix} must name parameter constraints.`);
98
+ continue;
99
+ }
100
+ const names = route.split("/").filter((part) => part.startsWith(":")).map((part) => part.slice(1));
101
+ for (const [name, rule] of Object.entries(rules)) {
102
+ const field = `${prefix}[${JSON.stringify(name)}]`;
103
+ if (!names.includes(name))
104
+ problems.push(`${field} names no path parameter.`);
105
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
106
+ problems.push(`${field} must be a parameter constraint.`);
107
+ continue;
108
+ }
109
+ if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
110
+ problems.push(`${field} has an unknown constraint field.`);
111
+ if (rule.exclude !== undefined && (!Array.isArray(rule.exclude) || rule.exclude.some((value) => typeof value !== "string")))
112
+ problems.push(`${field}.exclude must contain strings.`);
113
+ if (rule.normalize !== undefined && rule.normalize !== "lowercase")
114
+ problems.push(`${field}.normalize must be lowercase.`);
115
+ if (rule.pattern !== undefined) {
116
+ try {
117
+ if (typeof rule.pattern !== "string")
118
+ throw new Error("not a string");
119
+ new RegExp(`^(?:${rule.pattern})$`);
120
+ } catch {
121
+ problems.push(`${field}.pattern must be a valid regular expression.`);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ return problems;
127
+ }
128
+ function normalizeNativePath(raw) {
129
+ if (typeof raw !== "string")
130
+ return null;
131
+ let path = raw;
132
+ const cut = path.search(/[?#]/);
133
+ if (cut >= 0)
134
+ path = path.slice(0, cut);
135
+ if (!path.startsWith("/"))
136
+ return null;
137
+ path = path.replace(/\/{2,}/g, "/");
138
+ if (path.length > 1 && path.endsWith("/"))
139
+ path = path.slice(0, -1);
140
+ return path;
141
+ }
142
+ function routeSegments(path) {
143
+ return path === "/" ? [] : path.slice(1).split("/");
144
+ }
145
+ function matchNativePath(nativeScreens, raw, constraints) {
146
+ if (constraints !== undefined && (!constraints || typeof constraints !== "object" || Array.isArray(constraints)))
147
+ return null;
148
+ const path = normalizeNativePath(raw);
149
+ if (path === null)
150
+ return null;
151
+ const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ key: pattern, pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
152
+ const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
153
+ const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
154
+ routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
155
+ const decode = (value) => {
156
+ try {
157
+ return decodeURIComponent(value);
158
+ } catch {
159
+ return value;
160
+ }
161
+ };
162
+ const parts = routeSegments(path);
163
+ for (const route of routes) {
164
+ const params = {};
165
+ let matched = true;
166
+ for (let i = 0;i < route.segments.length; i++) {
167
+ const segment = route.segments[i];
168
+ if (segment === "*") {
169
+ params["*"] = parts.slice(i).join("/");
170
+ break;
171
+ }
172
+ if (i >= parts.length) {
173
+ matched = false;
174
+ break;
175
+ }
176
+ if (segment.startsWith(":")) {
177
+ if (!parts[i]) {
178
+ matched = false;
179
+ break;
180
+ }
181
+ params[segment.slice(1)] = decode(parts[i]);
182
+ } else if (segment !== parts[i]) {
183
+ matched = false;
184
+ break;
185
+ }
186
+ }
187
+ if (!matched)
188
+ continue;
189
+ if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
190
+ continue;
191
+ if (!applyNativeParameterConstraints(params, constraints?.[route.key]))
192
+ continue;
193
+ const query = raw.indexOf("?");
194
+ if (query >= 0) {
195
+ for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
196
+ if (!item)
197
+ continue;
198
+ const [rawKey = "", ...rest] = item.split("=");
199
+ const key = decode(rawKey);
200
+ if (!key || key in params)
201
+ continue;
202
+ params[key] = decode(rest.join("=").replace(/\+/g, " "));
203
+ }
204
+ }
205
+ return { path, screen: route.screen, params };
206
+ }
207
+ return null;
208
+ }
209
+
2
210
  // ../android/src/index.ts
3
211
  import { gzipSync } from "zlib";
4
212
  import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, rmSync as rmSync2, statSync, writeFileSync } from "fs";
@@ -128,7 +336,7 @@ import { readFileSync } from "fs";
128
336
  import { mkdtemp, rm, writeFile } from "fs/promises";
129
337
  import { tmpdir } from "os";
130
338
  import { dirname, join } from "path";
131
- async function runKotlinFixture(source) {
339
+ async function runKotlinFixture(source, libraries = []) {
132
340
  const gradle = Bun.which("gradle");
133
341
  if (!gradle)
134
342
  throw new Error("Gradle is required to run native Kotlin fixtures");
@@ -152,6 +360,8 @@ configurations {
152
360
  dependencies {
153
361
  kotlinCompiler 'org.jetbrains.kotlin:kotlin-compiler-embeddable:${version}'
154
362
  kotlinRuntime 'org.jetbrains.kotlin:kotlin-stdlib:${version}'
363
+ ${libraries.map((library) => ` kotlinRuntime ${JSON.stringify(library)}`).join(`
364
+ `)}
155
365
  }
156
366
  tasks.register('compileFixture', JavaExec) {
157
367
  classpath = configurations.kotlinCompiler
@@ -932,32 +1142,8 @@ function normalizeNativeRoutePath(raw) {
932
1142
  function nativeRouteSegments(path) {
933
1143
  return path === "/" ? [] : path.slice(1).split("/");
934
1144
  }
935
- function matchesNativeRoute(nativeScreens, raw) {
936
- const path = normalizeNativeRoutePath(raw);
937
- if (path === null)
938
- return false;
939
- const parts = nativeRouteSegments(path);
940
- return Object.entries(nativeScreens ?? {}).some(([pattern, screen]) => {
941
- if (!screen)
942
- return false;
943
- const normalized = normalizeNativeRoutePath(pattern);
944
- if (normalized === null)
945
- return false;
946
- const route = nativeRouteSegments(normalized);
947
- for (let index = 0;index < route.length; index++) {
948
- const segment = route[index];
949
- if (segment === "*")
950
- return index === route.length - 1;
951
- if (index >= parts.length)
952
- return false;
953
- if (segment.startsWith(":")) {
954
- if (!parts[index])
955
- return false;
956
- } else if (segment !== parts[index])
957
- return false;
958
- }
959
- return route.length === parts.length;
960
- });
1145
+ function matchesNativeRoute(nativeScreens, raw, constraints) {
1146
+ return matchNativePath(nativeScreens, raw, constraints) !== null;
961
1147
  }
962
1148
  function nativeStartPath(config) {
963
1149
  if (!config.devServerURL)
@@ -970,6 +1156,15 @@ function nativeStartPath(config) {
970
1156
  }
971
1157
  }
972
1158
  function validateNativeRouteConfig(config) {
1159
+ const constraintProblems = [
1160
+ ...nativeRouteConstraintProblems(config.nativeScreens, config.nativeScreenConstraints),
1161
+ ...nativeNotificationProblems(config.notificationCategories, config.nativeApp, config.renderer)
1162
+ ];
1163
+ if ((config.notificationCategories?.length ?? 0) > 0 && config.renderer !== "native")
1164
+ constraintProblems.push("notificationCategories currently requires the native Android renderer.");
1165
+ if (constraintProblems.length)
1166
+ throw new Error(constraintProblems.join(`
1167
+ `));
973
1168
  const screens = Object.entries(config.nativeScreens ?? {});
974
1169
  for (const [pattern, screen] of screens) {
975
1170
  if (normalizeNativeRoutePath(pattern) !== pattern || /[?#]/.test(pattern))
@@ -989,11 +1184,11 @@ function validateNativeRouteConfig(config) {
989
1184
  }
990
1185
  if (config.renderer === "native" && screens.length > 0) {
991
1186
  for (const id of ids) {
992
- if (!matchesNativeRoute(config.nativeScreens, id))
1187
+ if (!matchesNativeRoute(config.nativeScreens, id, config.nativeScreenConstraints))
993
1188
  throw new Error(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
994
1189
  }
995
1190
  const start = nativeStartPath(config);
996
- if (!matchesNativeRoute(config.nativeScreens, start))
1191
+ if (!matchesNativeRoute(config.nativeScreens, start, config.nativeScreenConstraints))
997
1192
  throw new Error(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
998
1193
  }
999
1194
  if (config.nativeApp !== undefined) {
@@ -1059,7 +1254,7 @@ function renderAndroidPermissions(config) {
1059
1254
  permissions.add("android.permission.FOREGROUND_SERVICE");
1060
1255
  permissions.add("android.permission.FOREGROUND_SERVICE_LOCATION");
1061
1256
  }
1062
- if (config.enablePushNotifications)
1257
+ if (config.enablePushNotifications || config.enableLocalNotifications)
1063
1258
  permissions.add("android.permission.POST_NOTIFICATIONS");
1064
1259
  if (config.enableKeepAwake || config.enableBarometer && config.enableBackgroundLocation)
1065
1260
  permissions.add("android.permission.WAKE_LOCK");
@@ -1767,6 +1962,7 @@ async function run(options) {
1767
1962
  throw error;
1768
1963
  }
1769
1964
  }
1965
+ var nativeRouteConstraints = true;
1770
1966
  export {
1771
1967
  ANDROID_MIN_SDK,
1772
1968
  HEALTH_CONNECT_PERMISSIONS,
@@ -1781,6 +1977,7 @@ export {
1781
1977
  nativeMapProviders,
1782
1978
  nativeMessages,
1783
1979
  nativeRouteBundles,
1980
+ nativeRouteConstraints,
1784
1981
  nativeRouteRuntime,
1785
1982
  nativeSensorCapabilities,
1786
1983
  open,
@@ -1,2 +1,2 @@
1
1
  /** Uses the Android builder's Kotlin version, including its transitive compiler dependencies. */
2
- export declare function runKotlinFixture(source: string): Promise<void>;
2
+ export declare function runKotlinFixture(source: string, libraries?: readonly string[]): Promise<void>;
@@ -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 {