mithril-lynx 2.0.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.