-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
ENPAF runs the same app in two environments: a desktop dev server and an on-device Android WebView. The bridge abstracts the transport so your code is identical in both.
┌─────────────────────────── Your app ───────────────────────────┐
│ main.py (Python) app/ (HTML/CSS/JS) │
│ routes · bridge handlers · events UI · enpaf.* calls │
└───────────────┬─────────────────────────────┬──────────────────┘
│ │
┌─────────┴─────────┐ ┌─────────┴──────────┐
│ DEV (desktop) │ │ ANDROID (device) │
│ Flask + SocketIO │ │ Chaquopy + WebView│
└─────────┬─────────┘ └─────────┬──────────┘
│ WebSocket / HTTP │ JS interface
└──────────────┬───────────────┘
Python ↔ JS Bridge
| Component | File | Role |
|---|---|---|
EnpafApp |
core/app.py |
Facade tying everything together; detects Android; wires modules. |
Bridge |
core/bridge.py |
Python↔JS calls + event push; SocketIO (dev) or WebView (Android). |
EventEmitter |
core/events.py |
Thread-safe pub/sub. |
Router |
core/router.py |
Routes + Jinja2 rendering (dev); lazy-imports Jinja2. |
Storage |
core/storage.py |
SQLite key/value + collections. |
DeviceAPI |
core/api.py |
Sensors, permissions, toasts, NFC, … (Android + dev stubs). |
| Capability modules | android/*.py |
wifi, bluetooth, location, sensors, nfc, audio, battery, notifications, device, permissions, media, biometric. |
DevServer |
core/server.py |
Flask + Flask-SocketIO dev server. |
AndroidRuntime |
android/runtime.py |
On-device startup. |
APKBuilder / ProjectGenerator
|
builder/*.py |
Generate + build the Gradle/Chaquopy project. |
app.run() on desktop starts DevServer (Flask + Socket.IO). It:
- Serves your
app/folder. - Injects
/enpaf-bridge/enpaf.jsinto the<head>. - Bridges JS calls over a WebSocket, with an HTTP fallback
(
POST /enpaf-api/bridge-call) when Socket.IO isn't available. - Exposes a settings/inspector panel at
/enpaf-settings.
This is why you can build and preview the entire app — including bridge handlers and device stubs — without a phone.
The dev server is meant for localhost, and is hardened accordingly:
-
Socket.IO CORS is locked to same-origin on loopback binds
(
http://localhost:<port>/http://127.0.0.1:<port>), so a random web page you have open can't open a WebSocket to the server and drive the bridge or storage (cross-site WebSocket hijacking). If you explicitly bind to a non-loopback host you've opted into exposure, so CORS falls back to*with a warning — set a realENPAF_SECRET_KEYand only do this on a trusted network. -
Static file serving is guarded against path traversal — requested paths
are resolved and must stay within the
app/directory (the HTML branch can't escape it). - The Flask
SECRET_KEYis overridable via theENPAF_SECRET_KEYenvironment variable.
These only affect the desktop dev server; on-device there is no HTTP server (the WebView loads local assets and talks to Python through the JS interface).
The builder generates a Gradle project where Chaquopy embeds CPython. A Java
MainActivity hosts a full-screen WebView that loads your UI from
file:///android_asset/www. On startup, MainActivity:
- Sets
ENPAF_ANDROID=1andENPAF_DATA_DIR=getFilesDir(). - Imports the Python
mainmodule (running yourmain.py). - Hands the
Activity+WebViewto Python (app._attach_android).
The bridge then uses a JavaScript interface (window.EnpafAndroidBridge) for
JS→Python calls, and evaluates window.__enpaf_event(...) / __enpaf_callback(...)
in the WebView for Python→JS pushes.
JS reaches Python capabilities through two gateways, both allow-listed:
-
enpaf.api(method, args)→__enpaf_api→DeviceAPI.invoke(method, args). -
enpaf.mod(module, method, args)→__enpaf_mod→app.<module>.invoke(method, args).
Each module validates the method name against an _ALLOWED set, so JS can only
call intended methods — never arbitrary Python attributes.
Chaquopy's dynamic_proxy can implement Java interfaces but not subclass
abstract classes. So native callbacks for abstract classes are implemented in
MainActivity (Java) and forwarded to Python:
-
BroadcastReceiver→ a genericEnpafBroadcastReceiver+registerEnpafReceiver(tag, actions)→app._on_broadcast_receive(tag, intent)(used by Wi-Fi/Bluetooth scanning). -
BiometricPrompt.AuthenticationCallback→MainActivity.authenticateBiometric()→app._on_biometric_result(ok, err)→ thebiometric_resultevent.
MainActivity extends Activity (not FragmentActivity), so the biometric prompt
uses the framework android.hardware.biometrics.BiometricPrompt (API 28+).
Two rules keep on-device apps from crashing on launch — important if you hack on the framework or bundle extra Python:
-
No top-level third-party imports in bundled modules. Chaquopy only ships the stdlib plus whatever
python_requirementsadds. A module-levelimport jinja2/flask/… in any bundled module →ModuleNotFoundError→ instant crash. Keep such imports lazy (inside the function that needs them). That's whyrouter.pyimports Jinja2 only insiderender(). -
Writable data must use
ENPAF_DATA_DIR. The app's sources are packaged read-only; writing next to them crashes.Storagewrites toENPAF_DATA_DIR(the app's private files dir on-device), set byMainActivity.
Android detection avoids import android (which Chaquopy can't resolve as a
bare module) and instead checks the ENPAF_ANDROID env var.
enpaf.json + main.py + app/ ──► ProjectGenerator ──► Gradle project
(manifest, MainActivity, assets, Chaquopy)
│
▼
Gradle (AGP 8.2, Gradle 8.4)
+ Chaquopy 15.0.1 ──► signed APK ──► dist/
See Building APKs and Release & Signing.
Getting started
Reference
- CLI Reference
- enpaf.json Configuration
- Python API
- JavaScript Bridge
- Storage
- Events
- Device Capabilities
Building & shipping
Project