@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.
@@ -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<void>;
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
@@ -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;;;;;GAKG;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,IAAI,CAqDd,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,OAAO,GAClB,SAAS,MAAM,EACf,MAAM,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,MAAM,GAAG,SAAS,CAOzB,CAAC"}
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', record);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotch/angular-lynx-native",
3
- "version": "22.2.4",
3
+ "version": "22.2.6",
4
4
  "description": "Builds AngularLynx apps into iOS and Android binaries",
5
5
  "homepage": "https://github.com/Blotch-Smart-Frames/AngularLynx#readme",
6
6
  "bugs": {
@@ -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