craft-native 0.0.449 → 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/dist/android/src/index.d.ts +8 -0
- package/dist/android/src/index.js +227 -30
- package/dist/android/src/testing.d.ts +1 -1
- package/dist/android/templates/CraftNativeBackground.kt.template +65 -25
- package/dist/android/templates/CraftNativeCapabilities.kt.template +3 -1
- package/dist/android/templates/CraftNativeMessagingService.kt.template +8 -28
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +213 -15
- package/dist/android/templates/CraftNativeServices.kt.template +6 -4
- package/dist/android/templates/CraftNativeShell.kt.template +72 -21
- package/dist/android/templates/MainActivityNative.kt.template +227 -12
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +18 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +13 -14
- package/dist/ios/src/index.js +292 -81
- package/dist/ios/templates/CraftApp.swift +118 -16
- package/dist/ios/templates/CraftHybrid.swift +75 -5
- package/dist/ios/templates/CraftNativeActions.swift +84 -13
- package/dist/ios/templates/CraftNativeFiles.swift +83 -1
- package/dist/ios/templates/CraftNativeScreen.swift +130 -4
- package/dist/ios/templates/CraftNotificationService.swift +199 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +61 -6
- package/dist/mobile.d.ts +2 -0
- package/dist/mobile.js +120 -0
- package/dist/native-notifications.d.ts +17 -0
- package/dist/native-routing.d.ts +26 -0
- package/dist/testing/media-cache/media-cache.kt +28 -2
- package/dist/testing/media-cache/media-cache.swift.fixture +18 -4
- package/dist/typescript/src/native-notifications.d.ts +17 -0
- package/dist/typescript/src/native-routing.d.ts +26 -0
- 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
|
-
|
|
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
|
-
/**
|
|
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 {
|