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.
- 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 +23 -5
- package/dist/android/templates/CraftNativeShell.kt.template +201 -50
- 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 +293 -81
- package/dist/ios/templates/CraftApp.swift +118 -16
- package/dist/ios/templates/CraftHybrid.swift +75 -5
- package/dist/ios/templates/CraftNativeActions.swift +105 -14
- package/dist/ios/templates/CraftNativeFiles.swift +230 -18
- package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
- package/dist/ios/templates/CraftNativeScreen.swift +136 -6
- package/dist/ios/templates/CraftNotificationService.swift +199 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
- package/dist/mobile-testing.js +12 -3
- 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 +74 -5
- package/dist/testing/media-cache/media-cache.swift.fixture +60 -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
package/dist/mobile-testing.js
CHANGED
|
@@ -201,6 +201,7 @@ ${errors}`);
|
|
|
201
201
|
try {
|
|
202
202
|
if (options.platform === "ios") {
|
|
203
203
|
const files = await Bun.file(join(root, "ios/templates/CraftNativeFiles.swift")).text();
|
|
204
|
+
const ownership = await Bun.file(join(root, "ios/templates/CraftNativeMediaOwnership.swift")).text();
|
|
204
205
|
const media = files.slice(files.indexOf("enum CraftNativeMedia {"));
|
|
205
206
|
if (!media.startsWith("enum CraftNativeMedia"))
|
|
206
207
|
throw new Error("iOS media implementation missing");
|
|
@@ -208,8 +209,10 @@ ${errors}`);
|
|
|
208
209
|
const isolated = media.replace(/static var root: URL \{[\s\S]*?\n \}/, 'static var root: URL { CommandLine.arguments.count > 3 ? URL(fileURLWithPath: CommandLine.arguments[3]) : URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("hq-download-probe-media") }');
|
|
209
210
|
await Bun.write(source, `import Foundation
|
|
210
211
|
import CryptoKit
|
|
212
|
+
import Network
|
|
211
213
|
struct CraftNativeActionError: Error { let code: String;
|
|
212
214
|
let message: String }
|
|
215
|
+
${ownership}
|
|
213
216
|
${isolated}
|
|
214
217
|
${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
215
218
|
const iosSDK = (await run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])).trim();
|
|
@@ -229,6 +232,7 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
229
232
|
await run(["xcrun", "simctl", "bootstatus", simulator, "-b"]);
|
|
230
233
|
}
|
|
231
234
|
const base = `http://127.0.0.1:${server.port}/ios`;
|
|
235
|
+
const mediaRoot = join(scratch, "media");
|
|
232
236
|
const image = join(scratch, "cache-pressure.sparseimage");
|
|
233
237
|
const volume = join(scratch, "cache-volume");
|
|
234
238
|
await mkdir(volume);
|
|
@@ -240,14 +244,16 @@ ${await Bun.file(join(fixtures, "media-cache.swift.fixture")).text()}`);
|
|
|
240
244
|
mountedVolume = "";
|
|
241
245
|
print("PASS: iOS native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
242
246
|
if (!storageOnly) {
|
|
243
|
-
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal"]));
|
|
244
|
-
const
|
|
247
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "normal", mediaRoot]));
|
|
248
|
+
for (const phase of ["retire", "retired-recover"])
|
|
249
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, phase, mediaRoot]));
|
|
250
|
+
const interrupted = await run(["xcrun", "simctl", "spawn", simulator, executable, base, "interrupt", mediaRoot], scratch, true);
|
|
245
251
|
print(interrupted);
|
|
246
252
|
if (!interrupted.includes("KILLING"))
|
|
247
253
|
throw new Error("iOS probe did not reach process-death checkpoint");
|
|
248
254
|
if (counts.get("/ios/restart.mp4") !== 1)
|
|
249
255
|
throw new Error("iOS process did not start interrupted download");
|
|
250
|
-
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover"]));
|
|
256
|
+
print(await run(["xcrun", "simctl", "spawn", simulator, executable, base, "recover", mediaRoot]));
|
|
251
257
|
if (counts.get("/ios/slow.mp4") !== 1)
|
|
252
258
|
throw new Error("iOS duplicate fetch: keep did not coalesce");
|
|
253
259
|
if (counts.get("/ios/account/slow.mp4") !== 2)
|
|
@@ -328,6 +334,9 @@ ${await Bun.file(join(fixtures, "media-cache.kt")).text()}`);
|
|
|
328
334
|
print("PASS: Android native ENOSPC, cache cleanup, progress preservation and video redownload");
|
|
329
335
|
if (!storageOnly) {
|
|
330
336
|
await probe("normal");
|
|
337
|
+
await probe("retire");
|
|
338
|
+
await run([adb, "-s", serial, "shell", "am", "force-stop", packageName]);
|
|
339
|
+
await probe("retired-recover");
|
|
331
340
|
await probe("interrupt", true);
|
|
332
341
|
if (counts.get("/android/restart.mp4") !== 1)
|
|
333
342
|
throw new Error("Android process did not start interrupted download");
|
package/dist/mobile.d.ts
CHANGED
|
@@ -6,3 +6,5 @@
|
|
|
6
6
|
* which intentionally target Bun and must not enter a browser bundle.
|
|
7
7
|
*/
|
|
8
8
|
export * from './api/mobile.js';
|
|
9
|
+
export { matchNativePath, normalizeNativePath } from './native-routing.js';
|
|
10
|
+
export type { NativePathMatch, NativeParameterConstraint, NativeRouteConstraints } from './native-routing.js';
|
package/dist/mobile.js
CHANGED
|
@@ -1964,6 +1964,124 @@ function getCraftMobile() {
|
|
|
1964
1964
|
}
|
|
1965
1965
|
return;
|
|
1966
1966
|
}
|
|
1967
|
+
// src/native-routing.ts
|
|
1968
|
+
function applyNativeParameterConstraints(params, rules) {
|
|
1969
|
+
if (rules === undefined)
|
|
1970
|
+
return true;
|
|
1971
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules))
|
|
1972
|
+
return false;
|
|
1973
|
+
for (const [name, rule] of Object.entries(rules)) {
|
|
1974
|
+
let value = params[name];
|
|
1975
|
+
if (typeof value !== "string" || !rule || typeof rule !== "object" || Array.isArray(rule))
|
|
1976
|
+
return false;
|
|
1977
|
+
if (Object.keys(rule).some((key) => !["pattern", "exclude", "normalize"].includes(key)))
|
|
1978
|
+
return false;
|
|
1979
|
+
if (rule.normalize !== undefined && rule.normalize !== "lowercase")
|
|
1980
|
+
return false;
|
|
1981
|
+
if (rule.normalize === "lowercase")
|
|
1982
|
+
value = value.toLowerCase();
|
|
1983
|
+
if (rule.exclude !== undefined) {
|
|
1984
|
+
if (!Array.isArray(rule.exclude) || rule.exclude.some((item) => typeof item !== "string"))
|
|
1985
|
+
return false;
|
|
1986
|
+
if (rule.exclude.some((item) => (rule.normalize === "lowercase" ? item.toLowerCase() : item) === value))
|
|
1987
|
+
return false;
|
|
1988
|
+
}
|
|
1989
|
+
if (rule.pattern !== undefined) {
|
|
1990
|
+
if (typeof rule.pattern !== "string")
|
|
1991
|
+
return false;
|
|
1992
|
+
try {
|
|
1993
|
+
const matched = new RegExp(`^(?:${rule.pattern})$`).exec(value);
|
|
1994
|
+
if (!matched || matched[0] !== value)
|
|
1995
|
+
return false;
|
|
1996
|
+
} catch {
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
params[name] = value;
|
|
2001
|
+
}
|
|
2002
|
+
return true;
|
|
2003
|
+
}
|
|
2004
|
+
function normalizeNativePath(raw) {
|
|
2005
|
+
if (typeof raw !== "string")
|
|
2006
|
+
return null;
|
|
2007
|
+
let path = raw;
|
|
2008
|
+
const cut = path.search(/[?#]/);
|
|
2009
|
+
if (cut >= 0)
|
|
2010
|
+
path = path.slice(0, cut);
|
|
2011
|
+
if (!path.startsWith("/"))
|
|
2012
|
+
return null;
|
|
2013
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
2014
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
2015
|
+
path = path.slice(0, -1);
|
|
2016
|
+
return path;
|
|
2017
|
+
}
|
|
2018
|
+
function routeSegments(path) {
|
|
2019
|
+
return path === "/" ? [] : path.slice(1).split("/");
|
|
2020
|
+
}
|
|
2021
|
+
function matchNativePath(nativeScreens, raw, constraints) {
|
|
2022
|
+
if (constraints !== undefined && (!constraints || typeof constraints !== "object" || Array.isArray(constraints)))
|
|
2023
|
+
return null;
|
|
2024
|
+
const path = normalizeNativePath(raw);
|
|
2025
|
+
if (path === null)
|
|
2026
|
+
return null;
|
|
2027
|
+
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) }));
|
|
2028
|
+
const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
|
|
2029
|
+
const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
|
|
2030
|
+
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));
|
|
2031
|
+
const decode = (value) => {
|
|
2032
|
+
try {
|
|
2033
|
+
return decodeURIComponent(value);
|
|
2034
|
+
} catch {
|
|
2035
|
+
return value;
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
const parts = routeSegments(path);
|
|
2039
|
+
for (const route of routes) {
|
|
2040
|
+
const params = {};
|
|
2041
|
+
let matched = true;
|
|
2042
|
+
for (let i = 0;i < route.segments.length; i++) {
|
|
2043
|
+
const segment = route.segments[i];
|
|
2044
|
+
if (segment === "*") {
|
|
2045
|
+
params["*"] = parts.slice(i).join("/");
|
|
2046
|
+
break;
|
|
2047
|
+
}
|
|
2048
|
+
if (i >= parts.length) {
|
|
2049
|
+
matched = false;
|
|
2050
|
+
break;
|
|
2051
|
+
}
|
|
2052
|
+
if (segment.startsWith(":")) {
|
|
2053
|
+
if (!parts[i]) {
|
|
2054
|
+
matched = false;
|
|
2055
|
+
break;
|
|
2056
|
+
}
|
|
2057
|
+
params[segment.slice(1)] = decode(parts[i]);
|
|
2058
|
+
} else if (segment !== parts[i]) {
|
|
2059
|
+
matched = false;
|
|
2060
|
+
break;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
if (!matched)
|
|
2064
|
+
continue;
|
|
2065
|
+
if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
|
|
2066
|
+
continue;
|
|
2067
|
+
if (!applyNativeParameterConstraints(params, constraints?.[route.key]))
|
|
2068
|
+
continue;
|
|
2069
|
+
const query = raw.indexOf("?");
|
|
2070
|
+
if (query >= 0) {
|
|
2071
|
+
for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
|
|
2072
|
+
if (!item)
|
|
2073
|
+
continue;
|
|
2074
|
+
const [rawKey = "", ...rest] = item.split("=");
|
|
2075
|
+
const key = decode(rawKey);
|
|
2076
|
+
if (!key || key in params)
|
|
2077
|
+
continue;
|
|
2078
|
+
params[key] = decode(rest.join("=").replace(/\+/g, " "));
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return { path, screen: route.screen, params };
|
|
2082
|
+
}
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
1967
2085
|
export {
|
|
1968
2086
|
DEFAULT_SESSION_TOKEN_KEY,
|
|
1969
2087
|
SESSION_EVENT,
|
|
@@ -1995,6 +2113,7 @@ export {
|
|
|
1995
2113
|
lifecycle,
|
|
1996
2114
|
liveActivities,
|
|
1997
2115
|
location,
|
|
2116
|
+
matchNativePath,
|
|
1998
2117
|
music,
|
|
1999
2118
|
network,
|
|
2000
2119
|
normalizeDeepLinkURL,
|
|
@@ -2002,6 +2121,7 @@ export {
|
|
|
2002
2121
|
normalizeHeartRateReading,
|
|
2003
2122
|
normalizeHeartRateSamples,
|
|
2004
2123
|
normalizeHeartRateState,
|
|
2124
|
+
normalizeNativePath,
|
|
2005
2125
|
normalizeNowPlaying,
|
|
2006
2126
|
normalizeScheduledWorkout,
|
|
2007
2127
|
notifications,
|
|
@@ -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;
|
|
@@ -23,10 +23,10 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
23
23
|
proof.append("PASS: ").append(message).append('\n')
|
|
24
24
|
android.util.Log.i("CraftMediaProbe", "PASS: $message")
|
|
25
25
|
}
|
|
26
|
-
private fun keep(address: String): Boolean {
|
|
26
|
+
private fun keep(address: String, cacheKey: String? = null): Boolean {
|
|
27
27
|
val done = java.util.concurrent.CountDownLatch(1)
|
|
28
28
|
var ok = false
|
|
29
|
-
CraftNativeMedia.keep(targetContext, address, JSONObject(), true) {
|
|
29
|
+
CraftNativeMedia.keep(targetContext, address, JSONObject(), true, cacheKey = cacheKey) {
|
|
30
30
|
latestErrorCode = it.code
|
|
31
31
|
ok = (it.data as? JSONObject)?.optBoolean("ok") == true
|
|
32
32
|
done.countDown()
|
|
@@ -58,7 +58,7 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
58
58
|
check(keep(retained), "existing video is kept before storage pressure")
|
|
59
59
|
val original = CraftNativeMedia.local(targetContext, retained)!!.readBytes()
|
|
60
60
|
val fresh = "$base/storage.mp4"
|
|
61
|
-
val staging =
|
|
61
|
+
val staging = CraftNativeMedia.part(targetContext, fresh)
|
|
62
62
|
val root = staging.parentFile!!
|
|
63
63
|
check(root.totalSpace in 1..(32L * 1024 * 1024), "storage pressure is confined to a bounded disposable volume")
|
|
64
64
|
val pressure = File(root, "pressure.mp4")
|
|
@@ -90,6 +90,44 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
90
90
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
91
91
|
prefs.edit().clear().commit()
|
|
92
92
|
}
|
|
93
|
+
"retire" -> {
|
|
94
|
+
val a = "$base/selective-a.mp4"
|
|
95
|
+
val b = "$base/selective-b.mp4"
|
|
96
|
+
check(keep(a) && keep(b), "two authorized videos are kept")
|
|
97
|
+
val oldGeneration = CraftNativeMedia.generation(targetContext)
|
|
98
|
+
check(targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().putInt("old-generation", oldGeneration).commit(), "old worker generation is retained for restart verification")
|
|
99
|
+
val cacheRoot = CraftNativeMedia.file(targetContext, a).parentFile!!
|
|
100
|
+
android.system.Os.chmod(cacheRoot.absolutePath, 365) // 0555: read/search, no unlink
|
|
101
|
+
var unlinkFailed = false
|
|
102
|
+
try { CraftNativeMedia.remove(targetContext, a) } catch (error: java.io.IOException) { unlinkFailed = true }
|
|
103
|
+
finally { android.system.Os.chmod(cacheRoot.absolutePath, 448) } // 0700
|
|
104
|
+
check(unlinkFailed, "a real unlink failure is reported")
|
|
105
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "failed unlink hides revoked A and preserves authorized B")
|
|
106
|
+
check(CraftNativeMedia.remove(targetContext, a) == 1, "removing A removes only its copy")
|
|
107
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "B stays available after A is revoked")
|
|
108
|
+
}
|
|
109
|
+
"retired-recover" -> {
|
|
110
|
+
val a = "$base/selective-a.mp4"
|
|
111
|
+
val b = "$base/selective-b.mp4"
|
|
112
|
+
val oldGeneration = targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).getInt("old-generation", -1)
|
|
113
|
+
check(CraftNativeMedia.local(targetContext, a) == null && CraftNativeMedia.local(targetContext, b) != null, "selective revocation survives a fresh Android process")
|
|
114
|
+
fun obsoleteCompletion() {
|
|
115
|
+
val result = CraftNativeMedia.transfer(targetContext, a, JSONObject(), oldGeneration, 0, ownerRevision = "") { false }
|
|
116
|
+
check(result == CraftNativeMedia.Outcome.DONE, "obsolete worker retires without retry or publication")
|
|
117
|
+
}
|
|
118
|
+
obsoleteCompletion()
|
|
119
|
+
check(keep(a), "a new authorized keep can re-grant A")
|
|
120
|
+
val fresh = CraftNativeMedia.local(targetContext, a)!!.readBytes()
|
|
121
|
+
obsoleteCompletion()
|
|
122
|
+
check(CraftNativeMedia.local(targetContext, a)!!.readBytes().contentEquals(fresh), "obsolete worker cannot replace re-granted A")
|
|
123
|
+
val decoder = android.media.MediaMetadataRetriever()
|
|
124
|
+
try {
|
|
125
|
+
decoder.setDataSource(CraftNativeMedia.local(targetContext, b)!!.absolutePath)
|
|
126
|
+
check(decoder.getFrameAtTime(1000000) != null, "unrelated B still decodes offline after revocation and re-grant")
|
|
127
|
+
} finally { decoder.release() }
|
|
128
|
+
CraftNativeMedia.clear(targetContext)
|
|
129
|
+
targetContext.getSharedPreferences("media-probe", Context.MODE_PRIVATE).edit().clear().commit()
|
|
130
|
+
}
|
|
93
131
|
"interrupt" -> {
|
|
94
132
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
95
133
|
CraftNativeMedia.keep(targetContext, restart, JSONObject(), true) {}
|
|
@@ -113,6 +151,32 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
113
151
|
}
|
|
114
152
|
else -> {
|
|
115
153
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
154
|
+
val queuedKey = "account:one:lesson:8:version:a"
|
|
155
|
+
val expired = base + "/queued.mp4?token=expired"
|
|
156
|
+
val renewed = base + "/queued.mp4?token=renewed"
|
|
157
|
+
val queuedIdentity = CraftNativeMedia.identity(expired, JSONObject().put("cacheKey", queuedKey))
|
|
158
|
+
val queuedGeneration = CraftNativeMedia.generation(targetContext)
|
|
159
|
+
targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putString("source:${CraftNativeMedia.digest(queuedIdentity)}", CraftNativeMedia.digest(expired + "\n")).commit()
|
|
160
|
+
val queuedWork = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
161
|
+
.setInputData(androidx.work.workDataOf("address" to queuedIdentity, "source" to expired, "headers" to "{}", "generation" to queuedGeneration))
|
|
162
|
+
.setInitialDelay(1, java.util.concurrent.TimeUnit.DAYS)
|
|
163
|
+
.addTag("craft-media:generation:$queuedGeneration")
|
|
164
|
+
.build()
|
|
165
|
+
androidx.work.WorkManager.getInstance(targetContext).enqueueUniqueWork("craft-media:${CraftNativeMedia.digest(queuedIdentity)}:any", androidx.work.ExistingWorkPolicy.REPLACE, queuedWork).result.get()
|
|
166
|
+
check(keep(renewed, queuedKey), "a renewed authorized source replaces expired queued work immediately")
|
|
167
|
+
val signed = base + "/keyed.mp4?token=first"
|
|
168
|
+
val key = "account:one:lesson:7:version:a"
|
|
169
|
+
check(keep(signed, key), "signed URL downloads under an account/version identity")
|
|
170
|
+
val identity = CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", key))
|
|
171
|
+
val rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", JSONObject().put("cacheKey", key))
|
|
172
|
+
check(identity == rotated && CraftNativeMedia.local(targetContext, rotated) != null, "renewed signed URLs reuse the complete copy offline")
|
|
173
|
+
check(keep(base + "/keyed.mp4?token=second", key), "renewing authorization does not redownload unchanged content")
|
|
174
|
+
check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:two:lesson:7:version:a"))) == null, "another account cannot address the copy")
|
|
175
|
+
check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:one:lesson:7:version:b"))) == null, "a new version invalidates the copy")
|
|
176
|
+
CraftNativeMedia.prune(targetContext, listOf(rotated))
|
|
177
|
+
check(CraftNativeMedia.local(targetContext, rotated) != null, "key-aware prune retains the authorized source")
|
|
178
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
179
|
+
check(CraftNativeMedia.local(targetContext, rotated) == null, "revocation removes the keyed copy")
|
|
116
180
|
val broken = "$base/broken.mp4"
|
|
117
181
|
check(!keep(broken), "truncated transfer fails")
|
|
118
182
|
check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
|
|
@@ -129,7 +193,8 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
129
193
|
val settledName = "craft-media:${CraftNativeMedia.digest(settled)}:any"
|
|
130
194
|
val settledRequest = androidx.work.OneTimeWorkRequestBuilder<SettledMediaAttemptWorker>()
|
|
131
195
|
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(settled)}")
|
|
132
|
-
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
196
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
197
|
+
.addTag("craft-media:identity:${CraftNativeMedia.digest(settled)}:legacy").build()
|
|
133
198
|
work.enqueueUniqueWork(settledName, androidx.work.ExistingWorkPolicy.KEEP, settledRequest).result.get()
|
|
134
199
|
val settledDeadline = System.currentTimeMillis() + 10000
|
|
135
200
|
var settledInfo = work.getWorkInfoById(settledRequest.id).get()!!
|
|
@@ -150,7 +215,8 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
150
215
|
.setInitialDelay(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
151
216
|
.setInputData(androidx.work.workDataOf("address" to queued, "headers" to "{}", "generation" to CraftNativeMedia.generation(targetContext)))
|
|
152
217
|
.addTag("craft-media").addTag("craft-media:${CraftNativeMedia.digest(queued)}")
|
|
153
|
-
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
218
|
+
.addTag("craft-media:generation:${CraftNativeMedia.generation(targetContext)}")
|
|
219
|
+
.addTag("craft-media:identity:${CraftNativeMedia.digest(queued)}:legacy").build()
|
|
154
220
|
work.enqueueUniqueWork(queuedName, androidx.work.ExistingWorkPolicy.KEEP, queuedRequest).result.get()
|
|
155
221
|
val beforeJoin = work.getWorkInfoById(queuedRequest.id).get()!!
|
|
156
222
|
check(beforeJoin.state == androidx.work.WorkInfo.State.ENQUEUED && beforeJoin.runAttemptCount == 0, "the coalescing regression starts with a fresh queued transfer")
|
|
@@ -191,6 +257,9 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
191
257
|
check(currentDone.await(25, java.util.concurrent.TimeUnit.SECONDS) && currentOK, "an obsolete worker cannot cancel the next generation's waiters")
|
|
192
258
|
|
|
193
259
|
val inflight = "$base/inflight/slow.mp4"
|
|
260
|
+
// This fixture starts the old worker directly rather than through keep.
|
|
261
|
+
// Give it the same persisted grant keep records after a clear.
|
|
262
|
+
check(targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putBoolean("authorized:${CraftNativeMedia.digest(inflight)}", true).commit(), "the direct worker starts with an explicit grant")
|
|
194
263
|
val inflightGeneration = CraftNativeMedia.generation(targetContext)
|
|
195
264
|
var obsoleteOutcome: CraftNativeMedia.Outcome? = null
|
|
196
265
|
val obsoleteWriter = Thread { obsoleteOutcome = CraftNativeMedia.transfer(targetContext, inflight, JSONObject(), inflightGeneration, 0) { false } }
|
|
@@ -15,13 +15,14 @@ func wait(_ ready: () -> Bool) {
|
|
|
15
15
|
}
|
|
16
16
|
var latestTask: URLSessionTask?
|
|
17
17
|
var latestErrorCode: String?
|
|
18
|
-
func start(_
|
|
18
|
+
func start(_ source: String, cacheKey: String? = nil, _ done: @escaping (Bool) -> Void) {
|
|
19
|
+
let address = CraftNativeMedia.identity(source, options: cacheKey.map { ["cacheKey": $0] } ?? [:])
|
|
19
20
|
if let local = CraftNativeMedia.local(address) { done((CraftNativeMedia.describe(address, local)["size"] as? Int ?? 0) > 0); return }
|
|
20
21
|
guard let flight = CraftNativeMedia.begin(address, { result in
|
|
21
22
|
if case .success(let data) = result { done((data as? [String: Any])?["ok"] as? Bool == true) }
|
|
22
23
|
else if case .failure(let error) = result { latestErrorCode = error.code; done(false) }
|
|
23
24
|
}) else { return }
|
|
24
|
-
let task = URLSession.shared.downloadTask(with: URL(string:
|
|
25
|
+
let task = URLSession.shared.downloadTask(with: URL(string: source)!) { location, response, error in
|
|
25
26
|
let result: Result<Any, CraftNativeActionError>
|
|
26
27
|
if error != nil { result = .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "Interrupted")) }
|
|
27
28
|
else if let location, let response = response as? HTTPURLResponse { result = CraftNativeMedia.store(address, at: location, response: response, flight: flight) }
|
|
@@ -31,9 +32,9 @@ func start(_ address: String, _ done: @escaping (Bool) -> Void) {
|
|
|
31
32
|
latestTask = task
|
|
32
33
|
task.resume()
|
|
33
34
|
}
|
|
34
|
-
func keep(_ address: String) -> Bool {
|
|
35
|
+
func keep(_ address: String, cacheKey: String? = nil) -> Bool {
|
|
35
36
|
var result: Bool?
|
|
36
|
-
start(address) { result = $0 }
|
|
37
|
+
start(address, cacheKey: cacheKey) { result = $0 }
|
|
37
38
|
wait { result != nil }
|
|
38
39
|
return result!
|
|
39
40
|
}
|
|
@@ -78,6 +79,48 @@ if phase == "storage" {
|
|
|
78
79
|
_ = CraftNativeMedia.prune(keep: [])
|
|
79
80
|
exit(0)
|
|
80
81
|
}
|
|
82
|
+
if phase == "retire" || phase == "retired-recover" {
|
|
83
|
+
let a = base + "/selective-a.mp4"
|
|
84
|
+
let b = base + "/selective-b.mp4"
|
|
85
|
+
let saved = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-stamp.json")
|
|
86
|
+
if phase == "retire" {
|
|
87
|
+
check(keep(a) && keep(b), "two authorized videos are kept")
|
|
88
|
+
let flight = CraftNativeMedia.begin(a) { _ in }!
|
|
89
|
+
let stamp = CraftNativeMedia.ownershipStamp(a, flight: flight)!
|
|
90
|
+
try JSONEncoder().encode(stamp).write(to: saved)
|
|
91
|
+
try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: CraftNativeMedia.root.path)
|
|
92
|
+
var unlinkFailed = false
|
|
93
|
+
do { _ = try CraftNativeMedia.remove(a) } catch { unlinkFailed = true }
|
|
94
|
+
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: CraftNativeMedia.root.path)
|
|
95
|
+
check(unlinkFailed, "a real unlink failure is reported")
|
|
96
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "failed unlink hides revoked A and preserves authorized B")
|
|
97
|
+
let removed = try CraftNativeMedia.remove(a)
|
|
98
|
+
check(removed == 1, "removing A removes only its copy")
|
|
99
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "B stays available after A is revoked")
|
|
100
|
+
} else {
|
|
101
|
+
let stamp = try JSONDecoder().decode(CraftNativeMediaOwnership.Stamp.self, from: Data(contentsOf: saved))
|
|
102
|
+
check(CraftNativeMedia.local(a) == nil && CraftNativeMedia.local(b) != nil, "selective revocation survives a fresh simulator process")
|
|
103
|
+
func obsoleteCompletion() throws {
|
|
104
|
+
let payload = CraftNativeMedia.root.deletingLastPathComponent().appendingPathComponent("obsolete-download.mp4")
|
|
105
|
+
try Data(contentsOf: CraftNativeMedia.local(b)!).write(to: payload)
|
|
106
|
+
defer { try? fm.removeItem(at: payload) }
|
|
107
|
+
let response = HTTPURLResponse(url: URL(string: a)!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
|
108
|
+
let result = CraftNativeMedia.store(a, at: payload, response: response, stamp: stamp)
|
|
109
|
+
if case .failure(let error) = result { check(error.code == "CANCELLED", "obsolete transfer cannot publish after process restart") }
|
|
110
|
+
else { check(false, "obsolete transfer was accepted") }
|
|
111
|
+
}
|
|
112
|
+
try obsoleteCompletion()
|
|
113
|
+
check(keep(a), "a new authorized keep can re-grant A")
|
|
114
|
+
try obsoleteCompletion()
|
|
115
|
+
check(CraftNativeMedia.local(a) != nil && CraftNativeMedia.local(b) != nil, "old completion cannot replace re-granted A or remove B")
|
|
116
|
+
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: CraftNativeMedia.local(b)!))
|
|
117
|
+
_ = try generator.copyCGImage(at: CMTime(seconds: 1, preferredTimescale: 600), actualTime: nil)
|
|
118
|
+
check(true, "unrelated B still decodes offline after revocation and re-grant")
|
|
119
|
+
_ = try CraftNativeMedia.clear()
|
|
120
|
+
try fm.removeItem(at: saved)
|
|
121
|
+
}
|
|
122
|
+
exit(0)
|
|
123
|
+
}
|
|
81
124
|
if phase == "interrupt" {
|
|
82
125
|
_ = CraftNativeMedia.prune(keep: [])
|
|
83
126
|
start(restart) { _ in }
|
|
@@ -105,6 +148,19 @@ check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable
|
|
|
105
148
|
check(keep(broken), "same URL retries successfully")
|
|
106
149
|
check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
|
|
107
150
|
check(!keep(base + "/empty.mp4"), "empty file is refused")
|
|
151
|
+
let signed = base + "/keyed.mp4?token=first"
|
|
152
|
+
let key = "account:one:lesson:7:version:a"
|
|
153
|
+
check(keep(signed, cacheKey: key), "authorized signed URL downloads under account/version identity")
|
|
154
|
+
let identity = CraftNativeMedia.identity(signed, options: ["cacheKey": key])
|
|
155
|
+
let rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", options: ["cacheKey": key])
|
|
156
|
+
check(identity == rotated && CraftNativeMedia.local(rotated) != nil, "renewing a signed URL reuses the complete cached file offline")
|
|
157
|
+
check(keep(base + "/keyed.mp4?token=second", cacheKey: key), "renewed authorization does not redownload unchanged content")
|
|
158
|
+
check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:two:lesson:7:version:a"])) == nil, "another account cannot address the private copy")
|
|
159
|
+
check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:one:lesson:7:version:b"])) == nil, "a new prepared version invalidates the old copy")
|
|
160
|
+
_ = CraftNativeMedia.prune(keep: [rotated])
|
|
161
|
+
check(CraftNativeMedia.local(rotated) != nil, "key-aware prune retains the current authorized source")
|
|
162
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
163
|
+
check(CraftNativeMedia.local(rotated) == nil, "definitive revocation removes the keyed copy")
|
|
108
164
|
let slow = base + "/slow.mp4"
|
|
109
165
|
var answers = [Bool]()
|
|
110
166
|
start(slow) { answers.append($0) }
|
|
@@ -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;
|