# android-piano **Repository Path**: brt2/android-piano ## Basic Information - **Project Name**: android-piano - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-03 - **Last Updated**: 2026-10-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # webapp · Android 一个**业务无关的 Android WebView 模板**。把任意 Web 前端打包成一个最小依赖的安卓应用:WebView 跑 JS,Kotlin 只做 native adapter,业务以**插件**形式挂载。 > 本仓库由 ai-piano 的 Android 移植重构而来。原来的 `piano/` 业务层与自写 loopback > HTTP server 已全部移除,`piano` 现在是最小示例插件(见下方「插件」一节)。 > 设计取舍见 [ARCHITECTURE.md](ARCHITECTURE.md),项目协议见 [AGENTS.md](AGENTS.md)。 ## Build ```bash ./gradlew :app:assembleDebug # app/build/outputs/apk/debug/app-debug.apk ./gradlew :app:assembleRelease # app/build/outputs/apk/release/app-release-unsigned.apk ./gradlew :app:testDebugUnitTest # JVM 单元测试 ./gradlew :app:lint # Android Lint ``` Requires JDK 17 and an Android SDK with platform 34 / build-tools 34.0.0. Point Gradle at your SDK by copying `local.properties.example` to `local.properties` and filling in your path: ```properties sdk.dir=/path/to/Android/sdk ``` Toolchain pinned in `gradle/libs.versions.toml` (AGP 8.2.0 / Kotlin 1.9.22) and `gradle/wrapper/gradle-wrapper.properties` (Gradle 8.7). `kotlin { jvmToolchain(17) }` in `app/build.gradle.kts` makes the build self-provisioning — Gradle downloads Temurin 17 to `~/.gradle/jdks/` on first run, so no system JDK setup is needed. Install with `adb install -r app/build/outputs/apk/debug/app-debug.apk`. ## 架构 ``` ┌──────────────────────────────────────────────────────────────┐ │ WebView 沙箱(V8,JS) │ │ DOM / Web Audio / fetch — UI 与业务逻辑都在这里 │ ├──────────────────────────────────────────────────────────────┤ │ bridge: window.Native.call(action, body) → {status,payload} │ ← 唯一边界 ├──────────────────────────────────────────────────────────────┤ │ Android 沙箱(Kotlin) │ │ org.encv.webapp.host/* kernel 原语(13 文件) │ │ org.encv.webapp.plugin/* plugin 子系统(5 文件) │ ├──────────────────────────────────────────────────────────────┤ │ org.encv.plugins.piano 业务插件(示例,可替换/删除) │ └──────────────────────────────────────────────────────────────┘ ``` `host/` + `plugin/` 是通用脚手架,**不持有任何业务引用**——命令名、payload schema、事件类型全部由使用者定义。 ## 桥协议 前端只需要一个 `window.Native` 原生对象;demo 与 plugin 共用同一份 Promise 包装: ```javascript window.bridge = { call(action, payload = {}) { // action: "ping" 或 "piano.play_note" return new Promise((resolve, reject) => { try { const r = JSON.parse(window.Native.call(action, JSON.stringify(payload))); if (r.status >= 400) reject(r); else resolve(r.payload); } catch (e) { reject({ status: 500, payload: { error: String(e) } }); } }); }, setEventHandler(handler) { window.__bridgeHandler = handler; }, // host 事件推送 }; ``` - 不含 `.` 的 action → 全局 `CommandRouter`(如 `ping`) - 含 `.` 的 action → `PluginRegistry`,格式为 `.`(如 `piano.play_note`) **反向的 host → JS 事件推送**:handler 里调 `app.broadcast(event)`。注意**不要**直接用`EventBus.broadcast`——它只投进程内订阅者队列,而 host 自己从不注册订阅者,往那里广播等于扔进空集合,JS 侧收不到且不报错。`WebApp.broadcast` = bus + JS,才是组合根该用的入口。 ## 换掉前端 WebView 直接 `loadUrl("file:///android_asset/web/index.html")`,相对路径由 Android `AssetProvider` 解析,模板**不拦截请求**。因此换前端只需: ```bash rm -rf app/src/main/assets/web/* # 放入你自己的 index.html + 静态资源,保持 bridge 协议即可 ``` ## 插件 一个 plugin 是「一份 manifest + 一份 web 资源 + 一组 CommandHandler」: ``` app/src/main/assets/plugins/piano/ ├── manifest.json └── web/index.html ``` ```json { "id": "piano", "name": "Piano", "version": "0.1.0", "entry": "web/index.html", "permissions": ["wake_lock"], "commands": [ {"action": "play_note", "description": "触发音符高亮"} ] } ``` `manifest.json` 就是访问控制清单(manifest-as-ACL):plugin 只能注册 `commands` 里声明过的 action,多注册或少注册都会导致整个 plugin 被丢弃;`requires` 权限未在`grantedPermissions` 授予时返回 403。 挂载一个新 plugin: ```kotlin // MainActivity app = WebApp.Builder(this) .globalCommands(listOf(PingHandler())) .plugins( bundledDir = "plugins", userDir = File(filesDir, "plugins"), grantedPermissions = setOf("file_io", "store_io"), ) { manifest, assetSource -> when (manifest.id) { "piano" -> { val store = JsonStore(File(pluginDir, "store.json"), PIANO_SCHEMA) listOf( ReadAssetHandler(assetSource), StoreGetHandler(store), StoreSetHandler(store), ) } else -> emptyList() } } .activePlugin("piano") .build() ``` handlerProvider 现在收到的是 `(manifest, assetSource)`:第二个参数是 plugin 自己的 `PluginAssetSource`,handler 可以用来读 plugin 的私有文件。**把钢琴的 note 数据 / layout 列表 / 采样音频都放进 `assets/plugins/piano/` 后,APK 不再持有钢琴领域逻辑**——后续只动 `web/` + `data/` 就能升级。 ## 安全默认值 模板**默认无鉴权**——`@JavascriptInterface` 是进程内 V8 ↔ Java 桥,无 IPC、无 Intent 入口、无 socket 入口,由 APK 签名背书。模板自带: - WebView 默认配置:`allowFileAccess=false`、`allowContentAccess=false`、`MIXED_CONTENT_NEVER_ALLOW` - CSP meta(`default-src 'self'; object-src 'none'; frame-ancestors 'none'; …`) - release 自动关闭 `WebContentsDebuggingEnabled` - `CommandRouter` 可选 `authorizer: (String) -> Boolean` hook **必须显式开启鉴权**的场景:加载远程 origin、单进程多 WebView、嵌入第三方 script。详见 ARCHITECTURE.md §5.7。 ## Tests ```bash ./gradlew :app:testDebugUnitTest ``` 8 个 JVM 测试文件覆盖 EventBus、CommandRouter、AssetSource、JsonStore、PausableClock、PluginManifest、PluginRegistry,以及 `BridgeContractTest`(锁住 manifest / handler / JS 页面三处 action 名的拼写一致性)。均为纯 JVM,不需要 Android context 或设备。 ## 已知边界 - **不内置业务**。demo UI 只验证 bridge 往返;真实 UI 由你或 plugin 提供。 - **无 plugin 市场**。plugin 走 bundled(`assets/plugins/`)+ user-installed (`filesDir/plugins/`)双路径,没有签名校验与远程分发。 - **单 WebView**。多 WebView 场景需要自行扩展 `WebApp`。 - **`AndroidManifest` 未声明 `INTERNET`**。只有加载远程 origin 时才需要打开。