@blotch/angular-lynx-native 22.2.3 → 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.
- package/README.md +3 -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 +8 -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 +89 -4
- 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 +29 -3
- 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,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
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
package dev.angularlynx.shell
|
|
2
2
|
|
|
3
|
+
import android.app.UiModeManager
|
|
3
4
|
import android.content.res.Configuration
|
|
5
|
+
import android.database.ContentObserver
|
|
6
|
+
import android.graphics.drawable.ColorDrawable
|
|
7
|
+
import android.os.Build
|
|
4
8
|
import android.os.Bundle
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
import android.provider.Settings
|
|
5
12
|
import android.util.Log
|
|
13
|
+
import android.view.accessibility.AccessibilityManager
|
|
6
14
|
import android.widget.FrameLayout
|
|
7
15
|
import androidx.activity.ComponentActivity
|
|
8
16
|
import androidx.activity.OnBackPressedCallback
|
|
@@ -31,6 +39,16 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
31
39
|
private var safeArea = Insets.NONE
|
|
32
40
|
private var hasCutout = false
|
|
33
41
|
|
|
42
|
+
// Sends the accessibility settings again when the user changes one while
|
|
43
|
+
// the app is open: Remove animations (the animator duration scale), the
|
|
44
|
+
// contrast level (Android 14) and high contrast text (Android 16). The
|
|
45
|
+
// contrast listeners are kept as Any so the class loads on older Androids.
|
|
46
|
+
private val animationScaleObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
|
47
|
+
override fun onChange(selfChange: Boolean) = sendGlobalProps()
|
|
48
|
+
}
|
|
49
|
+
private var contrastListener: Any? = null
|
|
50
|
+
private var highContrastTextListener: Any? = null
|
|
51
|
+
|
|
34
52
|
// Disabled until the Angular app enables it (see ShellModule), so by
|
|
35
53
|
// default back leaves the app like any other root screen.
|
|
36
54
|
private val backHandler = object : OnBackPressedCallback(false) {
|
|
@@ -90,6 +108,23 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
90
108
|
}
|
|
91
109
|
setContentView(container)
|
|
92
110
|
|
|
111
|
+
contentResolver.registerContentObserver(
|
|
112
|
+
Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),
|
|
113
|
+
false,
|
|
114
|
+
animationScaleObserver,
|
|
115
|
+
)
|
|
116
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
117
|
+
val listener = UiModeManager.ContrastChangeListener { sendGlobalProps() }
|
|
118
|
+
getSystemService(UiModeManager::class.java).addContrastChangeListener(mainExecutor, listener)
|
|
119
|
+
contrastListener = listener
|
|
120
|
+
}
|
|
121
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
|
122
|
+
val listener = AccessibilityManager.HighContrastTextStateChangeListener { sendGlobalProps() }
|
|
123
|
+
getSystemService(AccessibilityManager::class.java)
|
|
124
|
+
.addHighContrastTextStateChangeListener(mainExecutor, listener)
|
|
125
|
+
highContrastTextListener = listener
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
onBackPressedDispatcher.addCallback(this, backHandler)
|
|
94
129
|
// Sent before the bundle loads, so the app starts with the theme and
|
|
95
130
|
// the language. The insets follow once the window reports them.
|
|
@@ -104,17 +139,35 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
104
139
|
// The manifest's configChanges keeps the activity, and the running app,
|
|
105
140
|
// through a dark mode switch, so the new theme is sent from here. A
|
|
106
141
|
// language change still restarts the activity, which sends the new
|
|
107
|
-
// 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.
|
|
108
144
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
109
145
|
super.onConfigurationChanged(newConfig)
|
|
146
|
+
updateWindowAppearance()
|
|
110
147
|
lynxView.updateGlobalProps(globalProps(newConfig))
|
|
111
148
|
}
|
|
112
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
|
+
|
|
159
|
+
private fun sendGlobalProps() {
|
|
160
|
+
lynxView.updateGlobalProps(globalProps(resources.configuration))
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
/**
|
|
114
164
|
* The global props the runtime reads: `theme` (LynxTheme), `isNotchScreen`
|
|
115
|
-
* (LynxSafeArea)
|
|
116
|
-
*
|
|
117
|
-
*
|
|
165
|
+
* (LynxSafeArea), `appLocale` (LynxLocale), the accessibility settings
|
|
166
|
+
* (LynxAccessibilitySettings) and `osVersion` (LynxSystemInfo), plus the
|
|
167
|
+
* safe-area insets in dp. The iOS shell sends the same keys, and Lynx
|
|
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.
|
|
118
171
|
*/
|
|
119
172
|
private fun globalProps(config: Configuration): Map<String, Any> {
|
|
120
173
|
val density = resources.displayMetrics.density
|
|
@@ -127,12 +180,33 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
127
180
|
"safeAreaBottom" to safeArea.bottom / density,
|
|
128
181
|
"safeAreaLeft" to safeArea.left / density,
|
|
129
182
|
"safeAreaRight" to safeArea.right / density,
|
|
183
|
+
// Lynx builds the main thread's SystemInfo, where Angular runs,
|
|
184
|
+
// without the OS version, so LynxSystemInfo reads it from here.
|
|
185
|
+
"osVersion" to Build.VERSION.RELEASE,
|
|
186
|
+
// Lynx has no API for these. Android has no Reduce Transparency.
|
|
187
|
+
"reduceMotion" to reduceMotion(),
|
|
188
|
+
"increaseContrast" to increaseContrast(),
|
|
130
189
|
)
|
|
131
190
|
// The user's language as a BCP 47 tag, such as "fr-FR".
|
|
132
191
|
ConfigurationCompat.getLocales(config)[0]?.let { props["appLocale"] = it.toLanguageTag() }
|
|
192
|
+
DynamicColors.read(this)?.let { props["dynamicColors"] = it }
|
|
133
193
|
return props
|
|
134
194
|
}
|
|
135
195
|
|
|
196
|
+
// Settings > Accessibility > Remove animations sets the animator
|
|
197
|
+
// duration scale to 0, as does turning animations off in the developer
|
|
198
|
+
// options.
|
|
199
|
+
private fun reduceMotion(): Boolean =
|
|
200
|
+
Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
|
201
|
+
|
|
202
|
+
// Medium or high contrast (Android 14), or high contrast text (Android 16):
|
|
203
|
+
// the closest Android has to iOS's Increase Contrast.
|
|
204
|
+
private fun increaseContrast(): Boolean =
|
|
205
|
+
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
|
|
206
|
+
getSystemService(UiModeManager::class.java).contrast > 0f) ||
|
|
207
|
+
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
|
|
208
|
+
getSystemService(AccessibilityManager::class.java).isHighContrastTextEnabled)
|
|
209
|
+
|
|
136
210
|
// Lynx pauses timers and animations in the background and fires the
|
|
137
211
|
// page's visibility events from these.
|
|
138
212
|
override fun onResume() {
|
|
@@ -146,6 +220,17 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
|
|
|
146
220
|
}
|
|
147
221
|
|
|
148
222
|
override fun onDestroy() {
|
|
223
|
+
contentResolver.unregisterContentObserver(animationScaleObserver)
|
|
224
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
225
|
+
(contrastListener as UiModeManager.ContrastChangeListener?)?.let {
|
|
226
|
+
getSystemService(UiModeManager::class.java).removeContrastChangeListener(it)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
|
230
|
+
(highContrastTextListener as AccessibilityManager.HighContrastTextStateChangeListener?)?.let {
|
|
231
|
+
getSystemService(AccessibilityManager::class.java).removeHighContrastTextStateChangeListener(it)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
149
234
|
lynxView.destroy()
|
|
150
235
|
super.onDestroy()
|
|
151
236
|
}
|
|
@@ -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">@
|
|
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>
|
|
@@ -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">@
|
|
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>
|