mithril-lynx 2.0.0 → 2.0.2
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/ANDROID_APK_GUIDE.md +1152 -0
- package/FETCH_INVESTIGATION.md +2 -2
- package/README.md +9 -0
- package/package.json +1 -1
- package/plugin.d.ts +2 -2
- package/plugin.js +36 -35
- package/src/apply-patch.js +4 -4
- package/src/channel.js +2 -2
- package/src/commit.js +10 -10
- package/src/dev-reload-client.js +6 -6
- package/src/fake-dom.js +4 -4
- package/src/mount-redraw.js +1 -1
- package/src/request.js +2 -2
- package/src/route.js +2 -2
- package/test/end-to-end.test.ts +2 -2
- package/test/route-hot-reload.test.ts +1 -1
- package/test/route.test.ts +1 -1
- package/test/setup.ts +3 -3
- package/test/structural-reload.test.ts +3 -3
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
# Guide: building an Android APK for a mithril-lynx app from scratch, on the command line
|
|
2
|
+
|
|
3
|
+
> **Status**: ported from the previous mithril-lynx, which this guide
|
|
4
|
+
> predates. Parts B (Android host), C (wiring the bundle in), and D (the
|
|
5
|
+
> font-prefetch hack) are framework-agnostic — they only care that
|
|
6
|
+
> `dist/main-thread.bundle` exists, never how it was produced — and are
|
|
7
|
+
> unchanged and still accurate, including the `create-mithril-lynx`
|
|
8
|
+
> command lines (that CLI already generates the current architecture).
|
|
9
|
+
> **Part A was rewritten** for the two-file (`main-thread.ts` +
|
|
10
|
+
> `background.ts`) architecture this rewrite requires (see the main
|
|
11
|
+
> README) — the old single-file main-thread-owned mode it described no
|
|
12
|
+
> longer exists. The cold-start numbers in §7 were measured against the
|
|
13
|
+
> old architecture and have not been re-measured against this one (an
|
|
14
|
+
> extra background thread + patch replay could plausibly change them);
|
|
15
|
+
> treat them as methodology, not as current numbers, until re-verified.
|
|
16
|
+
|
|
17
|
+
This guide documents the complete, real, reproducible procedure this project
|
|
18
|
+
uses (verified in `mithril-lynx-ui/demo` + `demo-android`, and in
|
|
19
|
+
`indicadores-android` for the font hack) to:
|
|
20
|
+
|
|
21
|
+
1. Create the JS project that compiles into a Lynx bundle (`mithril-lynx` + `rspeedy`).
|
|
22
|
+
2. Create the Android host **from scratch, without Android Studio**, using only the Gradle CLI.
|
|
23
|
+
3. Wire the compiled bundle (`.bundle`) into the APK.
|
|
24
|
+
4. Build and install the APK from the command line with `adb`.
|
|
25
|
+
5. Apply the `.ttf` font-prefetch hack that avoids Lynx's slow `@font-face`
|
|
26
|
+
cold start (a real bug, tracked in
|
|
27
|
+
[lynx-family/lynx#9431](https://github.com/lynx-family/lynx/issues/9431)).
|
|
28
|
+
|
|
29
|
+
Everything shown here is real code, taken from projects that already work and
|
|
30
|
+
have been verified on a physical device (`mithril-lynx-ui/demo-android`,
|
|
31
|
+
`indicadores-android`) — it is not an invented, generic tutorial.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Fast path — all of this, automated
|
|
36
|
+
|
|
37
|
+
Everything this document describes (Parts A, B, C and D) is generated by
|
|
38
|
+
`create-mithril-lynx` in a single command. That is the recommended way to
|
|
39
|
+
start; the rest of the guide is the reference for *what* each file does and
|
|
40
|
+
*why*.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# JS project + sibling Android host, font hack included:
|
|
44
|
+
npm create mithril-lynx@latest my-app --blank --android \
|
|
45
|
+
--android-id com.acme.miapp --app-name "My App" \
|
|
46
|
+
--with-font ~/fonts/ubuntu_mono.ttf
|
|
47
|
+
|
|
48
|
+
cd my-app
|
|
49
|
+
npm install
|
|
50
|
+
npm run android # bundle -> assets -> installDebug -> launch, prints TotalTime
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Without `--android-id`/`--app-name` it derives them from the project name
|
|
54
|
+
(and prompts for them when the CLI runs interactively). Without `--with-font`
|
|
55
|
+
none of Part D is generated.
|
|
56
|
+
|
|
57
|
+
| Flag | What it does |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `--android` (or `--target android`, or `target=android`) | Generates the sibling Gradle project `<name>-android/` |
|
|
60
|
+
| `--android-id <id>` | `applicationId` / `namespace` (default `com.example.<name>`) |
|
|
61
|
+
| `--app-name <name>` | Launcher label |
|
|
62
|
+
| `--with-font <file.ttf>` | Copies the font into assets, generates `AssetFontFaceLoader.kt`, wires up `prefetchFont()` |
|
|
63
|
+
| `--font-family <name>` | Overrides the family name derived from the file name |
|
|
64
|
+
|
|
65
|
+
What it produces, in terms of this guide's sections:
|
|
66
|
+
|
|
67
|
+
- **Part A** — the JS project (`package.json`, `lynx.config.ts`, `src/`), with `output.filename = "[name].bundle"` and a `main-thread` entry, i.e. exactly the `dist/main-thread.bundle` the APK expects (section 1).
|
|
68
|
+
- **Part B** — a complete `<name>-android/`: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`, `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT`, the Gradle wrapper (`gradlew`, `gradlew.bat`, `gradle-wrapper.jar` → Gradle itself does not need to be installed), `app/build.gradle.kts` with the dependencies from 2.7, `AndroidManifest.xml`, the resources from 2.9, and the Kotlin classes from 2.10–2.12.
|
|
69
|
+
- **Part C** — a `scripts/android.mjs` in the JS project that chains build → sync into `assets/` → Gradle → `adb am start -W`, exposed as `npm run android` / `android:apk` / `android:release` / `android:sync` / `android:keystore` (sections 3 and 3.1), plus the `keystore.properties`-driven release signing from 3.2.
|
|
70
|
+
- **Part D** — only with `--with-font`: the font in `app/src/main/assets/fonts/`, the `@font-face` with `asset:///fonts/...` inserted into the JS project's `src/style.css`, `AssetFontFaceLoader.kt`, its registration before `LynxEnv.inst().init()`, and the `prefetchFont()` before `renderTemplateUrl()` (sections 4.2–4.6).
|
|
71
|
+
|
|
72
|
+
The files the CLI generates are the ones the following sections document — the
|
|
73
|
+
CLI is not a parallel path, it is this document mechanized. If the two ever
|
|
74
|
+
diverge, this guide is the source of truth.
|
|
75
|
+
|
|
76
|
+
Unlike the version of this guide `create-mithril-lynx` used to generate,
|
|
77
|
+
there is now only one architecture — every template (including `--blank`)
|
|
78
|
+
always has both a `main-thread.ts` and a `background.ts` (section 1.3).
|
|
79
|
+
Part B's Android host doesn't need to know or care: `MainActivity` only
|
|
80
|
+
calls `renderTemplateUrl("main-thread.bundle", "")`, and that single
|
|
81
|
+
`.bundle` already contains both threads' code, packaged together.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 0. Prerequisites
|
|
86
|
+
|
|
87
|
+
| Tool | Version used in this project | Notes |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| Node.js | `^20.19.0` or `>=22.12.0` | required by `@lynx-js/rspeedy` |
|
|
90
|
+
| JDK | 17 (Temurin) | `sourceCompatibility`/`targetCompatibility` = 17 |
|
|
91
|
+
| Android SDK (cmdline-tools) | platform `android-34`, build-tools `34.0.0` | no Android Studio, just the SDK |
|
|
92
|
+
| `adb` | the one shipped in `platform-tools` | to install/launch on a device or emulator |
|
|
93
|
+
|
|
94
|
+
Installing the Android SDK **from the command line only** (no Android Studio):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# 1. Download the "command line tools" from
|
|
98
|
+
# https://developer.android.com/studio#command-tools
|
|
99
|
+
mkdir -p ~/android-sdk/cmdline-tools
|
|
100
|
+
unzip commandlinetools-linux-*.zip -d ~/android-sdk/cmdline-tools
|
|
101
|
+
mv ~/android-sdk/cmdline-tools/cmdline-tools ~/android-sdk/cmdline-tools/latest
|
|
102
|
+
|
|
103
|
+
export ANDROID_HOME=~/android-sdk
|
|
104
|
+
export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools"
|
|
105
|
+
|
|
106
|
+
# 2. Install exactly what is needed to build (accept licenses first)
|
|
107
|
+
yes | sdkmanager --licenses
|
|
108
|
+
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
That gives you `adb`, `platforms/android-34` and `build-tools/34.0.0` —
|
|
112
|
+
enough to build and sign a debug APK without ever installing the IDE.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 1. Part A — The JS project (mithril-lynx + rspeedy)
|
|
117
|
+
|
|
118
|
+
This part produces the artifact the APK actually needs: a `.bundle` file
|
|
119
|
+
(main-thread/Lepus patch-replay code plus the real background/JS view code,
|
|
120
|
+
packaged together by `mithril-lynx/plugin`). Unlike the old single-file
|
|
121
|
+
main-thread-owned mode, mithril-lynx now has exactly one architecture:
|
|
122
|
+
`main-thread.ts` only replays patches onto real Element PAPI nodes;
|
|
123
|
+
`background.ts` runs the real Mithril render — see the main README's
|
|
124
|
+
"Why a rewrite" section for why.
|
|
125
|
+
|
|
126
|
+
### 1.1 `package.json`
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"name": "my-app",
|
|
131
|
+
"version": "0.0.1",
|
|
132
|
+
"private": true,
|
|
133
|
+
"type": "module",
|
|
134
|
+
"scripts": {
|
|
135
|
+
"dev": "rspeedy dev",
|
|
136
|
+
"build": "rspeedy build"
|
|
137
|
+
},
|
|
138
|
+
"dependencies": {
|
|
139
|
+
"mithril-runtime": "^1.1.0",
|
|
140
|
+
"mithril-lynx": "latest"
|
|
141
|
+
},
|
|
142
|
+
"devDependencies": {
|
|
143
|
+
"@lynx-js/config-rsbuild-plugin": "^0.2.0",
|
|
144
|
+
"@lynx-js/qrcode-rsbuild-plugin": "^0.7.0",
|
|
145
|
+
"@lynx-js/rspeedy": "^0.17.0",
|
|
146
|
+
"@lynx-js/types": "4.1.0",
|
|
147
|
+
"@rsbuild/plugin-type-check": "^1.6.0",
|
|
148
|
+
"@types/mithril": "^2.2.9",
|
|
149
|
+
"typescript": "~5.9.0"
|
|
150
|
+
},
|
|
151
|
+
"engines": {
|
|
152
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`mithril-lynx` depends on [`mithril-runtime`](https://github.com/carlos-sweb/mithril-runtime)
|
|
158
|
+
(a maintained Mithril 2.3.8 fork, not the official `mithril` package —
|
|
159
|
+
`m.route`/`m.trust`/`m.request` are stripped there and reimplemented
|
|
160
|
+
Lynx-side, see `mithril-lynx`'s own README). It ships these subpaths today:
|
|
161
|
+
`mithril-lynx/background`, `mithril-lynx/main-thread`, `mithril-lynx/plugin`,
|
|
162
|
+
`mithril-lynx/route`, `mithril-lynx/request`. Gestures, list virtualization,
|
|
163
|
+
imperative refs, and a stack navigator existed in the previous mithril-lynx
|
|
164
|
+
but haven't been ported to this architecture yet (see the main README's
|
|
165
|
+
"Deliberately not carried over" note) — `mithril-lynx-ui` (ready-made
|
|
166
|
+
components) has not been re-verified against this rewrite either.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npm install
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 1.2 `lynx.config.ts` (`rspeedy` configuration)
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import path from "node:path";
|
|
176
|
+
import { fileURLToPath } from "node:url";
|
|
177
|
+
|
|
178
|
+
import { pluginLynxConfig } from "@lynx-js/config-rsbuild-plugin";
|
|
179
|
+
import { pluginQRCode } from "@lynx-js/qrcode-rsbuild-plugin";
|
|
180
|
+
import { defineConfig } from "@lynx-js/rspeedy";
|
|
181
|
+
import { pluginTypeCheck } from "@rsbuild/plugin-type-check";
|
|
182
|
+
|
|
183
|
+
import { pluginMithrilLynx } from "mithril-lynx/plugin";
|
|
184
|
+
|
|
185
|
+
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
186
|
+
|
|
187
|
+
export default defineConfig({
|
|
188
|
+
source: {
|
|
189
|
+
entry: {
|
|
190
|
+
"main-thread": path.join(projectRoot, "src/main-thread.ts"),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
output: {
|
|
194
|
+
distPath: { root: path.join(projectRoot, "dist") },
|
|
195
|
+
filename: "[name].bundle",
|
|
196
|
+
dataUriLimit: Infinity,
|
|
197
|
+
},
|
|
198
|
+
plugins: [
|
|
199
|
+
pluginMithrilLynx(),
|
|
200
|
+
// enableNewGesture: the native gesture arena — mithril-lynx/gesture
|
|
201
|
+
// doesn't exist yet (see 1.1), but `create-mithril-lynx` turns this on
|
|
202
|
+
// by default so the flag is already there once it does.
|
|
203
|
+
// enableCSSRule: needed for a plain `:root { ... }` rule to apply at
|
|
204
|
+
// all — confirmed, not optional for a typical stylesheet.
|
|
205
|
+
pluginLynxConfig({ enableNewGesture: true, enableCSSRule: true }),
|
|
206
|
+
pluginQRCode({ schema: (url) => `${url}?fullscreen=true` }),
|
|
207
|
+
pluginTypeCheck(),
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### 1.3 The entry points
|
|
213
|
+
|
|
214
|
+
`mithril-lynx` always needs BOTH files below — there is no mode where one
|
|
215
|
+
is optional (unlike the previous mithril-lynx, which had a main-thread-owned
|
|
216
|
+
mode needing only the first).
|
|
217
|
+
|
|
218
|
+
`src/main-thread.ts` — no app code, ever; only starts the patch-replay
|
|
219
|
+
runtime:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
import { setupRenderer } from "mithril-lynx/main-thread";
|
|
223
|
+
|
|
224
|
+
setupRenderer();
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`src/background.ts` — where the app actually mounts. This is where
|
|
228
|
+
`renderApp()` is called and where all view code lives:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import m from "mithril-runtime";
|
|
232
|
+
import { renderApp } from "mithril-lynx/background";
|
|
233
|
+
|
|
234
|
+
const Root = {
|
|
235
|
+
view: () =>
|
|
236
|
+
m("view", { class: "Page" }, [
|
|
237
|
+
m("text", { class: "Title" }, "Hello from mithril-lynx"),
|
|
238
|
+
]),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
renderApp({ root: () => m(Root) });
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
A tap handler that mutates state repaints the screen with no explicit
|
|
245
|
+
`redraw()` call anywhere — mithril-lynx redraws automatically after any
|
|
246
|
+
event, same contract real Mithril has always had.
|
|
247
|
+
|
|
248
|
+
`src/style.css`:
|
|
249
|
+
|
|
250
|
+
```css
|
|
251
|
+
.Page {
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-direction: column;
|
|
254
|
+
align-items: center;
|
|
255
|
+
justify-content: center;
|
|
256
|
+
height: 100vh;
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`src/rspeedy-env.d.ts` (ambient types, TypeScript only):
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
/// <reference types="@lynx-js/rspeedy/client" />
|
|
264
|
+
/// <reference types="@lynx-js/types" />
|
|
265
|
+
/// <reference types="@lynx-js/type-element-api" />
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`mithril-runtime` has no published types of its own yet — a project needs a
|
|
269
|
+
small ambient module declaration aliasing `@types/mithril`'s shape onto it
|
|
270
|
+
(see `create-mithril-lynx`'s `templates/_shared/ts/src/mithril-runtime.d.ts`
|
|
271
|
+
for the exact file, including the caveat about which real-Mithril APIs it
|
|
272
|
+
over-declares as present).
|
|
273
|
+
|
|
274
|
+
### 1.4 Building the bundle
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
npm run build
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
This produces `dist/main-thread.bundle` — a single binary file containing BOTH
|
|
281
|
+
the main-thread (Lepus) code AND the background (JS) code, encoded together.
|
|
282
|
+
That is the file the APK needs to package.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## 2. Part B — The Android project, from scratch, Gradle CLI only
|
|
287
|
+
|
|
288
|
+
None of these steps needs Android Studio — it is all text files and
|
|
289
|
+
`./gradlew` from the terminal.
|
|
290
|
+
|
|
291
|
+
### 2.1 Folder structure
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
my-app-android/
|
|
295
|
+
├── settings.gradle.kts
|
|
296
|
+
├── build.gradle.kts
|
|
297
|
+
├── gradle.properties
|
|
298
|
+
├── local.properties
|
|
299
|
+
├── gradle/wrapper/gradle-wrapper.properties
|
|
300
|
+
└── app/
|
|
301
|
+
├── build.gradle.kts
|
|
302
|
+
└── src/main/
|
|
303
|
+
├── AndroidManifest.xml
|
|
304
|
+
├── assets/ ← the .bundle goes here
|
|
305
|
+
├── java/com/myapp/
|
|
306
|
+
│ ├── MyApp.kt ← Application
|
|
307
|
+
│ ├── MainActivity.kt
|
|
308
|
+
│ └── AssetTemplateProvider.kt
|
|
309
|
+
└── res/
|
|
310
|
+
├── values/strings.xml
|
|
311
|
+
├── values/themes.xml
|
|
312
|
+
├── drawable/ic_launcher_background.xml
|
|
313
|
+
├── drawable/ic_launcher_foreground.xml
|
|
314
|
+
└── mipmap-anydpi-v26/ic_launcher.xml
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
mkdir -p my-app-android/app/src/main/{assets,java/com/myapp,res/values,res/drawable,res/mipmap-anydpi-v26}
|
|
319
|
+
mkdir -p my-app-android/gradle/wrapper
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### 2.2 `settings.gradle.kts` (root)
|
|
323
|
+
|
|
324
|
+
```kotlin
|
|
325
|
+
pluginManagement {
|
|
326
|
+
repositories {
|
|
327
|
+
google()
|
|
328
|
+
mavenCentral()
|
|
329
|
+
gradlePluginPortal()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
dependencyResolutionManagement {
|
|
334
|
+
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
335
|
+
repositories {
|
|
336
|
+
google()
|
|
337
|
+
mavenCentral()
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
rootProject.name = "my-app-android"
|
|
342
|
+
include(":app")
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### 2.3 `build.gradle.kts` (root)
|
|
346
|
+
|
|
347
|
+
```kotlin
|
|
348
|
+
plugins {
|
|
349
|
+
id("com.android.application") version "8.5.2" apply false
|
|
350
|
+
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### 2.4 `gradle.properties`
|
|
355
|
+
|
|
356
|
+
```properties
|
|
357
|
+
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
358
|
+
android.useAndroidX=true
|
|
359
|
+
kotlin.code.style=official
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### 2.5 `local.properties`
|
|
363
|
+
|
|
364
|
+
```properties
|
|
365
|
+
sdk.dir=/path/to/your/android-sdk
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### 2.6 The Gradle wrapper (without Android Studio)
|
|
369
|
+
|
|
370
|
+
If you already have Gradle installed somewhere (or `sdkmanager` doesn't ship
|
|
371
|
+
it), generate the wrapper directly:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
cd my-app-android
|
|
375
|
+
gradle wrapper --gradle-version 8.14.2
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
That creates `gradlew`, `gradlew.bat` and
|
|
379
|
+
`gradle/wrapper/gradle-wrapper.properties`. If you don't have Gradle installed
|
|
380
|
+
locally, you can write `gradle-wrapper.properties` by hand and Gradle will
|
|
381
|
+
download the wrapper `.jar` the first time you run `./gradlew`:
|
|
382
|
+
|
|
383
|
+
```properties
|
|
384
|
+
distributionBase=GRADLE_USER_HOME
|
|
385
|
+
distributionPath=wrapper/dists
|
|
386
|
+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
|
|
387
|
+
networkTimeout=10000
|
|
388
|
+
validateDistributionUrl=true
|
|
389
|
+
zipStoreBase=GRADLE_USER_HOME
|
|
390
|
+
zipStorePath=wrapper/dists
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
(in that case you also need the `gradlew`/`gradlew.bat` scripts and
|
|
394
|
+
`gradle/wrapper/gradle-wrapper.jar`, which you can copy from any existing
|
|
395
|
+
Gradle project or generate once with `gradle wrapper` on a machine that does
|
|
396
|
+
have Gradle).
|
|
397
|
+
|
|
398
|
+
### 2.7 `app/build.gradle.kts` — Lynx's real dependencies
|
|
399
|
+
|
|
400
|
+
```kotlin
|
|
401
|
+
plugins {
|
|
402
|
+
id("com.android.application")
|
|
403
|
+
id("org.jetbrains.kotlin.android")
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
android {
|
|
407
|
+
namespace = "com.myapp"
|
|
408
|
+
compileSdk = 34
|
|
409
|
+
|
|
410
|
+
defaultConfig {
|
|
411
|
+
applicationId = "com.myapp"
|
|
412
|
+
minSdk = 24
|
|
413
|
+
targetSdk = 34
|
|
414
|
+
versionCode = 1
|
|
415
|
+
versionName = "1.0"
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
buildTypes {
|
|
419
|
+
debug {
|
|
420
|
+
isMinifyEnabled = false
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
compileOptions {
|
|
425
|
+
sourceCompatibility = JavaVersion.VERSION_17
|
|
426
|
+
targetCompatibility = JavaVersion.VERSION_17
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
kotlinOptions {
|
|
430
|
+
jvmTarget = "17"
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
dependencies {
|
|
435
|
+
// The core artifact — LynxView, LynxViewBuilder, the layout engine, etc.
|
|
436
|
+
implementation("org.lynxsdk.lynx:lynx:4.1.0")
|
|
437
|
+
|
|
438
|
+
// OPT-IN, add them only if you use them:
|
|
439
|
+
|
|
440
|
+
// <input>/<textarea> — without these those elements take up zero size
|
|
441
|
+
// and never open the keyboard. They are not part of the core artifact.
|
|
442
|
+
implementation("org.lynxsdk.lynx:xelement:4.1.0")
|
|
443
|
+
implementation("org.lynxsdk.lynx:xelement-input:4.1.0")
|
|
444
|
+
|
|
445
|
+
// <overlay> — without this the element mounts without error but is never
|
|
446
|
+
// visible (mithril-lynx-ui's Dialog/Sheet/Popover need it).
|
|
447
|
+
implementation("org.lynxsdk.lynx:xelement-overlay:4.1.0")
|
|
448
|
+
|
|
449
|
+
// Without a registered ILynxLogService, console.log() and JS errors from
|
|
450
|
+
// the bundle are silently dropped — logcat won't show them either.
|
|
451
|
+
implementation("org.lynxsdk.lynx:lynx-service-log:4.1.0")
|
|
452
|
+
|
|
453
|
+
// <refresh> (pull-to-refresh, inside the xelement artifact) internally
|
|
454
|
+
// depends on SmartRefreshLayout, whose touch-dispatch code references
|
|
455
|
+
// ViewPager2 even though you don't use it — without this, every touch on
|
|
456
|
+
// a <refresh> throws NoClassDefFoundError (silently caught by the Lynx
|
|
457
|
+
// engine, but the refresh gesture then simply never works).
|
|
458
|
+
implementation("androidx.viewpager2:viewpager2:1.1.0")
|
|
459
|
+
|
|
460
|
+
implementation("androidx.appcompat:appcompat:1.7.0")
|
|
461
|
+
implementation("androidx.core:core-splashscreen:1.0.1")
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### 2.8 `AndroidManifest.xml`
|
|
466
|
+
|
|
467
|
+
```xml
|
|
468
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
469
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
470
|
+
|
|
471
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
472
|
+
|
|
473
|
+
<application
|
|
474
|
+
android:name=".MyApp"
|
|
475
|
+
android:allowBackup="true"
|
|
476
|
+
android:label="@string/app_name"
|
|
477
|
+
android:icon="@mipmap/ic_launcher"
|
|
478
|
+
android:roundIcon="@mipmap/ic_launcher_round"
|
|
479
|
+
android:hardwareAccelerated="true"
|
|
480
|
+
android:usesCleartextTraffic="true"
|
|
481
|
+
android:theme="@style/AppTheme">
|
|
482
|
+
|
|
483
|
+
<activity
|
|
484
|
+
android:name=".MainActivity"
|
|
485
|
+
android:theme="@style/Theme.App.Starting"
|
|
486
|
+
android:exported="true">
|
|
487
|
+
<intent-filter>
|
|
488
|
+
<action android:name="android.intent.action.MAIN" />
|
|
489
|
+
<category android:name="android.intent.category.LAUNCHER" />
|
|
490
|
+
</intent-filter>
|
|
491
|
+
</activity>
|
|
492
|
+
|
|
493
|
+
</application>
|
|
494
|
+
|
|
495
|
+
</manifest>
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### 2.9 Minimum resources needed to compile
|
|
499
|
+
|
|
500
|
+
`res/values/strings.xml`:
|
|
501
|
+
|
|
502
|
+
```xml
|
|
503
|
+
<resources>
|
|
504
|
+
<string name="app_name">My App</string>
|
|
505
|
+
</resources>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`res/values/themes.xml`:
|
|
509
|
+
|
|
510
|
+
```xml
|
|
511
|
+
<resources>
|
|
512
|
+
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
|
513
|
+
|
|
514
|
+
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
|
515
|
+
<item name="windowSplashScreenBackground">#0F1115</item>
|
|
516
|
+
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
|
517
|
+
</style>
|
|
518
|
+
</resources>
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
`res/mipmap-anydpi-v26/ic_launcher.xml` (minimal adaptive icon):
|
|
522
|
+
|
|
523
|
+
```xml
|
|
524
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
525
|
+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
526
|
+
<background android:drawable="@drawable/ic_launcher_background" />
|
|
527
|
+
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
528
|
+
</adaptive-icon>
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`res/drawable/ic_launcher_background.xml` (a flat color will do):
|
|
532
|
+
|
|
533
|
+
```xml
|
|
534
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
535
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
536
|
+
android:width="108dp" android:height="108dp"
|
|
537
|
+
android:viewportWidth="108" android:viewportHeight="108">
|
|
538
|
+
<path android:pathData="M0,0h108v108h-108z" android:fillColor="#0F1115" />
|
|
539
|
+
</vector>
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
`res/drawable/ic_launcher_foreground.xml` (any simple vector):
|
|
543
|
+
|
|
544
|
+
```xml
|
|
545
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
546
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
547
|
+
android:width="108dp" android:height="108dp"
|
|
548
|
+
android:viewportWidth="108" android:viewportHeight="108">
|
|
549
|
+
<path
|
|
550
|
+
android:pathData="M30,54 L50,74 L78,34"
|
|
551
|
+
android:strokeColor="#6EE7B7"
|
|
552
|
+
android:strokeWidth="6"
|
|
553
|
+
android:strokeLineCap="round"
|
|
554
|
+
android:strokeLineJoin="round" />
|
|
555
|
+
</vector>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
(you also need `mipmap-anydpi-v26/ic_launcher_round.xml`, identical to the
|
|
559
|
+
`ic_launcher.xml` above).
|
|
560
|
+
|
|
561
|
+
### 2.10 `MyApp.kt` — the `Application` class
|
|
562
|
+
|
|
563
|
+
```kotlin
|
|
564
|
+
package com.myapp
|
|
565
|
+
|
|
566
|
+
import android.app.Application
|
|
567
|
+
import com.lynx.service.log.LynxLogService
|
|
568
|
+
import com.lynx.tasm.LynxEnv
|
|
569
|
+
import com.lynx.tasm.service.LynxServiceCenter
|
|
570
|
+
|
|
571
|
+
class MyApp : Application() {
|
|
572
|
+
override fun onCreate() {
|
|
573
|
+
super.onCreate()
|
|
574
|
+
// Register the log service BEFORE LynxEnv.inst().init() — without
|
|
575
|
+
// this, console.log() and JS errors from the bundle never show up,
|
|
576
|
+
// not even in logcat. Drop it in a production build if you don't
|
|
577
|
+
// need it.
|
|
578
|
+
LynxServiceCenter.inst().registerService(LynxLogService)
|
|
579
|
+
LynxLogService.switchLogToSystem(true)
|
|
580
|
+
|
|
581
|
+
LynxEnv.inst().init(this, null, null, null)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
### 2.11 `AssetTemplateProvider.kt` — reading the bundle WITHOUT blocking the UI thread
|
|
587
|
+
|
|
588
|
+
```kotlin
|
|
589
|
+
package com.myapp
|
|
590
|
+
|
|
591
|
+
import android.content.Context
|
|
592
|
+
import com.lynx.tasm.provider.AbsTemplateProvider
|
|
593
|
+
import java.io.IOException
|
|
594
|
+
|
|
595
|
+
// Reads the bundle on a separate thread. Measured on a real device: reading
|
|
596
|
+
// the same asset SYNCHRONOUSLY in MainActivity.onCreate() (blocking Android's
|
|
597
|
+
// UI thread — distinct from Lynx's internal main/background thread split, but
|
|
598
|
+
// just as harmful to the first frame) cost 2.4-2.8s of cold start; with this
|
|
599
|
+
// async provider, ~300ms.
|
|
600
|
+
class AssetTemplateProvider(private val context: Context) : AbsTemplateProvider() {
|
|
601
|
+
override fun loadTemplate(url: String, callback: Callback) {
|
|
602
|
+
Thread {
|
|
603
|
+
try {
|
|
604
|
+
val bytes = context.assets.open(url).use { it.readBytes() }
|
|
605
|
+
callback.onSuccess(bytes)
|
|
606
|
+
} catch (e: IOException) {
|
|
607
|
+
callback.onFailed(e.toString())
|
|
608
|
+
}
|
|
609
|
+
}.start()
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### 2.12 `MainActivity.kt` — mounting the `LynxView`
|
|
615
|
+
|
|
616
|
+
```kotlin
|
|
617
|
+
package com.myapp
|
|
618
|
+
|
|
619
|
+
import android.os.Bundle
|
|
620
|
+
import androidx.appcompat.app.AppCompatActivity
|
|
621
|
+
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
622
|
+
import com.lynx.tasm.LynxViewBuilder
|
|
623
|
+
import com.lynx.tasm.ThreadStrategyForRendering
|
|
624
|
+
|
|
625
|
+
class MainActivity : AppCompatActivity() {
|
|
626
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
627
|
+
// Must be called BEFORE super.onCreate() — a Splash Screen API
|
|
628
|
+
// requirement.
|
|
629
|
+
installSplashScreen()
|
|
630
|
+
super.onCreate(savedInstanceState)
|
|
631
|
+
|
|
632
|
+
val builder = LynxViewBuilder()
|
|
633
|
+
// If you use <input>/<textarea>, also:
|
|
634
|
+
// builder.addBehaviors(com.lynx.xelement.XElementBehaviors().create())
|
|
635
|
+
builder.setThreadStrategyForRendering(ThreadStrategyForRendering.ALL_ON_UI)
|
|
636
|
+
builder.setTemplateProvider(AssetTemplateProvider(this))
|
|
637
|
+
val lynxView = builder.build(this)
|
|
638
|
+
setContentView(lynxView)
|
|
639
|
+
|
|
640
|
+
// "main-thread.bundle" must exist in app/src/main/assets/ — see
|
|
641
|
+
// section 3 of this guide.
|
|
642
|
+
lynxView.renderTemplateUrl("main-thread.bundle", "")
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## 3. Part C — Wiring the JS bundle into the APK
|
|
650
|
+
|
|
651
|
+
This is the step that joins the two previous parts: the `.bundle` produced by
|
|
652
|
+
`rspeedy` (Part A) has to end up inside `app/src/main/assets/` (Part B) **with
|
|
653
|
+
the same name** you pass to `renderTemplateUrl(...)`.
|
|
654
|
+
|
|
655
|
+
```bash
|
|
656
|
+
# 1. Build the JS bundle
|
|
657
|
+
npm --prefix my-app-js run build
|
|
658
|
+
|
|
659
|
+
# 2. Copy it into the Android assets (same name as renderTemplateUrl)
|
|
660
|
+
mkdir -p my-app-android/app/src/main/assets
|
|
661
|
+
cp my-app-js/dist/main-thread.bundle my-app-android/app/src/main/assets/main-thread.bundle
|
|
662
|
+
|
|
663
|
+
# 3. Build and install the debug APK on the connected device/emulator
|
|
664
|
+
cd my-app-android
|
|
665
|
+
./gradlew installDebug
|
|
666
|
+
|
|
667
|
+
# 4. Launch it
|
|
668
|
+
adb shell am force-stop com.myapp
|
|
669
|
+
adb shell am start -W -n com.myapp/.MainActivity
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`./gradlew installDebug` builds `app-debug.apk` (with the `debug.keystore`
|
|
673
|
+
AGP generates for you, signed automatically) and installs it straight onto the
|
|
674
|
+
connected device — no need to go through `adb install` by hand, though that
|
|
675
|
+
works too:
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
./gradlew assembleDebug # build only, don't install
|
|
679
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### 3.1 All-in-one script
|
|
683
|
+
|
|
684
|
+
This is how this very project chains the 4 steps into a single command
|
|
685
|
+
(`mithril-lynx-ui/demo.sh`):
|
|
686
|
+
|
|
687
|
+
```bash
|
|
688
|
+
#!/usr/bin/env bash
|
|
689
|
+
set -euo pipefail
|
|
690
|
+
cd "$(dirname "$0")"
|
|
691
|
+
|
|
692
|
+
npm --prefix my-app-js run build
|
|
693
|
+
mkdir -p my-app-android/app/src/main/assets
|
|
694
|
+
cp my-app-js/dist/main-thread.bundle my-app-android/app/src/main/assets/main-thread.bundle
|
|
695
|
+
|
|
696
|
+
(cd my-app-android && ./gradlew --quiet installDebug)
|
|
697
|
+
|
|
698
|
+
adb shell am force-stop com.myapp
|
|
699
|
+
adb shell am start -W -n com.myapp/.MainActivity | grep -E "TotalTime|LaunchState"
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
`am start -W` blocks until the Activity finishes drawing its first frame and
|
|
703
|
+
prints `TotalTime`/`WaitTime` — the real way to measure cold start from the
|
|
704
|
+
command line, with no extra instrumentation.
|
|
705
|
+
|
|
706
|
+
### 3.2 Release APK (signed)
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
# Generate a keystore (once)
|
|
710
|
+
keytool -genkey -v -keystore release.keystore -alias my-app \
|
|
711
|
+
-keyalg RSA -keysize 2048 -validity 10000
|
|
712
|
+
|
|
713
|
+
# In app/build.gradle.kts, inside android { }:
|
|
714
|
+
# signingConfigs {
|
|
715
|
+
# create("release") {
|
|
716
|
+
# storeFile = file("../release.keystore")
|
|
717
|
+
# storePassword = System.getenv("KEYSTORE_PASSWORD")
|
|
718
|
+
# keyAlias = "my-app"
|
|
719
|
+
# keyPassword = System.getenv("KEY_PASSWORD")
|
|
720
|
+
# }
|
|
721
|
+
# }
|
|
722
|
+
# buildTypes {
|
|
723
|
+
# release {
|
|
724
|
+
# signingConfig = signingConfigs.getByName("release")
|
|
725
|
+
# isMinifyEnabled = true
|
|
726
|
+
# }
|
|
727
|
+
# }
|
|
728
|
+
|
|
729
|
+
KEYSTORE_PASSWORD=... KEY_PASSWORD=... ./gradlew assembleRelease
|
|
730
|
+
# Final APK: app/build/outputs/apk/release/app-release.apk
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
#### 3.2.1 `isMinifyEnabled = true` breaks both the build and the runtime (verified)
|
|
734
|
+
|
|
735
|
+
This part of the guide used to be incomplete, and it cost two real bugs. Both
|
|
736
|
+
were found by building and installing a **minified** release on a Galaxy A07
|
|
737
|
+
(`assembleRelease` + R8, `lynx:4.1.0` + AGP 8.5.2). If you turn on
|
|
738
|
+
`isMinifyEnabled = true` without the fixes below, you do not end up with a
|
|
739
|
+
working APK.
|
|
740
|
+
|
|
741
|
+
**Bug 1 — R8 aborts the build.** The `xelement` artifact references
|
|
742
|
+
**optional** dependencies that are not on the classpath (Fresco, Gson, the
|
|
743
|
+
Markdown module), and in full mode R8 fails with `Missing classes detected
|
|
744
|
+
while running R8`. AGP writes the exact names to
|
|
745
|
+
`app/build/outputs/mapping/release/missing_rules.txt`, but it is better to
|
|
746
|
+
ignore whole packages so it does not break again with the next SDK release:
|
|
747
|
+
|
|
748
|
+
```proguard
|
|
749
|
+
-dontwarn com.facebook.**
|
|
750
|
+
-dontwarn com.google.gson.**
|
|
751
|
+
-dontwarn com.lynx.markdown.**
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
**Bug 2 — the APK builds but crashes on startup.** The `lynx-base` AAR calls
|
|
755
|
+
`com.lynx.base.log.LynxLog.log(int, String, String, int, long, int, int)` from
|
|
756
|
+
C++ via JNI `GetStaticMethodID`. That class is not annotated with
|
|
757
|
+
`@CalledByNative` (unlike almost the whole bridge), so the *consumer* rules
|
|
758
|
+
the `lynx` AAR does ship — `lynx-base` contributes none for this — do not
|
|
759
|
+
cover it, R8 renames it, and the process dies **before drawing anything**:
|
|
760
|
+
|
|
761
|
+
```
|
|
762
|
+
java.lang.NoSuchMethodError: no static method
|
|
763
|
+
"Lcom/lynx/base/log/LynxLog;.log(ILjava/lang/String;Ljava/lang/String;IJII)V"
|
|
764
|
+
FATAL: JNI DETECTED ERROR IN APPLICATION: mid == null
|
|
765
|
+
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid (LynxTraceInit)
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The rule that fixes it:
|
|
769
|
+
|
|
770
|
+
```proguard
|
|
771
|
+
-keep class com.lynx.base.log.LynxLog { *; }
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
Upstream this is still **open** with no official answer
|
|
775
|
+
([lynx-family/lynx#695](https://github.com/lynx-family/lynx/issues/695), open
|
|
776
|
+
since 2025-04, with the team pointing people at "use the demo", which
|
|
777
|
+
curiously ships no ProGuard rules either). The `proguard-rules.pro` that
|
|
778
|
+
`create-mithril-lynx` generates has both fixes in place and commented.
|
|
779
|
+
|
|
780
|
+
Verifying the fix, on a real device, with the **minified release** APK:
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
./gradlew assembleRelease
|
|
784
|
+
adb install -r app/build/outputs/apk/release/app-release.apk
|
|
785
|
+
adb shell am start -W -n com.myapp/.MainActivity
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
Zero `FATAL`/`SIGABRT`/`NoSuchMethodError`, and
|
|
789
|
+
`FontFaceManager: prefetchFont success: asset:///fonts/ubuntu_mono.ttf` in
|
|
790
|
+
logcat — i.e. Part D survives minification.
|
|
791
|
+
|
|
792
|
+
If your app registers its own native modules
|
|
793
|
+
(`builder.registerModule("X", X::class.java)`) or behaviors reached by
|
|
794
|
+
reflection, add `-keep` rules for those too: the AAR cannot know they exist.
|
|
795
|
+
|
|
796
|
+
#### 3.2.2 Minification is worth it for cold start, not just APK size
|
|
797
|
+
|
|
798
|
+
Same device, same app, `am start -W`, `LaunchState: COLD`, after a
|
|
799
|
+
`force-stop`:
|
|
800
|
+
|
|
801
|
+
| Build | Cold start (steady state) |
|
|
802
|
+
|---|---|
|
|
803
|
+
| `debug` (no minification) | 746–829 ms |
|
|
804
|
+
| `release` (R8, minified) | 246–272 ms |
|
|
805
|
+
|
|
806
|
+
Roughly a 3x difference on a budget device. Measuring cold start against a
|
|
807
|
+
debug build is measuring the wrong build — the numbers you'd be optimizing
|
|
808
|
+
have little to do with what users get. Section 7 has the full table.
|
|
809
|
+
|
|
810
|
+
### 3.3 Installing the APK: two things that bite on real devices
|
|
811
|
+
|
|
812
|
+
Neither of these is a bug in your app, but both cost time if you don't expect
|
|
813
|
+
them.
|
|
814
|
+
|
|
815
|
+
**`./gradlew installDebug` can fail with `InstallException: EOF`.** The
|
|
816
|
+
debug APK is fat (all ABIs, see below), and `ddmlib`'s streamed install over
|
|
817
|
+
USB occasionally drops it. The failure looks alarming and is not:
|
|
818
|
+
|
|
819
|
+
```
|
|
820
|
+
Execution failed for task ':app:installDebug'.
|
|
821
|
+
> com.android.ddmlib.InstallException: EOF
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
Retrying the same command usually works, and going around Gradle works
|
|
825
|
+
immediately:
|
|
826
|
+
|
|
827
|
+
```bash
|
|
828
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
**APKs are large — 71 MB debug / 65 MB release for a "Hello World".** That is
|
|
832
|
+
Lynx shipping its native libraries (`.so`) for every ABI plus the QuickJS/V8
|
|
833
|
+
bridges; it is not your bundle. Measured on the generated Blank project: **72
|
|
834
|
+
shared libraries** inside a single APK. Check yours with:
|
|
835
|
+
|
|
836
|
+
```bash
|
|
837
|
+
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep '\.so$' | wc -l
|
|
838
|
+
# 72
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
To ship a reasonable APK, use an App Bundle (`./gradlew bundleRelease`, which
|
|
842
|
+
lets Play split by ABI and density) or `splits { abi { ... } }` in
|
|
843
|
+
`app/build.gradle.kts`. The difference is not marginal — same project, same
|
|
844
|
+
code:
|
|
845
|
+
|
|
846
|
+
| Artifact | Size |
|
|
847
|
+
|---|---|
|
|
848
|
+
| `app-debug.apk` | 71.3 MB |
|
|
849
|
+
| `app-release.apk` (R8 minified, all ABIs) | 65.5 MB |
|
|
850
|
+
| `app-release.aab` (`./gradlew bundleRelease`) | 29.5 MB |
|
|
851
|
+
|
|
852
|
+
...and the 29.5 MB is what you upload; what a given device downloads is smaller
|
|
853
|
+
still, because Play delivers only the ABI and density that device needs. Just
|
|
854
|
+
don't be surprised by the APK size when testing locally.
|
|
855
|
+
|
|
856
|
+
**Android 15+/One UI shows a "16 KB page size" compatibility dialog.** On
|
|
857
|
+
recent Samsung firmware (and any Android 15+ device with 16 KB pages), the
|
|
858
|
+
first launch after installing pops a full-screen system warning:
|
|
859
|
+
|
|
860
|
+
> This app is not compatible with 16 KB. ELF alignment could not be verified.
|
|
861
|
+
> The following libraries are not aligned to 16 KB: `libanimax.so`,
|
|
862
|
+
> `libharfbuzz.so`, `libnapi_v8.so`, …
|
|
863
|
+
|
|
864
|
+
It lists Lynx's prebuilt `.so` files. It is a system dialog, not your app's
|
|
865
|
+
Activity, it appears in front of your UI (making a naive screenshot look
|
|
866
|
+
wrong), and it can be dismissed with "OK". Nothing in the Android host can fix
|
|
867
|
+
it — the alignment lives inside the native libraries the Lynx SDK ships. When
|
|
868
|
+
scripting device tests, either tap it away or assert on logcat instead of on a
|
|
869
|
+
screenshot.
|
|
870
|
+
|
|
871
|
+
---
|
|
872
|
+
|
|
873
|
+
## 4. Part D — The `.ttf` font hack (`@font-face` prefetch)
|
|
874
|
+
|
|
875
|
+
### 4.1 The problem
|
|
876
|
+
|
|
877
|
+
A custom `@font-face` in Lynx (confirmed on `org.lynxsdk.lynx:lynx:4.1.0`, and
|
|
878
|
+
also reproduced in ReactLynx — it is not a mithril-lynx bug) is resolved
|
|
879
|
+
**synchronously inside the first native call to `__FlushElementTree()`**, and
|
|
880
|
+
that cost scales with how many text nodes end up resolving that font family —
|
|
881
|
+
up to +2s of cold start measured on a mid/low-end device (Samsung Galaxy A07)
|
|
882
|
+
with the font applied through a plain `text { font-family: ...; }` selector.
|
|
883
|
+
|
|
884
|
+
Reported upstream:
|
|
885
|
+
[lynx-family/lynx#9431](https://github.com/lynx-family/lynx/issues/9431).
|
|
886
|
+
|
|
887
|
+
**The fix**: instead of letting `@font-face` resolve the first time layout
|
|
888
|
+
needs it, *prefetch* the `Typeface` from Kotlin **before**
|
|
889
|
+
`renderTemplateUrl()` even starts the bundle — so when the real CSS asks for
|
|
890
|
+
that font during the first layout, it is already cached and resolution is
|
|
891
|
+
instant.
|
|
892
|
+
|
|
893
|
+
Measured on the same device, with the font applied to the broadest possible
|
|
894
|
+
selector (`text { font-family: ...; }`, every text node): from ~1.4-2.9s down
|
|
895
|
+
to **~750-800ms — indistinguishable from the baseline with no custom font.**
|
|
896
|
+
|
|
897
|
+
### 4.2 Step 1 — the `.ttf` goes in `assets/fonts/`
|
|
898
|
+
|
|
899
|
+
```bash
|
|
900
|
+
mkdir -p app/src/main/assets/fonts
|
|
901
|
+
cp ubuntu_mono.ttf app/src/main/assets/fonts/ubuntu_mono.ttf
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
### 4.3 Step 2 — declare `@font-face` with `asset:///` in your CSS
|
|
905
|
+
|
|
906
|
+
In the **JS** project's `style.css` (the one `@import`ed by `lynx.config.ts`),
|
|
907
|
+
not in Android:
|
|
908
|
+
|
|
909
|
+
```css
|
|
910
|
+
@font-face {
|
|
911
|
+
font-family: "Ubuntu Mono";
|
|
912
|
+
src: url("asset:///fonts/ubuntu_mono.ttf");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
text {
|
|
916
|
+
font-family: "Ubuntu Mono", sans-serif;
|
|
917
|
+
}
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
The string `asset:///fonts/ubuntu_mono.ttf` must be **byte-for-byte identical**
|
|
921
|
+
to the one you use in `prefetchFont()` (step 4.5) — Lynx caches the
|
|
922
|
+
prefetched `Typeface` using that exact string as the key.
|
|
923
|
+
|
|
924
|
+
### 4.4 Step 3 — register a `LynxFontFaceLoader.Loader` that can resolve `asset:///`
|
|
925
|
+
|
|
926
|
+
Lynx's default loader (decompiled from `lynx-4.1.0.aar`) **does not resolve
|
|
927
|
+
`asset:///` in any way** — neither for the prefetch nor for the real
|
|
928
|
+
resolution — unless you register your own:
|
|
929
|
+
|
|
930
|
+
```kotlin
|
|
931
|
+
package com.myapp
|
|
932
|
+
|
|
933
|
+
import android.graphics.Typeface
|
|
934
|
+
import com.lynx.tasm.behavior.LynxContext
|
|
935
|
+
import com.lynx.tasm.fontface.FontFace
|
|
936
|
+
import com.lynx.tasm.loader.LynxFontFaceLoader
|
|
937
|
+
|
|
938
|
+
object AssetFontFaceLoader : LynxFontFaceLoader.Loader() {
|
|
939
|
+
private const val ASSET_PREFIX = "asset:///"
|
|
940
|
+
|
|
941
|
+
override fun onLoadFontFace(
|
|
942
|
+
context: LynxContext,
|
|
943
|
+
type: FontFace.TYPE,
|
|
944
|
+
src: String,
|
|
945
|
+
): Typeface? {
|
|
946
|
+
if (!src.startsWith(ASSET_PREFIX)) return null
|
|
947
|
+
return try {
|
|
948
|
+
Typeface.createFromAsset(context.context.assets, src.removePrefix(ASSET_PREFIX))
|
|
949
|
+
} catch (e: Exception) {
|
|
950
|
+
null
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
### 4.5 Step 4 — register the loader BEFORE `LynxEnv.inst().init()`
|
|
957
|
+
|
|
958
|
+
In your `Application.onCreate()` (`MyApp.kt` from section 2.10):
|
|
959
|
+
|
|
960
|
+
```kotlin
|
|
961
|
+
class MyApp : Application() {
|
|
962
|
+
override fun onCreate() {
|
|
963
|
+
super.onCreate()
|
|
964
|
+
|
|
965
|
+
LynxServiceCenter.inst().registerService(LynxLogService)
|
|
966
|
+
LynxLogService.switchLogToSystem(true)
|
|
967
|
+
|
|
968
|
+
// MUST come before LynxEnv.inst().init() — this is what makes
|
|
969
|
+
// "asset:///" resolvable, both by the prefetch and by the real
|
|
970
|
+
// @font-face resolution.
|
|
971
|
+
LynxFontFaceLoader.setLoader(AssetFontFaceLoader)
|
|
972
|
+
|
|
973
|
+
LynxEnv.inst().init(this, null, null, null)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
### 4.6 Step 5 — `prefetchFont()` BEFORE `renderTemplateUrl()`
|
|
979
|
+
|
|
980
|
+
In `MainActivity.kt`, right after `builder.build(this)` and BEFORE
|
|
981
|
+
`lynxView.renderTemplateUrl(...)`:
|
|
982
|
+
|
|
983
|
+
```kotlin
|
|
984
|
+
import com.lynx.tasm.fontface.FontFaceManager
|
|
985
|
+
|
|
986
|
+
// ...
|
|
987
|
+
val lynxView = builder.build(this)
|
|
988
|
+
setContentView(lynxView)
|
|
989
|
+
|
|
990
|
+
FontFaceManager.getInstance().prefetchFont(
|
|
991
|
+
lynxView.lynxContext,
|
|
992
|
+
"asset:///fonts/ubuntu_mono.ttf", // identical to the CSS src: url(...)
|
|
993
|
+
null,
|
|
994
|
+
object : FontFaceManager.FontFacePrefetchListener {
|
|
995
|
+
override fun onComplete(code: Int, msg: String) {}
|
|
996
|
+
},
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
lynxView.renderTemplateUrl("main-thread.bundle", "")
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
`prefetchFont()` runs on Lynx's own IO thread pool — it blocks nothing. If the
|
|
1003
|
+
race against the first layout is won (the normal case, since the bundle's JS
|
|
1004
|
+
hasn't even started executing yet) there is no visible fallback-font flash; if
|
|
1005
|
+
it is somehow lost, it simply degrades to the normal behavior (the font
|
|
1006
|
+
resolves where it always did, nothing breaks).
|
|
1007
|
+
|
|
1008
|
+
**How to confirm it actually ran.** Lynx logs one line per prefetch on
|
|
1009
|
+
success, and it is the cheapest end-to-end check for the whole Part D:
|
|
1010
|
+
|
|
1011
|
+
```bash
|
|
1012
|
+
adb logcat | grep FontFaceManager
|
|
1013
|
+
# I FontFaceManager: prefetchFont success: asset:///fonts/ubuntu_mono.ttf
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
If you don't see that line, the loader is not registered, the URI doesn't
|
|
1017
|
+
match the CSS byte-for-byte, or the `.ttf` isn't in the APK's assets.
|
|
1018
|
+
|
|
1019
|
+
### 4.7 Why this path and not the documented JS APIs
|
|
1020
|
+
|
|
1021
|
+
Lynx documents `lynx.addFont()` and
|
|
1022
|
+
`lynx.requestResourcePrefetch({type:"font"})`, both callable from JS. **They
|
|
1023
|
+
do not solve this problem**: they are invoked from a post-mount hook, meaning
|
|
1024
|
+
the engine already has to be up and running for either of them to fire — they
|
|
1025
|
+
cannot win the *first* frame race. On top of that, every official example
|
|
1026
|
+
points at a font served over HTTPS, never at a packaged local asset.
|
|
1027
|
+
|
|
1028
|
+
The native (Kotlin) path runs **before the JS engine even starts**, so there
|
|
1029
|
+
is no window in which the fallback is visible.
|
|
1030
|
+
|
|
1031
|
+
**Trade-off to keep in mind**: `FontFaceManager`/`LynxFontFaceLoader` are
|
|
1032
|
+
public classes (not `@RestrictTo`) but are not documented for this specific
|
|
1033
|
+
use — they could change without notice in a future major version of the Lynx
|
|
1034
|
+
SDK, unlike the official JS API surface.
|
|
1035
|
+
|
|
1036
|
+
---
|
|
1037
|
+
|
|
1038
|
+
## 5. Cheatsheet — quick reference commands
|
|
1039
|
+
|
|
1040
|
+
```bash
|
|
1041
|
+
# Build the JS bundle
|
|
1042
|
+
npm --prefix my-app-js run build
|
|
1043
|
+
|
|
1044
|
+
# Copy it into Android
|
|
1045
|
+
cp my-app-js/dist/main-thread.bundle my-app-android/app/src/main/assets/main-thread.bundle
|
|
1046
|
+
|
|
1047
|
+
# Build + install on the connected device
|
|
1048
|
+
(cd my-app-android && ./gradlew installDebug)
|
|
1049
|
+
|
|
1050
|
+
# Restart and measure cold start
|
|
1051
|
+
adb shell am force-stop com.myapp
|
|
1052
|
+
adb shell am start -W -n com.myapp/.MainActivity
|
|
1053
|
+
|
|
1054
|
+
# Watch logs live (requires LynxLogService registered, see 2.10)
|
|
1055
|
+
adb logcat | grep -i lynx
|
|
1056
|
+
|
|
1057
|
+
# Confirm the font prefetch ran (Part D)
|
|
1058
|
+
adb logcat | grep FontFaceManager
|
|
1059
|
+
|
|
1060
|
+
# Confirm the bundle actually executed
|
|
1061
|
+
adb logcat | grep __RenderPage
|
|
1062
|
+
|
|
1063
|
+
# Build the APK without installing
|
|
1064
|
+
(cd my-app-android && ./gradlew assembleDebug)
|
|
1065
|
+
# → my-app-android/app/build/outputs/apk/debug/app-debug.apk
|
|
1066
|
+
|
|
1067
|
+
# If installDebug dies with "InstallException: EOF" (see 3.3)
|
|
1068
|
+
adb install -r my-app-android/app/build/outputs/apk/debug/app-debug.apk
|
|
1069
|
+
|
|
1070
|
+
# Clean the build (if something got into a weird state)
|
|
1071
|
+
(cd my-app-android && ./gradlew clean)
|
|
1072
|
+
|
|
1073
|
+
# See which devices/emulators are connected
|
|
1074
|
+
adb devices -l
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
### 5.1 All of the above, via the CLI
|
|
1078
|
+
|
|
1079
|
+
If the project came out of `create-mithril-lynx --android`, the steps above are
|
|
1080
|
+
npm scripts in the JS project:
|
|
1081
|
+
|
|
1082
|
+
```bash
|
|
1083
|
+
npm run android:sync # = build + cp into assets (sections 1.4 and 3)
|
|
1084
|
+
npm run android # = sync + installDebug + am start -W (section 3.1)
|
|
1085
|
+
npm run android:apk # = sync + assembleDebug
|
|
1086
|
+
npm run android:release # = sync + assembleRelease (sections 3.2 and 3.2.1)
|
|
1087
|
+
npm run android:keystore # = keytool + keystore.properties (section 3.2)
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
`npm run android` prints `TotalTime` directly, already grepped, and tells you
|
|
1091
|
+
the APK path for `android:apk`/`android:release`.
|
|
1092
|
+
|
|
1093
|
+
---
|
|
1094
|
+
|
|
1095
|
+
## 6. Troubleshooting
|
|
1096
|
+
|
|
1097
|
+
| Symptom | Cause | Fix |
|
|
1098
|
+
|---|---|---|
|
|
1099
|
+
| `<input>`/`<textarea>` take up zero size, never open the keyboard | Missing `xelement`/`xelement-input`, or missing `builder.addBehaviors(XElementBehaviors().create())` | Sections 2.7 and 2.12 |
|
|
1100
|
+
| `<overlay>` mounts but nothing is ever visible | Missing `xelement-overlay` | Section 2.7 |
|
|
1101
|
+
| A raw native gesture (`__SetGestureDetector`) never fires, with no error | Missing `pluginLynxConfig({ enableNewGesture: true })` in `lynx.config.ts` (`mithril-lynx/gesture` itself doesn't exist yet, see 1.1) | Section 1.2 |
|
|
1102
|
+
| `console.log()` / JS errors appear nowhere | Missing `lynx-service-log` + `LynxServiceCenter.inst().registerService(LynxLogService)` | Sections 2.7 and 2.10 |
|
|
1103
|
+
| Touching a `<refresh>` throws `NoClassDefFoundError` (caught, the gesture just never works) | Missing `androidx.viewpager2:viewpager2` | Section 2.7 |
|
|
1104
|
+
| Cold start of 2+ seconds | Bundle read synchronously on the UI thread | Use the async `AssetTemplateProvider` (2.11), never a direct `context.assets.open(...)` in `onCreate()` |
|
|
1105
|
+
| Custom `@font-face` adds +1-2s of cold start | Synchronous resolution inside `__FlushElementTree()` — known bug | All of Part D |
|
|
1106
|
+
| `asset:///...` resolves neither in `prefetchFont()` nor in the real CSS | Missing `LynxFontFaceLoader.setLoader(...)`, or it was registered after `LynxEnv.inst().init()` | Section 4.5 |
|
|
1107
|
+
| No `FontFaceManager: prefetchFont success:` line in logcat | Loader not registered, URI not byte-identical to the CSS `src: url(...)`, or the `.ttf` isn't in the APK's assets | Sections 4.3–4.6 |
|
|
1108
|
+
| `./gradlew` fails with "SDK location not found" | Missing `local.properties` with `sdk.dir=...` | Section 2.5 |
|
|
1109
|
+
| `./gradlew installDebug` fails with `com.android.ddmlib.InstallException: EOF` | `ddmlib`'s streamed install over USB dropped the fat debug APK. Transient | Retry, or `adb install -r app/build/outputs/apk/debug/app-debug.apk` (section 3.3) |
|
|
1110
|
+
| `assembleRelease` fails with `Missing classes detected while running R8` | `xelement` references Fresco/Gson/Markdown, optional dependencies outside the classpath | Section 3.2.1 — the `-dontwarn` rules |
|
|
1111
|
+
| The release APK builds but crashes on startup with `NoSuchMethodError: ...LynxLog;.log(...)` / `JNI DETECTED ERROR: mid == null` / `SIGABRT` in the `LynxTraceInit` thread | R8 renames `com.lynx.base.log.LynxLog`, which native code calls by exact name via JNI; the AAR does not annotate it `@CalledByNative` | Section 3.2.1 — `-keep class com.lynx.base.log.LynxLog { *; }` |
|
|
1112
|
+
| A full-screen "not compatible with 16 KB" dialog covers the app on Android 15+/One UI | System warning about Lynx's prebuilt, non-16K-aligned `.so` files. Not your app | Dismiss it; see section 3.3. Assert on logcat, not screenshots, in device tests |
|
|
1113
|
+
| SDK license errors while building | Licenses not accepted | `yes \| sdkmanager --licenses` |
|
|
1114
|
+
|
|
1115
|
+
---
|
|
1116
|
+
|
|
1117
|
+
## 7. What was verified on device
|
|
1118
|
+
|
|
1119
|
+
All of the following was measured on a Samsung Galaxy A07 (SM-A075M, Android
|
|
1120
|
+
15/One UI), with a project generated by
|
|
1121
|
+
`npm create mithril-lynx@latest --ts --blank --android --with-font`, installed
|
|
1122
|
+
over USB with `adb`.
|
|
1123
|
+
|
|
1124
|
+
Cold start via `adb shell am start -W`, after `am force-stop` and a settle
|
|
1125
|
+
delay, `LaunchState: COLD`:
|
|
1126
|
+
|
|
1127
|
+
| Build | First launch after install | Steady state |
|
|
1128
|
+
|---|---|---|
|
|
1129
|
+
| `debug` (`assembleDebug` / `installDebug`) | 2610 ms | 746, 750, 764, 829 ms |
|
|
1130
|
+
| `release` (R8 minified, signed) | 576 ms | 246, 260, 263, 272 ms |
|
|
1131
|
+
|
|
1132
|
+
The first launch after a fresh install is always the slowest one (dexopt/AOT
|
|
1133
|
+
warm-up) — that number is real but not representative, which is why it is
|
|
1134
|
+
listed separately.
|
|
1135
|
+
|
|
1136
|
+
Other things confirmed on the same device:
|
|
1137
|
+
|
|
1138
|
+
- `FontFaceManager: prefetchFont success: asset:///fonts/ubuntu_mono.ttf` in
|
|
1139
|
+
logcat, in both debug and minified release (Part D survives R8).
|
|
1140
|
+
- `LepusClosureEventListener::Invoke name: __RenderPage` in logcat — the
|
|
1141
|
+
bundle's main-thread entry actually ran.
|
|
1142
|
+
- Zero `FATAL` / `SIGABRT` / `NoSuchMethodError` in logcat across debug,
|
|
1143
|
+
minified release, and every relaunch.
|
|
1144
|
+
- The Hello World template renders (`Mithril` / `on Lynx` / "Tap the logo and
|
|
1145
|
+
have fun!") and so does the Blank template.
|
|
1146
|
+
- `aapt2 dump badging` on the built APK shows the expected
|
|
1147
|
+
`package`, `application-label`, `sdkVersion:'24'`, `targetSdkVersion:'34'`.
|
|
1148
|
+
- The `.bundle` copied by the CLI is byte-identical to `dist/main-thread.bundle`
|
|
1149
|
+
(47806 bytes in the Blank case), and the `.ttf` is packaged at
|
|
1150
|
+
`assets/fonts/` inside the APK.
|
|
1151
|
+
- `apksigner verify` confirms the release APK is signed with the generated
|
|
1152
|
+
keystore.
|