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
|
@@ -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?
|
|
@@ -245,6 +247,9 @@ final class DriverTests: XCTestCase {
|
|
|
245
247
|
/// nor any descendant has keyboard focus". A later tap lands once the app
|
|
246
248
|
/// is idle again.
|
|
247
249
|
func focus(_ target: XCUIElement, seconds: Double = 15) -> Bool {
|
|
250
|
+
// Retapping an already focused field can collapse its selection. A
|
|
251
|
+
// caller that just selected text must type into that same selection.
|
|
252
|
+
if hasKeyboardFocus(target) { return true }
|
|
248
253
|
let end = Date().addingTimeInterval(seconds)
|
|
249
254
|
repeat {
|
|
250
255
|
target.tap()
|
|
@@ -290,24 +295,39 @@ final class DriverTests: XCTestCase {
|
|
|
290
295
|
return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
|
|
291
296
|
}
|
|
292
297
|
|
|
293
|
-
/// A switch reports "0" or "1". Any other value is not a switch state to
|
|
294
|
-
/// wait on, so the tap stands as it would for any other control.
|
|
295
298
|
/// The switch answers `/exec` with 200 whatever the hook did, its first
|
|
296
299
|
/// word the hook's exit code: only `exit 0` is a hook that worked.
|
|
297
300
|
func hookSucceeded(_ answer: String) -> Bool {
|
|
298
|
-
|
|
301
|
+
// get() already requires HTTP success. Custom control servers may
|
|
302
|
+
// return a message or no body. Preserve the legacy shell protocol's
|
|
303
|
+
// explicit failures, without accepting "exit 01" as a zero status.
|
|
304
|
+
let body = answer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
305
|
+
if body == "no such hook" { return false }
|
|
306
|
+
let fields = body.split(whereSeparator: { $0.isWhitespace })
|
|
307
|
+
guard fields.first == "exit" else { return true }
|
|
308
|
+
return fields.count >= 2 && fields[1] == "0"
|
|
299
309
|
}
|
|
300
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.
|
|
301
313
|
func switchFlipped(from before: String?, to after: String?) -> Bool {
|
|
302
314
|
let states: Set<String> = ["0", "1"]
|
|
303
|
-
|
|
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 }
|
|
304
317
|
return before != after
|
|
305
318
|
}
|
|
306
319
|
|
|
307
320
|
/// Deliver one gesture; the command's observation determines success.
|
|
308
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
|
|
309
326
|
if element.isHittable { element.tap() }
|
|
310
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
|
|
311
331
|
}
|
|
312
332
|
|
|
313
333
|
func answerOpenPrompt() {
|
|
@@ -343,6 +363,11 @@ final class DriverTests: XCTestCase {
|
|
|
343
363
|
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
|
|
344
364
|
let command = parts[0]
|
|
345
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
|
+
}
|
|
346
371
|
if bundledJourney && ["net", "delay", "exec"].contains(command) {
|
|
347
372
|
check(false, "\(command) requires a control-server journey")
|
|
348
373
|
return
|
|
@@ -460,12 +485,15 @@ final class DriverTests: XCTestCase {
|
|
|
460
485
|
if let target = poll(10, { exact(arg) }) { press(target); note("tapexact \(arg): ok") }
|
|
461
486
|
else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
|
|
462
487
|
case "tapid":
|
|
488
|
+
lastBooleanTap = nil
|
|
463
489
|
let target = identified(arg)
|
|
464
490
|
if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
465
|
-
else if target.elementType == .switch {
|
|
491
|
+
else if target.elementType == .switch || ["0", "1"].contains(target.value as? String ?? "") {
|
|
466
492
|
// Observe one physical gesture. Retapping could conceal a
|
|
467
493
|
// renderer or hit-testing failure (HQ agreement job 7570).
|
|
468
494
|
let before = target.value as? String
|
|
495
|
+
let enabledBefore = target.isEnabled
|
|
496
|
+
let frameBefore = target.frame
|
|
469
497
|
press(target)
|
|
470
498
|
let settle = Date().addingTimeInterval(1.5)
|
|
471
499
|
var flipped = false
|
|
@@ -473,6 +501,15 @@ final class DriverTests: XCTestCase {
|
|
|
473
501
|
flipped = switchFlipped(from: before, to: target.value as? String)
|
|
474
502
|
if !flipped { Thread.sleep(forTimeInterval: 0.25) }
|
|
475
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)") }
|
|
476
513
|
check(flipped, "tapid \(arg) flips the switch")
|
|
477
514
|
}
|
|
478
515
|
else { press(target); note("tapid \(arg): ok") }
|
|
@@ -482,6 +519,12 @@ final class DriverTests: XCTestCase {
|
|
|
482
519
|
check(false, "longpressid \(arg) is hittable")
|
|
483
520
|
return
|
|
484
521
|
}
|
|
522
|
+
if [.textField, .secureTextField, .textView].contains(target.elementType) {
|
|
523
|
+
guard focus(target) else {
|
|
524
|
+
check(false, "longpressid \(arg) takes keyboard focus")
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
}
|
|
485
528
|
target.press(forDuration: 1.2)
|
|
486
529
|
note("longpressid \(arg): ok")
|
|
487
530
|
case "holdtab":
|
|
@@ -580,9 +623,20 @@ final class DriverTests: XCTestCase {
|
|
|
580
623
|
start.press(forDuration: 0.05, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)))
|
|
581
624
|
note("back: edge swipe")
|
|
582
625
|
}
|
|
583
|
-
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)
|
|
584
637
|
case "return": app.typeText(XCUIKeyboardKey.return.rawValue); note("return")
|
|
585
638
|
case "typeid":
|
|
639
|
+
lastTappedInput = nil
|
|
586
640
|
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
587
641
|
let target = identified(idParts[0])
|
|
588
642
|
if target.waitForExistence(timeout: 10) {
|
|
@@ -611,6 +665,7 @@ final class DriverTests: XCTestCase {
|
|
|
611
665
|
let target = XCUIApplication(bundleIdentifier: bundle)
|
|
612
666
|
check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
|
|
613
667
|
case "field":
|
|
668
|
+
lastTappedInput = nil
|
|
614
669
|
let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
615
670
|
let index = Int(fieldParts[0]) ?? 0
|
|
616
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;
|