A lightweight dynamic UI engine for Android that renders native Jetpack Compose widgets from JavaScript scripts at runtime, powered by QuickJS.
No WebView. No HTML. Pure native UI driven by scripts.
┌──────────────────────────────────────────────────────────────┐
│ JavaScript (main.js) │
│ │
│ OnTheFly.setUI({ │
│ type: "Column", children: [ │
│ { type: "Text", props: { text: "Hello" } }, │
│ { type: "Button", props: { text: "Tap", onClick: "f" }}│
│ ] │
│ }); │
│ │ ▲ │
│ ▼ │ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ QuickJS │───▶│ JNI │───▶│ Compose │──▶ Native UI │
│ │ (C) │ │ Bridge │ │ Render │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │
│ User taps button │
│ │ │
│ onClick("f") → JS │
└──────────────────────────────────────────────────────────────┘
JavaScript defines the UI structure and logic. QuickJS executes it natively (no WebView). The JNI bridge passes the UI tree to Kotlin, where Jetpack Compose renders real native widgets. User interactions flow back to JS.
| Feature | Description |
|---|---|
| Dynamic UI | Define UI in JavaScript, rendered as native Compose widgets |
| Hot Reload | Edit JS → save → app auto-reloads in ~2 seconds |
| Navigation | Multi-screen navigation driven entirely by JS |
| API Calls | JS requests HTTP calls, native Ktor client executes, response returns to JS |
| Popups | Full-screen overlays and confirm dialogs, controlled from JS |
| Event System | Lifecycle, data, component events — bidirectional bridge |
| Targeted Updates | Update only changed nodes, not the entire UI tree |
| Style System | Centralized themes in theme.js, components reference by name |
| Timber Logging | OnTheFly.log.d/i/w/e() in JS → Timber in Logcat |
| Dev Server | Python server with file watcher, validator, deploy commands |
| Script Validator | Check JS syntax from terminal before deploying |
graph TB
subgraph "Presentation"
MA[MainActivity]
SP[SplashScreen]
SS[ScriptScreen]
DR[DynamicRenderer]
end
subgraph "ViewModel"
VM[ScriptViewModel]
end
subgraph "Domain"
UC[LoadScriptUseCase]
EE[EngineEvent]
NA[NativeAction]
end
subgraph "Data"
LS[LocalScriptStorage]
UM[ScriptUpdateManager]
NS[NetworkSource - Ktor]
DS[DevServerSource]
end
subgraph "Engine"
QJS[QuickJSEngine]
STY[StyleRegistry]
BR[bridge.cpp - JNI]
QC[QuickJS C Library]
end
MA --> SP
SP --> SS
SS --> VM
SS --> DR
VM --> UC
VM --> QJS
VM --> UM
VM --> NS
UM --> DS
UM --> LS
UC --> LS
QJS --> STY
QJS --> BR
BR --> QC
┌─────────────────────────────────────────────────────────────┐
│ │
│ FIRST LAUNCH │
│ assets/scripts/ ──copy──▶ files/scripts/ (local storage) │
│ │
│ RUNTIME │
│ Always load from files/scripts/ (local) │
│ │
│ UPDATE (Dev) │
│ Dev Server ──download──▶ files/scripts/ ──▶ auto-reload │
│ │
│ UPDATE (Production) │
│ Remote CDN ──download──▶ files/scripts/ ──▶ reload │
│ │
└─────────────────────────────────────────────────────────────┘
OnTheFly-Android/
├── devserver/ # Dev tools
│ ├── scripts/ # Source of truth for all JS bundles
│ │ ├── version.json # Version manifest
│ │ ├── home/ # Home screen
│ │ │ ├── manifest.json
│ │ │ ├── main.js
│ │ │ └── theme.js
│ │ ├── demo-app/ # Demo: navigation, popups, events
│ │ ├── detail-app/ # Demo: data passing
│ │ ├── api-demo/ # Demo: HTTP API calls
│ │ ├── popup-fullscreen/ # Demo: full screen popups
│ │ └── popup-confirm/ # Demo: confirm dialogs
│ ├── server.py # Dev server + validator + deploy
│ └── start.sh # Quick start script
│
├── app/src/main/
│ ├── assets/scripts/ # Build artifact (auto-copied from devserver/)
│ ├── cpp/ # Native C/C++ layer
│ │ ├── quickjs/ # QuickJS engine source
│ │ ├── bridge.cpp # JNI bridge
│ │ └── CMakeLists.txt
│ └── java/com/dongnh/onthefly/
│ ├── engine/ # QuickJS wrapper + style registry
│ ├── domain/ # Models, use cases, interfaces
│ ├── data/ # Local storage, network, dev server
│ ├── presentation/ # Screens, renderer, navigation, viewmodel
│ ├── ui/theme/ # Material3 theme
│ ├── OnTheFlyApp.kt # Application class (init storage)
│ └── MainActivity.kt
│
├── docs/images/ # Screenshots
└── README.md
Interactive dev server with file watcher, validator, and deploy:
# Start (auto-setup venv on first run)
./devserver/start.sh OnTheFly Dev Server
───────────────────────────────────────
Port: 8080
Bundles: home, demo-app, api-demo, ...
Emulator: http://10.0.2.2:8080
───────────────────────────────────────
✓ File watcher active (watchdog)
✓ HTTP server on port 8080
───────────────────────────────────────
onthefly> _
| Command | Description |
|---|---|
v, validate [bundle] |
Check JS syntax errors |
d, deploy [bundle] |
Copy scripts → Android assets |
l, list |
List all bundles |
r, reload |
Force app reload |
h, help |
Show commands |
q, quit |
Stop server |
python server.py # Start server
python server.py validate # Validate all bundles
python server.py validate home # Validate one bundle
python server.py deploy # Deploy all → Android assets
python server.py deploy home # Deploy one bundlesequenceDiagram
participant Dev as Developer
participant FS as File System
participant WD as Watchdog
participant SRV as Dev Server
participant APP as Android App
Dev->>FS: Save main.js
FS->>WD: File change detected
WD->>SRV: Bump globalVersion
APP->>SRV: Poll /version (every 2s)
SRV-->>APP: New globalVersion
APP->>SRV: Download changed files
SRV-->>APP: main.js, theme.js
APP->>APP: Save to local storage
APP->>APP: Reload engine + re-render UI
Each screen is a bundle — a folder with manifest, theme, and entry script:
home/
├── manifest.json # { "name": "Home", "version": "1.0.0", "entry": "main.js" }
├── theme.js # Centralized styles
└── main.js # UI + logic
function onCreateView() {
OnTheFly.log.i("Screen loaded");
}
function render() {
OnTheFly.setUI({
type: "Column",
props: { style: "container" },
children: [
{ type: "Text", props: { text: "Hello", style: "title" } },
{ type: "Button", props: { text: "Next", onClick: "goNext", style: "primary" } }
]
});
}
function goNext() {
OnTheFly.sendToNative("navigate", { screen: "detail", data: { id: 1 } });
}
render();OnTheFly.registerStyles({
title: { fontSize: 28, fontWeight: "bold", color: "#1A1A2E" },
primary: { backgroundColor: "#0F3460", color: "#FFF", cornerRadius: 12 },
container: { padding: 24, spacing: 16, alignment: "center" }
});| Component | Props | Description |
|---|---|---|
Column |
padding, spacing, alignment, style |
Vertical layout |
Row |
spacing, alignment, style |
Horizontal layout |
Text |
text, style, id |
Text display |
Button |
text, onClick, id, style |
Clickable button |
Toggle |
id, label, checked |
Switch/toggle |
Spacer |
height, style |
Empty space |
FullScreenPopup |
id, visible, onDismiss, style |
Full screen overlay |
ConfirmDialog |
id, visible, title, message, onConfirm, onCancel |
Alert dialog |
| Method | Description |
|---|---|
OnTheFly.setUI(tree) |
Set full UI tree |
OnTheFly.update(id, props) |
Targeted update (fast) |
OnTheFly.registerStyles(styles) |
Register named styles |
OnTheFly.sendToNative(action, data) |
Navigate, API call, toast, goBack |
OnTheFly.log(msg) |
Log to Timber (INFO) |
OnTheFly.log.d/i/w/e(msg) |
Log with level |
console.log(msg) |
Log to Timber (DEBUG) |
OnTheFly.sendToNative("navigate", { screen: "detail", data: {...} });
OnTheFly.sendToNative("goBack");
OnTheFly.sendToNative("showToast", { message: "Hello!" });
OnTheFly.sendToNative("sendRequest", { id: "r1", url: "...", method: "GET" });function onCreateView() { } // Screen created
function onResume() { } // App foreground
function onPause() { } // App background
function onDestroy() { } // Screen destroyed
function onVisible() { } // Screen re-entered
function onBackPressed() { } // Back button
function onDataReceived(data) { } // API response
function onViewData(data) { } // Data from navigation| Component | Technology |
|---|---|
| UI | Jetpack Compose + Material3 |
| Script Engine | QuickJS (v2025-09-13, ~210KB) |
| Native Bridge | JNI via NDK + CMake |
| HTTP Client | Ktor Client + OkHttp |
| Navigation | Compose Navigation |
| Logging | Timber |
| Architecture | Clean Architecture |
| Language | Kotlin + C/C++ |
| Dev Server | Python + watchdog |
| Min SDK | 24 (Android 7.0) |
| Target SDK | 35 |
# Build
./gradlew assembleDebug
# Install
./gradlew installDebug
# With hot-reload
./devserver/start.sh # Terminal 1
./gradlew installDebug # Terminal 2
# Edit devserver/scripts/home/main.js → app auto-reloadsMIT


