@blotch/angular-lynx-native 22.2.4 → 22.2.6
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/README.md +5 -2
- package/dist/android/run.d.ts +61 -0
- package/dist/android/run.d.ts.map +1 -0
- package/dist/android/run.js +241 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/ios/run.d.ts +61 -0
- package/dist/ios/run.d.ts.map +1 -0
- package/dist/ios/run.js +176 -0
- package/dist/process.d.ts +4 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +9 -2
- package/dist/run.d.ts +21 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +1 -0
- package/package.json +1 -1
- package/templates/android/README.md +13 -0
- package/templates/android/app/build.gradle.kts +2 -0
- package/templates/android/app/src/main/java/dev/angularlynx/shell/DynamicColors.kt +165 -0
- package/templates/android/app/src/main/java/dev/angularlynx/shell/MainActivity.kt +64 -3
- package/templates/android/app/src/main/java/dev/angularlynx/shell/WebAuthModule.kt +77 -0
- package/templates/android/app/src/main/res/values/colors.xml +9 -0
- package/templates/android/app/src/main/res/values/themes.xml +1 -1
- package/templates/android/app/src/main/res/values-night/colors.xml +4 -0
- package/templates/android/app/src/main/res/values-night/themes.xml +1 -1
- package/templates/ios/App/BlurView.swift +149 -0
- package/templates/ios/App/GlassTransitions.swift +408 -0
- package/templates/ios/App/LynxViewController.swift +4 -1
- package/templates/ios/App/WebAuthModule.swift +95 -0
- package/templates/ios/App/XElementRegistry.swift +3 -2
package/dist/ios/run.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { NativeBuildError, capture, run } from "../process.js";
|
|
4
|
+
/**
|
|
5
|
+
* Chooses the simulator `ng build --platform ios --run` opens the app on.
|
|
6
|
+
*
|
|
7
|
+
* With `--device`, the simulator of that name or UDID. Otherwise one that's
|
|
8
|
+
* already started, so the app opens where the developer is looking, and the
|
|
9
|
+
* most recently used one if several are. Otherwise the newest iPhone on the
|
|
10
|
+
* newest iOS runtime, preferring a simulator that exists (Xcode creates one
|
|
11
|
+
* of each iPhone) over creating one.
|
|
12
|
+
*/
|
|
13
|
+
export const pickSimulator = (list, device) => {
|
|
14
|
+
const runtimes = list.runtimes
|
|
15
|
+
.filter((runtime) => runtime.isAvailable &&
|
|
16
|
+
(runtime.platform
|
|
17
|
+
? runtime.platform === 'iOS'
|
|
18
|
+
: runtime.identifier.includes('.iOS-')))
|
|
19
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
20
|
+
if (runtimes.length === 0) {
|
|
21
|
+
throw new NativeBuildError("No iOS Simulator runtime is installed. Install one with `xcodebuild -downloadPlatform iOS`, or in Xcode's settings under Components.");
|
|
22
|
+
}
|
|
23
|
+
// Newest runtime first, as `runtimes` is.
|
|
24
|
+
const simulators = runtimes.flatMap((runtime) => (list.devices[runtime.identifier] ?? [])
|
|
25
|
+
.filter((candidate) => candidate.isAvailable)
|
|
26
|
+
.map((candidate) => ({
|
|
27
|
+
udid: candidate.udid,
|
|
28
|
+
name: candidate.name,
|
|
29
|
+
deviceTypeIdentifier: candidate.deviceTypeIdentifier,
|
|
30
|
+
runtime,
|
|
31
|
+
// `bootstatus` waits for one that's still booting.
|
|
32
|
+
booted: candidate.state === 'Booted' || candidate.state === 'Booting',
|
|
33
|
+
lastUsedAt: candidate.lastUsedAt,
|
|
34
|
+
})));
|
|
35
|
+
if (device) {
|
|
36
|
+
const wanted = device.toLowerCase();
|
|
37
|
+
const matches = simulators.filter((simulator) => simulator.udid.toLowerCase() === wanted ||
|
|
38
|
+
simulator.name.toLowerCase() === wanted);
|
|
39
|
+
const match = matches.find((simulator) => simulator.booted) ?? matches[0];
|
|
40
|
+
if (!match) {
|
|
41
|
+
const names = [...new Set(simulators.map((simulator) => simulator.name))];
|
|
42
|
+
throw new NativeBuildError(`There's no iOS simulator named "${device}". Pass the name or UDID of one of these: ${names.join(', ')}. Or create one in Xcode's DeviceHub.`);
|
|
43
|
+
}
|
|
44
|
+
return match;
|
|
45
|
+
}
|
|
46
|
+
const booted = simulators
|
|
47
|
+
.filter((simulator) => simulator.booted)
|
|
48
|
+
.sort((a, b) => (b.lastUsedAt ?? '').localeCompare(a.lastUsedAt ?? ''));
|
|
49
|
+
if (booted[0]) {
|
|
50
|
+
return booted[0];
|
|
51
|
+
}
|
|
52
|
+
for (const runtime of runtimes) {
|
|
53
|
+
for (const type of iPhonesOf(runtime)) {
|
|
54
|
+
const ofType = simulators.filter((simulator) => simulator.runtime === runtime &&
|
|
55
|
+
simulator.deviceTypeIdentifier === type.identifier);
|
|
56
|
+
const existing = ofType.find((simulator) => simulator.name === type.name) ?? ofType[0];
|
|
57
|
+
if (existing) {
|
|
58
|
+
return existing;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const runtime of runtimes) {
|
|
63
|
+
const [newest] = iPhonesOf(runtime);
|
|
64
|
+
if (newest) {
|
|
65
|
+
return {
|
|
66
|
+
name: newest.name,
|
|
67
|
+
deviceTypeIdentifier: newest.identifier,
|
|
68
|
+
runtime,
|
|
69
|
+
booted: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new NativeBuildError(`No installed iOS runtime runs an iPhone simulator (${runtimes.map((runtime) => runtime.name).join(', ')}).`);
|
|
74
|
+
};
|
|
75
|
+
const iPhonesOf = (runtime) => runtime.supportedDeviceTypes?.filter((type) => type.productFamily === 'iPhone') ?? [];
|
|
76
|
+
/**
|
|
77
|
+
* Orders runtime versions number by number: `27.0` > `26.4` > `26.0.1`.
|
|
78
|
+
*/
|
|
79
|
+
const compareVersions = (a, b) => {
|
|
80
|
+
const left = a.split('.').map(Number);
|
|
81
|
+
const right = b.split('.').map(Number);
|
|
82
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
83
|
+
const difference = (left[i] ?? 0) - (right[i] ?? 0);
|
|
84
|
+
if (difference !== 0) {
|
|
85
|
+
return difference;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return 0;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Picks the simulator for `--run` and starts booting it, so it boots while
|
|
92
|
+
* xcodebuild compiles. The returned target installs and opens the app once
|
|
93
|
+
* it's built.
|
|
94
|
+
*/
|
|
95
|
+
export const startIosSimulator = async ({ device, logFile, log, }) => {
|
|
96
|
+
const output = await capture('xcrun', [
|
|
97
|
+
'simctl',
|
|
98
|
+
'list',
|
|
99
|
+
'--json',
|
|
100
|
+
'devices',
|
|
101
|
+
'runtimes',
|
|
102
|
+
]);
|
|
103
|
+
if (output === undefined) {
|
|
104
|
+
throw new NativeBuildError("Couldn't list the iOS simulators with `xcrun simctl`. Install Xcode and open it once, so it installs its command-line tools and the iOS platform.");
|
|
105
|
+
}
|
|
106
|
+
const simulator = pickSimulator(JSON.parse(output), device);
|
|
107
|
+
const xcrun = (args) => run('xcrun', args, { cwd: process.cwd(), logFile });
|
|
108
|
+
const label = `${simulator.name} (${simulator.runtime.name})`;
|
|
109
|
+
log(simulator.booted
|
|
110
|
+
? `Running the app on the ${label} simulator`
|
|
111
|
+
: `Starting the ${label} simulator`);
|
|
112
|
+
const udid = simulator.udid ??
|
|
113
|
+
(await xcrun([
|
|
114
|
+
'simctl',
|
|
115
|
+
'create',
|
|
116
|
+
simulator.name,
|
|
117
|
+
simulator.deviceTypeIdentifier,
|
|
118
|
+
simulator.runtime.identifier,
|
|
119
|
+
])).trim();
|
|
120
|
+
// Boots the simulator if it isn't, and waits until it is. Awaited only
|
|
121
|
+
// after the build; the empty catch keeps a failed boot from being an
|
|
122
|
+
// unhandled rejection while xcodebuild runs, and the await still throws it.
|
|
123
|
+
const booted = xcrun(['simctl', 'bootstatus', udid, '-b']);
|
|
124
|
+
booted.catch(() => { });
|
|
125
|
+
return {
|
|
126
|
+
launch: async (binary, app) => {
|
|
127
|
+
await booted;
|
|
128
|
+
await showSimulator(udid, logFile, log);
|
|
129
|
+
log(`Installing the app on ${simulator.name}`);
|
|
130
|
+
await xcrun(['simctl', 'install', udid, binary]);
|
|
131
|
+
// Replaces the copy a previous `--run` left open, which would otherwise
|
|
132
|
+
// stay in front, still running the old build.
|
|
133
|
+
await xcrun([
|
|
134
|
+
'simctl',
|
|
135
|
+
'launch',
|
|
136
|
+
'--terminate-running-process',
|
|
137
|
+
udid,
|
|
138
|
+
app.id,
|
|
139
|
+
]);
|
|
140
|
+
log(`Opened ${app.name} on ${simulator.name}`);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Shows the simulator's screen. `simctl` boots a simulator without a
|
|
146
|
+
* window, and where the window comes from depends on Xcode's version:
|
|
147
|
+
*
|
|
148
|
+
* - Xcode 27.1 and later have no Simulator app. Its replacement, DeviceHub,
|
|
149
|
+
* opens to a list of devices and doesn't show a simulator because it
|
|
150
|
+
* booted, but its `devices://device/open?id=<UDID>` URL opens that
|
|
151
|
+
* simulator in a window of its own, launching DeviceHub if it isn't
|
|
152
|
+
* running. With its "Stop simulators on quit" setting on, quitting
|
|
153
|
+
* DeviceHub shuts this simulator down too, though `simctl` booted it; the
|
|
154
|
+
* next `--run` boots it again.
|
|
155
|
+
* - Earlier versions show every booted simulator in the Simulator app.
|
|
156
|
+
* `-CurrentDeviceUDID` picks the one it shows first when it isn't open yet.
|
|
157
|
+
*
|
|
158
|
+
* Both are looked up in the Xcode that `xcode-select` (or DEVELOPER_DIR)
|
|
159
|
+
* selects, the one that built the app. The app runs whether the window opens
|
|
160
|
+
* or not, so a failure here is only reported.
|
|
161
|
+
*/
|
|
162
|
+
const showSimulator = async (udid, logFile, log) => {
|
|
163
|
+
const developerDir = (await capture('xcode-select', ['-p']))?.trim() ?? '';
|
|
164
|
+
const deviceHub = path.join(developerDir, '../Applications/DeviceHub.app');
|
|
165
|
+
const simulatorApp = path.join(developerDir, 'Applications/Simulator.app');
|
|
166
|
+
const args = developerDir && existsSync(deviceHub)
|
|
167
|
+
? ['-a', deviceHub, `devices://device/open?id=${udid}`]
|
|
168
|
+
: developerDir && existsSync(simulatorApp)
|
|
169
|
+
? ['-a', simulatorApp, '--args', '-CurrentDeviceUDID', udid]
|
|
170
|
+
: undefined;
|
|
171
|
+
if (!args) {
|
|
172
|
+
log("Couldn't find Xcode's DeviceHub or Simulator app to show the simulator. The app runs on it anyway.");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
await run('open', args, { cwd: process.cwd(), logFile }).catch((error) => log(`Couldn't show the simulator's window: ${error.message}`));
|
|
176
|
+
};
|
package/dist/process.d.ts
CHANGED
|
@@ -6,12 +6,15 @@ export declare class NativeBuildError extends Error {
|
|
|
6
6
|
* terminal. xcodebuild and Gradle print thousands of lines on success, which
|
|
7
7
|
* would bury the builder's own progress; on failure, the end of the log is
|
|
8
8
|
* almost always the error, so that's what the thrown error quotes.
|
|
9
|
+
*
|
|
10
|
+
* Resolves with the tool's stdout, for the short commands whose answer is
|
|
11
|
+
* there, such as `simctl create` printing the new simulator's UDID.
|
|
9
12
|
*/
|
|
10
13
|
export declare const run: (command: string, args: readonly string[], options: {
|
|
11
14
|
readonly cwd: string;
|
|
12
15
|
readonly logFile: string;
|
|
13
16
|
readonly env?: NodeJS.ProcessEnv;
|
|
14
|
-
}) => Promise<
|
|
17
|
+
}) => Promise<string>;
|
|
15
18
|
/** Runs a command and returns its stdout, or undefined if it fails. */
|
|
16
19
|
export declare const capture: (command: string, args: readonly string[]) => Promise<string | undefined>;
|
|
17
20
|
//# sourceMappingURL=process.d.ts.map
|
package/dist/process.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../src/process.ts"],"names":[],"mappings":"AAIA,8EAA8E;AAC9E,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAQ9C
|
|
1
|
+
{"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../src/process.ts"],"names":[],"mappings":"AAIA,8EAA8E;AAC9E,qBAAa,gBAAiB,SAAQ,KAAK;CAAG;AAQ9C;;;;;;;;GAQG;AACH,eAAO,MAAM,GAAG,GACd,SAAS,MAAM,EACf,MAAM,SAAS,MAAM,EAAE,EACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CAClC,KACA,OAAO,CAAC,MAAM,CAyDhB,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,OAAO,GAClB,SAAS,MAAM,EACf,MAAM,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,MAAM,GAAG,SAAS,CAOzB,CAAC"}
|
package/dist/process.js
CHANGED
|
@@ -14,6 +14,9 @@ const ERROR_LINE = /(^|:\d+(:\d+)?:\s|^\s*e:\s)error:|^error:|^e: /;
|
|
|
14
14
|
* terminal. xcodebuild and Gradle print thousands of lines on success, which
|
|
15
15
|
* would bury the builder's own progress; on failure, the end of the log is
|
|
16
16
|
* almost always the error, so that's what the thrown error quotes.
|
|
17
|
+
*
|
|
18
|
+
* Resolves with the tool's stdout, for the short commands whose answer is
|
|
19
|
+
* there, such as `simctl create` printing the new simulator's UDID.
|
|
17
20
|
*/
|
|
18
21
|
export const run = (command, args, options) => {
|
|
19
22
|
mkdirSync(path.dirname(options.logFile), { recursive: true });
|
|
@@ -26,6 +29,7 @@ export const run = (command, args, options) => {
|
|
|
26
29
|
});
|
|
27
30
|
const recent = [];
|
|
28
31
|
const errors = new Set();
|
|
32
|
+
let stdout = '';
|
|
29
33
|
let partial = '';
|
|
30
34
|
const record = (chunk) => {
|
|
31
35
|
appendFileSync(options.logFile, chunk);
|
|
@@ -42,12 +46,15 @@ export const run = (command, args, options) => {
|
|
|
42
46
|
recent.push(...lines);
|
|
43
47
|
recent.splice(0, Math.max(0, recent.length - TAIL_LINES));
|
|
44
48
|
};
|
|
45
|
-
child.stdout.on('data',
|
|
49
|
+
child.stdout.on('data', (chunk) => {
|
|
50
|
+
stdout += chunk.toString();
|
|
51
|
+
record(chunk);
|
|
52
|
+
});
|
|
46
53
|
child.stderr.on('data', record);
|
|
47
54
|
child.on('error', (error) => reject(new NativeBuildError(`Could not run ${command}: ${error.message}`)));
|
|
48
55
|
child.on('close', (code) => {
|
|
49
56
|
if (code === 0) {
|
|
50
|
-
resolve();
|
|
57
|
+
resolve(stdout);
|
|
51
58
|
return;
|
|
52
59
|
}
|
|
53
60
|
const detail = errors.size > 0
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ResolvedApp } from './options.ts';
|
|
2
|
+
/** `ng build --platform <platform> --run`'s options. */
|
|
3
|
+
export type RunOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Where to open the app. On iOS, a simulator's name or UDID; on Android, a
|
|
6
|
+
* virtual device's name or an adb serial. When it's left out, a running
|
|
7
|
+
* simulator, emulator or phone, or else the newest iPhone simulator or the
|
|
8
|
+
* first virtual device.
|
|
9
|
+
*/
|
|
10
|
+
readonly device?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A simulator, emulator or phone picked for `--run`. Picking it and starting
|
|
14
|
+
* it come before the native build, so a device that doesn't exist fails in
|
|
15
|
+
* seconds, and a simulator or emulator boots while Xcode or Gradle compiles.
|
|
16
|
+
*/
|
|
17
|
+
export type RunTarget = {
|
|
18
|
+
/** Waits for the device to finish starting, then installs and opens the app. */
|
|
19
|
+
readonly launch: (binary: string, app: ResolvedApp) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,wDAAwD;AACxD,MAAM,MAAM,UAAU,GAAG;IACvB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,gFAAgF;IAChF,QAAQ,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtE,CAAC"}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -34,11 +34,24 @@ Build commands and outputs (no flavors, so the names are fixed):
|
|
|
34
34
|
|
|
35
35
|
Back is off by default, so it leaves the app like any root screen. The runtime turns it on while the Router has history by calling `NativeModules.AngularLynxShell.setBackHandlerEnabled(true)`, and off again on the first screen (`LynxBackButton` in `packages/runtime/src/lib/router/lynx-back-button.ts`, which `provideRouter()` starts; the call goes through the background thread, where `NativeModules` lives). While it's on, a back press sends the global event `angularLynxBack` instead, which the runtime turns into `Location.back()`. The app has to say beforehand because Android decides synchronously, at the start of the gesture, whether an app handles back (predictive back animates differently). Layer 6's `native/back-button.yaml` checks both ends.
|
|
36
36
|
|
|
37
|
+
## Web sign-in
|
|
38
|
+
|
|
39
|
+
`NativeModules.AngularLynxWebAuth.start(request, callback)` (`WebAuthModule.kt`, behind the runtime's `LynxWebAuthSession`) opens `request.url` in a Custom Tab (`androidx.browser`). The page's redirect to `request.callbackScheme` comes back as a deep link: the activity is `singleTask`, so the intent reaches the open one in `onNewIntent`, which hands the URL to the callback, and starting it closes the Custom Tab above it. That's why the scheme has to be in the app's `deepLinkSchemes`. Android says nothing when a Custom Tab is closed, so the activity resuming with a session still open, after having gone behind the tab, means the person canceled. iOS's `WebAuthModule.swift` reports the same results through `ASWebAuthenticationSession`.
|
|
40
|
+
|
|
41
|
+
## Global props
|
|
42
|
+
|
|
43
|
+
`MainActivity.globalProps()` sends what the runtime reads and Lynx has no API for (see the package README), again on every configuration change and accessibility setting change.
|
|
44
|
+
|
|
45
|
+
- **Dynamic color.** On Android 12 and later, `dynamicColors` carries the wallpaper's colors as Material 3's color roles, light and dark, as hex colors such as `#6750a4` (`DynamicColors.kt`, read by `LynxTheme.dynamicColors`). Android 14 has a color resource for most roles, which follows its contrast setting; on Android 12 and 13 each role takes its tone from the five tonal palettes, as Material Components does (a palette's own shade, or tone 40 moved to the tone with `ColorStateList.withLStar`). On the Android 14 emulator the two agree to within a few levels on the surfaces and exactly elsewhere.
|
|
46
|
+
- **A new wallpaper doesn't restart the activity.** Android applies the new colors as a configuration change, which `onConfigurationChanged` handles like a dark mode switch, so the running app restyles with its state intact.
|
|
47
|
+
- **The window follows dark mode by hand.** `configChanges` keeps the activity through a dark mode switch, so the window keeps the theme it started with. `onConfigurationChanged` sets its background (`@color/window_background`, which shows behind the system bars) and the bars' icons (`enableEdgeToEdge()`) again.
|
|
48
|
+
|
|
37
49
|
## Versions
|
|
38
50
|
|
|
39
51
|
Pinned in `app/build.gradle.kts`, `build.gradle.kts` and `gradle/wrapper/gradle-wrapper.properties`:
|
|
40
52
|
|
|
41
53
|
- Lynx 4.1.0 and PrimJS 4.1.1 (Maven Central), Fresco 2.3.0 (the version Lynx's image service is built against), OkHttp 4.12.
|
|
54
|
+
- `androidx.browser` 1.8.0, for the Custom Tabs `WebAuthModule` opens sign-in pages in.
|
|
42
55
|
- Lynx's XElement 4.1.0 (`<input>`, `<textarea>`, `<overlay>`, `<svg>`, `<blur-view>`, `<viewpager>`, `<refresh>`, the `<scroll-coordinator>` family, `<webview>`, `<video>`), without its AnimaX and Markdown modules, which the runtime doesn't support. `MainActivity` registers the elements with `XElementBehaviors`. XElement depends on appcompat and material 1.0.0, whose vectordrawable libraries share a namespace that AGP 9 rejects, so appcompat 1.7.1 and material 1.12.0 are pinned.
|
|
43
56
|
- AGP 9.4.1 with its built-in Kotlin, Gradle 9.6.1 (AGP 9.4's minimum), JDK 17 or newer.
|
|
44
57
|
- `compileSdk` and `targetSdk` 36, `minSdk` 24. Google Play raises its target API requirement every August; bump `targetSdk` with it.
|
|
@@ -118,6 +118,8 @@ android {
|
|
|
118
118
|
dependencies {
|
|
119
119
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
|
120
120
|
implementation("androidx.core:core-ktx:1.16.0")
|
|
121
|
+
// Custom Tabs, which WebAuthModule opens sign-in pages in.
|
|
122
|
+
implementation("androidx.browser:browser:1.8.0")
|
|
121
123
|
|
|
122
124
|
// The Lynx engine, its JavaScript runtime (PrimJS) and its JS SDK.
|
|
123
125
|
implementation("org.lynxsdk.lynx:lynx:$lynxVersion")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package dev.angularlynx.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.res.ColorStateList
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import androidx.annotation.RequiresApi
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The colors Android 12 and later take from the user's wallpaper (Material
|
|
10
|
+
* You's dynamic color), for the `dynamicColors` global prop, which the
|
|
11
|
+
* runtime's `LynxTheme.dynamicColors` reads: Material 3's color roles, by
|
|
12
|
+
* their names in Material (`primary`, `onSurfaceVariant`), as hex colors such
|
|
13
|
+
* as `#6750a4`. Both the light and the dark scheme are sent, since an app can
|
|
14
|
+
* keep its own light or dark theme rather than follow the phone's.
|
|
15
|
+
*
|
|
16
|
+
* Android 14 has a color for most roles (`system_primary_light` and so on),
|
|
17
|
+
* which also follow its contrast setting. Android 12 and 13 have only the five
|
|
18
|
+
* tonal palettes the roles come from, at 13 tones each, so each role takes its
|
|
19
|
+
* tone from its palette as Material Components does on those versions (its
|
|
20
|
+
* `m3_sys_color_dynamic_*` colors): the palette's own color at that tone, or,
|
|
21
|
+
* for a tone in between, its tone 40 moved there with `withLStar`, which keeps
|
|
22
|
+
* the hue and chroma.
|
|
23
|
+
*/
|
|
24
|
+
object DynamicColors {
|
|
25
|
+
/** The `dynamicColors` global prop, or null before Android 12. */
|
|
26
|
+
fun read(context: Context): Map<String, Any>? {
|
|
27
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null
|
|
28
|
+
return mapOf(
|
|
29
|
+
"light" to scheme(context, light = true),
|
|
30
|
+
"dark" to scheme(context, light = false),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@RequiresApi(Build.VERSION_CODES.S)
|
|
35
|
+
private fun scheme(context: Context, light: Boolean): Map<String, String> =
|
|
36
|
+
ROLES.associate { role ->
|
|
37
|
+
val systemColor = if (light) role.lightColor else role.darkColor
|
|
38
|
+
val color = if (systemColor != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
39
|
+
context.getColor(systemColor)
|
|
40
|
+
} else {
|
|
41
|
+
(if (light) role.lightTone else role.darkTone).color(context)
|
|
42
|
+
}
|
|
43
|
+
role.name to "#%06x".format(color and 0xffffff)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private enum class Palette(vararg val shades: Int) {
|
|
47
|
+
PRIMARY(
|
|
48
|
+
android.R.color.system_accent1_0, android.R.color.system_accent1_10,
|
|
49
|
+
android.R.color.system_accent1_50, android.R.color.system_accent1_100,
|
|
50
|
+
android.R.color.system_accent1_200, android.R.color.system_accent1_300,
|
|
51
|
+
android.R.color.system_accent1_400, android.R.color.system_accent1_500,
|
|
52
|
+
android.R.color.system_accent1_600, android.R.color.system_accent1_700,
|
|
53
|
+
android.R.color.system_accent1_800, android.R.color.system_accent1_900,
|
|
54
|
+
android.R.color.system_accent1_1000,
|
|
55
|
+
),
|
|
56
|
+
SECONDARY(
|
|
57
|
+
android.R.color.system_accent2_0, android.R.color.system_accent2_10,
|
|
58
|
+
android.R.color.system_accent2_50, android.R.color.system_accent2_100,
|
|
59
|
+
android.R.color.system_accent2_200, android.R.color.system_accent2_300,
|
|
60
|
+
android.R.color.system_accent2_400, android.R.color.system_accent2_500,
|
|
61
|
+
android.R.color.system_accent2_600, android.R.color.system_accent2_700,
|
|
62
|
+
android.R.color.system_accent2_800, android.R.color.system_accent2_900,
|
|
63
|
+
android.R.color.system_accent2_1000,
|
|
64
|
+
),
|
|
65
|
+
TERTIARY(
|
|
66
|
+
android.R.color.system_accent3_0, android.R.color.system_accent3_10,
|
|
67
|
+
android.R.color.system_accent3_50, android.R.color.system_accent3_100,
|
|
68
|
+
android.R.color.system_accent3_200, android.R.color.system_accent3_300,
|
|
69
|
+
android.R.color.system_accent3_400, android.R.color.system_accent3_500,
|
|
70
|
+
android.R.color.system_accent3_600, android.R.color.system_accent3_700,
|
|
71
|
+
android.R.color.system_accent3_800, android.R.color.system_accent3_900,
|
|
72
|
+
android.R.color.system_accent3_1000,
|
|
73
|
+
),
|
|
74
|
+
NEUTRAL(
|
|
75
|
+
android.R.color.system_neutral1_0, android.R.color.system_neutral1_10,
|
|
76
|
+
android.R.color.system_neutral1_50, android.R.color.system_neutral1_100,
|
|
77
|
+
android.R.color.system_neutral1_200, android.R.color.system_neutral1_300,
|
|
78
|
+
android.R.color.system_neutral1_400, android.R.color.system_neutral1_500,
|
|
79
|
+
android.R.color.system_neutral1_600, android.R.color.system_neutral1_700,
|
|
80
|
+
android.R.color.system_neutral1_800, android.R.color.system_neutral1_900,
|
|
81
|
+
android.R.color.system_neutral1_1000,
|
|
82
|
+
),
|
|
83
|
+
NEUTRAL_VARIANT(
|
|
84
|
+
android.R.color.system_neutral2_0, android.R.color.system_neutral2_10,
|
|
85
|
+
android.R.color.system_neutral2_50, android.R.color.system_neutral2_100,
|
|
86
|
+
android.R.color.system_neutral2_200, android.R.color.system_neutral2_300,
|
|
87
|
+
android.R.color.system_neutral2_400, android.R.color.system_neutral2_500,
|
|
88
|
+
android.R.color.system_neutral2_600, android.R.color.system_neutral2_700,
|
|
89
|
+
android.R.color.system_neutral2_800, android.R.color.system_neutral2_900,
|
|
90
|
+
android.R.color.system_neutral2_1000,
|
|
91
|
+
),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The tone of each of a palette's shades (`system_accent1_0` is 100). */
|
|
95
|
+
private val SHADE_TONES = intArrayOf(100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0)
|
|
96
|
+
|
|
97
|
+
private class Tone(val palette: Palette, val tone: Int) {
|
|
98
|
+
@RequiresApi(Build.VERSION_CODES.S)
|
|
99
|
+
fun color(context: Context): Int {
|
|
100
|
+
val shade = SHADE_TONES.indexOf(tone)
|
|
101
|
+
if (shade >= 0) return context.getColor(palette.shades[shade])
|
|
102
|
+
val base = context.getColor(palette.shades[SHADE_TONES.indexOf(40)])
|
|
103
|
+
return ColorStateList.valueOf(base).withLStar(tone.toFloat()).defaultColor
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A role, and its color on Android 14, where it has one. */
|
|
108
|
+
private class Role(
|
|
109
|
+
val name: String,
|
|
110
|
+
val lightTone: Tone,
|
|
111
|
+
val darkTone: Tone,
|
|
112
|
+
val lightColor: Int?,
|
|
113
|
+
val darkColor: Int?,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
private fun role(
|
|
117
|
+
name: String,
|
|
118
|
+
palette: Palette,
|
|
119
|
+
lightTone: Int,
|
|
120
|
+
darkTone: Int,
|
|
121
|
+
lightColor: Int?,
|
|
122
|
+
darkColor: Int?,
|
|
123
|
+
) = Role(name, Tone(palette, lightTone), Tone(palette, darkTone), lightColor, darkColor)
|
|
124
|
+
|
|
125
|
+
// Each role's palette and tones are Material Components' (1.12) for
|
|
126
|
+
// Android 12 and 13, and its colors Android 14's. Material Components
|
|
127
|
+
// makes the surfaces from the neutral variant palette. Android 14 has no
|
|
128
|
+
// inverse roles: the inverse primary is the other scheme's primary, and
|
|
129
|
+
// the inverse surfaces come from the palette on every version, at
|
|
130
|
+
// Material's tones. (Material Components gives them the other scheme's
|
|
131
|
+
// surface, tone 6 or 98, which is further from the surface than Material
|
|
132
|
+
// has them.)
|
|
133
|
+
private val ROLES = listOf(
|
|
134
|
+
role("primary", Palette.PRIMARY, 40, 80, android.R.color.system_primary_light, android.R.color.system_primary_dark),
|
|
135
|
+
role("onPrimary", Palette.PRIMARY, 100, 20, android.R.color.system_on_primary_light, android.R.color.system_on_primary_dark),
|
|
136
|
+
role("primaryContainer", Palette.PRIMARY, 90, 30, android.R.color.system_primary_container_light, android.R.color.system_primary_container_dark),
|
|
137
|
+
role("onPrimaryContainer", Palette.PRIMARY, 10, 90, android.R.color.system_on_primary_container_light, android.R.color.system_on_primary_container_dark),
|
|
138
|
+
role("inversePrimary", Palette.PRIMARY, 80, 40, android.R.color.system_primary_dark, android.R.color.system_primary_light),
|
|
139
|
+
role("secondary", Palette.SECONDARY, 40, 80, android.R.color.system_secondary_light, android.R.color.system_secondary_dark),
|
|
140
|
+
role("onSecondary", Palette.SECONDARY, 100, 20, android.R.color.system_on_secondary_light, android.R.color.system_on_secondary_dark),
|
|
141
|
+
role("secondaryContainer", Palette.SECONDARY, 90, 30, android.R.color.system_secondary_container_light, android.R.color.system_secondary_container_dark),
|
|
142
|
+
role("onSecondaryContainer", Palette.SECONDARY, 10, 90, android.R.color.system_on_secondary_container_light, android.R.color.system_on_secondary_container_dark),
|
|
143
|
+
role("tertiary", Palette.TERTIARY, 40, 80, android.R.color.system_tertiary_light, android.R.color.system_tertiary_dark),
|
|
144
|
+
role("onTertiary", Palette.TERTIARY, 100, 20, android.R.color.system_on_tertiary_light, android.R.color.system_on_tertiary_dark),
|
|
145
|
+
role("tertiaryContainer", Palette.TERTIARY, 90, 30, android.R.color.system_tertiary_container_light, android.R.color.system_tertiary_container_dark),
|
|
146
|
+
role("onTertiaryContainer", Palette.TERTIARY, 10, 90, android.R.color.system_on_tertiary_container_light, android.R.color.system_on_tertiary_container_dark),
|
|
147
|
+
role("background", Palette.NEUTRAL_VARIANT, 98, 6, android.R.color.system_background_light, android.R.color.system_background_dark),
|
|
148
|
+
role("onBackground", Palette.NEUTRAL, 10, 90, android.R.color.system_on_background_light, android.R.color.system_on_background_dark),
|
|
149
|
+
role("surface", Palette.NEUTRAL_VARIANT, 98, 6, android.R.color.system_surface_light, android.R.color.system_surface_dark),
|
|
150
|
+
role("onSurface", Palette.NEUTRAL, 10, 90, android.R.color.system_on_surface_light, android.R.color.system_on_surface_dark),
|
|
151
|
+
role("surfaceVariant", Palette.NEUTRAL_VARIANT, 90, 30, android.R.color.system_surface_variant_light, android.R.color.system_surface_variant_dark),
|
|
152
|
+
role("onSurfaceVariant", Palette.NEUTRAL_VARIANT, 30, 80, android.R.color.system_on_surface_variant_light, android.R.color.system_on_surface_variant_dark),
|
|
153
|
+
role("surfaceDim", Palette.NEUTRAL_VARIANT, 87, 6, android.R.color.system_surface_dim_light, android.R.color.system_surface_dim_dark),
|
|
154
|
+
role("surfaceBright", Palette.NEUTRAL_VARIANT, 98, 24, android.R.color.system_surface_bright_light, android.R.color.system_surface_bright_dark),
|
|
155
|
+
role("surfaceContainerLowest", Palette.NEUTRAL_VARIANT, 100, 4, android.R.color.system_surface_container_lowest_light, android.R.color.system_surface_container_lowest_dark),
|
|
156
|
+
role("surfaceContainerLow", Palette.NEUTRAL_VARIANT, 96, 10, android.R.color.system_surface_container_low_light, android.R.color.system_surface_container_low_dark),
|
|
157
|
+
role("surfaceContainer", Palette.NEUTRAL_VARIANT, 94, 12, android.R.color.system_surface_container_light, android.R.color.system_surface_container_dark),
|
|
158
|
+
role("surfaceContainerHigh", Palette.NEUTRAL_VARIANT, 92, 17, android.R.color.system_surface_container_high_light, android.R.color.system_surface_container_high_dark),
|
|
159
|
+
role("surfaceContainerHighest", Palette.NEUTRAL_VARIANT, 90, 22, android.R.color.system_surface_container_highest_light, android.R.color.system_surface_container_highest_dark),
|
|
160
|
+
role("inverseSurface", Palette.NEUTRAL, 20, 90, null, null),
|
|
161
|
+
role("inverseOnSurface", Palette.NEUTRAL, 95, 20, null, null),
|
|
162
|
+
role("outline", Palette.NEUTRAL_VARIANT, 50, 60, android.R.color.system_outline_light, android.R.color.system_outline_dark),
|
|
163
|
+
role("outlineVariant", Palette.NEUTRAL_VARIANT, 80, 30, android.R.color.system_outline_variant_light, android.R.color.system_outline_variant_dark),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
package dev.angularlynx.shell
|
|
2
2
|
|
|
3
3
|
import android.app.UiModeManager
|
|
4
|
+
import android.content.ActivityNotFoundException
|
|
5
|
+
import android.content.Intent
|
|
4
6
|
import android.content.res.Configuration
|
|
5
7
|
import android.database.ContentObserver
|
|
8
|
+
import android.graphics.drawable.ColorDrawable
|
|
9
|
+
import android.net.Uri
|
|
6
10
|
import android.os.Build
|
|
7
11
|
import android.os.Bundle
|
|
8
12
|
import android.os.Handler
|
|
@@ -14,6 +18,7 @@ import android.widget.FrameLayout
|
|
|
14
18
|
import androidx.activity.ComponentActivity
|
|
15
19
|
import androidx.activity.OnBackPressedCallback
|
|
16
20
|
import androidx.activity.enableEdgeToEdge
|
|
21
|
+
import androidx.browser.customtabs.CustomTabsIntent
|
|
17
22
|
import androidx.core.graphics.Insets
|
|
18
23
|
import androidx.core.os.ConfigurationCompat
|
|
19
24
|
import androidx.core.view.ViewCompat
|
|
@@ -30,7 +35,7 @@ import com.lynx.xelement.XElementBehaviors
|
|
|
30
35
|
* The whole app: one full-screen LynxView running the Angular app. Angular
|
|
31
36
|
* Router navigates inside it, so there is no native navigation stack.
|
|
32
37
|
*/
|
|
33
|
-
class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
38
|
+
class MainActivity : ComponentActivity(), ShellModule.Host, WebAuthModule.Host {
|
|
34
39
|
private lateinit var lynxView: LynxView
|
|
35
40
|
|
|
36
41
|
// The system bar and cutout insets, and whether the screen has a cutout,
|
|
@@ -56,6 +61,13 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// The open sign-in (see WebAuthModule): the scheme its page returns to,
|
|
65
|
+
// who to tell, and whether the app has gone behind the Custom Tab yet.
|
|
66
|
+
private class WebAuthSession(val scheme: String, val done: (WebAuthModule.Result) -> Unit) {
|
|
67
|
+
var opened = false
|
|
68
|
+
}
|
|
69
|
+
private var webAuthSession: WebAuthSession? = null
|
|
70
|
+
|
|
59
71
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
60
72
|
// Android 15+ always draws apps edge to edge; opting in on older
|
|
61
73
|
// versions too keeps the layout the same everywhere.
|
|
@@ -69,6 +81,7 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
69
81
|
setEnableGenericResourceFetcher(LynxBooleanOption.TRUE)
|
|
70
82
|
setGenericResourceFetcher(AssetGenericResourceFetcher(this@MainActivity))
|
|
71
83
|
registerModule(ShellModule.NAME, ShellModule::class.java, this@MainActivity)
|
|
84
|
+
registerModule(WebAuthModule.NAME, WebAuthModule::class.java, this@MainActivity)
|
|
72
85
|
// Lynx's core has no <input>, <overlay>, <svg>, <blur-view> etc.;
|
|
73
86
|
// these come from XElement (see app/build.gradle.kts). Lynx
|
|
74
87
|
// Explorer registers them the same way.
|
|
@@ -135,15 +148,53 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
135
148
|
backHandler.isEnabled = enabled
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
override fun startWebAuthSession(url: Uri, callbackScheme: String, done: (WebAuthModule.Result) -> Unit) {
|
|
152
|
+
if (webAuthSession != null) {
|
|
153
|
+
done(WebAuthModule.Result.Failed("A sign-in is already open."))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
val session = WebAuthSession(callbackScheme.lowercase(), done)
|
|
157
|
+
webAuthSession = session
|
|
158
|
+
try {
|
|
159
|
+
CustomTabsIntent.Builder().build().launchUrl(this, url)
|
|
160
|
+
} catch (_: ActivityNotFoundException) {
|
|
161
|
+
webAuthSession = null
|
|
162
|
+
done(WebAuthModule.Result.Failed("No browser can open the sign-in page."))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The page's redirect to the callback scheme. The activity is singleTask,
|
|
167
|
+
// so the deep link comes back to this one, which closes the Custom Tab
|
|
168
|
+
// above it. It arrives before onResume, which then finds no session open.
|
|
169
|
+
override fun onNewIntent(intent: Intent) {
|
|
170
|
+
super.onNewIntent(intent)
|
|
171
|
+
val url = intent.data ?: return
|
|
172
|
+
val session = webAuthSession ?: return
|
|
173
|
+
if (url.scheme?.lowercase() != session.scheme) return
|
|
174
|
+
webAuthSession = null
|
|
175
|
+
session.done(WebAuthModule.Result.Returned(url.toString()))
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
// The manifest's configChanges keeps the activity, and the running app,
|
|
139
179
|
// through a dark mode switch, so the new theme is sent from here. A
|
|
140
180
|
// language change still restarts the activity, which sends the new
|
|
141
|
-
// appLocale: Angular reads LOCALE_ID only once.
|
|
181
|
+
// appLocale: Angular reads LOCALE_ID only once. A new wallpaper arrives
|
|
182
|
+
// here too, without a restart, and globalProps() reads its colors again.
|
|
142
183
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
143
184
|
super.onConfigurationChanged(newConfig)
|
|
185
|
+
updateWindowAppearance()
|
|
144
186
|
lynxView.updateGlobalProps(globalProps(newConfig))
|
|
145
187
|
}
|
|
146
188
|
|
|
189
|
+
// Kept through a dark mode switch, the window keeps the theme it started
|
|
190
|
+
// with: its background, which shows behind the system bars, and the
|
|
191
|
+
// bars' icons, which enableEdgeToEdge() picked for the old mode. Both are
|
|
192
|
+
// set again for the new one, from the same resources.
|
|
193
|
+
private fun updateWindowAppearance() {
|
|
194
|
+
window.setBackgroundDrawable(ColorDrawable(getColor(R.color.window_background)))
|
|
195
|
+
enableEdgeToEdge()
|
|
196
|
+
}
|
|
197
|
+
|
|
147
198
|
private fun sendGlobalProps() {
|
|
148
199
|
lynxView.updateGlobalProps(globalProps(resources.configuration))
|
|
149
200
|
}
|
|
@@ -153,7 +204,9 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
153
204
|
* (LynxSafeArea), `appLocale` (LynxLocale), the accessibility settings
|
|
154
205
|
* (LynxAccessibilitySettings) and `osVersion` (LynxSystemInfo), plus the
|
|
155
206
|
* safe-area insets in dp. The iOS shell sends the same keys, and Lynx
|
|
156
|
-
* Explorer sends theme and isNotchScreen too.
|
|
207
|
+
* Explorer sends theme and isNotchScreen too. Android 12 and later also
|
|
208
|
+
* send the wallpaper's colors, `dynamicColors` (LynxTheme.dynamicColors),
|
|
209
|
+
* which iOS has no equivalent of.
|
|
157
210
|
*/
|
|
158
211
|
private fun globalProps(config: Configuration): Map<String, Any> {
|
|
159
212
|
val density = resources.displayMetrics.density
|
|
@@ -175,6 +228,7 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
175
228
|
)
|
|
176
229
|
// The user's language as a BCP 47 tag, such as "fr-FR".
|
|
177
230
|
ConfigurationCompat.getLocales(config)[0]?.let { props["appLocale"] = it.toLanguageTag() }
|
|
231
|
+
DynamicColors.read(this)?.let { props["dynamicColors"] = it }
|
|
178
232
|
return props
|
|
179
233
|
}
|
|
180
234
|
|
|
@@ -197,10 +251,17 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
197
251
|
override fun onResume() {
|
|
198
252
|
super.onResume()
|
|
199
253
|
lynxView.onEnterForeground()
|
|
254
|
+
// Back from a Custom Tab without its redirect: the person closed it.
|
|
255
|
+
// Android says nothing else when a Custom Tab closes.
|
|
256
|
+
webAuthSession?.takeIf { it.opened }?.let {
|
|
257
|
+
webAuthSession = null
|
|
258
|
+
it.done(WebAuthModule.Result.Canceled)
|
|
259
|
+
}
|
|
200
260
|
}
|
|
201
261
|
|
|
202
262
|
override fun onPause() {
|
|
203
263
|
lynxView.onEnterBackground()
|
|
264
|
+
webAuthSession?.opened = true
|
|
204
265
|
super.onPause()
|
|
205
266
|
}
|
|
206
267
|
|