craft-native 0.0.451 → 0.0.464
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +44 -5
- 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
|
@@ -61,6 +61,8 @@ final class DriverTests: XCTestCase {
|
|
|
61
61
|
let tabs: [String] = {{TABS}}
|
|
62
62
|
let tabIdentifiers: [String: String] = {{TAB_IDENTIFIERS}}
|
|
63
63
|
private var bundledJourney = false
|
|
64
|
+
private var lastTappedInput: XCUIElement?
|
|
65
|
+
private var lastBooleanTap: [String: Any]?
|
|
64
66
|
|
|
65
67
|
func get(_ path: String, timeout: Double = 15) -> Data? {
|
|
66
68
|
var result: Data?
|
|
@@ -293,8 +295,6 @@ final class DriverTests: XCTestCase {
|
|
|
293
295
|
return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
|
|
294
296
|
}
|
|
295
297
|
|
|
296
|
-
/// A switch reports "0" or "1". Any other value is not a switch state to
|
|
297
|
-
/// wait on, so the tap stands as it would for any other control.
|
|
298
298
|
/// The switch answers `/exec` with 200 whatever the hook did, its first
|
|
299
299
|
/// word the hook's exit code: only `exit 0` is a hook that worked.
|
|
300
300
|
func hookSucceeded(_ answer: String) -> Bool {
|
|
@@ -308,16 +308,26 @@ final class DriverTests: XCTestCase {
|
|
|
308
308
|
return fields.count >= 2 && fields[1] == "0"
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
/// UIKit and Craft's solid toggle both publish "0" or "1", regardless
|
|
312
|
+
/// of the accessibility element type that XCTest assigns to them.
|
|
311
313
|
func switchFlipped(from before: String?, to after: String?) -> Bool {
|
|
312
314
|
let states: Set<String> = ["0", "1"]
|
|
313
|
-
|
|
315
|
+
// A lost accessibility value is missing evidence, not a successful tap.
|
|
316
|
+
guard let before, let after, states.contains(before), states.contains(after) else { return false }
|
|
314
317
|
return before != after
|
|
315
318
|
}
|
|
316
319
|
|
|
317
320
|
/// Deliver one gesture; the command's observation determines success.
|
|
318
321
|
func press(_ element: XCUIElement) {
|
|
322
|
+
// Resolve this before activation: a menu item can disappear as soon
|
|
323
|
+
// as its gesture completes (for example Select All).
|
|
324
|
+
let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
|
|
325
|
+
? element : nil
|
|
319
326
|
if element.isHittable { element.tap() }
|
|
320
327
|
else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
|
|
328
|
+
// A tap on a read-only text view need not open the keyboard. Only a
|
|
329
|
+
// following type command observes whether this target took focus.
|
|
330
|
+
lastTappedInput = input
|
|
321
331
|
}
|
|
322
332
|
|
|
323
333
|
func answerOpenPrompt() {
|
|
@@ -353,6 +363,11 @@ final class DriverTests: XCTestCase {
|
|
|
353
363
|
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
|
|
354
364
|
let command = parts[0]
|
|
355
365
|
let arg = parts.count > 1 ? parts[1] : ""
|
|
366
|
+
// Context changes can move focus without going through press().
|
|
367
|
+
if ["launch", "activate", "terminate", "home", "background", "openurl", "universallink",
|
|
368
|
+
"back", "return", "tab", "tapxy", "edge", "notifications", "systemtap", "longpressid", "exec"].contains(command) {
|
|
369
|
+
lastTappedInput = nil
|
|
370
|
+
}
|
|
356
371
|
if bundledJourney && ["net", "delay", "exec"].contains(command) {
|
|
357
372
|
check(false, "\(command) requires a control-server journey")
|
|
358
373
|
return
|
|
@@ -470,12 +485,15 @@ final class DriverTests: XCTestCase {
|
|
|
470
485
|
if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
|
|
471
486
|
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
472
487
|
case "tapid":
|
|
488
|
+
lastBooleanTap = nil
|
|
473
489
|
let target = identified(arg)
|
|
474
490
|
if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
475
|
-
else if target.elementType == .switch {
|
|
491
|
+
else if target.elementType == .switch || ["0", "1"].contains(target.value as? String ?? "") {
|
|
476
492
|
// Observe one physical gesture. Retapping could conceal a
|
|
477
493
|
// renderer or hit-testing failure (HQ agreement job 7570).
|
|
478
494
|
let before = target.value as? String
|
|
495
|
+
let enabledBefore = target.isEnabled
|
|
496
|
+
let frameBefore = target.frame
|
|
479
497
|
press(target)
|
|
480
498
|
let settle = Date().addingTimeInterval(1.5)
|
|
481
499
|
var flipped = false
|
|
@@ -483,6 +501,15 @@ final class DriverTests: XCTestCase {
|
|
|
483
501
|
flipped = switchFlipped(from: before, to: target.value as? String)
|
|
484
502
|
if !flipped { Thread.sleep(forTimeInterval: 0.25) }
|
|
485
503
|
} while !flipped && Date() < settle
|
|
504
|
+
let observation: [String: Any] = [
|
|
505
|
+
"id": arg, "gestures": 1, "flipped": flipped,
|
|
506
|
+
"enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
|
|
507
|
+
"valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
|
|
508
|
+
"frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
|
|
509
|
+
]
|
|
510
|
+
lastBooleanTap = observation
|
|
511
|
+
if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),
|
|
512
|
+
let json = String(data: data, encoding: .utf8) { note("BOOLEAN_TAP \(json)") }
|
|
486
513
|
check(flipped, "tapid \(arg) flips the switch")
|
|
487
514
|
}
|
|
488
515
|
else { press(target); note("tapid \(arg): ok") }
|
|
@@ -596,9 +623,20 @@ final class DriverTests: XCTestCase {
|
|
|
596
623
|
start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
|
|
597
624
|
note("back: edge swipe")
|
|
598
625
|
}
|
|
599
|
-
case "type":
|
|
626
|
+
case "type":
|
|
627
|
+
let target = lastTappedInput ?? app
|
|
628
|
+
lastTappedInput = nil
|
|
629
|
+
// A returned gesture is not proof of keyboard focus. Observe the
|
|
630
|
+
// one gesture before typing, including WebKit/cold-keyboard focus
|
|
631
|
+
// delivery. Never retap and conceal a dropped input event.
|
|
632
|
+
guard poll(15, { self.hasKeyboardFocus(target) ? target : nil }) != nil else {
|
|
633
|
+
check(false, "type into \(target.label) takes keyboard focus")
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
app.typeText(arg)
|
|
600
637
|
case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
|
|
601
638
|
case "typeid":
|
|
639
|
+
lastTappedInput = nil
|
|
602
640
|
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
603
641
|
let target = identified(idParts[0])
|
|
604
642
|
if target.waitForExistence(timeout: 10) {
|
|
@@ -627,6 +665,7 @@ final class DriverTests: XCTestCase {
|
|
|
627
665
|
let target = XCUIApplication(bundleIdentifier: bundle)
|
|
628
666
|
check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
|
|
629
667
|
case "field":
|
|
668
|
+
lastTappedInput = nil
|
|
630
669
|
let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
631
670
|
let index = Int(fieldParts[0]) ?? 0
|
|
632
671
|
let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
|
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()
|
|
@@ -113,6 +113,32 @@ class DownloadProbe : android.app.Instrumentation() {
|
|
|
113
113
|
}
|
|
114
114
|
else -> {
|
|
115
115
|
CraftNativeMedia.prune(targetContext, emptyList())
|
|
116
|
+
val queuedKey = "account:one:lesson:8:version:a"
|
|
117
|
+
val expired = base + "/queued.mp4?token=expired"
|
|
118
|
+
val renewed = base + "/queued.mp4?token=renewed"
|
|
119
|
+
val queuedIdentity = CraftNativeMedia.identity(expired, JSONObject().put("cacheKey", queuedKey))
|
|
120
|
+
val queuedGeneration = CraftNativeMedia.generation(targetContext)
|
|
121
|
+
targetContext.getSharedPreferences("craft.media", Context.MODE_PRIVATE).edit().putString("source:${CraftNativeMedia.digest(queuedIdentity)}", CraftNativeMedia.digest(expired + "\n")).commit()
|
|
122
|
+
val queuedWork = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
123
|
+
.setInputData(androidx.work.workDataOf("address" to queuedIdentity, "source" to expired, "headers" to "{}", "generation" to queuedGeneration))
|
|
124
|
+
.setInitialDelay(1, java.util.concurrent.TimeUnit.DAYS)
|
|
125
|
+
.addTag("craft-media:generation:$queuedGeneration")
|
|
126
|
+
.build()
|
|
127
|
+
androidx.work.WorkManager.getInstance(targetContext).enqueueUniqueWork("craft-media:${CraftNativeMedia.digest(queuedIdentity)}:any", androidx.work.ExistingWorkPolicy.REPLACE, queuedWork).result.get()
|
|
128
|
+
check(keep(renewed, queuedKey), "a renewed authorized source replaces expired queued work immediately")
|
|
129
|
+
val signed = base + "/keyed.mp4?token=first"
|
|
130
|
+
val key = "account:one:lesson:7:version:a"
|
|
131
|
+
check(keep(signed, key), "signed URL downloads under an account/version identity")
|
|
132
|
+
val identity = CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", key))
|
|
133
|
+
val rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", JSONObject().put("cacheKey", key))
|
|
134
|
+
check(identity == rotated && CraftNativeMedia.local(targetContext, rotated) != null, "renewed signed URLs reuse the complete copy offline")
|
|
135
|
+
check(keep(base + "/keyed.mp4?token=second", key), "renewing authorization does not redownload unchanged content")
|
|
136
|
+
check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:two:lesson:7:version:a"))) == null, "another account cannot address the copy")
|
|
137
|
+
check(CraftNativeMedia.local(targetContext, CraftNativeMedia.identity(signed, JSONObject().put("cacheKey", "account:one:lesson:7:version:b"))) == null, "a new version invalidates the copy")
|
|
138
|
+
CraftNativeMedia.prune(targetContext, listOf(rotated))
|
|
139
|
+
check(CraftNativeMedia.local(targetContext, rotated) != null, "key-aware prune retains the authorized source")
|
|
140
|
+
CraftNativeMedia.prune(targetContext, emptyList())
|
|
141
|
+
check(CraftNativeMedia.local(targetContext, rotated) == null, "revocation removes the keyed copy")
|
|
116
142
|
val broken = "$base/broken.mp4"
|
|
117
143
|
check(!keep(broken), "truncated transfer fails")
|
|
118
144
|
check(CraftNativeMedia.local(targetContext, broken) == null, "truncated transfer is not playable cache")
|
|
@@ -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
|
}
|
|
@@ -105,6 +106,19 @@ check(CraftNativeMedia.local(broken) == nil, "truncated transfer is not playable
|
|
|
105
106
|
check(keep(broken), "same URL retries successfully")
|
|
106
107
|
check(!keep(base + "/partial.mp4"), "unsolicited partial response is refused")
|
|
107
108
|
check(!keep(base + "/empty.mp4"), "empty file is refused")
|
|
109
|
+
let signed = base + "/keyed.mp4?token=first"
|
|
110
|
+
let key = "account:one:lesson:7:version:a"
|
|
111
|
+
check(keep(signed, cacheKey: key), "authorized signed URL downloads under account/version identity")
|
|
112
|
+
let identity = CraftNativeMedia.identity(signed, options: ["cacheKey": key])
|
|
113
|
+
let rotated = CraftNativeMedia.identity(base + "/keyed.mp4?token=second", options: ["cacheKey": key])
|
|
114
|
+
check(identity == rotated && CraftNativeMedia.local(rotated) != nil, "renewing a signed URL reuses the complete cached file offline")
|
|
115
|
+
check(keep(base + "/keyed.mp4?token=second", cacheKey: key), "renewed authorization does not redownload unchanged content")
|
|
116
|
+
check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:two:lesson:7:version:a"])) == nil, "another account cannot address the private copy")
|
|
117
|
+
check(CraftNativeMedia.local(CraftNativeMedia.identity(signed, options: ["cacheKey": "account:one:lesson:7:version:b"])) == nil, "a new prepared version invalidates the old copy")
|
|
118
|
+
_ = CraftNativeMedia.prune(keep: [rotated])
|
|
119
|
+
check(CraftNativeMedia.local(rotated) != nil, "key-aware prune retains the current authorized source")
|
|
120
|
+
_ = CraftNativeMedia.prune(keep: [])
|
|
121
|
+
check(CraftNativeMedia.local(rotated) == nil, "definitive revocation removes the keyed copy")
|
|
108
122
|
let slow = base + "/slow.mp4"
|
|
109
123
|
var answers = [Bool]()
|
|
110
124
|
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;
|