@blotch/angular-lynx-native 22.2.4 → 22.2.5

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.5",
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,6 +34,14 @@ 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
+ ## Global props
38
+
39
+ `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.
40
+
41
+ - **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.
42
+ - **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.
43
+ - **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.
44
+
37
45
  ## Versions
38
46
 
39
47
  Pinned in `app/build.gradle.kts`, `build.gradle.kts` and `gradle/wrapper/gradle-wrapper.properties`:
@@ -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
+ }
@@ -3,6 +3,7 @@ package dev.angularlynx.shell
3
3
  import android.app.UiModeManager
4
4
  import android.content.res.Configuration
5
5
  import android.database.ContentObserver
6
+ import android.graphics.drawable.ColorDrawable
6
7
  import android.os.Build
7
8
  import android.os.Bundle
8
9
  import android.os.Handler
@@ -138,12 +139,23 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
138
139
  // The manifest's configChanges keeps the activity, and the running app,
139
140
  // through a dark mode switch, so the new theme is sent from here. A
140
141
  // language change still restarts the activity, which sends the new
141
- // appLocale: Angular reads LOCALE_ID only once.
142
+ // appLocale: Angular reads LOCALE_ID only once. A new wallpaper arrives
143
+ // here too, without a restart, and globalProps() reads its colors again.
142
144
  override fun onConfigurationChanged(newConfig: Configuration) {
143
145
  super.onConfigurationChanged(newConfig)
146
+ updateWindowAppearance()
144
147
  lynxView.updateGlobalProps(globalProps(newConfig))
145
148
  }
146
149
 
150
+ // Kept through a dark mode switch, the window keeps the theme it started
151
+ // with: its background, which shows behind the system bars, and the
152
+ // bars' icons, which enableEdgeToEdge() picked for the old mode. Both are
153
+ // set again for the new one, from the same resources.
154
+ private fun updateWindowAppearance() {
155
+ window.setBackgroundDrawable(ColorDrawable(getColor(R.color.window_background)))
156
+ enableEdgeToEdge()
157
+ }
158
+
147
159
  private fun sendGlobalProps() {
148
160
  lynxView.updateGlobalProps(globalProps(resources.configuration))
149
161
  }
@@ -153,7 +165,9 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
153
165
  * (LynxSafeArea), `appLocale` (LynxLocale), the accessibility settings
154
166
  * (LynxAccessibilitySettings) and `osVersion` (LynxSystemInfo), plus the
155
167
  * safe-area insets in dp. The iOS shell sends the same keys, and Lynx
156
- * Explorer sends theme and isNotchScreen too.
168
+ * Explorer sends theme and isNotchScreen too. Android 12 and later also
169
+ * send the wallpaper's colors, `dynamicColors` (LynxTheme.dynamicColors),
170
+ * which iOS has no equivalent of.
157
171
  */
158
172
  private fun globalProps(config: Configuration): Map<String, Any> {
159
173
  val density = resources.displayMetrics.density
@@ -175,6 +189,7 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
175
189
  )
176
190
  // The user's language as a BCP 47 tag, such as "fr-FR".
177
191
  ConfigurationCompat.getLocales(config)[0]?.let { props["appLocale"] = it.toLanguageTag() }
192
+ DynamicColors.read(this)?.let { props["dynamicColors"] = it }
178
193
  return props
179
194
  }
180
195
 
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <!--
4
+ The window background, which shows before the first Lynx frame and
5
+ behind the system bars. MainActivity sets it again when dark mode
6
+ switches while the app is open, from values-night's.
7
+ -->
8
+ <color name="window_background">#FFFFFFFF</color>
9
+ </resources>
@@ -6,7 +6,7 @@
6
6
  light/dark page background instead of flashing.
7
7
  -->
8
8
  <style name="Theme.AngularLynxShell" parent="android:Theme.Material.Light.NoActionBar">
9
- <item name="android:windowBackground">@android:color/white</item>
9
+ <item name="android:windowBackground">@color/window_background</item>
10
10
  <item name="android:windowLightStatusBar">true</item>
11
11
  <item name="android:windowLightNavigationBar">true</item>
12
12
  </style>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <color name="window_background">#FF000000</color>
4
+ </resources>
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <resources>
3
3
  <style name="Theme.AngularLynxShell" parent="android:Theme.Material.NoActionBar">
4
- <item name="android:windowBackground">@android:color/black</item>
4
+ <item name="android:windowBackground">@color/window_background</item>
5
5
  <item name="android:windowLightStatusBar">false</item>
6
6
  <item name="android:windowLightNavigationBar">false</item>
7
7
  </style>