diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3a9e9d4..56bb414 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,6 +42,15 @@ jobs:
yes | "$sdkmanager" --licenses >/dev/null || true
"$sdkmanager" "platforms;android-36" "build-tools;36.0.0"
+ - name: Validate native packaging scripts
+ shell: bash
+ run: |
+ python3 native/verify-engine-apk.py --self-test
+ python3 native/verify-octave-runtime.py --lock-only
+ python3 native/verify-octave-apk.py --self-test
+ python3 native/verify-elf-page-size.py --help >/dev/null
+ bash -n native/*.sh
+
- name: Run unit tests
shell: bash
run: |
@@ -57,10 +66,9 @@ jobs:
shell: bash
run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:assembleDebug
- # 暂不阻断:lint 从未在本仓库跑过,先让历史问题可见,基线清理干净后
- # 去掉 continue-on-error 变成硬门禁。
+ # Lint errors are release blockers. Dependency update notices remain
+ # visible as warnings without weakening the gate.
- name: Android Lint
- continue-on-error: true
shell: bash
run: ./gradlew --no-daemon -Pmaxmath.buildNative=false :app:lintDebug
diff --git a/.gitignore b/.gitignore
index a0815ea..5a31ad2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@
.externalNativeBuild/
captures/
*.iml
+__pycache__/
+*.py[cod]
# Machine-local SDK and signing configuration
local.properties
@@ -20,6 +22,7 @@ keystore/
# Native toolchain workspace and generated runtime payloads
.build/
app/src/main/assets/engine/
+app/src/main/assets/octave/
app/src/main/jniLibs/
# Packaged outputs
diff --git a/README.md b/README.md
index 9b250a0..7a17626 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# MaxMath (Higher Algebra Calculator)
+# MaxMath (Higher Algebra Calculator / MATLAB on the phone)
[](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml)
[](https://github.com/ParuhParhat/MaxMath/releases/latest)
@@ -9,20 +9,25 @@
简体中文
-[Download MaxMath 1.1.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk)
-· [Release notes](https://github.com/ParuhParhat/MaxMath/releases/tag/v1.1.1)
+[Download MaxMath 2.0.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v2.0.1/MaxMath-v2.0.1-arm64-v8a.apk)
+· [Release notes](https://github.com/ParuhParhat/MaxMath/releases/tag/v2.0.1)
-MaxMath is an offline Android app for higher-algebra computation and interactive
-plotting, powered by GNU Maxima. Its interface is built with Kotlin and Jetpack
-Compose, mathematical input is handled by a pure Kotlin parser, and complex
-symbolic computations run in a separate engine process.
+MaxMath is an offline Android app for higher-algebra computation, numeric
+computation and interactive plotting, powered by GNU Maxima and GNU Octave. Its
+interface is built with Kotlin and Jetpack Compose, mathematical input is
+handled by a pure Kotlin parser, and complex computations run in separate
+engine processes.
-> Current release: 1.1.1. Minimum supported version: Android 8.0 (API 26).
+> Current release: 2.0.1. Minimum supported version: Android 8.0 (API 26).
> The native computation engine currently provides an `arm64-v8a` build
> workflow only.
## Features
+- Octave console: MATLAB-style command line, workspace browser, .m script
+ editing/import/export, interactive plot/plot3/surf/contour/subplot/colormap/
+ colorbar rendering and a LaTeX toggle for results (numeric engine is the
+ Termux build of GNU Octave 11.3.0, fully offline)
- Matrices: determinant, inverse, transpose, rank, trace, eigenvalues, and eigenvectors
- Systems of equations: natural syntax such as `x+y=1; 2x-y=3`, plus raw Maxima input
- Polynomials: factorization, greatest common divisor, root finding, expansion, and simplification
@@ -42,7 +47,7 @@ symbolic computations run in a separate engine process.
| Path | Responsibility |
| --- | --- |
| `parser/` | Lexing, AST construction, expression evaluation, and Maxima/NumPy code generation |
-| `engine/` | Typed computation tasks, Maxima scripts, JNI subprocesses, the isolated process service, and 2D plotting |
+| `engine/` | Typed computation tasks, Maxima scripts, JNI subprocesses, isolated process services, 2D plotting, and the Octave interactive subprocess (:octave) |
| `app/` | Compose screens, state management, LaTeX output, and interactive OpenGL 3D/contour rendering |
| `native/` | Android cross-compilation and runtime packaging scripts for ECL and Maxima |
| `docs/` | Product specifications and implementation constraints |
@@ -52,6 +57,8 @@ through Android Messenger to the isolated `:engine` process. Cancellation or a
timeout terminates the corresponding Maxima subprocess. Maxima assists with 2D
plot analysis before Matplotlib renders the image; 3D surfaces and contour plots
are sampled locally from the same expression AST and rendered with OpenGL.
+The Octave console runs in its own `:octave` process; its plot commands are
+exported as plot_spec.json by the bridge layer and rendered by Compose/OpenGL.
## Getting the Source
@@ -111,6 +118,13 @@ cd ..
./gradlew :app:assembleDebug
~~~
+`package-engine.sh` writes one `assets/engine/runtime.zip` plus a content-addressed
+manifest. Installation uses full hash verification, staging, and an atomic runtime
+switch while keeping `user/` and `work/` separate. After assembling, run
+`python3 native/verify-engine-apk.py app/build/outputs/apk/debug/app-debug.apk` to
+verify the nested archive file set, `linearalgebra`, ECL data, and Maxima/ECL JNI
+hashes at the final APK boundary.
+
The current scripts target a Linux x86_64 host and the NDK's `linux-x86_64`
toolchain. See [native/README.md](native/README.md) for complete dependency,
environment-variable, and troubleshooting information.
@@ -119,8 +133,37 @@ Generated directories that must not be committed include:
- `.build/`
- `app/src/main/assets/engine/`
+- `app/src/main/assets/octave/`
- `app/src/main/jniLibs/`
+## Octave console engine
+
+The console uses the official Termux GNU Octave 11.3.0 runtime and supports
+`arm64-v8a` only. Every Termux package version, filename and SHA-256 is pinned in
+`native/octave-termux.lock`; fetching never resolves a mutable latest package.
+Recreate and package the runtime with:
+
+~~~bash
+./native/download-octave-termux.sh arm64-v8a
+./native/build-octave-16k-overrides.sh
+./native/package-octave-engine.sh arm64-v8a
+~~~
+
+The download step rebuilds a clean stage from only the locked archives. The
+packager follows `DT_NEEDED` from the CLI entry and arm64 `.oct` modules, keeps
+the matching Termux `libc++_shared.so`, removes stale/non-arm64 Octave assets,
+verifies the complete payload before transactionally switching it, and writes a
+deterministic `assets/octave/runtime-manifest.json`. The complex-math and WebP
+packages which still ship 4 KiB-aligned ELF segments are rebuilt from pinned
+official Android/WebM sources for 16 KiB Android page compatibility. It finishes by
+running `native/verify-octave-runtime.py`, which checks architecture, dependency
+and strong C++ symbol closure, locked libc++ identity, file hashes and the
+manifest `runtimeId`. After assembling the APK, run
+`python3 native/verify-octave-apk.py app/build/outputs/apk/debug/app-debug.apk` to
+confirm that AAPT retained every manifest-owned file, including `.oct-config`,
+and that every packaged native ELF has 16 KiB-compatible LOAD alignment.
+See [native/README.md](native/README.md) for prerequisites.
+
## Project Documentation
- [Product and implementation specification](docs/SPEC.md)
@@ -133,7 +176,7 @@ Generated directories that must not be committed include:
## Known Limitations
-- The native computation engine currently targets `arm64-v8a` only; the `x86_64` build scripts still need further verification.
+- The packaged Maxima and Octave computation runtimes support `arm64-v8a` only.
- A fresh source checkout does not include a prebuilt Maxima/ECL runtime. Generate it as described above before running full computations.
- Results come from computer algebra and numerical algorithms and should not be treated as formal proofs.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0a8b894..b0f2201 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,4 +1,4 @@
-# MaxMath(高代计算器)
+# MaxMath(高代计算器 / 手机端 MATLAB)
[](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml)
[](https://github.com/ParuhParhat/MaxMath/releases/latest)
@@ -9,18 +9,21 @@
简体中文
-[下载 MaxMath 1.1.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v1.1.1/MaxMath-v1.1.1-arm64-v8a.apk)
-· [发布说明](https://github.com/ParuhParhat/MaxMath/releases/tag/v1.1.1)
+[下载 MaxMath 2.0.1 APK](https://github.com/ParuhParhat/MaxMath/releases/download/v2.0.1/MaxMath-v2.0.1-arm64-v8a.apk)
+· [发布说明](https://github.com/ParuhParhat/MaxMath/releases/tag/v2.0.1)
-基于 GNU Maxima 的离线 Android 高等代数计算与交互式绘图应用。界面使用
-Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂符号计算在独立
-引擎进程中执行。
+基于 GNU Maxima 与 GNU Octave 的离线 Android 高等代数计算、数值计算与交互式
+绘图应用。界面使用 Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,
+复杂符号计算与 Octave 控制台在独立引擎进程中执行。
-> 当前版本 1.1.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供
+> 当前版本 2.0.1;最低系统 Android 8.0(API 26);原生计算引擎目前仅提供
> `arm64-v8a` 构建流程。
## 功能
+- Octave 控制台:MATLAB 风格命令行、工作区变量列表、.m 脚本编辑/导入/导出,
+ 支持 plot/plot3/surf/contour/subplot/colormap/colorbar 实时交互绘图与 LaTeX
+ 结果切换(数值引擎为 Termux 预编译的 GNU Octave 11.3.0,完全离线)
- 矩阵:行列式、逆、转置、秩、迹、特征值与特征向量
- 方程组:支持 `x+y=1; 2x-y=3` 一类自然写法,也支持原始 Maxima 输入
- 多项式:因式分解、最大公因式、求根、展开与化简
@@ -39,7 +42,7 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂
| 路径 | 职责 |
| --- | --- |
| `parser/` | 词法分析、AST、表达式求值,以及 Maxima/NumPy 代码生成 |
-| `engine/` | 类型化计算任务、Maxima 脚本、JNI 子进程、独立进程服务和 2D 绘图 |
+| `engine/` | 类型化计算任务、Maxima 脚本、JNI 子进程、独立进程服务、2D 绘图,以及 Octave 交互子进程与 :octave 服务 |
| `app/` | Compose 页面、状态管理、LaTeX 输出及 OpenGL 3D/等高线交互 |
| `native/` | ECL 与 Maxima 的 Android 交叉编译和运行时打包脚本 |
| `docs/` | 产品规格与实现约束 |
@@ -47,6 +50,8 @@ Kotlin 与 Jetpack Compose,数学输入由纯 Kotlin 解析器处理,复杂
轻量操作在 UI 进程执行;耗时操作通过 Android Messenger 转发到 `:engine` 独立
进程。取消或超时会终止对应的 Maxima 子进程。2D 图像由 Maxima 辅助分析并交给
Matplotlib 渲染;3D 曲面和等高线由同一表达式 AST 在本地采样并交给 OpenGL 绘制。
+Octave 控制台由独立的 `:octave` 进程承载,绘图命令经桥接层导出为 plot_spec.json
+后交给 Compose/OpenGL 渲染,与 Maxima 引擎互不干扰。
## 获取源码
@@ -104,6 +109,11 @@ cd ..
./gradlew :app:assembleDebug
~~~
+`package-engine.sh` 会生成单文件 `assets/engine/runtime.zip` 和内容寻址清单;安装器用
+暂存目录、完整哈希校验和原子切换升级,保留独立的 `user/`、`work/`。构建后运行
+`python3 native/verify-engine-apk.py app/build/outputs/apk/debug/app-debug.apk`,确认最终
+APK 中 `linearalgebra`、ECL 数据、归档文件集合和 Maxima/ECL JNI 哈希全部一致。
+
当前脚本以 Linux x86_64 主机和 NDK 的 `linux-x86_64` 工具链为目标。完整依赖、
环境变量与故障说明见 [native/README.md](native/README.md)。
@@ -111,8 +121,33 @@ cd ..
- `.build/`
- `app/src/main/assets/engine/`
+- `app/src/main/assets/octave/`
- `app/src/main/jniLibs/`
+## Octave 控制台引擎
+
+控制台使用 Termux 官方 GNU Octave 11.3.0 运行时,仅支持 `arm64-v8a`。
+`native/octave-termux.lock` 严格锁定每个 Termux 包的版本、文件名与 SHA-256,
+下载过程不会在构建时解析可变的“最新版”。重新生成与打包命令为:
+
+~~~bash
+./native/download-octave-termux.sh arm64-v8a
+./native/build-octave-16k-overrides.sh
+./native/package-octave-engine.sh arm64-v8a
+~~~
+
+下载脚本只使用锁定归档重建干净 stage。打包脚本从 CLI 入口与 arm64 `.oct`
+模块沿 `DT_NEEDED` 收集实际闭包,保留匹配的 Termux `libc++_shared.so`,清理旧版
+及非 arm64 Octave 资产;新载荷在临时目录通过验证后才事务切换,并生成确定性的
+`assets/octave/runtime-manifest.json`。官方 Termux 包中仍为 4 KiB 对齐的 complex-math
+与 WebP 库会从锁定的 Android/WebM 官方源码重建为 16 KiB 兼容版本。
+最后由 `native/verify-octave-runtime.py` 校验架构、依赖与强 C++ 符号闭包、锁定
+libc++、逐文件哈希和清单 `runtimeId`。APK 构建后再运行
+`python3 native/verify-octave-apk.py app/build/outputs/apk/debug/app-debug.apk`,从最终
+制品确认 AAPT 没有过滤 `.oct-config` 等清单文件,并扫描每个 native ELF 的
+16 KiB LOAD 对齐。主机依赖见
+[native/README.md](native/README.md)。
+
## 项目文档
- [产品与实现规格](docs/SPEC.md)
@@ -125,7 +160,7 @@ cd ..
## 已知限制
-- 原生计算引擎当前只面向 `arm64-v8a`;`x86_64` 构建脚本仍需进一步验证。
+- Maxima 与 Octave 原生计算运行时目前均只支持 `arm64-v8a`。
- 新检出的源码仓库不含预编译 Maxima/ECL 运行时,必须按上文生成后才能执行完整计算。
- 计算结果来自计算机代数与数值算法,不应被视为形式化证明。
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 65fa93c..31561c9 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,52 @@
+# MaxMath 2.0.1
+
+手机端 MATLAB 修复版(2026-08-16):加固 Maxima 矩阵计算与 Octave 三维交互。
+
+## 本版改进
+
+- 修复矩阵“迹”调用 `mat_trace` 时触发未预编译 `linearalgebra` 自动加载、长时间
+ 停在“计算中”;现直接遍历主对角线求和,并保留非方阵的明确错误。
+- Octave 三维曲面取消仰角 0–180°硬限位,水平和垂直方向均可连续环绕;同时
+ 修正上下拖动方向,使模型跟随手指旋转。
+- 将占满绘图区高度且方向错误的彩色色标改成紧凑水平渐变图例,最小值、最大值
+ 与颜色方向保持一致,不再遮挡坐标与曲面。
+
+# MaxMath 2.0.0
+
+手机端 MATLAB 升级版(2026-08-12):新增基于 GNU Octave 11.3.0 的控制台模式。
+
+## 本版改进
+
+- 修复 Maxima 交叉编译时误读取外层 MaxMath 仓库 Git 标签,导致二进制搜索
+ `share/maxima/v1.1.0_...` 而打包目录实际为 `5.49.0`、所有计算均报启动失败;
+ 构建、运行时清单和最终 APK 现在都会校验编译版本与资源目录版本一致。
+- 修复 Octave `surf(X,Y,Z)` 把完整 `meshgrid` 的 Y 矩阵当一维向量读取、使各行
+ Y 坐标相同并把三维曲面压成平面;曲面与等高线渲染同时兼容完整网格和坐标向量。
+- Maxima 运行时改为确定性的单文件归档与内容清单;同版本缺失或损坏
+ `linearalgebra` 会自动重装,升级使用暂存、完整哈希校验和原子回滚,并保留
+ 独立的 `user/`、`work/`。
+- 所有 Maxima 计算统一进入 `:engine` 服务,新增请求 ID、Preparing/Running 进度、
+ 90 秒准备上限、130 秒客户端执行上限、请求级取消及断连终态,避免永久“计算中”。
+- Octave 变量改用独立详情页;图表从控制台大画布改为轻量结果卡片,点按后进入
+ 纵向滚动的独立多子图页面;命令输入框不再显示可见提示词。
+- 新增 Octave 控制台模式:MATLAB 风格命令行、命令历史、实时流式输出、取消与
+ 120 秒超时;单个数值结果可在 MATLAB 文本与 LaTeX 渲染之间切换。
+- 新增工作区面板:列出变量名称、类型、维数与大小,点按预览、单删或清空。
+- 新增 .m 脚本编辑器:新建/导入/导出(系统文件选择器)、运行/停止、错误定位。
+- 新增绘图桥:控制台里的 plot/plot3/surf/contour/subplot/hold/axis/grid/
+ legend/colormap/colorbar 等高层命令导出为 plot_spec.json,由 Compose/OpenGL
+ 实时渲染,2D 可平移缩放、3D/等高线可旋转缩放,支持多子图布局。
+- 引擎架构:Octave 运行在独立 `:octave` 进程,与 Maxima `:engine` 互不干扰;
+ 常驻会话、哨兵协议、RSS 1.5GB 内存保护与空闲回收。
+- 原生运行时:严格锁定 Termux GNU Octave 11.3.0 及全部 arm64-v8a 包的版本与
+ SHA-256;从 CLI 与 `.oct` 根节点收集实际 ELF 闭包,使用匹配的 Termux libc++,
+ 生成稳定运行时清单并通过依赖、架构、C++ 符号与文件哈希静态门禁。
+- 16 KiB 页面:从锁定的 Android/WebM 官方源码重建 complex-math 与 WebP,ECL、
+ Maxima 和应用 JNI 统一使用 16 KiB 链接参数,最终 APK 逐 ELF 检查 LOAD 对齐。
+- 运行链路加固:运行时事务安装与崩溃回滚、请求 ID 全链路校验、即时取消、
+ 异常退出自动恢复、1 MiB 输出上限,以及 typed preview/绘图制品的有界传输。
+- 符号计算仍由 Maxima 模式承担,控制台首版聚焦数值计算。
+
# MaxMath 1.1.1
3D/等高线绘图性能、交互与坐标可读性更新(2026-08-08)。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index ea9e206..e2aec93 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -14,6 +14,7 @@ MIT License 不会覆盖或替代这些条款。
| Chaquopy | 17.0.0 | Android 内嵌 CPython | MIT;https://github.com/chaquo/chaquopy |
| Matplotlib | 3.6.0 | 2D 图像渲染 | Matplotlib License;https://matplotlib.org/3.6.0/users/project/license.html |
| NumPy | 1.23.3 | 数值数组与表达式求值 | BSD-3-Clause;https://github.com/numpy/numpy |
+| GNU Octave | 11.3.0 | Octave 控制台数值引擎(Termux 预编译 CLI) | GNU GPL-3.0-or-later;https://octave.org/ |
| ContourPy | 1.0.5 | 等高线计算 | BSD-3-Clause;https://github.com/contourpy/contourpy |
| kiwisolver | 1.4.5 | Matplotlib 约束求解 | BSD-3-Clause;https://github.com/nucleic/kiwi |
| Pillow | 9.2.0 | Python 图像支持 | HPND;https://github.com/python-pillow/Pillow/blob/9.2.0/LICENSE |
@@ -29,6 +30,7 @@ Gradle 的测试依赖不会打入正式 APK,其准确版本记录在 gradle/l
- .build/
- app/src/main/assets/engine/
+- app/src/main/assets/octave/
- app/src/main/jniLibs/
本地工作区可能仍保留这些目录。发布 APK 或重新分发其中的 Maxima、ECL、Python
@@ -36,8 +38,51 @@ Gradle 的测试依赖不会打入正式 APK,其准确版本记录在 gradle/l
源代码提供、署名及其他再分发义务。
尤其需要注意:应用直接依赖 GPL-2.0 的 jlatexmath-android,并可包含 GPL 的
-Maxima。分发完整 APK 时必须同时满足这些 GPL 组件的条款;将 MaxMath 原创源码置于
-MIT License 下并不会消除该义务。
+Maxima 与 GPL-3.0 的 GNU Octave。分发完整 APK 时必须同时满足这些 GPL 组件的
+条款;将 MaxMath 原创源码置于 MIT License 下并不会消除该义务。GNU Octave 的
+对应源码可从 https://ftpmirror.gnu.org/octave/ 取得(11.3.0 版本);
+Termux 的构建配方见 https://github.com/termux/termux-packages (packages/octave)。
+
+## Octave 运行时依赖(Termux 预编译包)
+
+Octave 引擎及其共享库来自 Termux 官方仓库(packages.termux.dev,当前仅使用
+arm64-v8a 对应的 aarch64 包)。主要组件与许可证:
+
+| 组件 | 许可证 |
+| --- | --- |
+| GNU Octave 11.3.0 | GPL-3.0-or-later |
+| OpenBLAS(含 LAPACK) | BSD-3-Clause |
+| FFTW | GPL-2.0-or-later(可选,已随包分发) |
+| ARPACK-ng | BSD-3-Clause |
+| SuiteSparse(CHOLMOD/UMFPACK/SPQR 等) | BSD-2-Clause |
+| Sundials | BSD-3-Clause |
+| PCRE2 | BSD-3-Clause |
+| Qhull | Qhull License(BSD 风格) |
+| readline | GPL-3.0-or-later |
+| ncurses | MIT |
+| zlib | zlib License |
+| bzip2 | BSD-4-Clause |
+| libcurl / libssh2 / nghttp2 | curl License / BSD-3-Clause / MIT |
+| OpenSSL | Apache-2.0 |
+| HDF5 | BSD-3-Clause |
+| GraphicsMagick | MIT |
+| freetype | FTL / BSD |
+| glib / gdk-pixbuf | LGPL-2.1-or-later |
+| libxml2 / libexpat | MIT |
+| libpng / libjpeg-turbo / libtiff / libwebp / giflib | 各自 BSD/MIT 风格许可 |
+| libsndfile / FLAC / Ogg / Vorbis / Opus | LGPL-2.1-or-later / BSD |
+| libiconv | LGPL-2.1-or-later |
+| libicu | Unicode-3.0 |
+| GMP / MPFR | LGPL-3.0-or-later / LGPL-3.0-or-later |
+| Android Bionic/NetBSD complex math | BSD-2-Clause;锁定 Android 官方源码见 `native/octave-16k-overrides.lock` |
+| GLib 相关 Termux 兼容库(其他 libandroid-* 等) | 以 Termux 包元数据为准 |
+| libc++ | Apache-2.0 with LLVM exception |
+
+以上清单以 Octave 的 ELF DT_NEEDED 闭包为准(native/package-octave-engine.sh
+自动收集)。每个包的精确版本、来源与校验和记录在 `native/octave-termux.lock`、
+`native/octave-16k-overrides.lock` 及打包生成的
+`assets/octave/runtime-manifest.json` 中;
+完整逐包许可证以 Termux 仓库的 .deb 元数据(usr/share/doc/*/copyright)为准。
如果在本地加入 QEPCAD 或其他可选组件,请在分发前单独确认其来源和许可证。本仓库
默认不会发布此类本地生成或手动加入的资产。
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 983e070..6deae3b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
@@ -23,8 +24,8 @@ android {
applicationId = "com.paruh.maxmath"
minSdk = 26
targetSdk = 36
- versionCode = 17
- versionName = "1.1.1"
+ versionCode = 21
+ versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -41,8 +42,7 @@ android {
}
debug {
ndk {
- // 引擎当前只打包了 arm64 的 Maxima 5.49;x86_64 需要
- // 运行 build-maxima-android.sh x86_64 后再放开。
+ // Maxima/ECL 与锁定的 Termux Octave 运行时均只打包 arm64。
abiFilters += listOf("arm64-v8a")
}
}
@@ -67,12 +67,29 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = "17"
- }
buildFeatures {
compose = true
}
+ androidResources {
+ // Octave uses hidden .oct-config files as runtime metadata. AGP's default
+ // asset ignore list contains ".*", which silently removes them from the APK.
+ // Keep the other standard VCS/editor filters, but package required dotfiles.
+ ignoreAssetsPattern =
+ "!.svn:!.git:!.ds_store:!*.scc:!.directory:_*:!CVS:!thumbs.db:!picasa.ini:!*~"
+ }
+ bundle {
+ // The app switches between bundled Chinese and English resources itself.
+ // Language splits would remove the non-device locale from an installed AAB.
+ language {
+ enableSplit = false
+ }
+ }
+ lint {
+ // Octave/Maxima ship an arm64-only native runtime by product decision.
+ disable += "ChromeOsAbiSupport"
+ // AAPT only exposes this adaptive icon from its v26-qualified directory.
+ disable += "ObsoleteSdkInt"
+ }
testOptions {
unitTests {
isIncludeAndroidResources = true
@@ -84,10 +101,21 @@ android {
// nativeLibraryDir 才能被 execve(Android 10+ 禁止从 filesDir 执行)。
jniLibs {
useLegacyPackaging = true
+ // OctaveInstaller verifies the packaged native payload against the
+ // runtime manifest. Stripping prebuilt Termux ELF files would change
+ // their size/hash after the manifest was generated and reject every
+ // install as corrupt.
+ keepDebugSymbols += "**/*.so"
}
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
dependencies {
implementation(project(":parser"))
implementation(project(":engine"))
@@ -99,6 +127,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.jlatexmath.android)
implementation(libs.jlatexmath.android.font.greek)
+ implementation(libs.org.json)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f953a8f..57a4484 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,7 +9,8 @@
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
diff --git a/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt b/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt
index d63afb0..74aefc5 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/AppLocale.kt
@@ -4,11 +4,12 @@ import android.content.Context
import android.content.res.Configuration
import android.os.LocaleList
import androidx.annotation.StringRes
+import androidx.core.content.edit
import com.paruh.maxmath.R
import java.util.Locale
/** 应用内可选语言。 */
-enum class AppLanguage(@StringRes val labelRes: Int, val code: String) {
+enum class AppLanguage(@param:StringRes val labelRes: Int, val code: String) {
SYSTEM(R.string.language_system, "system"),
ZH(R.string.language_zh, "zh"),
EN(R.string.language_en, "en"),
@@ -30,10 +31,9 @@ object AppLocale {
?: AppLanguage.SYSTEM
fun set(context: Context, value: AppLanguage) {
- context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- .edit()
- .putString(KEY_LOCALE, value.code)
- .apply()
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
+ putString(KEY_LOCALE, value.code)
+ }
}
/** 返回应用了所选语言的 base context;跟随系统时原样返回。 */
diff --git a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
index 094da50..cf3408b 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/CalcViewModel.kt
@@ -5,11 +5,14 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.paruh.maxmath.engine.CalcRequest
import com.paruh.maxmath.engine.CalcResponse
+import com.paruh.maxmath.engine.CalcEvent
+import com.paruh.maxmath.engine.CalcFailure
import com.paruh.maxmath.engine.EngineClient
import com.paruh.maxmath.engine.MathTask
import com.paruh.maxmath.engine.MaximaEngine
import com.paruh.maxmath.R
import kotlinx.coroutines.Job
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -25,11 +28,22 @@ import java.util.UUID
* 「结果已过期」不单独存字段——它就是 `loading && response != null`,
* 再加一个标志位只会多一份可能对不上的真相。
*/
+sealed interface CalcActivity {
+ val requestId: String?
+ data object Idle : CalcActivity { override val requestId: String? = null }
+ data class Preparing(override val requestId: String) : CalcActivity
+ data class Running(override val requestId: String) : CalcActivity
+ data class Cancelling(override val requestId: String) : CalcActivity
+}
+
data class CalcUiState(
- val loading: Boolean = false,
+ val activity: CalcActivity = CalcActivity.Idle,
val response: CalcResponse? = null,
val error: String? = null,
-)
+ val failure: CalcFailure? = null,
+) {
+ val loading: Boolean get() = activity !is CalcActivity.Idle
+}
class CalcViewModel : AndroidViewModel {
@@ -47,36 +61,86 @@ class CalcViewModel : AndroidViewModel {
val state: StateFlow = _state.asStateFlow()
private var job: Job? = null
+ private var cancelJob: Job? = null
fun compute(task: MathTask) {
- job?.cancel()
- MaximaEngine.cancel()
+ if (_state.value.loading) return
// 保留上一次结果:重算时不清空,界面不会塌陷再展开。
// 错误必须清掉,否则旧报错会和新的进度指示并排显示。
- _state.update { it.copy(loading = true, error = null) }
+ val request = CalcRequest(UUID.randomUUID().toString(), task)
+ _state.update {
+ it.copy(
+ activity = CalcActivity.Preparing(request.id),
+ error = null,
+ failure = null,
+ )
+ }
job = viewModelScope.launch {
- val request = CalcRequest(UUID.randomUUID().toString(), task)
- val response = client.compute(request)
+ val response = try {
+ client.compute(request) event@{ event ->
+ if (event.requestId != request.id) return@event
+ when (event) {
+ is CalcEvent.Preparing -> _state.update { current ->
+ if (current.activity.requestId == request.id) {
+ current.copy(activity = CalcActivity.Preparing(request.id))
+ } else {
+ current
+ }
+ }
+ is CalcEvent.Running -> _state.update { current ->
+ if (current.activity is CalcActivity.Preparing &&
+ current.activity.requestId == request.id
+ ) {
+ current.copy(activity = CalcActivity.Running(request.id))
+ } else {
+ current
+ }
+ }
+ is CalcEvent.Done,
+ is CalcEvent.Failure,
+ -> Unit
+ }
+ }
+ } catch (cancelled: CancellationException) {
+ return@launch
+ }
+ if (_state.value.activity.requestId != request.id) return@launch
+ if (_state.value.activity is CalcActivity.Cancelling) return@launch
// 这里整体赋值:一次计算结束后,旧结果就该被新结果或新错误取代。
// 若协程已被 cancel(),withContext 恢复时会先抛 CancellationException,
// 走不到这一行,因此被取消的计算不会覆盖主线程刚写好的状态。
_state.value = CalcUiState(
- loading = false,
+ activity = CalcActivity.Idle,
response = response.takeIf { it.ok },
- error = response.error,
+ error = response.failure?.message ?: response.error,
+ failure = response.failure,
)
}
}
/** 取消的是这次请求,不是整个界面:结果停留在取消前的样子。 */
fun cancel() {
- job?.cancel()
- MaximaEngine.cancel()
- _state.update { it.copy(loading = false) }
+ val requestId = _state.value.activity.requestId ?: return
+ if (_state.value.activity is CalcActivity.Cancelling) return
+ _state.update { it.copy(activity = CalcActivity.Cancelling(requestId)) }
+ cancelJob?.cancel()
+ cancelJob = viewModelScope.launch {
+ val terminal = runCatching { client.cancel(requestId) }.getOrNull()
+ if (_state.value.activity.requestId != requestId) return@launch
+ if (terminal is CalcEvent.Done) {
+ job?.cancel()
+ job = null
+ _state.update { it.copy(activity = CalcActivity.Idle) }
+ } else {
+ _state.update { it.copy(activity = CalcActivity.Running(requestId)) }
+ }
+ }
}
fun showError(message: String?) {
val text = message ?: AppLocale.wrap(getApplication()).getString(R.string.error_input_invalid)
- _state.update { it.copy(loading = false, error = text) }
+ _state.update {
+ it.copy(activity = CalcActivity.Idle, error = text, failure = null)
+ }
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
index 65480e8..beeff09 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/MaxMathApp.kt
@@ -5,10 +5,24 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
+import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import com.paruh.maxmath.ui.screens.CalculusScreen
+import com.paruh.maxmath.ui.console.ConsoleScreen
+import com.paruh.maxmath.ui.console.ConsoleViewModel
+import com.paruh.maxmath.ui.console.OctavePlotScreen
+import com.paruh.maxmath.ui.console.VariableDetailScreen
+import com.paruh.maxmath.engine.OctaveClient
import com.paruh.maxmath.ui.screens.HomeScreen
import com.paruh.maxmath.ui.screens.MatrixScreen
import com.paruh.maxmath.ui.screens.PlotScreen
@@ -27,6 +41,10 @@ object Routes {
const val QUADRATIC = "quadratic"
const val CALCULUS = "calculus"
const val PLOT = "plot"
+ const val CONSOLE = "console"
+ const val CONSOLE_HOME = "console/home"
+ const val CONSOLE_VARIABLE = "console/variable"
+ const val CONSOLE_PLOT = "console/plot"
}
private const val NAV_DURATION_MS = 280
@@ -59,6 +77,34 @@ fun MaxMathApp() {
composable(Routes.HOME) {
HomeScreen(onNavigate = { route -> nav.navigate(route) })
}
+ navigation(startDestination = Routes.CONSOLE_HOME, route = Routes.CONSOLE) {
+ composable(Routes.CONSOLE_HOME) { entry ->
+ val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) }
+ val vm = sharedConsoleViewModel(parent)
+ ConsoleScreen(
+ onBack = { nav.popBackStack() },
+ onOpenVariable = {
+ nav.navigate(Routes.CONSOLE_VARIABLE) { launchSingleTop = true }
+ },
+ onOpenPlot = {
+ nav.navigate(Routes.CONSOLE_PLOT) { launchSingleTop = true }
+ },
+ viewModelOverride = vm,
+ )
+ }
+ composable(Routes.CONSOLE_VARIABLE) { entry ->
+ val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) }
+ val vm = sharedConsoleViewModel(parent)
+ val state by vm.state.collectAsState()
+ VariableDetailScreen(state, vm, onBack = { nav.popBackStack() })
+ }
+ composable(Routes.CONSOLE_PLOT) { entry ->
+ val parent = remember(entry) { nav.getBackStackEntry(Routes.CONSOLE) }
+ val vm = sharedConsoleViewModel(parent)
+ val state by vm.state.collectAsState()
+ OctavePlotScreen(state, onBack = { nav.popBackStack() })
+ }
+ }
composable(Routes.MATRIX) {
MatrixScreen(onBack = { nav.popBackStack() })
}
@@ -82,3 +128,14 @@ fun MaxMathApp() {
}
}
}
+
+@Composable
+private fun sharedConsoleViewModel(owner: NavBackStackEntry): ConsoleViewModel {
+ val context = LocalContext.current.applicationContext
+ return viewModel(
+ viewModelStoreOwner = owner,
+ factory = viewModelFactory {
+ initializer { ConsoleViewModel(OctaveClient(context)) }
+ },
+ )
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt b/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
index dd79c57..43cf80e 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/ModeIcons.kt
@@ -73,6 +73,17 @@ object ModeIcons {
),
)
+ /** Octave 控制台:终端风格的大于号 + 下划线。 */
+ val Console: ImageVector = icon(
+ "ModeConsole",
+ paths = emptyList(),
+ strokes = listOf(
+ "M4,3.5 H20 V20.5 H4 Z",
+ "M8,9.5 L12.5,13.5 L8,17.5",
+ "M14.5,17.5 H18",
+ ),
+ )
+
/** 语言切换按钮图标:描边地球。 */
val LanguageGlobe: ImageVector = icon(
"LanguageGlobe",
@@ -95,7 +106,16 @@ object ModeIcons {
*/
val Minus: ImageVector = icon("Minus", paths = listOf("M5,11 H19 V13 H5 Z"))
- val all: List = listOf(Matrix, Equations, Polynomial, Vector, Quadratic, Calculus, Plot)
+ val all: List = listOf(
+ Console,
+ Matrix,
+ Equations,
+ Polynomial,
+ Vector,
+ Quadratic,
+ Calculus,
+ Plot,
+ )
private fun icon(
name: String,
diff --git a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
index 9a309fc..0770c05 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/components/Components.kt
@@ -77,6 +77,7 @@ import kotlinx.coroutines.delay
import com.paruh.maxmath.R
import com.paruh.maxmath.engine.CalcResponse
import com.paruh.maxmath.ui.CalcUiState
+import com.paruh.maxmath.ui.CalcActivity
import com.paruh.maxmath.ui.ModeIcons
import com.paruh.maxmath.ui.theme.MathMonoStyle
import com.paruh.maxmath.ui.theme.Sizing
@@ -142,8 +143,8 @@ fun ExpressionField(
label: String,
value: String,
onValueChange: (String) -> Unit,
- hint: String? = null,
modifier: Modifier = Modifier,
+ hint: String? = null,
) {
OutlinedTextField(
value = value,
@@ -316,7 +317,7 @@ fun ResultCard(
)
Column(verticalArrangement = Arrangement.spacedBy(Spacing.s)) {
if (state.loading) {
- LoadingCard()
+ LoadingCard(state.activity)
}
state.error?.let { ErrorCard(it) }
state.response?.let {
@@ -326,16 +327,7 @@ fun ResultCard(
}
@Composable
-private fun LoadingCard() {
- // 空闲超过 60 秒(EngineService.IDLE_TIMEOUT_MS / MaximaEngine.IDLE_STOP_MS)
- // 之后的第一次计算要重新加载 ECL 镜像,实测 1–3 秒;超过 2.5 秒基本可以
- // 断定是冷启动。整棵子树在 loading 结束时销毁,计时器每次计算自然重置,
- // 热计算永远看不到这句提示。
- var slow by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- delay(2_500)
- slow = true
- }
+private fun LoadingCard(activity: CalcActivity) {
Card(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(Spacing.l),
@@ -344,14 +336,15 @@ private fun LoadingCard() {
) {
CircularProgressIndicator(modifier = Modifier.size(Sizing.progress))
Column {
- Text(stringResource(R.string.computing))
- if (slow) {
- Text(
- stringResource(R.string.engine_warming_up),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
+ Text(
+ stringResource(
+ when (activity) {
+ is CalcActivity.Preparing -> R.string.engine_preparing
+ is CalcActivity.Cancelling -> R.string.engine_cancelling
+ else -> R.string.computing
+ },
+ ),
+ )
}
}
}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt
new file mode 100644
index 0000000..62afc7f
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt
@@ -0,0 +1,19 @@
+package com.paruh.maxmath.ui.console
+
+/**
+ * 控制台命令分类:首版为数值专用,符号命令只给提示;错误文本行号用于
+ * 脚本编辑器定位。独立成纯函数,方便单测。
+ */
+internal fun isSymbolicCommand(command: String): Boolean {
+ val pattern = Regex(
+ """^(?:[A-Za-z_][A-Za-z0-9_]*\s*=\s*)?(syms|sym|solve|dsolve|diff|limit|laplace|ilaplace|fourier|ifourier|ztrans|iztrans|int)(\s|\(|$)""",
+ RegexOption.IGNORE_CASE,
+ )
+ return pattern.containsMatchIn(command.trim())
+}
+
+/** 从 Octave 错误文本提取行号:error: ... at line N 或 called from ... line N。 */
+internal fun parseErrorLine(errorText: String): Int? {
+ val regex = Regex("line\\s+(\\d+)", RegexOption.IGNORE_CASE)
+ return regex.findAll(errorText).lastOrNull()?.groupValues?.get(1)?.toIntOrNull()
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt
new file mode 100644
index 0000000..d5520fe
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt
@@ -0,0 +1,199 @@
+@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
+
+package com.paruh.maxmath.ui.console
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.paruh.maxmath.R
+import com.paruh.maxmath.ui.components.LatexResult
+import com.paruh.maxmath.ui.theme.MathMonoStyle
+
+internal const val VARIABLE_DETAIL_VALUE_TAG = "variable_detail_value"
+internal const val OCTAVE_PLOT_SCREEN_TAG = "octave_plot_screen"
+
+@Composable
+fun VariableDetailScreen(
+ state: ConsoleUiState,
+ viewModel: ConsoleViewModel,
+ onBack: () -> Unit,
+) {
+ val name = state.selectedVariableName
+ val variable = state.workspace.firstOrNull { it.name == name }
+ val previewText = state.previewText
+ val previewJson = state.previewJson
+ val context = LocalContext.current
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(name ?: stringResource(R.string.console_variable_details)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (variable == null) {
+ Text(stringResource(R.string.console_variable_missing))
+ return@Column
+ }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(variable.name, style = MaterialTheme.typography.titleLarge)
+ Text(
+ "${variable.className} ${variable.dims.joinToString("×")}",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ formatBytes(variable.bytes),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (state.running) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ CircularProgressIndicator()
+ Text(stringResource(R.string.console_loading_variable))
+ }
+ }
+ state.failure?.let { ConsoleFailureCard(it) }
+ if (!state.running && state.failure != null) {
+ Button(onClick = viewModel::retrySelectedVariable) {
+ Text(stringResource(R.string.console_retry))
+ }
+ }
+ if (previewText != null) {
+ Card(
+ Modifier
+ .fillMaxWidth()
+ .testTag(VARIABLE_DETAIL_VALUE_TAG),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(R.string.console_preview),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ if (previewJson != null) {
+ Text(stringResource(R.string.console_latex))
+ Spacer(Modifier.width(6.dp))
+ Switch(
+ checked = state.showLatex,
+ onCheckedChange = { viewModel.toggleLatex() },
+ )
+ }
+ }
+ if (state.showLatex && previewJson != null) {
+ val tex = OctaveLatex.fromValueJson(previewJson)
+ if (tex != null) LatexResult(tex, previewText) else Text(previewText, style = MathMonoStyle)
+ } else {
+ Text(previewText, style = MathMonoStyle)
+ }
+ if (state.preview?.truncated == true) {
+ Text(
+ stringResource(R.string.console_preview_truncated),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(onClick = { copyVariable(context, name.orEmpty(), previewText) }) {
+ Text(stringResource(R.string.console_copy_value))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OctavePlotScreen(state: ConsoleUiState, onBack: () -> Unit) {
+ val plot = state.plot
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.console_plot_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (plot == null) {
+ Text(
+ stringResource(R.string.console_no_plot),
+ modifier = Modifier.padding(padding).padding(16.dp),
+ )
+ } else {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 12.dp)
+ .verticalScroll(rememberScrollState())
+ .testTag(OCTAVE_PLOT_SCREEN_TAG),
+ ) {
+ OctavePlotPanel(plot, Modifier.fillMaxWidth(), stacked = true)
+ }
+ }
+ }
+}
+
+private fun copyVariable(context: Context, label: String, value: String) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
+ clipboard.setPrimaryClip(ClipData.newPlainText(label, value))
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt
new file mode 100644
index 0000000..d2fe338
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt
@@ -0,0 +1,752 @@
+@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
+
+package com.paruh.maxmath.ui.console
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.ScaffoldDefaults
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import com.paruh.maxmath.R
+import com.paruh.maxmath.engine.OctaveClient
+import com.paruh.maxmath.engine.OctaveFailure
+import com.paruh.maxmath.engine.OctaveFailureCode
+import com.paruh.maxmath.ui.components.LatexResult
+import com.paruh.maxmath.ui.theme.MathMonoStyle
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun ConsoleScreen(
+ onBack: () -> Unit,
+ onOpenVariable: (String) -> Unit = {},
+ onOpenPlot: () -> Unit = {},
+ viewModelOverride: ConsoleViewModel? = null,
+) {
+ val context = LocalContext.current
+ val vm: ConsoleViewModel = viewModelOverride ?: viewModel(
+ factory = viewModelFactory {
+ initializer {
+ ConsoleViewModel(OctaveClient(context.applicationContext))
+ }
+ },
+ )
+ val state by vm.state.collectAsState()
+ val store = remember { ScriptStore(context.applicationContext) }
+
+ var tab by rememberSaveable { mutableIntStateOf(0) }
+ var input by rememberSaveable { mutableStateOf("") }
+ var history by rememberSaveable { mutableStateOf(listOf()) }
+ var historyIndex by rememberSaveable { mutableIntStateOf(-1) }
+ val symbolicHint = stringResource(R.string.console_symbolic_hint)
+
+ ConsoleScaffold(
+ selectedTab = tab,
+ onTabSelected = { tab = it },
+ onBack = onBack,
+ ) {
+ when (tab) {
+ 0 -> ConsoleTab(
+ state = state,
+ input = input,
+ onInputChange = { input = it },
+ onSubmit = {
+ val cmd = input
+ input = ""
+ history = (listOf(cmd) + history).take(100)
+ historyIndex = -1
+ if (isSymbolicCommand(cmd)) {
+ vm.submitHint(symbolicHint)
+ } else {
+ vm.submit(cmd)
+ }
+ },
+ onCancel = vm::cancel,
+ onHistoryPrev = {
+ if (history.isNotEmpty()) {
+ val next = (historyIndex + 1).coerceAtMost(history.size - 1)
+ historyIndex = next
+ input = history[next]
+ }
+ },
+ onHistoryNext = {
+ if (historyIndex > 0) {
+ historyIndex -= 1
+ input = history[historyIndex]
+ } else {
+ historyIndex = -1
+ input = ""
+ }
+ },
+ timeoutMs = state.timeoutMs,
+ onTimeoutChange = vm::setTimeoutMs,
+ onOpenPlot = onOpenPlot,
+ )
+ 1 -> WorkspaceTab(state, vm, onOpenVariable)
+ 2 -> ScriptsTab(store, vm, activity = state.activity)
+ }
+ }
+}
+
+internal const val CONSOLE_TAB_ROW_TAG = "console_tab_row"
+internal const val CONSOLE_COMMAND_INPUT_TAG = "console_command_input"
+internal const val CONSOLE_PLOT_CARD_TAG = "console_plot_card"
+
+@Composable
+internal fun ConsoleScaffold(
+ selectedTab: Int,
+ onTabSelected: (Int) -> Unit,
+ onBack: () -> Unit,
+ contentWindowInsets: WindowInsets =
+ ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
+ content: @Composable () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.module_console)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ )
+ },
+ contentWindowInsets = contentWindowInsets,
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .consumeWindowInsets(padding),
+ ) {
+ PrimaryTabRow(
+ selectedTabIndex = selectedTab,
+ modifier = Modifier.testTag(CONSOLE_TAB_ROW_TAG),
+ ) {
+ Tab(
+ selected = selectedTab == 0,
+ onClick = { onTabSelected(0) },
+ text = { Text(stringResource(R.string.console_tab_console)) },
+ )
+ Tab(
+ selected = selectedTab == 1,
+ onClick = { onTabSelected(1) },
+ text = { Text(stringResource(R.string.console_tab_workspace)) },
+ )
+ Tab(
+ selected = selectedTab == 2,
+ onClick = { onTabSelected(2) },
+ text = { Text(stringResource(R.string.console_tab_scripts)) },
+ )
+ }
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+internal fun ConsoleTab(
+ state: ConsoleUiState,
+ input: String,
+ onInputChange: (String) -> Unit,
+ onSubmit: () -> Unit,
+ onCancel: () -> Unit,
+ onHistoryPrev: () -> Unit,
+ onHistoryNext: () -> Unit,
+ timeoutMs: Long,
+ onTimeoutChange: (Long) -> Unit,
+ onOpenPlot: () -> Unit,
+) {
+ val listState = rememberLazyListState()
+ val plot = state.plot
+ val inputDescription = stringResource(R.string.console_input_hint)
+ Column(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ ) {
+ items(state.lines) { line ->
+ Text(
+ line.text,
+ style = MathMonoStyle,
+ fontSize = MaterialTheme.typography.bodySmall.fontSize,
+ color = when (line.kind) {
+ ConsoleLineKind.CMD -> MaterialTheme.colorScheme.primary
+ ConsoleLineKind.ERROR -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.padding(vertical = 1.dp),
+ )
+ }
+ if (plot != null) {
+ item {
+ Card(
+ onClick = onOpenPlot,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp)
+ .testTag(CONSOLE_PLOT_CARD_TAG),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.console_plot_ready),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ pluralStringResource(
+ R.plurals.console_plot_axes_count,
+ plot.axes.size,
+ plot.axes.size,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(onClick = onOpenPlot) {
+ Text(stringResource(R.string.console_view_plot))
+ }
+ }
+ }
+ }
+ }
+ item { Spacer(Modifier.height(8.dp)) }
+ }
+ LaunchedEffect(state.lines.size) {
+ val count = state.lines.size
+ if (count > 0) listState.animateScrollToItem(count - 1)
+ }
+
+ state.failure?.let { failure ->
+ ConsoleFailureCard(failure)
+ Spacer(Modifier.height(6.dp))
+ }
+
+ if (state.running) {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(
+ when (state.activity) {
+ is ConsoleActivity.Starting -> R.string.console_starting
+ is ConsoleActivity.Running -> R.string.console_running
+ is ConsoleActivity.Cancelling -> R.string.console_cancelling
+ ConsoleActivity.Idle -> R.string.console_running
+ },
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ onClick = onCancel,
+ enabled = state.activity !is ConsoleActivity.Cancelling,
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(R.string.console_timeout),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ listOf(30_000L to "30s", 120_000L to "120s", 300_000L to "300s").forEach { (ms, label) ->
+ TextButton(
+ onClick = { onTimeoutChange(ms) },
+ enabled = timeoutMs != ms,
+ ) {
+ Text(label, style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onHistoryPrev) { Text("↑", style = MaterialTheme.typography.titleMedium) }
+ IconButton(onClick = onHistoryNext) { Text("↓", style = MaterialTheme.typography.titleMedium) }
+ OutlinedTextField(
+ value = input,
+ onValueChange = onInputChange,
+ modifier = Modifier
+ .weight(1f)
+ .testTag(CONSOLE_COMMAND_INPUT_TAG)
+ .semantics { contentDescription = inputDescription },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ singleLine = false,
+ minLines = 1,
+ maxLines = 4,
+ )
+ Spacer(Modifier.width(6.dp))
+ Button(
+ onClick = onSubmit,
+ enabled = state.activity is ConsoleActivity.Idle && input.isNotBlank(),
+ ) {
+ Text(stringResource(R.string.console_run))
+ }
+ }
+ }
+}
+
+@Composable
+private fun WorkspaceTab(
+ state: ConsoleUiState,
+ vm: ConsoleViewModel,
+ onOpenVariable: (String) -> Unit,
+) {
+ Column(Modifier.fillMaxSize().padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(R.string.console_workspace, state.workspace.size),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f),
+ )
+ IconButton(onClick = vm::refreshWorkspace, enabled = !state.running) {
+ Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.console_refresh))
+ }
+ TextButton(onClick = { vm.clearVariable("") }, enabled = !state.running) {
+ Text(stringResource(R.string.console_clear_all))
+ }
+ }
+ if (state.workspace.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ stringResource(R.string.console_workspace_empty),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ } else {
+ LazyColumn(Modifier.fillMaxSize()) {
+ items(state.workspace, key = { it.name }) { v ->
+ Card(
+ onClick = {
+ if (!state.running) {
+ vm.openVariable(v.name)
+ onOpenVariable(v.name)
+ }
+ },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
+ ) {
+ Row(
+ Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(v.name, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "${v.className} ${v.dims.joinToString("×")}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Text(
+ formatBytes(v.bytes),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ IconButton(
+ onClick = { vm.clearVariable(v.name) },
+ enabled = !state.running,
+ ) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(R.string.console_delete_var),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScriptsTab(store: ScriptStore, vm: ConsoleViewModel, activity: ConsoleActivity) {
+ val context = LocalContext.current
+ var scripts by remember { mutableStateOf(store.list()) }
+ var selected by remember { mutableStateOf(null) }
+ var content by remember { mutableStateOf(TextFieldValue()) }
+ var errorLine by remember { mutableStateOf(null) }
+ val editorScroll = rememberScrollState()
+ val gutterScroll = rememberScrollState()
+ val density = androidx.compose.ui.platform.LocalDensity.current
+ val lineHeightPx = with(density) { 22.dp.toPx() }
+ val scope = rememberCoroutineScope()
+ val cursorPosition = remember(content.text, content.selection.start) {
+ scriptCursorPosition(content.text, content.selection.start)
+ }
+ LaunchedEffect(editorScroll.value, gutterScroll.maxValue) {
+ gutterScroll.scrollTo(editorScroll.value.coerceAtMost(gutterScroll.maxValue))
+ }
+ fun reload() {
+ scripts = store.list()
+ }
+
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ val name = store.importFrom(context, uri)
+ if (name != null) {
+ selected = name
+ val text = store.read(name)
+ content = TextFieldValue(text, TextRange(text.length))
+ reload()
+ }
+ }
+ }
+ val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
+ if (uri != null) {
+ selected?.let { store.exportTo(context, it, uri) }
+ }
+ }
+
+ Column(Modifier.fillMaxSize().padding(12.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(
+ onClick = { importLauncher.launch(arrayOf("text/plain", "application/octet-stream")) },
+ enabled = activity is ConsoleActivity.Idle,
+ ) {
+ Text(stringResource(R.string.console_import))
+ }
+ OutlinedButton(
+ onClick = {
+ selected?.let { name -> exportLauncher.launch(name) }
+ },
+ enabled = selected != null && activity is ConsoleActivity.Idle,
+ ) {
+ Text(stringResource(R.string.console_export))
+ }
+ OutlinedButton(
+ onClick = {
+ var name = "script.m"
+ var i = 1
+ while (scripts.any { it.name == name }) {
+ name = "script$i.m"
+ i++
+ }
+ store.save(name, "% $name\n")
+ selected = name
+ val text = store.read(name)
+ content = TextFieldValue(text, TextRange(text.length))
+ reload()
+ },
+ enabled = activity is ConsoleActivity.Idle,
+ ) {
+ Text(stringResource(R.string.console_new_script))
+ }
+ }
+ if (selected != null) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(selected!!, style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f))
+ Button(
+ onClick = {
+ val name = selected ?: return@Button
+ val saved = store.save(name, content.text)
+ errorLine = null
+ vm.runScript(saved, content.text) { failure ->
+ errorLine = failure.location?.line
+ ?: parseErrorLine(failure.diagnosticText())
+ errorLine?.let { line ->
+ scope.launch {
+ editorScroll.scrollTo(((line - 1) * lineHeightPx).toInt().coerceAtLeast(0))
+ }
+ }
+ }
+ },
+ enabled = activity is ConsoleActivity.Idle,
+ ) {
+ Text(stringResource(R.string.console_run_script))
+ }
+ if (activity !is ConsoleActivity.Idle) {
+ CircularProgressIndicator(modifier = Modifier.width(22.dp).height(22.dp))
+ TextButton(
+ onClick = vm::cancel,
+ enabled = activity !is ConsoleActivity.Cancelling,
+ ) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ IconButton(
+ onClick = {
+ selected?.let { store.delete(it) }
+ selected = null
+ content = TextFieldValue()
+ reload()
+ },
+ enabled = activity is ConsoleActivity.Idle,
+ ) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.console_delete_script))
+ }
+ }
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ if (errorLine != null) {
+ Text(
+ stringResource(R.string.console_error_line, errorLine!!),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ Spacer(Modifier.weight(1f))
+ Text(
+ stringResource(
+ R.string.console_cursor_position,
+ cursorPosition.line,
+ cursorPosition.column,
+ cursorPosition.totalLines,
+ ),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Row(Modifier.weight(1f).fillMaxWidth().heightIn(min = 160.dp)) {
+ Column(
+ modifier = Modifier
+ .verticalScroll(gutterScroll, enabled = false)
+ .padding(end = 6.dp),
+ ) {
+ content.text.lines().forEachIndexed { index, _ ->
+ Text(
+ "${index + 1}",
+ style = MathMonoStyle,
+ fontSize = 12.sp,
+ lineHeight = 22.sp,
+ color = if (index + 1 == errorLine) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ }
+ OutlinedTextField(
+ value = content,
+ onValueChange = {
+ content = it
+ errorLine = null
+ },
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(editorScroll),
+ enabled = activity is ConsoleActivity.Idle,
+ textStyle = MathMonoStyle.copy(lineHeight = 22.sp),
+ )
+ }
+ // 草稿自动保存:停止输入 500ms 后写回沙箱,避免误触返回丢失内容。
+ LaunchedEffect(selected, content.text) {
+ val name = selected ?: return@LaunchedEffect
+ if (content.text.isNotEmpty()) {
+ delay(500)
+ store.save(name, content.text)
+ }
+ }
+ } else {
+ LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
+ items(scripts, key = { it.name }) { s ->
+ Card(
+ onClick = {
+ selected = s.name
+ val text = store.read(s.name)
+ content = TextFieldValue(text, TextRange(text.length))
+ },
+ enabled = activity is ConsoleActivity.Idle,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
+ ) {
+ Row(Modifier.padding(12.dp)) {
+ Text(s.name, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
+ Text(
+ formatBytes(s.size),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+internal data class ScriptCursorPosition(
+ val line: Int,
+ val column: Int,
+ val totalLines: Int,
+)
+
+internal fun scriptCursorPosition(text: String, cursor: Int): ScriptCursorPosition {
+ val safeCursor = cursor.coerceIn(0, text.length)
+ val beforeCursor = text.substring(0, safeCursor)
+ val lastNewline = beforeCursor.lastIndexOf('\n')
+ return ScriptCursorPosition(
+ line = beforeCursor.count { it == '\n' } + 1,
+ column = safeCursor - lastNewline,
+ totalLines = text.count { it == '\n' } + 1,
+ )
+}
+
+@Composable
+internal fun ConsoleFailureCard(failure: OctaveFailure) {
+ var expanded by rememberSaveable(failure.code, failure.stage, failure.message) {
+ mutableStateOf(false)
+ }
+ val context = LocalContext.current
+ val diagnostic = remember(failure) { failure.diagnosticText() }
+ val summary = when (failure.code) {
+ OctaveFailureCode.INSTALL_FAILED -> stringResource(R.string.console_failure_install)
+ OctaveFailureCode.LINK_FAILED -> stringResource(R.string.console_failure_link)
+ OctaveFailureCode.START_FAILED -> stringResource(R.string.console_failure_start)
+ OctaveFailureCode.TIMEOUT,
+ OctaveFailureCode.CLIENT_DEADLINE,
+ -> stringResource(R.string.console_failure_timeout)
+ OctaveFailureCode.MEMORY_LIMIT -> stringResource(R.string.console_failure_memory)
+ OctaveFailureCode.IPC_ERROR,
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureCode.IO_ERROR,
+ -> stringResource(R.string.console_failure_ipc)
+ OctaveFailureCode.BIND_FAILED,
+ OctaveFailureCode.NULL_BINDER,
+ OctaveFailureCode.SERVICE_DISCONNECTED,
+ -> stringResource(R.string.console_failure_service)
+ OctaveFailureCode.CANCELLED -> stringResource(R.string.console_failure_cancelled)
+ OctaveFailureCode.PROCESS_EXITED -> stringResource(R.string.console_failure_exited)
+ else -> failure.message
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Column(Modifier.padding(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(Icons.Default.Warning, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ stringResource(R.string.error_detail),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f),
+ )
+ Icon(
+ if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = stringResource(
+ if (expanded) R.string.collapse_details else R.string.expand_details,
+ ),
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ if (expanded) {
+ Text(
+ diagnostic,
+ style = MathMonoStyle,
+ modifier = Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState()),
+ )
+ TextButton(onClick = { copyConsoleError(context, diagnostic) }) {
+ Text(stringResource(R.string.copy_error))
+ }
+ } else {
+ Text(
+ summary,
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+private fun copyConsoleError(context: Context, text: String) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
+ clipboard.setPrimaryClip(ClipData.newPlainText("Octave error", text))
+}
+
+
+internal fun formatBytes(bytes: Long): String = when {
+ bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1e6)
+ bytes >= 1_000 -> "%.1f KB".format(bytes / 1e3)
+ else -> "$bytes B"
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt
new file mode 100644
index 0000000..05ecb33
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt
@@ -0,0 +1,480 @@
+package com.paruh.maxmath.ui.console
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.paruh.maxmath.engine.OctaveClearTask
+import com.paruh.maxmath.engine.OctaveEvalTask
+import com.paruh.maxmath.engine.OctaveEvent
+import com.paruh.maxmath.engine.OctaveFailure
+import com.paruh.maxmath.engine.OctaveFailureCode
+import com.paruh.maxmath.engine.OctaveFailureStage
+import com.paruh.maxmath.engine.OctaveFigure
+import com.paruh.maxmath.engine.OctaveGateway
+import com.paruh.maxmath.engine.OctavePreview
+import com.paruh.maxmath.engine.OctavePreviewTask
+import com.paruh.maxmath.engine.OctaveRequest
+import com.paruh.maxmath.engine.OctaveResetTask
+import com.paruh.maxmath.engine.OctaveResponse
+import com.paruh.maxmath.engine.OctaveRunScriptTask
+import com.paruh.maxmath.engine.OctaveTask
+import com.paruh.maxmath.engine.OctaveVariable
+import com.paruh.maxmath.engine.OctaveWhosTask
+import java.io.File
+import java.util.UUID
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+enum class ConsoleLineKind { CMD, OUTPUT, ERROR, INFO }
+
+data class ConsoleLine(val text: String, val kind: ConsoleLineKind)
+
+/** Explicit request lifecycle; Cancelling remains busy until its handshake terminates. */
+sealed interface ConsoleActivity {
+ val requestId: String?
+
+ data object Idle : ConsoleActivity {
+ override val requestId: String? = null
+ }
+
+ data class Starting(override val requestId: String) : ConsoleActivity
+ data class Running(override val requestId: String) : ConsoleActivity
+ data class Cancelling(override val requestId: String) : ConsoleActivity
+}
+
+data class ConsoleUiState(
+ val lines: List = emptyList(),
+ val activity: ConsoleActivity = ConsoleActivity.Idle,
+ val workspace: List = emptyList(),
+ val plot: OctaveFigure? = null,
+ val failure: OctaveFailure? = null,
+ val preview: OctavePreview? = null,
+ val showLatex: Boolean = false,
+ val timeoutMs: Long = OctaveRequest.DEFAULT_TIMEOUT_MS,
+ val selectedVariableName: String? = null,
+) {
+ val running: Boolean get() = activity !is ConsoleActivity.Idle
+ val previewText: String? get() = preview?.text
+ val previewJson: String? get() = preview?.latexValueJson()
+ val error: String? get() = failure?.message
+}
+
+class ConsoleViewModel(
+ private val gateway: OctaveGateway,
+ private val requestIdFactory: () -> String = { UUID.randomUUID().toString() },
+) : ViewModel() {
+ private val _state = MutableStateFlow(ConsoleUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var requestJob: Job? = null
+ private var cancelJob: Job? = null
+ private var generation = 0L
+ private var activeRequestId: String? = null
+ private var pendingTerminal: PendingTerminal? = null
+
+ fun submit(command: String) {
+ val trimmed = command.trim()
+ if (trimmed.isEmpty() || !isIdle()) return
+ append(ConsoleLine(">> $trimmed", ConsoleLineKind.CMD))
+ run(OctaveEvalTask(trimmed))
+ }
+
+ /** Adds an informational line without entering Octave. */
+ fun submitHint(message: String) {
+ if (!isIdle()) return
+ append(ConsoleLine(message, ConsoleLineKind.INFO))
+ }
+
+ fun runScript(name: String, content: String, onError: (OctaveFailure) -> Unit = {}) {
+ if (!isIdle()) return
+ append(ConsoleLine(">> run $name", ConsoleLineKind.CMD))
+ run(OctaveRunScriptTask(script = content, name = name), onError = onError)
+ }
+
+ fun refreshWorkspace() {
+ if (isIdle()) run(OctaveWhosTask, quiet = true)
+ }
+
+ fun openVariable(name: String) {
+ if (!isIdle()) return
+ if (_state.value.workspace.none { it.name == name }) return
+ _state.update {
+ it.copy(
+ selectedVariableName = name,
+ preview = null,
+ showLatex = false,
+ failure = null,
+ )
+ }
+ run(OctavePreviewTask(name), quiet = true)
+ }
+
+ fun retrySelectedVariable() {
+ val name = _state.value.selectedVariableName ?: return
+ if (!isIdle()) return
+ _state.update { it.copy(preview = null, failure = null, showLatex = false) }
+ run(OctavePreviewTask(name), quiet = true)
+ }
+
+ fun clearVariable(name: String) {
+ if (isIdle()) run(OctaveClearTask(name.ifBlank { null }))
+ }
+
+ fun reset() {
+ if (isIdle()) run(OctaveResetTask)
+ }
+
+ fun cancel() {
+ val requestId = activeRequestId ?: return
+ if (_state.value.activity is ConsoleActivity.Cancelling) return
+ val requestGeneration = generation
+ _state.update { it.copy(activity = ConsoleActivity.Cancelling(requestId)) }
+ cancelJob?.cancel()
+ cancelJob = viewModelScope.launch {
+ val terminal = try {
+ gateway.cancel(requestId)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ OctaveFailureCode.UNKNOWN,
+ OctaveFailureStage.CANCELLATION,
+ "Unable to cancel Octave request",
+ error.message,
+ ),
+ )
+ }
+ if (!isCurrent(requestGeneration, requestId)) return@launch
+
+ if (terminal is OctaveEvent.Done) {
+ // The service queues this acknowledgement after the worker has reaped the old
+ // process generation. Only this path may unlock the next request immediately.
+ generation += 1
+ activeRequestId = null
+ val discarded = pendingTerminal
+ pendingTerminal = null
+ deletePlotArtifact(discarded?.response)
+ val oldRequest = requestJob
+ requestJob = null
+ oldRequest?.cancel()
+ _state.update { it.copy(activity = ConsoleActivity.Idle) }
+ return@launch
+ }
+
+ val cancellationFailure = (terminal as? OctaveEvent.Failure)?.failure
+ ?: OctaveFailure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureStage.CANCELLATION,
+ "Invalid cancellation response",
+ )
+ val deferred = pendingTerminal
+ pendingTerminal = null
+ if (deferred != null) {
+ activeRequestId = null
+ completeResponse(deferred.response, deferred.quiet, deferred.onError)
+ } else {
+ // A transport/deadline failure does not prove the child stopped. Keep the UI
+ // single-flight and continue listening for the original RUN terminal.
+ _state.update { current ->
+ current.copy(
+ activity = ConsoleActivity.Running(requestId),
+ failure = cancellationFailure.takeUnless {
+ it.code == OctaveFailureCode.CANCEL_NOT_ACTIVE
+ } ?: current.failure,
+ )
+ }
+ }
+ }
+ }
+
+ fun dismissFailure() {
+ _state.update { it.copy(failure = null) }
+ }
+
+ fun toggleLatex() {
+ _state.update { current ->
+ if (current.previewJson == null) current else current.copy(showLatex = !current.showLatex)
+ }
+ }
+
+ fun setTimeoutMs(timeoutMs: Long) {
+ if (isIdle() && timeoutMs > 0) _state.update { it.copy(timeoutMs = timeoutMs) }
+ }
+
+ private fun isIdle(): Boolean = _state.value.activity is ConsoleActivity.Idle
+
+ private fun append(line: ConsoleLine) {
+ _state.update { it.copy(lines = appendBounded(it.lines, line)) }
+ }
+
+ private fun run(
+ task: OctaveTask,
+ quiet: Boolean = false,
+ onError: (OctaveFailure) -> Unit = {},
+ ) {
+ if (!isIdle()) return
+ val requestId = requestIdFactory().ifBlank { UUID.randomUUID().toString() }
+ val requestGeneration = ++generation
+ activeRequestId = requestId
+ pendingTerminal = null
+ val request = OctaveRequest(requestId, task, _state.value.timeoutMs)
+ _state.update {
+ it.copy(
+ activity = ConsoleActivity.Starting(requestId),
+ failure = null,
+ )
+ }
+
+ requestJob = viewModelScope.launch {
+ try {
+ val response = gateway.run(request) { event ->
+ handleEvent(event, requestGeneration, requestId, quiet)
+ }
+ if (!isCurrent(requestGeneration, requestId)) return@launch
+ if (_state.value.activity is ConsoleActivity.Cancelling) {
+ pendingTerminal = PendingTerminal(response, quiet, onError)
+ return@launch
+ }
+ if (response.failure?.code == OctaveFailureCode.CLIENT_DEADLINE) {
+ // The client has issued request-scoped cancellation, but the service has not
+ // yet acknowledged that the old generation is reaped. Keep single-flight.
+ _state.update {
+ it.copy(
+ activity = ConsoleActivity.Cancelling(requestId),
+ failure = response.failure,
+ )
+ }
+ confirmDeadlineCancellation(
+ requestGeneration,
+ requestId,
+ response,
+ quiet,
+ onError,
+ )
+ return@launch
+ }
+ activeRequestId = null
+ completeResponse(response, quiet, onError)
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: Exception) {
+ if (!isCurrent(requestGeneration, requestId)) return@launch
+ val failure = OctaveFailure(
+ OctaveFailureCode.UNKNOWN,
+ OctaveFailureStage.RESPONSE,
+ "Octave request failed",
+ error.stackTraceToString(),
+ )
+ if (_state.value.activity is ConsoleActivity.Cancelling) {
+ pendingTerminal = PendingTerminal(
+ OctaveResponse.failed(requestId, failure),
+ quiet,
+ onError,
+ )
+ return@launch
+ }
+ activeRequestId = null
+ showFailure(failure, quiet, onError)
+ }
+ }
+ }
+
+ private fun handleEvent(
+ event: OctaveEvent,
+ requestGeneration: Long,
+ requestId: String,
+ quiet: Boolean,
+ ) {
+ if (!isCurrent(requestGeneration, requestId) || event.requestId != requestId) return
+ when (event) {
+ is OctaveEvent.Started -> _state.update { current ->
+ if (current.activity is ConsoleActivity.Starting) {
+ current.copy(activity = ConsoleActivity.Running(requestId))
+ } else {
+ current
+ }
+ }
+ is OctaveEvent.Output -> {
+ if (!quiet && _state.value.activity !is ConsoleActivity.Cancelling) {
+ val text = event.text.trimEnd('\r', '\n')
+ if (text.isNotEmpty()) append(ConsoleLine(text, ConsoleLineKind.OUTPUT))
+ }
+ }
+ is OctaveEvent.Done,
+ is OctaveEvent.Failure,
+ -> Unit // The gateway returns exactly one terminal result to the coroutine above.
+ }
+ }
+
+ private suspend fun confirmDeadlineCancellation(
+ requestGeneration: Long,
+ requestId: String,
+ deadlineResponse: OctaveResponse,
+ quiet: Boolean,
+ onError: (OctaveFailure) -> Unit,
+ ) {
+ var terminal: OctaveEvent? = null
+ repeat(DEADLINE_CANCEL_ATTEMPTS) { attempt ->
+ terminal = runCatching { gateway.cancel(requestId) }.getOrNull()
+ if (!isCurrent(requestGeneration, requestId)) return
+ if (terminal is OctaveEvent.Done ||
+ (terminal as? OctaveEvent.Failure)?.failure?.code ==
+ OctaveFailureCode.CANCEL_NOT_ACTIVE
+ ) {
+ activeRequestId = null
+ showFailure(
+ deadlineResponse.effectiveFailure() ?: return,
+ quiet,
+ onError,
+ )
+ return
+ }
+ if (attempt + 1 < DEADLINE_CANCEL_ATTEMPTS) delay(DEADLINE_CANCEL_RETRY_MS)
+ }
+ // The RUN channel is already gone, so keeping Running here would strand the UI forever.
+ // The service remains the source of truth and will reject a new request as BUSY if the
+ // old worker somehow survived every request-scoped cancellation attempt.
+ activeRequestId = null
+ showFailure(deadlineResponse.effectiveFailure() ?: return, quiet, onError)
+ }
+
+ private suspend fun completeResponse(
+ response: OctaveResponse,
+ quiet: Boolean,
+ onError: (OctaveFailure) -> Unit,
+ ) {
+ val failure = response.effectiveFailure()
+ if (failure != null) {
+ response.workspace?.let { workspace ->
+ _state.update { it.copy(workspace = workspace) }
+ }
+ showFailure(failure, quiet, onError)
+ return
+ }
+
+ val (newPlot, plotFailure) = readPlot(response)
+ _state.update { current ->
+ current.copy(
+ activity = ConsoleActivity.Idle,
+ workspace = response.workspace ?: current.workspace,
+ plot = newPlot ?: current.plot,
+ preview = response.preview,
+ showLatex = false,
+ failure = plotFailure,
+ lines = if (plotFailure != null && !quiet) {
+ appendBounded(
+ current.lines,
+ ConsoleLine(plotFailure.message, ConsoleLineKind.ERROR),
+ )
+ } else {
+ current.lines
+ },
+ )
+ }
+ plotFailure?.let(onError)
+ }
+
+ private fun showFailure(
+ failure: OctaveFailure,
+ quiet: Boolean,
+ onError: (OctaveFailure) -> Unit,
+ ) {
+ _state.update { current ->
+ current.copy(
+ activity = ConsoleActivity.Idle,
+ failure = failure,
+ lines = if (!quiet && failure.code == OctaveFailureCode.EXECUTION_FAILED) {
+ appendBounded(current.lines, ConsoleLine(failure.message, ConsoleLineKind.ERROR))
+ } else {
+ current.lines
+ },
+ )
+ }
+ onError(failure)
+ }
+
+ /** Reads bounded same-UID plot output and always removes the per-request file. */
+ private suspend fun readPlot(response: OctaveResponse): Pair {
+ if (response.plotPath == null && response.plotSpec == null) return null to null
+ return withContext(Dispatchers.IO) {
+ val path = response.plotPath
+ val raw = if (path != null) {
+ val file = File(path)
+ try {
+ if (!file.isFile) {
+ return@withContext null to plotFailure("Octave plot file is missing", path)
+ }
+ if (file.length() > MAX_PLOT_BYTES) {
+ return@withContext null to plotFailure(
+ "Octave plot is too large",
+ "path=$path\nbytes=${file.length()}\nlimit=$MAX_PLOT_BYTES",
+ )
+ }
+ file.readText()
+ } catch (error: Exception) {
+ return@withContext null to plotFailure("Unable to read Octave plot", error.message)
+ } finally {
+ runCatching { file.delete() }
+ }
+ } else {
+ response.plotSpec
+ }
+ if (raw == null) return@withContext null to null
+ val figure = OctaveFigure.fromJson(raw)
+ ?: return@withContext null to plotFailure("Invalid Octave plot response", raw.take(4_096))
+ if (path != null && (figure.protocolVersion != 1 || figure.requestId != response.id)) {
+ return@withContext null to plotFailure(
+ "Octave plot response does not match the request",
+ "expected=${response.id} actual=${figure.requestId} version=${figure.protocolVersion}",
+ )
+ }
+ figure to null
+ }
+ }
+
+ private fun plotFailure(message: String, details: String?): OctaveFailure = OctaveFailure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureStage.RESPONSE,
+ message,
+ details,
+ )
+
+ private fun deletePlotArtifact(response: OctaveResponse?) {
+ response?.plotPath?.let { path -> runCatching { File(path).delete() } }
+ }
+
+ private fun isCurrent(expectedGeneration: Long, requestId: String): Boolean =
+ generation == expectedGeneration && activeRequestId == requestId
+
+ private fun appendBounded(lines: List, line: ConsoleLine): List {
+ val result = (lines + line).takeLast(MAX_CONSOLE_LINES).toMutableList()
+ var characters = result.sumOf { it.text.length.coerceAtMost(MAX_CONSOLE_CHARS + 1) }
+ while (result.isNotEmpty() && characters > MAX_CONSOLE_CHARS) {
+ characters -= result.removeAt(0).text.length.coerceAtMost(MAX_CONSOLE_CHARS + 1)
+ }
+ return result
+ }
+
+ private data class PendingTerminal(
+ val response: OctaveResponse,
+ val quiet: Boolean,
+ val onError: (OctaveFailure) -> Unit,
+ )
+
+ companion object {
+ private const val MAX_PLOT_BYTES = 4L * 1024L * 1024L
+ private const val MAX_CONSOLE_CHARS = 2 * 1024 * 1024
+ private const val MAX_CONSOLE_LINES = 4_096
+ private const val DEADLINE_CANCEL_ATTEMPTS = 3
+ private const val DEADLINE_CANCEL_RETRY_MS = 1_000L
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt b/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt
new file mode 100644
index 0000000..5e1bceb
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt
@@ -0,0 +1,64 @@
+package com.paruh.maxmath.ui.console
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.log10
+import kotlin.math.pow
+
+/**
+ * 把 Octave 预览的结构化 JSON 值转成 LaTeX,供控制台的结果切换渲染。
+ * 只处理标量/向量/矩阵;字符串与其它类型直接回落纯文本。
+ */
+object OctaveLatex {
+
+ fun fromValueJson(json: String): String? {
+ return runCatching {
+ val arr = JSONArray(json)
+ val rows = arr.length()
+ if (rows == 0) return null
+ val first = arr.optJSONArray(0)
+ if (first == null) {
+ // 标量或行向量
+ return "\\begin{pmatrix}${arr.joinToString(" & ") { num(it) }}\\end{pmatrix}"
+ }
+ val cols = first.length()
+ if (rows > 50 || cols > 50) return null
+ buildString {
+ append("\\begin{pmatrix}\n")
+ for (i in 0 until rows) {
+ val row = arr.getJSONArray(i)
+ append(row.joinToString(" & ") { num(it) })
+ if (i < rows - 1) append("\\\\\n")
+ }
+ append("\n\\end{pmatrix}")
+ }
+ }.getOrNull()
+ }
+
+ private fun JSONArray.joinToString(separator: String, transform: (Any?) -> String): String =
+ buildString {
+ for (i in 0 until length()) {
+ if (i > 0) append(separator)
+ append(transform(opt(i)))
+ }
+ }
+
+ private fun num(value: Any?): String = when (value) {
+ is Number -> formatNumber(value.toDouble())
+ is JSONArray -> "\\ldots"
+ null, JSONObject.NULL -> "\\mathrm{NaN}"
+ else -> "\\ldots"
+ }
+
+ private fun formatNumber(v: Double): String {
+ if (v.isNaN()) return "\\mathrm{NaN}"
+ if (v.isInfinite()) return if (v > 0) "+\\infty" else "-\\infty"
+ if (v == 0.0) return "0"
+ val abs = abs(v)
+ val digits = if (abs >= 1e5 || abs < 1e-4) 2 else 4
+ val text = String.format(Locale.ROOT, "%.${digits}g", v)
+ return if (text.contains('.')) text.trimEnd('0').trimEnd('.') else text
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt b/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt
new file mode 100644
index 0000000..d199056
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt
@@ -0,0 +1,590 @@
+package com.paruh.maxmath.ui.console
+
+import android.graphics.Paint as AndroidPaint
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Card
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.paruh.maxmath.engine.OctaveAxes
+import com.paruh.maxmath.engine.OctaveFigure
+import com.paruh.maxmath.engine.OctaveLine
+import com.paruh.maxmath.ui.plot.PlotRange
+import com.paruh.maxmath.ui.plot.PlotTicks
+import com.paruh.maxmath.ui.plot.gl.GlMesh
+import com.paruh.maxmath.ui.plot.gl.GlPlotKind
+import com.paruh.maxmath.ui.plot.gl.GlViewState
+import com.paruh.maxmath.ui.plot.gl.PlotGlController
+import com.paruh.maxmath.ui.plot.gl.PlotGlSurface
+import com.paruh.maxmath.ui.plot.GlGestureMath
+import com.paruh.maxmath.ui.theme.GlPalette
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.sin
+
+/**
+ * Octave 控制台的绘图面板:按 subplot 布局渲染 2D 折线、3D 曲面/折线
+ * 与等高线。每个子图的手势相互独立。
+ */
+@Composable
+fun OctavePlotPanel(
+ figure: OctaveFigure,
+ modifier: Modifier = Modifier,
+ stacked: Boolean = false,
+) {
+ if (figure.axes.isEmpty()) return
+ val rows = figure.rows.coerceAtLeast(1)
+ val cols = figure.cols.coerceAtLeast(1)
+ Column(
+ modifier = modifier.testTag(OCTAVE_PLOT_PANEL_TAG),
+ verticalArrangement = Arrangement.spacedBy(if (stacked) 12.dp else 4.dp),
+ ) {
+ if (stacked) {
+ figure.axes.sortedBy { it.position }.forEach { axes ->
+ Card(
+ Modifier
+ .fillMaxWidth()
+ .testTag("$OCTAVE_SUBPLOT_TAG_PREFIX${axes.position}"),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1.35f)
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ OctaveAxesView(axes)
+ }
+ }
+ }
+ return@Column
+ }
+ for (r in 0 until rows) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ for (c in 0 until cols) {
+ val index = r * cols + c + 1
+ val axes = figure.axes.firstOrNull { it.position == index }
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(1.35f)
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ if (axes != null) {
+ OctaveAxesView(axes)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+internal const val OCTAVE_PLOT_PANEL_TAG = "octave_plot_panel"
+internal const val OCTAVE_SUBPLOT_TAG_PREFIX = "octave_subplot_"
+
+@Composable
+private fun OctaveAxesView(axes: OctaveAxes) {
+ Box(Modifier.fillMaxSize()) {
+ when {
+ axes.type == "3d" && axes.surfaces.isNotEmpty() -> OctaveGlPanel(GlPlotKind.SURFACE, axes)
+ axes.type == "contour" && axes.contours.isNotEmpty() -> OctaveGlPanel(GlPlotKind.CONTOUR, axes)
+ axes.type == "3d" && axes.lines3d.isNotEmpty() -> OctaveLine3dCanvas(axes)
+ else -> OctaveLineCanvas(axes)
+ }
+ // 标题与坐标轴名用覆盖层,GL 画布不重复画文字。
+ if (axes.title.isNotBlank()) {
+ Text(
+ axes.title,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp),
+ )
+ }
+ if (axes.xlabel.isNotBlank()) {
+ Text(
+ axes.xlabel,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 1.dp),
+ )
+ }
+ if (axes.ylabel.isNotBlank()) {
+ Text(
+ axes.ylabel,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp),
+ )
+ }
+ if (axes.zlabel.isNotBlank()) {
+ Text(
+ axes.zlabel,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .rotate(-90f)
+ .padding(end = 2.dp),
+ )
+ }
+ if (axes.legend.isNotEmpty()) {
+ Column(
+ modifier = Modifier.align(Alignment.TopEnd).padding(top = 2.dp, end = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ ) {
+ axes.legend.take(6).forEach { item ->
+ Text(item, style = MaterialTheme.typography.labelSmall, fontSize = 9.sp)
+ }
+ }
+ }
+ }
+}
+
+private val lineColors = listOf(
+ Color(0xFF1F77B4),
+ Color(0xFFFF7F0E),
+ Color(0xFF2CA02C),
+ Color(0xFFD62728),
+ Color(0xFF9467BD),
+ Color(0xFF8C564B),
+ Color(0xFFE377C2),
+ Color(0xFF7F7F7F),
+)
+
+@Composable
+private fun OctaveLineCanvas(axes: OctaveAxes) {
+ var range by remember(axes) {
+ mutableStateOf(defaultRange(axes))
+ }
+ var size by remember { mutableStateOf(Size(1f, 1f)) }
+ val annotationColor = MaterialTheme.colorScheme.onSurface.toArgb()
+ val annotationSizePx = with(LocalDensity.current) { 11.sp.toPx() }
+ val annotationPaint = remember(annotationColor, annotationSizePx) {
+ AndroidPaint(AndroidPaint.ANTI_ALIAS_FLAG).apply {
+ color = annotationColor
+ textSize = annotationSizePx
+ }
+ }
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
+ .pointerInput(axes) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ range = panZoom(range, pan, zoom, size)
+ }
+ },
+ ) {
+ drawLines(axes, range, size, annotationPaint)
+ }
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawLines(
+ axes: OctaveAxes,
+ range: PlotRange,
+ size: Size,
+ annotationPaint: AndroidPaint,
+) {
+ if (size.width <= 0f || size.height <= 0f) return
+ val w = range.width
+ val h = range.height
+ if (w <= 0.0 || h <= 0.0) return
+ fun sx(x: Double): Float = ((x - range.xMin) / w * size.width).toFloat()
+ fun sy(y: Double): Float = ((range.yMax - y) / h * size.height).toFloat()
+
+ // 背景与网格
+ drawRect(Color.White)
+ if (axes.grid) {
+ val gridColor = Color(0xFFE0E0E0)
+ val gx = DoubleArray(PlotTicks.capacity(range.xMin, range.xMax, PlotTicks.TARGET_2D))
+ val n = PlotTicks.into(gx, range.xMin, range.xMax, PlotTicks.TARGET_2D)
+ for (i in 0 until n) {
+ drawLine(gridColor, Offset(sx(gx[i]), 0f), Offset(sx(gx[i]), size.height), 1f)
+ }
+ val gy = DoubleArray(PlotTicks.capacity(range.yMin, range.yMax, PlotTicks.TARGET_2D))
+ val m = PlotTicks.into(gy, range.yMin, range.yMax, PlotTicks.TARGET_2D)
+ for (i in 0 until m) {
+ drawLine(gridColor, Offset(0f, sy(gy[i])), Offset(size.width, sy(gy[i])), 1f)
+ }
+ }
+
+ // 坐标轴与边框
+ drawLine(Color(0xFF666666), Offset(0f, sy(0.0)), Offset(size.width, sy(0.0)), 1.2f)
+ drawLine(Color(0xFF666666), Offset(sx(0.0), 0f), Offset(sx(0.0), size.height), 1.2f)
+
+ // 数据线
+ axes.lines.forEachIndexed { index, line ->
+ val color = lineColors[index % lineColors.size]
+ val style = LineStyle.parse(line.style)
+ val path = Path()
+ var started = false
+ val n = min(line.x.size, line.y.size)
+ for (i in 0 until n) {
+ val x = line.x[i]
+ val y = line.y[i]
+ if (!x.isFinite() || !y.isFinite()) {
+ started = false
+ continue
+ }
+ val sxv = sx(x)
+ val syv = sy(y)
+ if (!started) {
+ path.moveTo(sxv, syv)
+ started = true
+ } else {
+ path.lineTo(sxv, syv)
+ }
+ if (style.marker != null && i % max(1, n / 40) == 0) {
+ drawCircle(color, radius = 3f, center = Offset(sxv, syv))
+ }
+ }
+ drawPath(path, color, style = Stroke(width = 2.2f))
+ }
+
+ axes.texts.forEach { annotation ->
+ if (annotation.x.isFinite() && annotation.y.isFinite() && annotation.text.isNotEmpty()) {
+ drawContext.canvas.nativeCanvas.drawText(
+ annotation.text,
+ sx(annotation.x) + 4f,
+ sy(annotation.y) - 4f,
+ annotationPaint,
+ )
+ }
+ }
+}
+
+private fun panZoom(range: PlotRange, pan: Offset, zoom: Float, size: Size): PlotRange {
+ val factor = 1f / zoom.coerceIn(0.2f, 8f)
+ val w = range.width * factor
+ val h = range.height * factor
+ val cx = range.xMin + range.width / 2
+ val cy = range.yMin + range.height / 2
+ val dx = pan.x / size.width.coerceAtLeast(1f) * w
+ val dy = pan.y / size.height.coerceAtLeast(1f) * h
+ return PlotRange(
+ xMin = cx - w / 2 - dx,
+ xMax = cx + w / 2 - dx,
+ yMin = cy - h / 2 + dy,
+ yMax = cy + h / 2 + dy,
+ )
+}
+
+private fun defaultRange(axes: OctaveAxes): PlotRange =
+ computeRange(axes, includeLines = true)
+
+/** 2D 与 GL 共用的数据范围:含 2D 折线时加 5% 边距,纯网格不加。 */
+private fun computeRange(axes: OctaveAxes, includeLines: Boolean): PlotRange {
+ var xMin = Double.POSITIVE_INFINITY
+ var xMax = Double.NEGATIVE_INFINITY
+ var yMin = Double.POSITIVE_INFINITY
+ var yMax = Double.NEGATIVE_INFINITY
+ if (includeLines) {
+ axes.lines.forEach { line ->
+ val n = min(line.x.size, line.y.size)
+ for (i in 0 until n) {
+ val x = line.x[i]
+ val y = line.y[i]
+ if (x.isFinite()) {
+ xMin = min(xMin, x)
+ xMax = max(xMax, x)
+ }
+ if (y.isFinite()) {
+ yMin = min(yMin, y)
+ yMax = max(yMax, y)
+ }
+ }
+ }
+ }
+ axes.surfaces.forEach { s ->
+ s.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } }
+ s.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } }
+ }
+ axes.contours.forEach { c ->
+ c.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } }
+ c.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } }
+ }
+ if (!xMin.isFinite()) xMin = -1.0
+ if (!xMax.isFinite()) xMax = 1.0
+ if (!yMin.isFinite()) yMin = -1.0
+ if (!yMax.isFinite()) yMax = 1.0
+ if (xMax <= xMin) {
+ xMax = xMin + 1
+ }
+ if (yMax <= yMin) {
+ yMax = yMin + 1
+ }
+ val xPad = if (includeLines) (xMax - xMin) * 0.05 else 0.0
+ val yPad = if (includeLines) (yMax - yMin) * 0.05 else 0.0
+ var r = PlotRange(
+ xMin = xMin - xPad,
+ xMax = xMax + xPad,
+ yMin = yMin - yPad,
+ yMax = yMax + yPad,
+ )
+ axes.xlim?.takeIf { it.size == 2 && it[1] > it[0] }?.let {
+ r = PlotRange(it[0], it[1], r.yMin, r.yMax)
+ }
+ axes.ylim?.takeIf { it.size == 2 && it[1] > it[0] }?.let {
+ r = PlotRange(r.xMin, r.xMax, it[0], it[1])
+ }
+ return r
+}
+
+/** 简化 linespec 解析:颜色字符与标记(虚线绘制暂不实现)。 */
+private data class ParsedLineStyle(val color: Color?, val marker: Char?)
+
+private object LineStyle {
+ fun parse(style: String): ParsedLineStyle {
+ var color: Color? = null
+ var marker: Char? = null
+ style.forEach { ch ->
+ when (ch) {
+ 'r' -> color = Color(0xFFD62728)
+ 'g' -> color = Color(0xFF2CA02C)
+ 'b' -> color = Color(0xFF1F77B4)
+ 'k' -> color = Color(0xFF222222)
+ 'm' -> color = Color(0xFF9467BD)
+ 'c' -> color = Color(0xFF17BECF)
+ 'y' -> color = Color(0xFFFFD700)
+ 'w' -> color = Color.White
+ '.' -> marker = '.'
+ 'o', '+', '*', 'x', 's', 'd', '^', 'v', '<', '>' -> marker = ch
+ }
+ }
+ return ParsedLineStyle(color, marker)
+ }
+}
+
+@Composable
+private fun OctaveGlPanel(kind: GlPlotKind, axes: OctaveAxes) {
+ val mesh = remember(axes, kind) {
+ when (kind) {
+ GlPlotKind.SURFACE -> buildSurfaceMesh(axes)
+ GlPlotKind.CONTOUR -> buildContourMesh(axes)
+ }
+ }
+ val range = remember(axes) { computeRange(axes, includeLines = false) }
+ if (mesh == null) return
+ val controller = remember { PlotGlController() }
+ var state by remember(axes, kind) {
+ mutableStateOf(octaveGlViewState(axes, kind))
+ }
+ var size by remember { mutableStateOf(Size(1f, 1f)) }
+ LaunchedEffect(mesh, range, axes.azimuth, axes.elevation) {
+ controller.setMesh(mesh, range)
+ controller.setState(state)
+ }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
+ .pointerInput(axes, kind) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ state = when (kind) {
+ GlPlotKind.SURFACE -> GlGestureMath.apply3d(state, pan, zoom)
+ GlPlotKind.CONTOUR -> GlGestureMath.applyContour(
+ state, pan, zoom, size.width, size.height,
+ )
+ }
+ controller.setState(state)
+ }
+ },
+ ) {
+ PlotGlSurface(
+ controller = controller,
+ modifier = Modifier.fillMaxSize(),
+ palette = GlPalette.Light,
+ )
+ if (axes.colorbar) {
+ ColorBar(
+ colormap = axes.colormap,
+ zMin = mesh.zMin.toDouble(),
+ zMax = mesh.zMax.toDouble(),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 24.dp, end = 4.dp),
+ )
+ }
+ }
+}
+
+internal fun octaveGlViewState(axes: OctaveAxes, kind: GlPlotKind): GlViewState =
+ GlViewState(
+ kind = kind,
+ azimuthDeg = axes.azimuth.toFloat(),
+ elevationDeg = axes.elevation.toFloat(),
+ )
+
+private fun buildSurfaceMesh(axes: OctaveAxes): GlMesh? {
+ val s = axes.surfaces.firstOrNull() ?: return null
+ return PlotDataMesh.surface(s.x, s.y, s.z, s.rows, s.cols, axes.colormap)
+}
+
+private fun buildContourMesh(axes: OctaveAxes): GlMesh? {
+ val c = axes.contours.firstOrNull() ?: return null
+ val levelCount = if (c.levels.size >= 2) c.levels.size else 10
+ return PlotDataMesh.contour(c.x, c.y, c.z, c.rows, c.cols, axes.colormap, levelCount)
+}
+
+@Composable
+private fun ColorBar(colormap: String, zMin: Double, zMax: Double, modifier: Modifier = Modifier) {
+ Box(modifier) {
+ Column(
+ modifier = Modifier
+ .testTag("octave_color_legend")
+ .width(112.dp)
+ .height(42.dp)
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f))
+ .padding(4.dp),
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ Canvas(
+ Modifier
+ .fillMaxWidth()
+ .height(12.dp),
+ ) {
+ val steps = 64
+ val stepWidth = size.width / steps
+ for (i in 0 until steps) {
+ val t = i / (steps - 1f)
+ drawRect(
+ color = MatlabColormaps.color(colormap, t),
+ topLeft = Offset(i * stepWidth, 0f),
+ size = Size(stepWidth + 1f, size.height),
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ PlotTicks.formatValue(zMin),
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = 8.sp,
+ )
+ Text(
+ PlotTicks.formatValue(zMax),
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = 8.sp,
+ textAlign = TextAlign.End,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun OctaveLine3dCanvas(axes: OctaveAxes) {
+ var azimuth by remember(axes) { mutableFloatStateOf(axes.azimuth.toFloat()) }
+ var elevation by remember(axes) { mutableFloatStateOf(axes.elevation.toFloat()) }
+ var size by remember { mutableStateOf(Size(1f, 1f)) }
+ Canvas(
+ Modifier
+ .fillMaxSize()
+ .onSizeChanged { size = Size(it.width.toFloat(), it.height.toFloat()) }
+ .pointerInput(axes) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ azimuth = (azimuth - pan.x * 0.5f) % 360f
+ elevation = (elevation + pan.y * 0.5f).coerceIn(-89f, 89f)
+ }
+ },
+ ) {
+ drawRect(Color.White)
+ var xMin = Double.POSITIVE_INFINITY
+ var xMax = Double.NEGATIVE_INFINITY
+ var yMin = Double.POSITIVE_INFINITY
+ var yMax = Double.NEGATIVE_INFINITY
+ var zMin = Double.POSITIVE_INFINITY
+ var zMax = Double.NEGATIVE_INFINITY
+ axes.lines3d.forEach { l ->
+ l.x.forEach { if (it.isFinite()) { xMin = min(xMin, it); xMax = max(xMax, it) } }
+ l.y.forEach { if (it.isFinite()) { yMin = min(yMin, it); yMax = max(yMax, it) } }
+ l.z.forEach { if (it.isFinite()) { zMin = min(zMin, it); zMax = max(zMax, it) } }
+ }
+ if (!xMin.isFinite()) return@Canvas
+ val sx0 = (azimuth * PI / 180f).toFloat()
+ val sy0 = (elevation * PI / 180f).toFloat()
+ val cosA = cos(sx0)
+ val sinA = sin(sx0)
+ val cosE = cos(sy0)
+ val sinE = sin(sy0)
+ val cx = (xMin + xMax) / 2
+ val cy = (yMin + yMax) / 2
+ val cz = (zMin + zMax) / 2
+ val scale = size.minDimension / maxOf(xMax - xMin, yMax - yMin, zMax - zMin) * 0.8f
+ fun proj(x: Double, y: Double, z: Double): Offset {
+ val xr = x - cx
+ val yr = y - cy
+ val zr = z - cz
+ val x1 = cosA * xr - sinA * yr
+ val y1 = sinA * xr + cosA * yr
+ val z1 = zr
+ val x2 = x1
+ val y2 = cosE * y1 - sinE * z1
+ val z2 = sinE * y1 + cosE * z1
+ return Offset(
+ (size.width / 2 + x2 * scale).toFloat(),
+ (size.height / 2 - y2 * scale).toFloat(),
+ )
+ }
+ axes.lines3d.forEachIndexed { index, line ->
+ val color = lineColors[index % lineColors.size]
+ val path = Path()
+ var started = false
+ val n = minOf(line.x.size, line.y.size, line.z.size)
+ for (i in 0 until n) {
+ val p = proj(line.x[i], line.y[i], line.z[i])
+ if (!line.x[i].isFinite() || !line.y[i].isFinite() || !line.z[i].isFinite()) {
+ started = false
+ continue
+ }
+ if (!started) {
+ path.moveTo(p.x, p.y)
+ started = true
+ } else {
+ path.lineTo(p.x, p.y)
+ }
+ }
+ drawPath(path, color, style = Stroke(width = 2.2f))
+ }
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt b/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt
new file mode 100644
index 0000000..ac926b3
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt
@@ -0,0 +1,401 @@
+package com.paruh.maxmath.ui.console
+
+import androidx.compose.ui.graphics.Color
+import com.paruh.maxmath.ui.plot.gl.GlMesh
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlin.math.sqrt
+
+/**
+ * MATLAB/Octave 常用 colormap 的近似实现(0..1 采样)。
+ * 2D 画布与 GL 顶点颜色共用同一份映射,保证 colorbar 与图面一致。
+ */
+object MatlabColormaps {
+
+ fun color(name: String, t: Float): Color {
+ val x = t.coerceIn(0f, 1f)
+ return when (name.lowercase()) {
+ "jet" -> jet(x)
+ "hot" -> Color(1f, x, x * x)
+ "gray" -> Color(x, x, x)
+ "autumn" -> Color(1f, x, 0f)
+ "cool" -> Color(x, 1f - x, 1f)
+ "hsv" -> hsv(x)
+ "parula", "viridis", "" -> viridis(x)
+ else -> viridis(x)
+ }
+ }
+
+ fun rgba(name: String, t: Float, alpha: Float = 1f): FloatArray {
+ val c = color(name, t)
+ return floatArrayOf(c.red, c.green, c.blue, alpha)
+ }
+
+ private fun viridis(t: Float): Color {
+ // 简化版 viridis:首末端点加两个中间控制色,足够区分等值面。
+ val stops = listOf(
+ 0.000f to Color(0.267f, 0.005f, 0.329f),
+ 0.300f to Color(0.230f, 0.322f, 0.546f),
+ 0.520f to Color(0.128f, 0.567f, 0.551f),
+ 0.740f to Color(0.369f, 0.789f, 0.383f),
+ 1.000f to Color(0.993f, 0.906f, 0.144f),
+ )
+ return lerp(stops, t)
+ }
+
+ private fun jet(t: Float): Color {
+ val stops = listOf(
+ 0.000f to Color(0f, 0f, 0.5f),
+ 0.125f to Color(0f, 0f, 1f),
+ 0.375f to Color(0f, 1f, 1f),
+ 0.625f to Color(1f, 1f, 0f),
+ 0.875f to Color(1f, 0f, 0f),
+ 1.000f to Color(0.5f, 0f, 0f),
+ )
+ return lerp(stops, t)
+ }
+
+ private fun hsv(t: Float): Color {
+ val h = t * 6f
+ val x = 1f - kotlin.math.abs(h % 2f - 1f)
+ return when {
+ h < 1f -> Color(1f, x, 0f)
+ h < 2f -> Color(x, 1f, 0f)
+ h < 3f -> Color(0f, 1f, x)
+ h < 4f -> Color(0f, x, 1f)
+ h < 5f -> Color(x, 0f, 1f)
+ else -> Color(1f, 0f, x)
+ }
+ }
+
+ private fun lerp(stops: List>, t: Float): Color {
+ if (t <= stops.first().first) return stops.first().second
+ if (t >= stops.last().first) return stops.last().second
+ for (i in 1 until stops.size) {
+ val (t0, c0) = stops[i - 1]
+ val (t1, c1) = stops[i]
+ if (t <= t1) {
+ val f = ((t - t0) / (t1 - t0)).coerceIn(0f, 1f)
+ return Color(
+ c0.red + (c1.red - c0.red) * f,
+ c0.green + (c1.green - c0.green) * f,
+ c0.blue + (c1.blue - c0.blue) * f,
+ )
+ }
+ }
+ return stops.last().second
+ }
+}
+
+/**
+ * 由 Octave 数据网格构建 GL 网格(行列数任意,不要求 n×n)。
+ * 曲面带逐顶点法线;等高线把 z 编码为热力图色并生成等值线段。
+ */
+object PlotDataMesh {
+
+ const val MAX_CELLS = 65_535
+
+ fun surface(
+ xs: DoubleArray,
+ ys: DoubleArray,
+ z: DoubleArray,
+ rows: Int,
+ cols: Int,
+ colormap: String = "viridis",
+ ): GlMesh? {
+ if (rows < 2 || cols < 2 || z.size < rows * cols) return null
+ if (rows * cols > MAX_CELLS) return null
+ if (!validCoordinates(xs, ys, rows, cols)) return null
+ val positions = FloatArray(rows * cols * 3)
+ val normals = FloatArray(rows * cols * 3)
+ val colors = FloatArray(rows * cols * 4)
+ val zf = FloatArray(rows * cols)
+ var zMin = Float.POSITIVE_INFINITY
+ var zMax = Float.NEGATIVE_INFINITY
+ for (i in 0 until rows) {
+ for (j in 0 until cols) {
+ val idx = i * cols + j
+ val zv = z[idx].toFloat()
+ zf[idx] = zv
+ positions[idx * 3] = xAt(xs, idx, j, rows, cols).toFloat()
+ positions[idx * 3 + 1] = yAt(ys, idx, i, rows, cols).toFloat()
+ positions[idx * 3 + 2] = zv
+ if (zv.isFinite()) {
+ if (zv < zMin) zMin = zv
+ if (zv > zMax) zMax = zv
+ }
+ }
+ }
+ if (!zMin.isFinite()) zMin = 0f
+ if (!zMax.isFinite()) zMax = 1f
+ val span = if (zMax > zMin) zMax - zMin else 1f
+ for (v in 0 until rows * cols) {
+ val t = if (zf[v].isFinite()) (zf[v] - zMin) / span else 0f
+ val c = MatlabColormaps.rgba(colormap, t)
+ System.arraycopy(c, 0, colors, v * 4, 4)
+ colors[v * 4 + 3] = if (zf[v].isFinite()) 1f else 0f
+ }
+
+ val indices = IntArray((rows - 1) * (cols - 1) * 6)
+ var written = 0
+ for (i in 0 until rows - 1) {
+ for (j in 0 until cols - 1) {
+ val a = i * cols + j
+ val b = a + 1
+ val c = a + cols + 1
+ val d = a + cols
+ if (finite(zf, a, b, c, d)) {
+ indices[written] = a
+ indices[written + 1] = b
+ indices[written + 2] = c
+ indices[written + 3] = a
+ indices[written + 4] = c
+ indices[written + 5] = d
+ written += 6
+ accumulateNormal(positions, normals, a, b, c, d)
+ }
+ }
+ }
+ for (v in 0 until rows * cols) {
+ val nx = normals[v * 3]
+ val ny = normals[v * 3 + 1]
+ val nz = normals[v * 3 + 2]
+ val len = sqrt(nx * nx + ny * ny + nz * nz)
+ if (len > 1e-6f) {
+ normals[v * 3] = nx / len
+ normals[v * 3 + 1] = ny / len
+ normals[v * 3 + 2] = nz / len
+ } else {
+ normals[v * 3 + 2] = 1f
+ }
+ }
+ return GlMesh(
+ positions = positions,
+ normals = normals,
+ colors = colors,
+ indices = if (written == indices.size) indices else indices.copyOf(written),
+ contourLines = FloatArray(0),
+ zMin = zMin,
+ zMax = zMax,
+ )
+ }
+
+ fun contour(
+ xs: DoubleArray,
+ ys: DoubleArray,
+ z: DoubleArray,
+ rows: Int,
+ cols: Int,
+ colormap: String = "viridis",
+ levelCount: Int = 10,
+ ): GlMesh? {
+ if (rows < 2 || cols < 2 || z.size < rows * cols) return null
+ if (rows * cols > MAX_CELLS) return null
+ if (!validCoordinates(xs, ys, rows, cols)) return null
+ val positions = FloatArray(rows * cols * 3)
+ val normals = FloatArray(rows * cols * 3)
+ val colors = FloatArray(rows * cols * 4)
+ val zf = FloatArray(rows * cols)
+ var zMin = Float.POSITIVE_INFINITY
+ var zMax = Float.NEGATIVE_INFINITY
+ for (i in 0 until rows) {
+ for (j in 0 until cols) {
+ val idx = i * cols + j
+ val zv = z[idx].toFloat()
+ zf[idx] = zv
+ positions[idx * 3] = xAt(xs, idx, j, rows, cols).toFloat()
+ positions[idx * 3 + 1] = yAt(ys, idx, i, rows, cols).toFloat()
+ normals[idx * 3 + 2] = 1f
+ if (zv.isFinite()) {
+ if (zv < zMin) zMin = zv
+ if (zv > zMax) zMax = zv
+ }
+ }
+ }
+ if (!zMin.isFinite()) zMin = 0f
+ if (!zMax.isFinite()) zMax = 1f
+ val span = if (zMax > zMin) zMax - zMin else 1f
+ for (v in 0 until rows * cols) {
+ val t = if (zf[v].isFinite()) (zf[v] - zMin) / span else 0f
+ val c = MatlabColormaps.rgba(colormap, t)
+ System.arraycopy(c, 0, colors, v * 4, 4)
+ colors[v * 4 + 3] = if (zf[v].isFinite()) 1f else 0f
+ }
+ val indices = IntArray((rows - 1) * (cols - 1) * 6)
+ var written = 0
+ for (i in 0 until rows - 1) {
+ for (j in 0 until cols - 1) {
+ val a = i * cols + j
+ val b = a + 1
+ val c = a + cols + 1
+ val d = a + cols
+ if (finite(zf, a, b, c, d)) {
+ indices[written] = a
+ indices[written + 1] = b
+ indices[written + 2] = c
+ indices[written + 3] = a
+ indices[written + 4] = c
+ indices[written + 5] = d
+ written += 6
+ }
+ }
+ }
+ return GlMesh(
+ positions = positions,
+ normals = normals,
+ colors = colors,
+ indices = if (written == indices.size) indices else indices.copyOf(written),
+ contourLines = contourLines(xs, ys, zf, rows, cols, zMin, zMax, levelCount),
+ zMin = zMin,
+ zMax = zMax,
+ )
+ }
+
+ private fun finite(zf: FloatArray, a: Int, b: Int, c: Int, d: Int): Boolean =
+ zf[a].isFinite() && zf[b].isFinite() && zf[c].isFinite() && zf[d].isFinite()
+
+ /**
+ * Octave's surf/mesh bridge serializes X and Y as full meshgrid matrices,
+ * while older artifacts may contain the compact x/y vectors. Keep both wire
+ * shapes, but never read a flattened Y matrix as though it were a vector: for
+ * an n x n mesh that made every row use Y[0] and collapsed the surface to a
+ * plane.
+ */
+ private fun validCoordinates(
+ xs: DoubleArray,
+ ys: DoubleArray,
+ rows: Int,
+ cols: Int,
+ ): Boolean {
+ val cells = rows * cols
+ val validX = xs.size >= cells || xs.size >= cols
+ val validY = ys.size >= cells || ys.size >= rows
+ return validX && validY
+ }
+
+ private fun xAt(
+ xs: DoubleArray,
+ cellIndex: Int,
+ column: Int,
+ rows: Int,
+ cols: Int,
+ ): Double = if (xs.size >= rows * cols) xs[cellIndex] else xs[column]
+
+ private fun yAt(
+ ys: DoubleArray,
+ cellIndex: Int,
+ row: Int,
+ rows: Int,
+ cols: Int,
+ ): Double = if (ys.size >= rows * cols) ys[cellIndex] else ys[row]
+
+ private fun accumulateNormal(
+ positions: FloatArray,
+ normals: FloatArray,
+ a: Int,
+ b: Int,
+ c: Int,
+ d: Int,
+ ) {
+ val ax = positions[a * 3]; val ay = positions[a * 3 + 1]; val az = positions[a * 3 + 2]
+ val bx = positions[b * 3]; val by = positions[b * 3 + 1]; val bz = positions[b * 3 + 2]
+ val dx = positions[d * 3]; val dy = positions[d * 3 + 1]; val dz = positions[d * 3 + 2]
+ var nx = (by - ay) * (dz - az) - (bz - az) * (dy - ay)
+ var ny = (bz - az) * (dx - ax) - (bx - ax) * (dz - az)
+ var nz = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax)
+ val len = sqrt(nx * nx + ny * ny + nz * nz)
+ if (len > 1e-8f) {
+ nx /= len; ny /= len; nz /= len
+ addNormal(normals, a, nx, ny, nz)
+ addNormal(normals, b, nx, ny, nz)
+ addNormal(normals, c, nx, ny, nz)
+ addNormal(normals, d, nx, ny, nz)
+ }
+ }
+
+ private fun addNormal(normals: FloatArray, v: Int, nx: Float, ny: Float, nz: Float) {
+ normals[v * 3] += nx
+ normals[v * 3 + 1] += ny
+ normals[v * 3 + 2] += nz
+ }
+
+ /** Marching squares(矩形网格版),输出 GL_LINES 顶点序列。 */
+ private fun contourLines(
+ xs: DoubleArray,
+ ys: DoubleArray,
+ zs: FloatArray,
+ rows: Int,
+ cols: Int,
+ zMin: Float,
+ zMax: Float,
+ levelCount: Int,
+ ): FloatArray {
+ if (zMax <= zMin || levelCount <= 0) return FloatArray(0)
+ val divisions = levelCount + 1
+ val levels = FloatArray(levelCount) { zMin + (zMax - zMin) * (it + 1) / divisions }
+ val toLevelIndex = divisions.toDouble() / (zMax - zMin).toDouble()
+ var lines = FloatArray(1024)
+ var count = 0
+ val hits = DoubleArray(8)
+ for (i in 0 until rows - 1) {
+ for (j in 0 until cols - 1) {
+ val a = i * cols + j
+ val b = a + 1
+ val c = a + cols + 1
+ val d = a + cols
+ val za = zs[a]; val zb = zs[b]; val zc = zs[c]; val zd = zs[d]
+ if (!finite(zs, a, b, c, d)) continue
+ val cellMin = minOf(minOf(za, zb), minOf(zc, zd))
+ val cellMax = maxOf(maxOf(za, zb), maxOf(zc, zd))
+ val kLo = (ceil((cellMin - zMin).toDouble() * toLevelIndex).toInt() - 2).coerceAtLeast(0)
+ val kHi = floor((cellMax - zMin).toDouble() * toLevelIndex).toInt().coerceAtMost(levelCount - 1)
+ for (k in kLo..kHi) {
+ val level = levels[k]
+ if (level < cellMin || level > cellMax) continue
+ val ax = xAt(xs, a, j, rows, cols)
+ val ay = yAt(ys, a, i, rows, cols)
+ val bx = xAt(xs, b, j + 1, rows, cols)
+ val by = yAt(ys, b, i, rows, cols)
+ val cx = xAt(xs, c, j + 1, rows, cols)
+ val cy = yAt(ys, c, i + 1, rows, cols)
+ val dx = xAt(xs, d, j, rows, cols)
+ val dy = yAt(ys, d, i + 1, rows, cols)
+ var hitCount = 0
+ hitCount = addHit(hits, hitCount, za, zb, ax, ay, bx, by, level)
+ hitCount = addHit(hits, hitCount, zb, zc, bx, by, cx, cy, level)
+ hitCount = addHit(hits, hitCount, zc, zd, cx, cy, dx, dy, level)
+ hitCount = addHit(hits, hitCount, zd, za, dx, dy, ax, ay, level)
+ if (hitCount == 2) {
+ if (count + 4 > lines.size) lines = lines.copyOf(lines.size * 2)
+ lines[count++] = hits[0].toFloat()
+ lines[count++] = hits[1].toFloat()
+ lines[count++] = hits[2].toFloat()
+ lines[count++] = hits[3].toFloat()
+ }
+ }
+ }
+ }
+ return lines.copyOf(count)
+ }
+
+ private fun addHit(
+ hits: DoubleArray,
+ count: Int,
+ z1: Float,
+ z2: Float,
+ x1: Double,
+ y1: Double,
+ x2: Double,
+ y2: Double,
+ level: Float,
+ ): Int {
+ if (count >= 4) return count
+ if ((z1 <= level && level <= z2) || (z2 <= level && level <= z1)) {
+ val t = if (z2 == z1) 0.5 else ((level - z1) / (z2 - z1)).toDouble()
+ hits[count * 2] = x1 + (x2 - x1) * t
+ hits[count * 2 + 1] = y1 + (y2 - y1) * t
+ return count + 1
+ }
+ return count
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt b/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt
new file mode 100644
index 0000000..d1878ff
--- /dev/null
+++ b/app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt
@@ -0,0 +1,71 @@
+package com.paruh.maxmath.ui.console
+
+import android.content.Context
+import android.net.Uri
+import java.io.File
+
+/**
+ * 脚本仓库:Octave 运行沙箱内的 .m 文件。
+ * SAF 导入/导出由界面层调用 [importFrom]/[exportTo] 完成,运行目录始终固定。
+ */
+class ScriptStore(context: Context) {
+
+ private val scriptsDir = File(context.filesDir, "octave/work/scripts").apply { mkdirs() }
+
+ data class ScriptFile(val name: String, val size: Long, val modified: Long)
+
+ fun list(): List =
+ scriptsDir.listFiles { f -> f.isFile && f.extension == "m" }
+ ?.map { ScriptFile(it.name, it.length(), it.lastModified()) }
+ ?.sortedByDescending { it.modified }
+ ?: emptyList()
+
+ fun read(name: String): String {
+ val f = file(name) ?: return ""
+ return if (f.exists()) f.readText() else ""
+ }
+
+ fun save(name: String, content: String): String {
+ val safe = sanitize(name)
+ File(scriptsDir, safe).writeText(content)
+ return safe
+ }
+
+ fun delete(name: String) {
+ file(name)?.delete()
+ }
+
+ /** 通过 SAF 导入:把外部文件内容复制进沙箱(保留原文件名)。 */
+ fun importFrom(context: Context, uri: Uri): String? {
+ val name = uri.lastPathSegment?.substringAfterLast('/')?.ifBlank { null }
+ ?: return null
+ val safe = sanitize(name)
+ val target = File(scriptsDir, safe)
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ target.outputStream().use { output -> input.copyTo(output) }
+ } ?: return null
+ return safe
+ }
+
+ /** 通过 SAF 导出:把沙箱脚本内容写到用户选择的 URI。 */
+ fun exportTo(context: Context, name: String, uri: Uri): Boolean {
+ val f = file(name) ?: return false
+ context.contentResolver.openOutputStream(uri)?.use { output ->
+ f.inputStream().use { input -> input.copyTo(output) }
+ } ?: return false
+ return true
+ }
+
+ private fun file(name: String): File? {
+ val safe = sanitize(name)
+ val f = File(scriptsDir, safe)
+ return if (f.exists()) f else null
+ }
+
+ private fun sanitize(name: String): String {
+ val safe = name.replace(Regex("[^A-Za-z0-9_.\\-]"), "_")
+ return safe.ifBlank { "script.m" }.let {
+ if (it.endsWith(".m")) it else "$it.m"
+ }
+ }
+}
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
index 6d05be7..aca4627 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/GlGestureMath.kt
@@ -5,7 +5,7 @@ import com.paruh.maxmath.ui.plot.gl.GlViewState
/**
* GL 绘图手势 → 视图状态换算(纯函数,可单测)。
- * 3D:单指拖动旋转(上下方向与屏幕一致:上移 = 仰角增大)、双指捏合缩放;
+ * 3D:单指拖动旋转(物体跟随手指方向)、双指捏合缩放;
* 等高线:拖动平移、捏合缩放。
*/
object GlGestureMath {
@@ -15,11 +15,20 @@ object GlGestureMath {
private const val MAX_ZOOM = 8f
fun apply3d(state: GlViewState, pan: Offset, zoom: Float): GlViewState = state.copy(
- azimuthDeg = state.azimuthDeg + pan.x * DEGREES_PER_PIXEL,
- elevationDeg = (state.elevationDeg - pan.y * DEGREES_PER_PIXEL).coerceIn(0f, 180f),
+ azimuthDeg = wrapDegrees(state.azimuthDeg + pan.x * DEGREES_PER_PIXEL),
+ elevationDeg = wrapDegrees(state.elevationDeg + pan.y * DEGREES_PER_PIXEL),
zoom = (state.zoom * zoom).coerceIn(MIN_ZOOM, MAX_ZOOM),
)
+ /**
+ * 把欧拉角限制到一个稳定周期,但不设置旋转端点。
+ * 旧实现把仰角夹在 0..180 度,手指到达两端后继续拖动不会再有响应。
+ */
+ internal fun wrapDegrees(value: Float): Float {
+ val wrapped = (value + 180f) % 360f
+ return (if (wrapped < 0f) wrapped + 360f else wrapped) - 180f
+ }
+
fun applyContour(state: GlViewState, pan: Offset, zoom: Float, imgW: Float, imgH: Float): GlViewState =
state.copy(
panX = state.panX + pan.x / imgW * 2f * (imgW / imgH),
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
index 5763ab3..fc78b3d 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/Plot2DPainter.kt
@@ -2,6 +2,7 @@ package com.paruh.maxmath.ui.plot
import android.graphics.Bitmap
import android.graphics.Paint as AndroidPaint
+import androidx.core.graphics.createBitmap
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -200,7 +201,7 @@ object Plot2DPainter {
textSizePx: Float = 12f,
palette: PlotPalette = PlotPalette.Light,
): Bitmap {
- val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(android.graphics.Canvas(bitmap))
draw(
canvas,
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
index ff8ac6b..4083850 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/PlotViewModel.kt
@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import com.paruh.maxmath.engine.CalcRequest
import com.paruh.maxmath.engine.CalcResponse
import com.paruh.maxmath.engine.EngineClient
-import com.paruh.maxmath.engine.MaximaEngine
import com.paruh.maxmath.engine.PlotAnnotations
import com.paruh.maxmath.engine.PlotKind
import com.paruh.maxmath.engine.PlotTask
@@ -54,10 +53,11 @@ class EnginePlotEngine(context: Context) : PlotEngine {
*/
class PlotViewModel(
private val engine: PlotEngine,
- private val context: Context,
+ context: Context,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel() {
+ private val resources = context.applicationContext.resources
private val _state = MutableStateFlow(PlotUiState())
val state: StateFlow = _state.asStateFlow()
@@ -84,9 +84,6 @@ class PlotViewModel(
job?.cancel()
// 迟到的重采样会把用户刚输入的范围盖回旧值。
resampleJob?.cancel()
- if (task.kind == PlotKind.PLOT_2D) {
- MaximaEngine.cancel()
- }
// 只改入口状态,不清空图像:重绘期间上一张图继续留在屏幕上。
// 完成时 regenerate2d/regenerateGl 仍整体赋值一个新的 PlotUiState,
// 这正是 2D↔3D 切换能丢掉另一种模式残留产物的原因,不要改成 copy。
@@ -103,7 +100,6 @@ class PlotViewModel(
fun cancel() {
job?.cancel()
resampleJob?.cancel()
- MaximaEngine.cancel()
_state.update { it.copy(loading = false) }
}
@@ -124,7 +120,7 @@ class PlotViewModel(
} catch (e: Exception) {
_state.value = PlotUiState(
loading = false,
- error = e.message ?: context.getString(R.string.error_plot_failed),
+ error = e.message ?: resources.getString(R.string.error_plot_failed),
)
return
}
@@ -139,7 +135,7 @@ class PlotViewModel(
} else {
PlotUiState(
loading = false,
- error = response.error ?: context.getString(R.string.error_plot_failed),
+ error = response.error ?: resources.getString(R.string.error_plot_failed),
)
}
}
@@ -157,7 +153,7 @@ class PlotViewModel(
val range = parseRange(task).getOrElse { e ->
_state.value = PlotUiState(
loading = false,
- error = e.message ?: context.getString(R.string.error_plot_params_invalid),
+ error = e.message ?: resources.getString(R.string.error_plot_params_invalid),
)
return
}
@@ -204,9 +200,9 @@ class PlotViewModel(
private fun parseRange(task: PlotTask): Result = runCatching {
fun number(raw: String, invalidRes: Int): Double =
raw.trim().toDoubleOrNull()
- ?: throw IllegalArgumentException(context.getString(invalidRes, raw.trim()))
+ ?: throw IllegalArgumentException(resources.getString(invalidRes, raw.trim()))
fun greaterThan(large: Double, small: Double, orderRes: Int) {
- require(large > small) { context.getString(orderRes) }
+ require(large > small) { resources.getString(orderRes) }
}
val xMin = number(task.xMin, R.string.error_plot_xmin_invalid)
val xMax = number(task.xMax, R.string.error_plot_xmax_invalid)
@@ -218,7 +214,7 @@ class PlotViewModel(
}
private fun expressionError(e: Exception): String =
- context.getString(R.string.error_plot_expression_invalid, e.message ?: "")
+ resources.getString(R.string.error_plot_expression_invalid, e.message ?: "")
private fun annotationsFrom(extra: JSONObject?): PlotAnnotations {
// 引擎标注是辅助信息:格式异常时降级为空标注,绝不能让整张图失败。
diff --git a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt
index 65d62a4..c415916 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/plot/gl/GlyphAtlas.kt
@@ -6,6 +6,7 @@ import android.graphics.Color
import android.graphics.Paint
import android.opengl.GLES20
import android.opengl.GLUtils
+import androidx.core.graphics.createBitmap
/**
* 坐标轴数值标签用的字形图集:一张纹理,加上排版用的 [metrics]。
@@ -64,7 +65,7 @@ internal class GlyphAtlas private constructor(
val atlasWidth = nextPowerOfTwo(kotlin.math.ceil(x).toInt().coerceAtLeast(1))
val atlasHeight = nextPowerOfTwo(kotlin.math.ceil(cellHeight).toInt().coerceAtLeast(1))
- val bitmap = Bitmap.createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888)
+ val bitmap = createBitmap(atlasWidth, atlasHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val baseline = padding - fm.top
for (i in 0 until n) {
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
index 67628c8..4084b14 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/HomeScreen.kt
@@ -52,6 +52,7 @@ private data class ModuleEntry(
)
private val modules = listOf(
+ ModuleEntry(Routes.CONSOLE, ModeIcons.Console, R.string.module_console, R.string.module_console_desc),
ModuleEntry(Routes.MATRIX, ModeIcons.Matrix, R.string.module_matrix, R.string.module_matrix_desc),
ModuleEntry(Routes.SYSTEM, ModeIcons.Equations, R.string.module_system, R.string.module_system_desc),
ModuleEntry(Routes.POLYNOMIAL, ModeIcons.Polynomial, R.string.module_polynomial, R.string.module_polynomial_desc),
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
index 2136fae..f829f18 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/MatrixScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -38,8 +39,8 @@ fun MatrixScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
val context = LocalContext.current
val state by vm.state.collectAsState()
- var rows by rememberSaveable { mutableStateOf(2) }
- var cols by rememberSaveable { mutableStateOf(2) }
+ var rows by rememberSaveable { mutableIntStateOf(2) }
+ var cols by rememberSaveable { mutableIntStateOf(2) }
var op by rememberSaveable { mutableStateOf(MatrixKind.DET) }
var advanced by rememberSaveable { mutableStateOf(false) }
var rawText by rememberSaveable { mutableStateOf("") }
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
index 0b0fee8..38e0a63 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/PlotScreen.kt
@@ -285,7 +285,14 @@ fun PlotScreen(onBack: () -> Unit, viewModelOverride: PlotViewModel? = null) {
// 刻意保持原样传 CalcUiState(loading, error):response 恒为 null,
// 绘图页复用的就是这张标准进度/错误卡片,PlotScreenStateTest 盯着这点。
ResultCard(
- state = CalcUiState(loading = state.loading, error = state.error),
+ state = CalcUiState(
+ activity = if (state.loading) {
+ com.paruh.maxmath.ui.CalcActivity.Running("plot")
+ } else {
+ com.paruh.maxmath.ui.CalcActivity.Idle
+ },
+ error = state.error,
+ ),
onCopyTex = {},
onCopyPlain = {},
)
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
index 602d57f..b57823f 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/QuadraticFormScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -35,7 +36,7 @@ fun QuadraticFormScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
val context = LocalContext.current
val state by vm.state.collectAsState()
- var n by rememberSaveable { mutableStateOf(2) }
+ var n by rememberSaveable { mutableIntStateOf(2) }
var op by rememberSaveable { mutableStateOf(MatrixKind.QUAD_EXPAND) }
var advanced by rememberSaveable { mutableStateOf(false) }
var variables by rememberSaveable { mutableStateOf("x1,x2") }
diff --git a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
index 6cd7656..20a28ea 100644
--- a/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
+++ b/app/src/main/java/com/paruh/maxmath/ui/screens/VectorSpaceScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -34,8 +35,8 @@ fun VectorSpaceScreen(onBack: () -> Unit) {
val vm: CalcViewModel = viewModel()
val context = LocalContext.current
val state by vm.state.collectAsState()
- var dims by rememberSaveable { mutableStateOf(2) }
- var count by rememberSaveable { mutableStateOf(2) }
+ var dims by rememberSaveable { mutableIntStateOf(2) }
+ var count by rememberSaveable { mutableIntStateOf(2) }
var op by rememberSaveable { mutableStateOf(VectorKind.INNER) }
var advanced by rememberSaveable { mutableStateOf(false) }
var cells by rememberSaveable(stateSaver = MatrixCellsSaver) {
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index a8a8fa5..5c84730 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,5 @@
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 0cc9f57..92cd6e5 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -4,6 +4,8 @@
MaxMath
Higher algebra & plotting powered by Maxima
+ Octave Console
+ MATLAB-style CLI, workspace and .m scripts
Matrices
Determinant, inverse, transpose, rank, trace
Equations
@@ -31,12 +33,61 @@
Show details
Hide details
Copy error
- Starting the engine, this may take a few seconds
+ Preparing the Maxima engine…
+ Cancelling calculation…
Image generated
Decrease %1$s
Increase %1$s
Advanced mode (raw Maxima syntax)
+ Console
+ Workspace
+ Scripts
+ Enter an Octave/MATLAB command
+ Run
+ Variable preview
+ LaTeX
+ Workspace variables (%1$d)
+ Workspace is empty
+ Refresh
+ Clear
+ Delete variable
+ Variable details
+ This variable no longer exists. Return and refresh the workspace.
+ Loading variable…
+ Retry
+ Copy value
+ This variable is large, so only a summary or truncated value is shown.
+ Plot generated
+
+ - %1$d subplot
+ - %1$d subplots
+
+ View plot
+ Octave plot
+ There is no plot to view.
+ Import
+ Export
+ New
+ Run script
+ Delete script
+ Timeout:
+ The console is numeric-only in v1; symbolic commands like syms/solve/diff/int belong to the Calculus, Matrix or Polynomial modes (Maxima engine).
+ Script error at line %1$d
+ Ln %1$d/%3$d, Col %2$d
+ Starting Octave…
+ Octave is running…
+ Cancelling, please wait…
+ Octave runtime installation or integrity check failed
+ Octave dynamic linking failed
+ Octave failed to start
+ Octave timed out
+ Octave exceeded the 1.5 GB memory limit
+ Octave response protocol or process communication failed
+ Unable to connect to the Octave service
+ Calculation cancelled
+ Octave process exited unexpectedly
+
Rows
Columns
Matrix (blank cells are 0)
@@ -75,8 +126,6 @@
Equations
e.g. x+y=1; 2x-y=3
Dimension
- Vector u
- Vector v
Vectors
Variables (default x1,x2,…)
u₁ … uₙ
@@ -100,7 +149,6 @@
Tap image for coordinates
Function plot
- Engine not ready: %1$s
Language
Follow system
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b2f7588..e556a5d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -5,6 +5,8 @@
MaxMath
基于 Maxima 的高等代数计算与绘图
+ Octave 控制台
+ MATLAB 风格命令行、工作区与 .m 脚本
矩阵
行列式、逆、转置、秩、迹
方程组
@@ -32,12 +34,60 @@
展开详情
收起详情
复制错误信息
- 首次计算需启动引擎,请稍候
+ 正在准备 Maxima 引擎…
+ 正在取消计算…
图像已生成
减少%1$s
增加%1$s
高级模式(直接输入 Maxima 语法)
+ 控制台
+ 工作区
+ 脚本
+ 输入 Octave/MATLAB 命令,回车发送
+ 运行
+ 变量预览
+ LaTeX
+ 工作区变量(%1$d)
+ 工作区为空
+ 刷新
+ 清空
+ 删除变量
+ 变量详情
+ 变量已不存在,请返回工作区刷新。
+ 正在读取变量…
+ 重试
+ 复制值
+ 变量过大,仅显示摘要或截断后的内容。
+ 图表已生成
+
+ - %1$d 个子图
+
+ 查看图表
+ Octave 图表
+ 当前没有可查看的图表。
+ 导入
+ 导出
+ 新建
+ 运行脚本
+ 删除脚本
+ 超时:
+ 控制台首版为数值计算专用;syms/solve/diff/int 等符号命令请使用微积分、矩阵或多项式模式(Maxima 引擎)。
+ 脚本错误定位:第 %1$d 行
+ 第 %1$d/%3$d 行,第 %2$d 列
+ 正在启动 Octave…
+ Octave 正在运行…
+ 正在取消,请稍候…
+ Octave 运行时安装或完整性校验失败
+ Octave 动态链接失败
+ Octave 启动失败
+ Octave 计算超时
+ Octave 超出 1.5 GB 内存上限
+ Octave 响应协议或进程通信失败
+ Octave 服务连接失败
+ 计算已取消
+ Octave 进程意外退出
+
行
列
矩阵(空白格按 0 处理)
@@ -76,8 +126,6 @@
方程组
例如 x+y=1; 2x-y=3
维数
- 向量 u
- 向量 v
向量组
变量(默认 x1,x2,…)
u₁ … uₙ
@@ -101,7 +149,6 @@
点击图像查看坐标
函数图像
- 引擎未就绪:%1$s
语言
跟随系统
diff --git a/app/src/test/kotlin/com/paruh/maxmath/MainActivitySoftInputModeTest.kt b/app/src/test/kotlin/com/paruh/maxmath/MainActivitySoftInputModeTest.kt
new file mode 100644
index 0000000..87fc3af
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/MainActivitySoftInputModeTest.kt
@@ -0,0 +1,31 @@
+package com.paruh.maxmath
+
+import android.content.ComponentName
+import android.content.Context
+import android.view.WindowManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class MainActivitySoftInputModeTest {
+
+ @Test
+ @Suppress("DEPRECATION")
+ fun mainActivityResizesForSoftwareKeyboard() {
+ val context = ApplicationProvider.getApplicationContext()
+ val activityInfo = context.packageManager.getActivityInfo(
+ ComponentName(context, MainActivity::class.java),
+ 0,
+ )
+
+ assertEquals(
+ WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE,
+ activityInfo.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt
index ec45384..70006dc 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerTest.kt
@@ -13,6 +13,7 @@ import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
+import org.robolectric.shadows.ShadowBuild
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileNotFoundException
@@ -30,30 +31,40 @@ import java.io.FileNotFoundException
@Config(sdk = [34])
class EngineInstallerTest {
- /** 铺一份假的 nativeLibraryDir:jniLibs 在真机由系统解压,测试里没有。 */
- private fun stageNativeLibDir(ctx: Context): File =
- File(ctx.cacheDir, "native-libs").apply {
+ /** 把本地已验证的 arm64 JNI 载荷铺成 Robolectric 的 nativeLibraryDir。 */
+ private fun stagePackagedNativeLibDir(ctx: Context): File {
+ val root = generateSequence(File(System.getProperty("user.dir") ?: ".")) { it.parentFile }
+ .take(8)
+ .firstOrNull { File(it, "app/src/main/jniLibs/arm64-v8a/libmaxima.so").isFile }
+ assumeTrue("源码检出未包含本地生成的 Maxima JNI 载荷", root != null)
+ val source = File(root!!, "app/src/main/jniLibs/arm64-v8a")
+ return File(ctx.cacheDir, "native-libs").apply {
deleteRecursively()
mkdirs()
- File(this, "libmaxima.so").apply {
- writeText("maxima")
+ File(source, "libmaxima.so").copyTo(File(this, "libmaxima.so"), overwrite = true).apply {
setExecutable(true, false)
}
- File(this, "libecl.so").writeText("ecl")
+ File(source, "libecl.so").copyTo(File(this, "libecl.so"), overwrite = true)
ctx.applicationInfo.nativeLibraryDir = absolutePath
}
+ }
+
+ private fun requirePackagedRuntime(ctx: Context) {
+ val present = runCatching {
+ ctx.assets.open("engine/runtime-manifest.json").close()
+ ctx.assets.open("engine/runtime.zip").close()
+ true
+ }.getOrDefault(false)
+ assumeTrue("源码检出未包含本地生成的 Maxima/ECL 运行时", present)
+ }
@Test
fun freshInstallExtractsEngineAssetsAndLocatesMaxima() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
val ctx = ApplicationProvider.getApplicationContext()
- val packagedEnginePresent = runCatching {
- !ctx.assets.list("engine").isNullOrEmpty()
- }.getOrDefault(false)
- assumeTrue(
- "源码检出未包含本地生成的 Maxima/ECL 运行时,跳过打包资产集成测试",
- packagedEnginePresent,
- )
- stageNativeLibDir(ctx)
+ requirePackagedRuntime(ctx)
+ File(ctx.filesDir, "engine").deleteRecursively()
+ stagePackagedNativeLibDir(ctx)
val result = EngineInstaller.install(ctx)
assertTrue(
@@ -62,7 +73,7 @@ class EngineInstallerTest {
)
result as EngineInstaller.InstallResult.Success
- val workDir = File(result.workDir)
+ val runtimeDir = File(result.runtimeDir)
assertTrue("Maxima 二进制应存在", File(result.maximaPath).exists())
assertTrue("init.lisp 应生成", File(result.initLispPath).exists())
assertTrue("libDir 应非空", result.libDir.isNotBlank())
@@ -73,20 +84,21 @@ class EngineInstallerTest {
)
assertTrue(
"share 目录应使用 autoconf 布局(share/maxima//share)",
- File(workDir, "share/maxima/5.49.0/share").isDirectory,
+ File(runtimeDir, "share/maxima/5.49.0/share").isDirectory,
)
assertTrue(
"draw 包应已移除(绘图改由 Matplotlib 渲染)",
- !File(workDir, "share/maxima/5.49.0/share/draw").exists(),
+ !File(runtimeDir, "share/maxima/5.49.0/share/draw").exists(),
)
- assertTrue("lisp-utils 应存在", File(workDir, "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp").exists())
+ assertTrue("linearalgebra 应存在", File(runtimeDir, "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac").exists())
+ assertTrue("lisp-utils 应存在", File(runtimeDir, "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp").exists())
assertTrue(
"init.lisp 不应再引用 gnuplot",
- !File(workDir, "init.lisp").readText().contains("gnuplot"),
+ !File(result.initLispPath).readText().contains("gnuplot"),
)
assertTrue(
- "版本标记应写入,避免每次计算重复解压",
- File(workDir, "engine_version").readText().isNotBlank(),
+ "运行时清单应写入并作为缓存身份",
+ File(runtimeDir, "runtime-manifest.json").readText().contains(result.runtimeId),
)
// 可执行文件只应来自 nativeLibraryDir。assets 里的 binary-ecl/maxima
// 与 lib/libecl.so 同 jniLibs 逐字节相同(合计约 16MB),既进 APK 又
@@ -97,15 +109,15 @@ class EngineInstallerTest {
)
assertTrue(
"binary-ecl/maxima 副本不应再打包",
- !File(workDir, "lib/maxima/5.49.0/binary-ecl/maxima").exists(),
+ !File(runtimeDir, "lib/maxima/5.49.0/binary-ecl/maxima").exists(),
)
assertTrue(
"libecl.so 副本不应再打包进 assets",
- !File(workDir, "lib/libecl.so").exists(),
+ !File(runtimeDir, "lib/libecl.so").exists(),
)
assertTrue(
"PDF 手册不应再打包",
- File(workDir, "share/maxima/5.49.0/share").walkTopDown()
+ File(runtimeDir, "share/maxima/5.49.0/share").walkTopDown()
.none { it.extension == "pdf" },
)
}
@@ -118,9 +130,16 @@ class EngineInstallerTest {
*/
@Test
fun installSucceedsWhenListThrowsForFilePathsLikeRealAndroid() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
val app = ApplicationProvider.getApplicationContext()
- stageNativeLibDir(app)
- val assets = mockAospAssetManager()
+ requirePackagedRuntime(app)
+ File(app.filesDir, "engine").deleteRecursively()
+ stagePackagedNativeLibDir(app)
+ val packaged = mapOf(
+ "engine/runtime-manifest.json" to app.assets.open("engine/runtime-manifest.json").use { it.readBytes() },
+ "engine/runtime.zip" to app.assets.open("engine/runtime.zip").use { it.readBytes() },
+ )
+ val assets = mockAospAssetManager(packaged)
val context = object : ContextWrapper(app) {
override fun getAssets(): AssetManager = assets
}
@@ -128,10 +147,10 @@ class EngineInstallerTest {
val result = EngineInstaller.install(context)
assertTrue("安装应成功:$result", result is EngineInstaller.InstallResult.Success)
- val workDir = File((result as EngineInstaller.InstallResult.Success).workDir)
- assertTrue("init.lisp.template 文件应被解压", File(workDir, "init.lisp.template").exists())
- assertTrue("ECL .fas 文件应被解压", File(workDir, "lib/ecl-26.3.27/sb-bsd-sockets.fas").exists())
- assertTrue("share 文件应被解压", File(workDir, "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp").exists())
+ val runtimeDir = File((result as EngineInstaller.InstallResult.Success).runtimeDir)
+ assertTrue("init.lisp.template 文件应被解压", File(runtimeDir, "init.lisp.template").exists())
+ assertTrue("ECL .fas 文件应被解压", File(runtimeDir, "lib/ecl-26.3.27/sb-bsd-sockets.fas").exists())
+ assertTrue("share 文件应被解压", File(runtimeDir, "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp").exists())
}
/**
@@ -140,7 +159,10 @@ class EngineInstallerTest {
*/
@Test
fun installFailsClearlyWhenNativeBinaryMissing() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
val ctx = ApplicationProvider.getApplicationContext()
+ requirePackagedRuntime(ctx)
+ File(ctx.filesDir, "engine").deleteRecursively()
val emptyNativeDir = File(ctx.cacheDir, "empty-native-libs").apply {
deleteRecursively()
mkdirs()
@@ -161,35 +183,11 @@ class EngineInstallerTest {
* 模拟 AOSP 真机 AssetManager:list() 对文件路径抛 FileNotFoundException,
* 对目录返回子项。这是与 Robolectric 行为(文件返回空数组)的关键差异。
*/
-private fun mockAospAssetManager(): AssetManager {
- // 与 package-engine.sh 的产物保持一致:assets 只放数据,可执行文件
- // (maxima、libecl.so)走 jniLibs,不再有 binary-ecl / additions。
- val dirs = mapOf(
- "engine" to listOf("init.lisp.template", "lib", "share"),
- "engine/lib" to listOf("ecl-26.3.27", "maxima"),
- "engine/lib/ecl-26.3.27" to listOf("encodings", "sb-bsd-sockets.fas"),
- "engine/lib/ecl-26.3.27/encodings" to emptyList(),
- "engine/lib/maxima" to listOf("5.49.0"),
- "engine/lib/maxima/5.49.0" to emptyList(),
- "engine/share" to listOf("maxima"),
- "engine/share/maxima" to listOf("5.49.0"),
- "engine/share/maxima/5.49.0" to listOf("share"),
- "engine/share/maxima/5.49.0/share" to listOf("lisp-utils"),
- "engine/share/maxima/5.49.0/share/lisp-utils" to listOf("defsystem.lisp"),
- )
- val files = mapOf(
- "engine/init.lisp.template" to "template".toByteArray(),
- "engine/lib/ecl-26.3.27/sb-bsd-sockets.fas" to "fas".toByteArray(),
- "engine/share/maxima/5.49.0/share/lisp-utils/defsystem.lisp" to "lisp".toByteArray(),
- )
-
+private fun mockAospAssetManager(files: Map): AssetManager {
val assets = Mockito.mock(AssetManager::class.java)
Mockito.`when`(assets.list(anyString())).thenAnswer { invocation ->
val path = invocation.getArgument(0)
- if (files.containsKey(path)) {
- throw FileNotFoundException("$path is a file")
- }
- dirs[path]?.toTypedArray() ?: throw FileNotFoundException("$path does not exist")
+ throw FileNotFoundException("$path is not a directory")
}
Mockito.`when`(assets.open(anyString())).thenAnswer { invocation ->
val path = invocation.getArgument(0)
diff --git a/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerUpgradeTest.kt b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerUpgradeTest.kt
new file mode 100644
index 0000000..368b2ad
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerUpgradeTest.kt
@@ -0,0 +1,226 @@
+package com.paruh.maxmath.engine
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.AssetManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.security.MessageDigest
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowBuild
+
+/** Upgrade behavior at the public installer boundary. */
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class EngineInstallerUpgradeTest {
+
+ @Test
+ fun sameRuntimeIdWithMissingLinearAlgebraIsRepairedWithoutDeletingUserFiles() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
+ val app = ApplicationProvider.getApplicationContext()
+ val nativeDir = File(app.cacheDir, "engine-upgrade-native").apply {
+ deleteRecursively()
+ mkdirs()
+ }
+ val maxima = File(nativeDir, "libmaxima.so").apply { writeText("maxima-binary") }
+ val ecl = File(nativeDir, "libecl.so").apply { writeText("ecl-binary") }
+ app.applicationInfo.nativeLibraryDir = nativeDir.absolutePath
+
+ val fixture = runtimeFixture(maxima, ecl)
+ val root = File(app.filesDir, "engine").apply {
+ deleteRecursively()
+ mkdirs()
+ }
+ val userFile = File(root, "user/keep.mac").apply {
+ parentFile!!.mkdirs()
+ writeText("keep-me")
+ }
+ // This is the state produced by a same-version partial/corrupt install: the marker says
+ // current, but a module required by determinant()/invert() is absent.
+ File(root, "runtime").mkdirs()
+ File(root, "runtime/runtime-manifest.json").writeText(fixture.manifest)
+
+ val context = object : ContextWrapper(app) {
+ override fun getAssets(): AssetManager = fixture.assets
+ }
+
+ val result = EngineInstaller.install(context)
+
+ assertTrue("install should repair the runtime: $result", result is EngineInstaller.InstallResult.Success)
+ assertTrue(
+ File(
+ root,
+ "runtime/" +
+ "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ ).isFile,
+ )
+ assertEquals("keep-me", userFile.readText())
+ }
+
+ @Test
+ fun sameSizeRuntimeCorruptionIsDetectedAndRepaired() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
+ val app = ApplicationProvider.getApplicationContext()
+ val nativeDir = File(app.cacheDir, "engine-corrupt-native").apply {
+ deleteRecursively()
+ mkdirs()
+ }
+ val maxima = File(nativeDir, "libmaxima.so").apply { writeText("maxima-binary") }
+ val ecl = File(nativeDir, "libecl.so").apply { writeText("ecl-binary") }
+ app.applicationInfo.nativeLibraryDir = nativeDir.absolutePath
+ val fixture = runtimeFixture(maxima, ecl)
+ val root = File(app.filesDir, "engine").apply { deleteRecursively() }
+ val context = object : ContextWrapper(app) {
+ override fun getAssets(): AssetManager = fixture.assets
+ }
+
+ assertTrue(EngineInstaller.install(context) is EngineInstaller.InstallResult.Success)
+ val module = File(
+ root,
+ "runtime/share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ )
+ val original = module.readBytes()
+ module.writeBytes(ByteArray(original.size) { 'x'.code.toByte() })
+
+ val repaired = EngineInstaller.install(context)
+
+ assertTrue("same-size corruption should trigger repair: $repaired", repaired is EngineInstaller.InstallResult.Success)
+ assertTrue("module bytes should be restored", original.contentEquals(module.readBytes()))
+ }
+
+ @Test
+ fun corruptUpgradeArchiveRollsBackAndPreservesUserData() {
+ ShadowBuild.setSupportedAbis(arrayOf("arm64-v8a"))
+ val app = ApplicationProvider.getApplicationContext()
+ val nativeDir = File(app.cacheDir, "engine-rollback-native").apply {
+ deleteRecursively()
+ mkdirs()
+ }
+ val maxima = File(nativeDir, "libmaxima.so").apply { writeText("maxima-binary") }
+ val ecl = File(nativeDir, "libecl.so").apply { writeText("ecl-binary") }
+ app.applicationInfo.nativeLibraryDir = nativeDir.absolutePath
+ val root = File(app.filesDir, "engine").apply { deleteRecursively() }
+ val current = runtimeFixture(maxima, ecl, runtimeDigit = '1')
+ val currentContext = object : ContextWrapper(app) {
+ override fun getAssets(): AssetManager = current.assets
+ }
+ assertTrue(EngineInstaller.install(currentContext) is EngineInstaller.InstallResult.Success)
+ val userFile = File(root, "user/keep.mac").apply {
+ parentFile!!.mkdirs()
+ writeText("keep-me")
+ }
+ val module = File(
+ root,
+ "runtime/share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ )
+ val original = module.readBytes()
+ val brokenUpgrade = runtimeFixture(
+ maxima,
+ ecl,
+ runtimeDigit = '2',
+ corruptPackagedArchive = true,
+ )
+ val brokenContext = object : ContextWrapper(app) {
+ override fun getAssets(): AssetManager = brokenUpgrade.assets
+ }
+
+ val result = EngineInstaller.install(brokenContext)
+
+ assertTrue("corrupt upgrade must fail: $result", result is EngineInstaller.InstallResult.Failure)
+ assertTrue("current runtime must remain usable", original.contentEquals(module.readBytes()))
+ assertEquals("keep-me", userFile.readText())
+ }
+
+ private data class Fixture(val manifest: String, val assets: AssetManager)
+
+ private fun runtimeFixture(
+ maxima: File,
+ ecl: File,
+ runtimeDigit: Char = '1',
+ corruptPackagedArchive: Boolean = false,
+ ): Fixture {
+ val files = linkedMapOf(
+ "init.lisp.template" to "__MAXIMA_DIR__|__TEMP_DIR__|__USER_DIR__".toByteArray(),
+ "lib/ecl-26.3.27/sb-bsd-sockets.fas" to "fas".toByteArray(),
+ "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac" to
+ "linearalgebra".toByteArray(),
+ "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp" to "defsystem".toByteArray(),
+ )
+ val archive = ByteArrayOutputStream().use { bytes ->
+ ZipOutputStream(bytes).use { zip ->
+ files.forEach { (path, value) ->
+ zip.putNextEntry(ZipEntry(path))
+ zip.write(value)
+ zip.closeEntry()
+ }
+ }
+ bytes.toByteArray()
+ }
+ fun record(path: String, value: ByteArray) = JSONObject()
+ .put("path", path)
+ .put("size", value.size)
+ .put("sha256", sha256(value))
+ val manifest = JSONObject()
+ .put("schemaVersion", 1)
+ .put("runtimeId", "sha256:${runtimeDigit.toString().repeat(64)}")
+ .put("maximaVersion", "5.49.0")
+ .put("compiledMaximaVersion", "5.49.0")
+ .put("eclVersion", "26.3.27")
+ .put("abi", "arm64-v8a")
+ .put("archive", record("runtime.zip", archive))
+ .put("files", JSONArray(files.map { (path, value) -> record(path, value) }))
+ .put(
+ "jniFiles",
+ JSONArray(
+ listOf(
+ record("libmaxima.so", maxima.readBytes()),
+ record("libecl.so", ecl.readBytes()),
+ ),
+ ),
+ )
+ .toString()
+
+ val assets = Mockito.mock(AssetManager::class.java)
+ val packagedArchive = if (corruptPackagedArchive) {
+ archive.copyOf().also { bytes ->
+ bytes[bytes.lastIndex] = (bytes.last().toInt() xor 1).toByte()
+ }
+ } else {
+ archive
+ }
+ val assetFiles = mapOf(
+ "engine/runtime-manifest.json" to manifest.toByteArray(),
+ "engine/runtime.zip" to packagedArchive,
+ )
+ Mockito.`when`(assets.list(anyString())).thenAnswer { invocation ->
+ when (val path = invocation.getArgument(0)) {
+ "engine" -> arrayOf("runtime-manifest.json", "runtime.zip")
+ in assetFiles -> throw FileNotFoundException("$path is a file")
+ else -> throw FileNotFoundException(path)
+ }
+ }
+ Mockito.`when`(assets.open(anyString())).thenAnswer { invocation ->
+ val path = invocation.getArgument(0)
+ assetFiles[path]?.let(::ByteArrayInputStream) ?: throw FileNotFoundException(path)
+ }
+ return Fixture(manifest, assets)
+ }
+
+ private fun sha256(value: ByteArray): String = MessageDigest.getInstance("SHA-256")
+ .digest(value)
+ .joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/engine/OctaveInstallerTest.kt b/app/src/test/kotlin/com/paruh/maxmath/engine/OctaveInstallerTest.kt
new file mode 100644
index 0000000..db87aed
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/engine/OctaveInstallerTest.kt
@@ -0,0 +1,265 @@
+package com.paruh.maxmath.engine
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.AssetManager
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.security.MessageDigest
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.Mockito
+import org.robolectric.annotation.Config
+import org.robolectric.util.ReflectionHelpers
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class OctaveInstallerTest {
+
+ @Test
+ fun sameOctaveVersionWithNewRuntimeIdUpdatesBridgeAndPreservesUserData() {
+ arm64Only()
+ val root = freshRoot("octave-installer-update")
+ val nativeDir = stageNativeRuntime(root)
+ val first = context(root, nativeDir, runtimeAssets(runtimeId('a'), "old bridge"))
+ val firstResult = OctaveInstaller.install(first)
+ assertTrue(firstResult.toString(), firstResult is OctaveInstaller.InstallResult.Success)
+ val firstInfo = (firstResult as OctaveInstaller.InstallResult.Success).info
+ File(firstInfo.workDir, "scripts/lesson.m").writeText("A = 1")
+ File(firstInfo.homeDir, ".octaverc").writeText("format long")
+
+ val second = context(root, nativeDir, runtimeAssets(runtimeId('b'), "fixed bridge"))
+ val secondResult = OctaveInstaller.install(second)
+
+ assertTrue(secondResult.toString(), secondResult is OctaveInstaller.InstallResult.Success)
+ val info = (secondResult as OctaveInstaller.InstallResult.Success).info
+ assertEquals("fixed bridge", File(info.bridgeDir, "maxmath_init.m").readText())
+ assertEquals("A = 1", File(info.workDir, "scripts/lesson.m").readText())
+ assertEquals("format long", File(info.homeDir, ".octaverc").readText())
+ assertEquals(runtimeId('b'), info.runtimeId)
+ }
+
+ @Test
+ fun corruptUpgradeRollsBackWithoutTouchingUserData() {
+ arm64Only()
+ val root = freshRoot("octave-installer-rollback")
+ val nativeDir = stageNativeRuntime(root)
+ val first = context(root, nativeDir, runtimeAssets(runtimeId('a'), "working bridge"))
+ val firstInfo = (OctaveInstaller.install(first) as OctaveInstaller.InstallResult.Success).info
+ val script = File(firstInfo.workDir, "scripts/keep.m").apply { writeText("keep = 1") }
+ val corrupt = runtimeAssets(runtimeId('b'), "broken bridge").toMutableMap()
+ val manifest = JSONObject(corrupt.getValue("octave/runtime-manifest.json").decodeToString())
+ manifest.getJSONArray("files").getJSONObject(0)
+ .put("sha256", "0".repeat(64))
+ corrupt["octave/runtime-manifest.json"] = manifest.toString().toByteArray()
+
+ val result = OctaveInstaller.install(context(root, nativeDir, corrupt))
+
+ assertTrue(result.toString(), result is OctaveInstaller.InstallResult.Failure)
+ assertEquals("working bridge", File(firstInfo.bridgeDir, "maxmath_init.m").readText())
+ assertEquals("keep = 1", script.readText())
+ }
+
+ @Test
+ fun sameSizeNativeCorruptionIsRejectedByHash() {
+ arm64Only()
+ val root = freshRoot("octave-installer-native-hash")
+ val nativeDir = stageNativeRuntime(root)
+ File(nativeDir, "liboctave.so").writeText("X".repeat("liboctave.so".length))
+
+ val result = OctaveInstaller.install(
+ context(root, nativeDir, runtimeAssets(runtimeId('a'), "bridge")),
+ )
+
+ assertTrue(result.toString(), result is OctaveInstaller.InstallResult.Failure)
+ }
+
+ @Test
+ fun interruptedRenameRestoresPreviousBeforeAttemptingUpgrade() {
+ arm64Only()
+ val root = freshRoot("octave-installer-crash-recovery")
+ val nativeDir = stageNativeRuntime(root)
+ val first = context(root, nativeDir, runtimeAssets(runtimeId('a'), "working bridge"))
+ val firstInfo = (OctaveInstaller.install(first) as OctaveInstaller.InstallResult.Success).info
+ File(firstInfo.workDir, "keep.m").writeText("keep = 7")
+ val octaveRoot = File(root, "files/octave")
+ val runtime = File(octaveRoot, "runtime")
+ val previous = File(octaveRoot, ".runtime-previous")
+ assertTrue(runtime.renameTo(previous))
+
+ val corrupt = runtimeAssets(runtimeId('b'), "broken bridge").toMutableMap()
+ val manifest = JSONObject(corrupt.getValue("octave/runtime-manifest.json").decodeToString())
+ manifest.getJSONArray("files").getJSONObject(0).put("sha256", "0".repeat(64))
+ corrupt["octave/runtime-manifest.json"] = manifest.toString().toByteArray()
+
+ val result = OctaveInstaller.install(context(root, nativeDir, corrupt))
+
+ assertTrue(result.toString(), result is OctaveInstaller.InstallResult.Failure)
+ assertEquals("working bridge", File(runtime, "maxmath/maxmath_init.m").readText())
+ assertEquals("keep = 7", File(octaveRoot, "work/keep.m").readText())
+ }
+
+ @Test
+ fun diskFullDuringCopyKeepsPreviousRuntimeAndUserHome() {
+ arm64Only()
+ val root = freshRoot("octave-installer-disk-full")
+ val nativeDir = stageNativeRuntime(root)
+ val firstInfo = (OctaveInstaller.install(
+ context(root, nativeDir, runtimeAssets(runtimeId('a'), "working bridge")),
+ ) as OctaveInstaller.InstallResult.Success).info
+ File(firstInfo.homeDir, ".octaverc").writeText("format long")
+ val nextFiles = runtimeAssets(runtimeId('b'), "new bridge")
+ val brokenAssets = mockAssets(nextFiles)
+ Mockito.`when`(brokenAssets.open("octave/maxmath/maxmath_init.m"))
+ .thenThrow(IOException("ENOSPC: No space left on device"))
+
+ val result = OctaveInstaller.install(context(root, nativeDir, brokenAssets))
+
+ assertTrue(result.toString(), result is OctaveInstaller.InstallResult.Failure)
+ assertTrue((result as OctaveInstaller.InstallResult.Failure).message.contains("空间不足"))
+ assertEquals("working bridge", File(firstInfo.bridgeDir, "maxmath_init.m").readText())
+ assertEquals("format long", File(firstInfo.homeDir, ".octaverc").readText())
+ }
+
+ private fun arm64Only() {
+ ReflectionHelpers.setStaticField(Build::class.java, "SUPPORTED_ABIS", arrayOf("arm64-v8a"))
+ }
+
+ private fun freshRoot(name: String): File {
+ val app = ApplicationProvider.getApplicationContext()
+ return File(app.cacheDir, name).apply {
+ deleteRecursively()
+ check(mkdirs())
+ }
+ }
+
+ private fun stageNativeRuntime(root: File): File = File(root, "native").apply {
+ check(mkdirs())
+ NATIVE_NAMES.forEach { name ->
+ File(this, name).apply {
+ writeText(name)
+ if (name == "liboctavebin.so") setExecutable(true, false)
+ }
+ }
+ }
+
+ private fun context(root: File, nativeDir: File, files: Map): Context {
+ return context(root, nativeDir, mockAssets(files))
+ }
+
+ private fun context(root: File, nativeDir: File, assets: AssetManager): Context {
+ val app = ApplicationProvider.getApplicationContext()
+ app.applicationInfo.nativeLibraryDir = nativeDir.absolutePath
+ return object : ContextWrapper(app) {
+ override fun getFilesDir(): File = File(root, "files").apply { mkdirs() }
+ override fun getAssets(): AssetManager = assets
+ }
+ }
+
+ private fun runtimeAssets(runtimeId: String, bridge: String): Map {
+ val payload = linkedMapOf(
+ "maxmath/maxmath_init.m" to bridge.toByteArray(),
+ "usr/share/octave/11.3.0/etc/startup/octaverc" to "startup".toByteArray(),
+ )
+ val records = payload.map { (path, bytes) ->
+ JSONObject()
+ .put("path", path)
+ .put("size", bytes.size)
+ .put("sha256", sha256(bytes))
+ }
+ val jniRecords = NATIVE_NAMES.map { name ->
+ val bytes = name.toByteArray()
+ JSONObject()
+ .put("path", name)
+ .put("size", bytes.size)
+ .put("sha256", sha256(bytes))
+ }
+ val libcxxSha = sha256("libc++_shared.so".toByteArray())
+ val manifest = JSONObject()
+ .put("schemaVersion", 1)
+ .put("runtimeId", runtimeId)
+ .put("octaveVersion", "11.3.0")
+ .put("bridgeVersion", 1)
+ .put("abi", "arm64-v8a")
+ .put(
+ "sourcePackages",
+ JSONArray(
+ listOf(
+ JSONObject()
+ .put("name", "octave")
+ .put("version", "2:11.3.0")
+ .put("filename", "octave.deb")
+ .put("sha256", "a".repeat(64)),
+ JSONObject()
+ .put("name", "libc++")
+ .put("version", "29")
+ .put("filename", "libcxx.deb")
+ .put("sha256", "b".repeat(64)),
+ ),
+ ),
+ )
+ .put(
+ "cxxRuntime",
+ JSONObject()
+ .put("path", "libc++_shared.so")
+ .put("sha256", libcxxSha)
+ .put("packageVersion", "29")
+ .put("compilerMarkers", JSONArray(listOf("Android clang version 21.0.0"))),
+ )
+ .put("files", JSONArray(records))
+ .put("jniFiles", JSONArray(jniRecords))
+ return buildMap {
+ payload.forEach { (path, bytes) -> put("octave/$path", bytes) }
+ put("octave/runtime-manifest.json", manifest.toString().toByteArray())
+ }
+ }
+
+ private fun mockAssets(files: Map): AssetManager {
+ val directories = mutableMapOf>()
+ files.keys.forEach { path ->
+ val parts = path.split('/')
+ for (index in 0 until parts.lastIndex) {
+ val dir = parts.take(index + 1).joinToString("/")
+ directories.getOrPut(dir) { linkedSetOf() }.add(parts[index + 1])
+ }
+ }
+ val assets = Mockito.mock(AssetManager::class.java)
+ Mockito.`when`(assets.list(anyString())).thenAnswer { invocation ->
+ val path = invocation.getArgument(0)
+ if (files.containsKey(path)) throw FileNotFoundException("$path is a file")
+ directories[path]?.toTypedArray() ?: throw FileNotFoundException("$path does not exist")
+ }
+ Mockito.`when`(assets.open(anyString())).thenAnswer { invocation ->
+ val path = invocation.getArgument(0)
+ files[path]?.let(::ByteArrayInputStream)
+ ?: throw FileNotFoundException("$path is not a file")
+ }
+ return assets
+ }
+
+ private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
+ .digest(bytes)
+ .joinToString("") { "%02x".format(it) }
+
+ private fun runtimeId(digit: Char): String = "sha256:" + digit.toString().repeat(64)
+
+ companion object {
+ private val NATIVE_NAMES = listOf(
+ "liboctavebin.so",
+ "liboctave.so",
+ "liboctinterp.so",
+ "liboctmex.so",
+ "libc++_shared.so",
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
index 3014ef9..c3d928d 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/CalcViewModelTest.kt
@@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.paruh.maxmath.engine.CalcRequest
import com.paruh.maxmath.engine.CalcResponse
import com.paruh.maxmath.engine.CalcResult
+import com.paruh.maxmath.engine.CalcEvent
import com.paruh.maxmath.engine.EngineClient
import com.paruh.maxmath.engine.SimplifyTask
import kotlinx.coroutines.CompletableDeferred
@@ -145,7 +146,12 @@ class CalcViewModelTest {
@Volatile
private var gate = CompletableDeferred()
- override suspend fun compute(request: CalcRequest): CalcResponse {
+ override suspend fun compute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Preparing(request.id))
+ onEvent(CalcEvent.Running(request.id))
gate.await()
return CalcResponse(
id = request.id,
@@ -154,6 +160,9 @@ class CalcViewModelTest {
)
}
+ override suspend fun cancel(requestId: String): CalcEvent =
+ CalcEvent.Done(requestId, CalcResponse(requestId, ok = false))
+
/** 开一道新门闩,让下一次 compute 重新挂起。 */
fun reopen() {
gate = CompletableDeferred()
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/ModeIconsTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/ModeIconsTest.kt
index e5e83c9..6ce4dcb 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/ModeIconsTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/ModeIconsTest.kt
@@ -8,8 +8,8 @@ import org.junit.Test
class ModeIconsTest {
@Test
- fun allSevenModeIconsShareConsistentGeometry() {
- assertEquals(7, ModeIcons.all.size)
+ fun allEightModeIconsShareConsistentGeometry() {
+ assertEquals(8, ModeIcons.all.size)
val names = ModeIcons.all.map { it.name }
assertEquals(names.size, names.distinct().size)
ModeIcons.all.forEach { icon ->
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
index f7d1e2a..64a9cf3 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/SystemScreenStateTest.kt
@@ -15,6 +15,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.paruh.maxmath.engine.CalcRequest
import com.paruh.maxmath.engine.CalcResponse
+import com.paruh.maxmath.engine.CalcEvent
import com.paruh.maxmath.engine.EngineClient
import com.paruh.maxmath.ui.screens.SystemScreen
import kotlinx.coroutines.CompletableDeferred
@@ -100,7 +101,12 @@ class SystemScreenStateTest {
androidx.test.core.app.ApplicationProvider.getApplicationContext()
) {
private val gate = CompletableDeferred()
- override suspend fun compute(request: CalcRequest): CalcResponse {
+ override suspend fun compute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Preparing(request.id))
+ onEvent(CalcEvent.Running(request.id))
gate.await()
return CalcResponse(id = request.id, ok = true)
}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleCommandClassifierTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleCommandClassifierTest.kt
new file mode 100644
index 0000000..c28d9df
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleCommandClassifierTest.kt
@@ -0,0 +1,41 @@
+package com.paruh.maxmath.ui.console
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ConsoleCommandClassifierTest {
+
+ @Test
+ fun detectsSymbolicCommands() {
+ assertTrue(isSymbolicCommand("syms x"))
+ assertTrue(isSymbolicCommand("x = solve(x^2-1, x)"))
+ assertTrue(isSymbolicCommand("diff(x^2)"))
+ assertTrue(isSymbolicCommand("int(sin(x), x)"))
+ assertTrue(isSymbolicCommand("limit(sin(x)/x, x, 0)"))
+ }
+
+ @Test
+ fun leavesNumericCommandsAlone() {
+ assertFalse(isSymbolicCommand("A = [1 2; 3 4]"))
+ assertFalse(isSymbolicCommand("plot(x, y)"))
+ assertFalse(isSymbolicCommand("integral(@(x) x.^2, 0, 1)"))
+ assertFalse(isSymbolicCommand("int8(3)"))
+ assertFalse(isSymbolicCommand("x = 1:10; diff(x)"))
+ }
+
+ @Test
+ fun extractsErrorLine() {
+ assertEquals(
+ 7,
+ parseErrorLine("error: 'foo' undefined near line 7, column 3"),
+ )
+ assertEquals(
+ 12,
+ parseErrorLine("error: called from\n script at line 12 column 1"),
+ )
+ assertNull(parseErrorLine("ans = 42"))
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleImeLayoutTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleImeLayoutTest.kt
new file mode 100644
index 0000000..a3e8138
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleImeLayoutTest.kt
@@ -0,0 +1,106 @@
+package com.paruh.maxmath.ui.console
+
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.MutableWindowInsets
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.paruh.maxmath.ui.theme.MaxMathTheme
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.android.controller.ActivityController
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@OptIn(ExperimentalLayoutApi::class)
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34], qualifiers = "en-rUS")
+class ConsoleImeLayoutTest {
+
+ @get:Rule
+ val compose = createEmptyComposeRule()
+
+ private lateinit var activityController: ActivityController
+
+ @Before
+ fun createHostActivity() {
+ activityController = Robolectric.buildActivity(ComponentActivity::class.java).setup()
+ }
+
+ @After
+ fun destroyHostActivity() {
+ activityController.pause().stop().destroy()
+ }
+
+ @Test
+ fun softwareKeyboardKeepsTabsFixedAndResizesContent() {
+ val contentInsets = MutableWindowInsets()
+
+ compose.runOnUiThread {
+ activityController.get().setContent {
+ MaxMathTheme {
+ ConsoleScaffold(
+ selectedTab = 0,
+ onTabSelected = {},
+ onBack = {},
+ contentWindowInsets = contentInsets,
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Box(
+ Modifier
+ .align(Alignment.BottomCenter)
+ .size(1.dp)
+ .testTag("console_test_content_bottom"),
+ )
+ }
+ }
+ }
+ }
+ }
+ compose.waitForIdle()
+
+ val tabsTopBefore = bounds("console_tab_row").top
+ val contentBottomBefore = bounds("console_test_content_bottom").bottom
+
+ compose.runOnIdle {
+ contentInsets.insets = WindowInsets(bottom = IME_HEIGHT_PX)
+ }
+ compose.waitForIdle()
+
+ val tabsTopAfter = bounds("console_tab_row").top
+ val contentBottomAfter = bounds("console_test_content_bottom").bottom
+
+ assertEquals(tabsTopBefore, tabsTopAfter, POSITION_TOLERANCE_PX)
+ assertEquals(
+ contentBottomBefore - IME_HEIGHT_PX,
+ contentBottomAfter,
+ POSITION_TOLERANCE_PX,
+ )
+ }
+
+ private fun bounds(tag: String) = compose
+ .onNodeWithTag(tag, useUnmergedTree = true)
+ .fetchSemanticsNode()
+ .boundsInRoot
+
+ private companion object {
+ const val IME_HEIGHT_PX = 240
+ const val POSITION_TOLERANCE_PX = 1f
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsolePlotNavigationStateTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsolePlotNavigationStateTest.kt
new file mode 100644
index 0000000..67d3697
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsolePlotNavigationStateTest.kt
@@ -0,0 +1,162 @@
+package com.paruh.maxmath.ui.console
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.paruh.maxmath.engine.OctaveEvent
+import com.paruh.maxmath.engine.OctaveFigure
+import com.paruh.maxmath.engine.OctaveGateway
+import com.paruh.maxmath.engine.OctavePreview
+import com.paruh.maxmath.engine.OctaveRequest
+import com.paruh.maxmath.engine.OctaveResponse
+import com.paruh.maxmath.engine.OctaveVariable
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import androidx.compose.ui.unit.dp
+
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34], qualifiers = "zh-rCN")
+class ConsolePlotNavigationStateTest {
+ @get:Rule
+ val compose = createComposeRule()
+
+ @Test
+ fun generatedPlotIsAClickableCardAndNeverOccupiesTheConsoleViewport() {
+ var opened = false
+ val figure = requireNotNull(
+ OctaveFigure.fromJson(
+ """{"layout":[2,1],"axes":[{"position":1,"type":"2d"},{"position":2,"type":"2d"}]}""",
+ ),
+ )
+ compose.setContent {
+ ConsoleTab(
+ state = ConsoleUiState(plot = figure),
+ input = "",
+ onInputChange = {},
+ onSubmit = {},
+ onCancel = {},
+ onHistoryPrev = {},
+ onHistoryNext = {},
+ timeoutMs = 30_000,
+ onTimeoutChange = {},
+ onOpenPlot = { opened = true },
+ )
+ }
+
+ compose.onNodeWithTag(CONSOLE_PLOT_CARD_TAG).assertExists().performClick()
+ compose.onNodeWithTag(OCTAVE_PLOT_PANEL_TAG).assertDoesNotExist()
+ compose.runOnIdle { assertTrue(opened) }
+ }
+
+ @Test
+ fun commandInputHasNoVisiblePromptButKeepsAnAccessibilityLabel() {
+ compose.setContent {
+ ConsoleTab(
+ state = ConsoleUiState(),
+ input = "",
+ onInputChange = {},
+ onSubmit = {},
+ onCancel = {},
+ onHistoryPrev = {},
+ onHistoryNext = {},
+ timeoutMs = 30_000,
+ onTimeoutChange = {},
+ onOpenPlot = {},
+ )
+ }
+
+ compose.onNodeWithText("输入 Octave/MATLAB 命令,回车发送").assertDoesNotExist()
+ compose.onNodeWithContentDescription("输入 Octave/MATLAB 命令,回车发送").assertExists()
+ }
+
+ @Test
+ fun variableDetailShowsTheSelectedPreviewOnItsOwnPage() {
+ val gateway = object : OctaveGateway {
+ override suspend fun run(
+ request: OctaveRequest,
+ onEvent: (OctaveEvent) -> Unit,
+ ) = OctaveResponse(request.id, ok = true, output = "")
+
+ override suspend fun cancel(requestId: String): OctaveEvent =
+ OctaveEvent.Done(requestId, OctaveResponse(requestId, ok = true, output = ""))
+ }
+ val state = ConsoleUiState(
+ workspace = listOf(
+ OctaveVariable(
+ "A",
+ "double",
+ intArrayOf(1, 1),
+ 8,
+ complex = false,
+ sparse = false,
+ global = false,
+ ),
+ ),
+ selectedVariableName = "A",
+ preview = OctavePreview(text = "42", valueJson = "42", kind = "scalar"),
+ )
+ compose.setContent {
+ VariableDetailScreen(state, ConsoleViewModel(gateway), onBack = {})
+ }
+
+ compose.onNodeWithTag(VARIABLE_DETAIL_VALUE_TAG).assertExists()
+ compose.onNodeWithText("42").assertExists()
+ }
+
+ @Test
+ fun plotPageStacksEverySubplotAsAnIndependentCard() {
+ val figure = requireNotNull(
+ OctaveFigure.fromJson(
+ """{"layout":[1,2],"axes":[{"position":1,"type":"2d"},{"position":2,"type":"2d"}]}""",
+ ),
+ )
+ compose.setContent { OctavePlotScreen(ConsoleUiState(plot = figure), onBack = {}) }
+
+ compose.onNodeWithTag(OCTAVE_PLOT_SCREEN_TAG).assertExists()
+ compose.onNodeWithTag("${OCTAVE_SUBPLOT_TAG_PREFIX}1").assertExists()
+ compose.onNodeWithTag("${OCTAVE_SUBPLOT_TAG_PREFIX}2").assertExists()
+ }
+
+ @Test
+ fun surfaceColorScaleIsACompactLegendInsteadOfAFullHeightStrip() {
+ val figure = requireNotNull(
+ OctaveFigure.fromJson(
+ """
+ {
+ "layout":[1,1],
+ "axes":[{
+ "position":1,"type":"3d","colorbar":true,"colormap":"jet",
+ "surfaces":[{"x":[[0,1],[0,1]],"y":[[0,0],[1,1]],
+ "z":[[0,1],[1,2]],"kind":"surf"}],
+ "lines":[],"lines3d":[],"contours":[],"legend":[],
+ "xlim":[],"ylim":[],"zlim":[]
+ }]
+ }
+ """.trimIndent(),
+ ),
+ )
+ compose.setContent {
+ Box(Modifier.size(400.dp)) {
+ OctavePlotPanel(figure, Modifier.fillMaxSize())
+ }
+ }
+
+ compose.onNodeWithTag("octave_color_legend")
+ .assertWidthIsEqualTo(112.dp)
+ .assertHeightIsEqualTo(42.dp)
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleViewModelTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleViewModelTest.kt
new file mode 100644
index 0000000..ed4d268
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleViewModelTest.kt
@@ -0,0 +1,334 @@
+package com.paruh.maxmath.ui.console
+
+import com.paruh.maxmath.engine.OctaveEvent
+import com.paruh.maxmath.engine.OctaveFailure
+import com.paruh.maxmath.engine.OctaveFailureCode
+import com.paruh.maxmath.engine.OctaveFailureStage
+import com.paruh.maxmath.engine.OctaveGateway
+import com.paruh.maxmath.engine.OctaveRequest
+import com.paruh.maxmath.engine.OctaveResponse
+import com.paruh.maxmath.engine.OctaveVariable
+import java.util.ArrayDeque
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ConsoleViewModelTest {
+ private val dispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun startingAndRunningAreExplicitRequestScopedStates() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "request-1")
+
+ vm.submit("1 + 1")
+ assertEquals(ConsoleActivity.Starting("request-1"), vm.state.value.activity)
+ runCurrent()
+ gateway.pending.single().emit(OctaveEvent.Started("request-1"))
+
+ assertEquals(ConsoleActivity.Running("request-1"), vm.state.value.activity)
+ }
+
+ @Test
+ fun cancellationHandshakeBlocksNewRequestsAndFiltersLateOutput() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val ids = ArrayDeque(listOf("old", "new"))
+ val vm = ConsoleViewModel(gateway) { ids.removeFirst() }
+ vm.submit("pause(30)")
+ runCurrent()
+ val old = gateway.pending.single()
+ old.emit(OctaveEvent.Started("old"))
+
+ vm.cancel()
+ runCurrent()
+ assertEquals(ConsoleActivity.Cancelling("old"), vm.state.value.activity)
+ vm.submit("2 + 2")
+ assertEquals(1, gateway.pending.size)
+
+ gateway.cancelGate.complete(
+ OctaveEvent.Done("old", OctaveResponse("old", true, "")),
+ )
+ runCurrent()
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+
+ vm.submit("2 + 2")
+ runCurrent()
+ assertEquals(2, gateway.pending.size)
+ old.emit(OctaveEvent.Output("old", "stale"))
+ assertFalse(vm.state.value.lines.any { it.text == "stale" })
+ }
+
+ @Test
+ fun failedCancellationDoesNotUnlockUntilOriginalRequestTerminates() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "slow")
+ vm.submit("pause(300)")
+ runCurrent()
+ val pending = gateway.pending.single()
+ pending.emit(OctaveEvent.Started("slow"))
+
+ vm.cancel()
+ runCurrent()
+ gateway.cancelGate.complete(
+ OctaveEvent.Failure(
+ "slow",
+ OctaveFailure(
+ OctaveFailureCode.CLIENT_DEADLINE,
+ OctaveFailureStage.CANCELLATION,
+ "cancel deadline",
+ ),
+ ),
+ )
+ runCurrent()
+
+ assertEquals(ConsoleActivity.Running("slow"), vm.state.value.activity)
+ vm.submit("must not start")
+ assertEquals(1, gateway.pending.size)
+
+ pending.complete(OctaveResponse("slow", true, "", workspace = emptyList()))
+ runCurrent()
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+ }
+
+ @Test
+ fun runDeadlineWaitsForTerminationConfirmationBeforeUnlocking() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "deadline")
+ vm.submit("pause(300)")
+ runCurrent()
+ gateway.pending.single().complete(
+ OctaveResponse.failed(
+ "deadline",
+ OctaveFailure(
+ OctaveFailureCode.CLIENT_DEADLINE,
+ OctaveFailureStage.RESPONSE,
+ "deadline",
+ ),
+ ),
+ )
+ runCurrent()
+
+ assertEquals(ConsoleActivity.Cancelling("deadline"), vm.state.value.activity)
+ vm.submit("must wait")
+ assertEquals(1, gateway.pending.size)
+
+ gateway.cancelGate.complete(
+ OctaveEvent.Done("deadline", OctaveResponse("deadline", true, "")),
+ )
+ runCurrent()
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+ assertEquals(OctaveFailureCode.CLIENT_DEADLINE, vm.state.value.failure?.code)
+ }
+
+ @Test
+ fun runDeadlineCannotLeaveTheUiPermanentlyRunningWhenCancelTransportFails() =
+ runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "deadline-fallback")
+ vm.submit("pause(300)")
+ runCurrent()
+ gateway.pending.single().complete(
+ OctaveResponse.failed(
+ "deadline-fallback",
+ OctaveFailure(
+ OctaveFailureCode.CLIENT_DEADLINE,
+ OctaveFailureStage.RESPONSE,
+ "deadline",
+ ),
+ ),
+ )
+ runCurrent()
+ gateway.cancelGate.complete(
+ OctaveEvent.Failure(
+ "deadline-fallback",
+ OctaveFailure(
+ OctaveFailureCode.CLIENT_DEADLINE,
+ OctaveFailureStage.CANCELLATION,
+ "cancel transport unavailable",
+ ),
+ ),
+ )
+
+ advanceTimeBy(2_000L)
+ runCurrent()
+
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+ assertEquals(OctaveFailureCode.CLIENT_DEADLINE, vm.state.value.failure?.code)
+ }
+
+ @Test
+ fun workspaceArrivesWithMainResponseWithoutASecondWhosRequest() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "workspace")
+ vm.submit("A = eye(2)")
+ runCurrent()
+ gateway.pending.single().complete(
+ OctaveResponse(
+ id = "workspace",
+ ok = true,
+ output = "",
+ workspace = listOf(
+ OctaveVariable("A", "double", intArrayOf(2, 2), 32, false, false, false),
+ ),
+ ),
+ )
+ runCurrent()
+
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+ assertEquals("A", vm.state.value.workspace.single().name)
+ assertEquals("No follow-up whos request is allowed", 1, gateway.pending.size)
+ }
+
+ @Test
+ fun workspaceFromAnErroredCommandFrameIsStillApplied() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "partial")
+ vm.submit("A = 1; error('boom')")
+ runCurrent()
+ gateway.pending.single().complete(
+ OctaveResponse(
+ id = "partial",
+ ok = false,
+ output = "",
+ workspace = listOf(
+ OctaveVariable("A", "double", intArrayOf(1, 1), 8, false, false, false),
+ ),
+ failure = OctaveFailure(
+ OctaveFailureCode.EXECUTION_FAILED,
+ OctaveFailureStage.EXECUTION,
+ "boom",
+ ),
+ ),
+ )
+ runCurrent()
+
+ assertEquals("A", vm.state.value.workspace.single().name)
+ assertEquals(OctaveFailureCode.EXECUTION_FAILED, vm.state.value.failure?.code)
+ }
+
+ @Test
+ fun openingWorkspaceVariableStartsAQuietDetailPreview() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val ids = ArrayDeque(listOf("seed", "preview"))
+ val vm = ConsoleViewModel(gateway) { ids.removeFirst() }
+ vm.submit("A = eye(2)")
+ runCurrent()
+ gateway.pending.single().complete(
+ OctaveResponse(
+ "seed",
+ true,
+ "",
+ workspace = listOf(
+ OctaveVariable("A", "double", intArrayOf(2, 2), 32, false, false, false),
+ ),
+ ),
+ )
+ runCurrent()
+
+ vm.openVariable("A")
+ runCurrent()
+
+ assertEquals("A", vm.state.value.selectedVariableName)
+ assertEquals(2, gateway.pending.size)
+ assertTrue(gateway.pending.last().request.task is com.paruh.maxmath.engine.OctavePreviewTask)
+ assertFalse(vm.state.value.lines.any { it.text == ">> A" })
+ }
+
+ @Test
+ fun requestIdMismatchAndDuplicateTerminalEventsCannotClobberState() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "current")
+ vm.submit("1")
+ runCurrent()
+ val pending = gateway.pending.single()
+
+ pending.emit(OctaveEvent.Output("different", "wrong"))
+ pending.emit(OctaveEvent.Started("different"))
+ pending.complete(OctaveResponse("current", true, "ok"))
+ runCurrent()
+ pending.emit(
+ OctaveEvent.Failure(
+ "current",
+ OctaveFailure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureStage.RESPONSE,
+ "duplicate",
+ ),
+ ),
+ )
+
+ assertEquals(ConsoleActivity.Idle, vm.state.value.activity)
+ assertFalse(vm.state.value.lines.any { it.text == "wrong" })
+ assertFalse(vm.state.value.lines.any { it.text == "duplicate" })
+ }
+
+ @Test
+ fun structuredFailureSurvivesForExpandableDiagnostics() = runTest(dispatcher) {
+ val gateway = FakeGateway()
+ val vm = viewModel(gateway, "failure")
+ vm.submit("bad()")
+ runCurrent()
+ val failure = OctaveFailure(
+ OctaveFailureCode.PROCESS_EXITED,
+ OctaveFailureStage.EXECUTION,
+ "Octave exited",
+ details = "loader error",
+ exitCode = 127,
+ )
+ gateway.pending.single().complete(OctaveResponse.failed("failure", failure))
+ runCurrent()
+
+ assertEquals(failure, vm.state.value.failure)
+ assertTrue(vm.state.value.failure!!.diagnosticText().contains("exitCode=127"))
+ }
+
+ private fun viewModel(gateway: FakeGateway, id: String) = ConsoleViewModel(gateway) { id }
+
+ private class FakeGateway : OctaveGateway {
+ val pending = mutableListOf()
+ var cancelGate = CompletableDeferred()
+
+ override suspend fun run(
+ request: OctaveRequest,
+ onEvent: (OctaveEvent) -> Unit,
+ ): OctaveResponse {
+ val call = Pending(request, onEvent)
+ pending += call
+ return call.response.await()
+ }
+
+ override suspend fun cancel(requestId: String): OctaveEvent = cancelGate.await()
+ }
+
+ private class Pending(
+ val request: OctaveRequest,
+ private val onEvent: (OctaveEvent) -> Unit,
+ ) {
+ val response = CompletableDeferred()
+ fun emit(event: OctaveEvent) = onEvent(event)
+ fun complete(value: OctaveResponse) = response.complete(value)
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctaveLatexTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctaveLatexTest.kt
new file mode 100644
index 0000000..ee85fd5
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctaveLatexTest.kt
@@ -0,0 +1,39 @@
+package com.paruh.maxmath.ui.console
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class OctaveLatexTest {
+
+ @Test
+ fun scalarVectorAndMatrix() {
+ assertEquals("\\begin{pmatrix}1.5\\end{pmatrix}", OctaveLatex.fromValueJson("[1.5]"))
+ assertEquals(
+ "\\begin{pmatrix}1 & 2 & 3\\end{pmatrix}",
+ OctaveLatex.fromValueJson("[1,2,3]"),
+ )
+ assertEquals(
+ "\\begin{pmatrix}\n1 & 2\\\\\n3 & 4\n\\end{pmatrix}",
+ OctaveLatex.fromValueJson("[[1,2],[3,4]]"),
+ )
+ }
+
+ @Test
+ fun nullBecomesNan() {
+ assertEquals("\\begin{pmatrix}\\mathrm{NaN}\\end{pmatrix}", OctaveLatex.fromValueJson("[null]"))
+ }
+
+ @Test
+ fun oversizedMatrixFallsBack() {
+ val big = buildString {
+ append('[')
+ repeat(60) {
+ if (it > 0) append(',')
+ append("[0]")
+ }
+ append(']')
+ }
+ assertNull(OctaveLatex.fromValueJson(big))
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctavePlotCameraTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctavePlotCameraTest.kt
new file mode 100644
index 0000000..77fc89c
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/OctavePlotCameraTest.kt
@@ -0,0 +1,21 @@
+package com.paruh.maxmath.ui.console
+
+import com.paruh.maxmath.engine.OctaveAxes
+import com.paruh.maxmath.ui.plot.gl.GlPlotKind
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class OctavePlotCameraTest {
+
+ @Test
+ fun bridgeCameraInitializesSurfaceRenderer() {
+ val state = octaveGlViewState(
+ OctaveAxes(azimuth = 45.0, elevation = 30.0),
+ GlPlotKind.SURFACE,
+ )
+
+ assertEquals(GlPlotKind.SURFACE, state.kind)
+ assertEquals(45f, state.azimuthDeg, 0f)
+ assertEquals(30f, state.elevationDeg, 0f)
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/PlotDataMeshTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/PlotDataMeshTest.kt
new file mode 100644
index 0000000..1a1a534
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/PlotDataMeshTest.kt
@@ -0,0 +1,77 @@
+package com.paruh.maxmath.ui.console
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class PlotDataMeshTest {
+
+ @Test
+ fun `surface preserves full meshgrid x and y coordinates`() {
+ val mesh = PlotDataMesh.surface(
+ xs = doubleArrayOf(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0),
+ ys = doubleArrayOf(-2.0, -2.0, 0.0, 0.0, 2.0, 2.0),
+ z = doubleArrayOf(0.0, 1.0, 2.0, 3.0, 4.0, 5.0),
+ rows = 3,
+ cols = 2,
+ )
+
+ assertNotNull(mesh)
+ assertArrayEquals(
+ floatArrayOf(
+ -1f, -2f, 0f,
+ 1f, -2f, 1f,
+ -1f, 0f, 2f,
+ 1f, 0f, 3f,
+ -1f, 2f, 4f,
+ 1f, 2f, 5f,
+ ),
+ mesh!!.positions,
+ 0f,
+ )
+ }
+
+ @Test
+ fun `surface still accepts compact coordinate vectors`() {
+ val mesh = PlotDataMesh.surface(
+ xs = doubleArrayOf(-1.0, 1.0),
+ ys = doubleArrayOf(-2.0, 0.0, 2.0),
+ z = doubleArrayOf(0.0, 1.0, 2.0, 3.0, 4.0, 5.0),
+ rows = 3,
+ cols = 2,
+ )
+
+ assertNotNull(mesh)
+ assertArrayEquals(
+ floatArrayOf(
+ -1f, -2f, 0f,
+ 1f, -2f, 1f,
+ -1f, 0f, 2f,
+ 1f, 0f, 3f,
+ -1f, 2f, 4f,
+ 1f, 2f, 5f,
+ ),
+ mesh!!.positions,
+ 0f,
+ )
+ }
+
+ @Test
+ fun `contour interpolation preserves full meshgrid y rows`() {
+ val mesh = PlotDataMesh.contour(
+ xs = doubleArrayOf(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0),
+ ys = doubleArrayOf(-2.0, -2.0, 0.0, 0.0, 2.0, 2.0),
+ z = doubleArrayOf(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0),
+ rows = 3,
+ cols = 2,
+ levelCount = 1,
+ )
+
+ assertNotNull(mesh)
+ assertArrayEquals(
+ floatArrayOf(0f, -2f, 0f, 0f, 0f, 0f, 0f, 2f),
+ mesh!!.contourLines,
+ 1e-6f,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/console/ScriptCursorPositionTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ScriptCursorPositionTest.kt
new file mode 100644
index 0000000..4fdadb5
--- /dev/null
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/console/ScriptCursorPositionTest.kt
@@ -0,0 +1,27 @@
+package com.paruh.maxmath.ui.console
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ScriptCursorPositionTest {
+
+ @Test
+ fun reportsCursorPositionBeyondOneHundredTwentyLines() {
+ val script = (1..160).joinToString("\n") { "line$it" }
+ val cursor = script.indexOf("line137") + 4
+
+ val position = scriptCursorPosition(script, cursor)
+
+ assertEquals(137, position.line)
+ assertEquals(5, position.column)
+ assertEquals(160, position.totalLines)
+ }
+
+ @Test
+ fun clampsOutOfRangeCursorAndCountsTrailingBlankLine() {
+ assertEquals(
+ ScriptCursorPosition(line = 2, column = 1, totalLines = 2),
+ scriptCursorPosition("x\n", Int.MAX_VALUE),
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt
index c7a9846..dc43f84 100644
--- a/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt
+++ b/app/src/test/kotlin/com/paruh/maxmath/ui/plot/GlGestureMathTest.kt
@@ -9,24 +9,28 @@ import org.junit.Test
class GlGestureMathTest {
@Test
- fun `3d drag up increases elevation`() {
+ fun `3d drag up rotates the object upward`() {
val state = GlViewState(kind = GlPlotKind.SURFACE, azimuthDeg = 60f, elevationDeg = 30f)
val next = GlGestureMath.apply3d(state, pan = Offset(0f, -10f), zoom = 1f)
- assertEquals(32f, next.elevationDeg, 1e-4f)
+ assertEquals(28f, next.elevationDeg, 1e-4f)
}
@Test
- fun `3d drag down decreases elevation`() {
+ fun `3d drag down rotates the object downward`() {
val state = GlViewState(kind = GlPlotKind.SURFACE, azimuthDeg = 60f, elevationDeg = 30f)
val next = GlGestureMath.apply3d(state, pan = Offset(0f, 10f), zoom = 1f)
- assertEquals(28f, next.elevationDeg, 1e-4f)
+ assertEquals(32f, next.elevationDeg, 1e-4f)
}
@Test
- fun `3d elevation clamps to valid range`() {
- val state = GlViewState(kind = GlPlotKind.SURFACE, elevationDeg = 5f)
- assertEquals(0f, GlGestureMath.apply3d(state, Offset(0f, 100f), 1f).elevationDeg, 1e-4f)
- assertEquals(180f, GlGestureMath.apply3d(state, Offset(0f, -1000f), 1f).elevationDeg, 1e-4f)
+ fun `3d elevation wraps through former dead angles`() {
+ val nearBottom = GlViewState(kind = GlPlotKind.SURFACE, elevationDeg = 179f)
+ val pastBottom = GlGestureMath.apply3d(nearBottom, Offset(0f, 50f), 1f)
+ assertEquals(-171f, pastBottom.elevationDeg, 1e-4f)
+
+ val nearTop = GlViewState(kind = GlPlotKind.SURFACE, elevationDeg = -179f)
+ val pastTop = GlGestureMath.apply3d(nearTop, Offset(0f, -50f), 1f)
+ assertEquals(171f, pastTop.elevationDeg, 1e-4f)
}
@Test
diff --git a/docs/SPEC.md b/docs/SPEC.md
index de3bc98..2c43e92 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -8,6 +8,12 @@ MaxMath 是一款以 GNU Maxima 为符号计算内核、面向 Android 的离线
## 用户能力
+- Octave 控制台:MATLAB 风格命令行(常驻会话、流式输出、历史、超时/取消)、
+ 工作区变量浏览与预览、.m 脚本新建/导入/导出/运行;数值引擎为 GNU Octave
+ 11.3.0(Termux 预编译),符号命令提示用户使用既有 Maxima 模式。
+- 控制台绘图:plot/plot3/surf/mesh/contour/subplot/hold/axis/grid/坐标轴名称/
+ title/legend/colormap/colorbar 高层命令导出 plot_spec.json,2D 用 Compose
+ 画布、3D/等高线用 OpenGL 渲染,各子图手势独立。
- 矩阵:行列式、逆、转置、秩、迹、特征值与特征向量
- 线性方程组:自然写法与高级原始 Maxima 输入
- 多项式:因式分解、最大公因式、求根、展开与化简
@@ -32,6 +38,11 @@ parser 模块提供纯 Kotlin 的词法分析、递归下降解析和 AST。自
## 执行架构
+- Octave 引擎在独立 `:octave` 进程:交互式子进程 + PS/哨兵协议,取消/超时/
+ 内存超限(1.5GB)终止子进程,下一次请求自动重建;空闲 60 秒回收。
+- Octave 绘图桥在 Octave 启动脚本中覆写高层绘图函数,记录到全局状态并在
+ 每次求值后由 maxmath_flush 原子写入带 requestId 的独立绘图制品;不支持低层
+ handle-graphics。
- 轻量操作在 UI 进程执行:化简、展开、代值、单变量求导,以及部分不超过四阶的
矩阵操作。
- 重量操作通过 Android Messenger 转发到 :engine 独立进程。该进程维护一个常驻的
diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts
index d8165a9..782b050 100644
--- a/engine/build.gradle.kts
+++ b/engine/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
@@ -5,6 +7,7 @@ plugins {
}
val buildNative = (project.findProperty("maxmath.buildNative") as String?) != "false"
+val configuredBuildPython = providers.environmentVariable("MAXMATH_BUILD_PYTHON")
android {
namespace = "com.paruh.maxmath.engine"
@@ -45,8 +48,15 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlinOptions {
- jvmTarget = "17"
+ lint {
+ // The containing app intentionally supports only its complete arm64 runtime.
+ disable += "ChromeOsAbiSupport"
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
}
}
@@ -56,6 +66,9 @@ android {
chaquopy {
defaultConfig {
version = "3.10"
+ configuredBuildPython.orNull
+ ?.takeIf { it.isNotBlank() }
+ ?.let { buildPython(it) }
pip {
install("matplotlib==3.6.0")
install("numpy==1.23.3")
diff --git a/engine/src/main/AndroidManifest.xml b/engine/src/main/AndroidManifest.xml
index 0fd6dad..3c63bf4 100644
--- a/engine/src/main/AndroidManifest.xml
+++ b/engine/src/main/AndroidManifest.xml
@@ -1,3 +1,8 @@
-
+
+
+
diff --git a/engine/src/main/cpp/CMakeLists.txt b/engine/src/main/cpp/CMakeLists.txt
index ef2297a..a92fc4a 100644
--- a/engine/src/main/cpp/CMakeLists.txt
+++ b/engine/src/main/cpp/CMakeLists.txt
@@ -3,6 +3,10 @@ project(maxmath_engine CXX)
add_library(maxmath_engine SHARED maxmath_engine.cpp)
target_compile_features(maxmath_engine PRIVATE cxx_std_17)
+target_link_options(maxmath_engine PRIVATE
+ "-Wl,-z,max-page-size=16384"
+ "-Wl,-z,common-page-size=16384"
+)
find_library(log-lib log)
target_link_libraries(maxmath_engine ${log-lib})
diff --git a/engine/src/main/cpp/maxmath_engine.cpp b/engine/src/main/cpp/maxmath_engine.cpp
index 0293fdf..342691c 100644
--- a/engine/src/main/cpp/maxmath_engine.cpp
+++ b/engine/src/main/cpp/maxmath_engine.cpp
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#include
@@ -30,7 +31,9 @@ volatile sig_atomic_t g_cancelled = 0;
// nativeStart 保存的启动参数:子进程被取消/超时/意外退出后按需重启。
std::string g_path;
+std::string g_runtime_dir;
std::string g_work_dir;
+std::string g_user_dir;
std::string g_init_lisp;
std::string g_lib_dir;
std::string g_ecl_data_dir;
@@ -74,11 +77,11 @@ void kill_child() {
// Maxima 找到打包后的 share 目录。
std::vector build_env_overrides() {
return {
- "PATH=" + g_lib_dir + ":" + g_work_dir + "/bin:" +
+ "PATH=" + g_lib_dir + ":" + g_runtime_dir + "/bin:" +
(getenv("PATH") ? getenv("PATH") : ""),
- "LD_LIBRARY_PATH=" + g_lib_dir + ":" + g_work_dir + "/lib:" + g_work_dir + "/bin",
- "MAXIMA_PREFIX=" + g_work_dir,
- "MAXIMA_USERDIR=" + g_work_dir + "/user",
+ "LD_LIBRARY_PATH=" + g_lib_dir + ":" + g_runtime_dir + "/lib:" + g_runtime_dir + "/bin",
+ "MAXIMA_PREFIX=" + g_runtime_dir,
+ "MAXIMA_USERDIR=" + g_user_dir,
"MAXIMA_TEMPDIR=" + g_work_dir + "/tmp",
"ECLDIR=" + g_ecl_data_dir + "/",
// 引擎数据使用标准 autoconf 布局(share/maxima//share),
@@ -138,8 +141,12 @@ std::string spawn_child() {
const_cast(quiet_arg.c_str()),
nullptr};
+ const pid_t expected_parent = getpid();
pid_t pid = fork();
if (pid == 0) {
+ if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || getppid() != expected_parent) {
+ _exit(126);
+ }
dup2(in_pipe[0], STDIN_FILENO);
dup2(out_pipe[1], STDOUT_FILENO);
dup2(err_pipe[1], STDERR_FILENO);
@@ -349,10 +356,12 @@ std::string run_script(const std::string &script_path, long timeout_ms) {
extern "C" JNIEXPORT void JNICALL
Java_com_paruh_maxmath_engine_MaximaEngine_nativeStart(
- JNIEnv *env, jobject, jstring jpath, jstring jworkdir, jstring jinit,
- jstring jlibdir, jstring jeclDataDir) {
+ JNIEnv *env, jobject, jstring jpath, jstring jruntimeDir, jstring jworkdir,
+ jstring juserDir, jstring jinit, jstring jlibdir, jstring jeclDataDir) {
g_path = jstring_to_string(env, jpath);
+ g_runtime_dir = jstring_to_string(env, jruntimeDir);
g_work_dir = jstring_to_string(env, jworkdir);
+ g_user_dir = jstring_to_string(env, juserDir);
g_init_lisp = jstring_to_string(env, jinit);
g_lib_dir = jstring_to_string(env, jlibdir);
g_ecl_data_dir = jstring_to_string(env, jeclDataDir);
@@ -360,8 +369,8 @@ Java_com_paruh_maxmath_engine_MaximaEngine_nativeStart(
// 写已关闭的管道会收到 SIGPIPE,默认动作是终止整个 :engine 进程。
signal(SIGPIPE, SIG_IGN);
__android_log_print(ANDROID_LOG_INFO, LOG_TAG,
- "configured: path=%s workdir=%s libdir=%s ecldir=%s",
- g_path.c_str(), g_work_dir.c_str(), g_lib_dir.c_str(),
+ "configured: path=%s runtime=%s workdir=%s libdir=%s ecldir=%s",
+ g_path.c_str(), g_runtime_dir.c_str(), g_work_dir.c_str(), g_lib_dir.c_str(),
g_ecl_data_dir.c_str());
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/CalcResponse.kt b/engine/src/main/java/com/paruh/maxmath/engine/CalcResponse.kt
index 50380ea..f5e0053 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/CalcResponse.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/CalcResponse.kt
@@ -2,6 +2,67 @@ package com.paruh.maxmath.engine
import org.json.JSONObject
+enum class CalcFailureCode(val wireValue: String) {
+ INVALID_REQUEST("invalid_request"),
+ BIND_FAILED("bind_failed"),
+ NULL_BINDER("null_binder"),
+ SERVICE_DISCONNECTED("service_disconnected"),
+ PREPARATION_TIMEOUT("preparation_timeout"),
+ EXECUTION_TIMEOUT("execution_timeout"),
+ CANCELLED("cancelled"),
+ CANCEL_NOT_ACTIVE("cancel_not_active"),
+ INSTALL_FAILED("install_failed"),
+ START_FAILED("start_failed"),
+ EXECUTION_FAILED("execution_failed"),
+ PROTOCOL_ERROR("protocol_error"),
+ IPC_ERROR("ipc_error"),
+ UNKNOWN("unknown");
+
+ companion object {
+ fun fromWire(value: String): CalcFailureCode = entries.firstOrNull {
+ it.wireValue == value
+ } ?: UNKNOWN
+ }
+}
+
+enum class CalcFailureStage(val wireValue: String) {
+ REQUEST("request"),
+ BIND("bind"),
+ PREPARATION("preparation"),
+ STARTUP("startup"),
+ EXECUTION("execution"),
+ CANCELLATION("cancellation"),
+ RESPONSE("response");
+
+ companion object {
+ fun fromWire(value: String): CalcFailureStage = entries.firstOrNull {
+ it.wireValue == value
+ } ?: RESPONSE
+ }
+}
+
+data class CalcFailure(
+ val code: CalcFailureCode,
+ val stage: CalcFailureStage,
+ val message: String,
+ val details: String? = null,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("code", code.wireValue)
+ .put("stage", stage.wireValue)
+ .put("message", message)
+ .apply { details?.let { put("details", it) } }
+
+ companion object {
+ fun fromJson(obj: JSONObject): CalcFailure = CalcFailure(
+ CalcFailureCode.fromWire(obj.optString("code")),
+ CalcFailureStage.fromWire(obj.optString("stage")),
+ obj.optString("message").ifBlank { "计算失败" },
+ obj.optString("details").ifBlank { null },
+ )
+ }
+}
+
/** 计算结果:tex 用于 jlatexmath 渲染,plain 用于复制/展示,imagePath 用于绘图。 */
data class CalcResult(
val tex: String? = null,
@@ -15,6 +76,7 @@ data class CalcResponse(
val ok: Boolean,
val result: CalcResult? = null,
val error: String? = null,
+ val failure: CalcFailure? = null,
) {
fun toJson(): String {
val obj = JSONObject()
@@ -29,6 +91,7 @@ data class CalcResponse(
obj.put("result", ro)
}
error?.let { obj.put("error", it) }
+ failure?.let { obj.put("failure", it.toJson()) }
return obj.toString()
}
@@ -52,7 +115,60 @@ data class CalcResponse(
ok = ok,
result = result,
error = obj.optString("error").ifBlank { null },
+ failure = obj.optJSONObject("failure")?.let(CalcFailure::fromJson),
)
}
+
+ fun failed(id: String, failure: CalcFailure): CalcResponse = CalcResponse(
+ id = id,
+ ok = false,
+ error = failure.message,
+ failure = failure,
+ )
+ }
+}
+
+/** Request-scoped service protocol. Terminal events are Done or Failure. */
+sealed interface CalcEvent {
+ val requestId: String
+
+ data class Preparing(override val requestId: String) : CalcEvent
+ data class Running(override val requestId: String) : CalcEvent
+ data class Done(override val requestId: String, val response: CalcResponse) : CalcEvent
+ data class Failure(override val requestId: String, val failure: CalcFailure) : CalcEvent
+
+ fun toJson(): String = JSONObject()
+ .put("version", PROTOCOL_VERSION)
+ .put("requestId", requestId)
+ .apply {
+ when (this@CalcEvent) {
+ is Preparing -> put("type", "preparing")
+ is Running -> put("type", "running")
+ is Done -> put("type", "done").put("response", JSONObject(response.toJson()))
+ is Failure -> put("type", "failure").put("failure", failure.toJson())
+ }
+ }
+ .toString()
+
+ companion object {
+ const val PROTOCOL_VERSION = 1
+
+ fun fromJson(json: String): CalcEvent {
+ val obj = JSONObject(json)
+ require(obj.getInt("version") == PROTOCOL_VERSION) { "Unsupported calculation protocol" }
+ val requestId = obj.getString("requestId")
+ require(requestId.isNotBlank()) { "Missing calculation request ID" }
+ return when (obj.getString("type")) {
+ "preparing" -> Preparing(requestId)
+ "running" -> Running(requestId)
+ "done" -> {
+ val response = CalcResponse.fromJson(obj.getJSONObject("response").toString())
+ require(response.id == requestId) { "Calculation response ID mismatch" }
+ Done(requestId, response)
+ }
+ "failure" -> Failure(requestId, CalcFailure.fromJson(obj.getJSONObject("failure")))
+ else -> throw IllegalArgumentException("Unknown calculation event")
+ }
+ }
}
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/EngineClient.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineClient.kt
index 8d1bbf8..9ddda12 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/EngineClient.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineClient.kt
@@ -5,109 +5,333 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
+import android.os.Handler
import android.os.IBinder
+import android.os.Looper
import android.os.Message
import android.os.Messenger
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.resume
import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.selects.select
+import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
-import kotlin.coroutines.resume
+import kotlinx.coroutines.withTimeoutOrNull
+
+internal interface EngineTransport {
+ suspend fun execute(request: CalcRequest, onEvent: (CalcEvent) -> Unit): CalcResponse
+ suspend fun cancel(requestId: String): CalcEvent
+ fun forceStop() = Unit
+}
+
+internal object CalcIpc {
+ const val ACTION = "action"
+ const val ACTION_RUN = "run"
+ const val ACTION_CANCEL = "cancel"
+ const val JSON = "json"
+ const val REQUEST_ID = "requestId"
+}
/**
- * 计算客户端:轻操作在当前进程执行,重操作转发到 :engine 独立进程。
+ * Request-scoped Maxima client. All operations use :engine and have independent preparation and
+ * execution deadlines, so a lost Binder terminal can never strand the UI.
*/
-open class EngineClient(private val context: Context) {
+open class EngineClient internal constructor(
+ private val transport: EngineTransport,
+ private val prepareTimeoutMs: Long,
+ private val executionTimeoutMs: Long,
+) {
+ constructor(context: Context) : this(
+ MessengerEngineTransport(context.applicationContext),
+ PREPARE_TIMEOUT_MS,
+ EXECUTION_TIMEOUT_MS,
+ )
- open suspend fun compute(request: CalcRequest): CalcResponse =
- if (OpRegistry.isHeavy(request.task)) {
- computeHeavy(request)
- } else {
- computeLight(request)
- }
+ init {
+ require(prepareTimeoutMs > 0)
+ require(executionTimeoutMs > 0)
+ }
+
+ open suspend fun compute(request: CalcRequest): CalcResponse = compute(request) { }
- private suspend fun computeLight(request: CalcRequest): CalcResponse =
- withContext(Dispatchers.Default) {
- val initError = MaximaEngine.init(context)
- if (initError != null) {
- return@withContext CalcResponse(id = request.id, ok = false, error = initError)
+ open suspend fun compute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse = supervisorScope {
+ val running = CompletableDeferred()
+ val execution = async {
+ transport.execute(request) { event ->
+ if (event is CalcEvent.Running) running.complete(Unit)
+ runCatching { onEvent(event) }
+ }
+ }
+ try {
+ val prepared = withTimeoutOrNull(prepareTimeoutMs) {
+ select {
+ execution.onAwait { PrepareOutcome.Completed(it) }
+ running.onAwait { PrepareOutcome.Running }
+ }
+ }
+ when (prepared) {
+ is PrepareOutcome.Completed -> prepared.response
+ PrepareOutcome.Running -> withTimeoutOrNull(executionTimeoutMs) {
+ execution.await()
+ } ?: timeout(request, execution, preparing = false)
+ null -> timeout(request, execution, preparing = true)
}
- try {
- MaximaEngine.run(request)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- CalcResponse(id = request.id, ok = false, error = e.message ?: "引擎未就绪")
+ } catch (cancelled: CancellationException) {
+ withContext(NonCancellable) {
+ withTimeoutOrNull(CANCEL_TIMEOUT_MS) { transport.cancel(request.id) }
}
+ throw cancelled
+ } catch (error: Exception) {
+ CalcResponse.failed(
+ request.id,
+ CalcFailure(
+ CalcFailureCode.UNKNOWN,
+ CalcFailureStage.RESPONSE,
+ "计算请求失败",
+ error.stackTraceToString().take(MAX_DETAILS),
+ ),
+ )
+ }
+ }
+
+ open suspend fun cancel(requestId: String): CalcEvent =
+ withTimeoutOrNull(CANCEL_TIMEOUT_MS) { transport.cancel(requestId) }
+ ?: CalcEvent.Failure(
+ requestId,
+ CalcFailure(
+ CalcFailureCode.SERVICE_DISCONNECTED,
+ CalcFailureStage.CANCELLATION,
+ "取消请求未得到引擎确认",
+ ),
+ )
+
+ private suspend fun timeout(
+ request: CalcRequest,
+ execution: kotlinx.coroutines.Deferred,
+ preparing: Boolean,
+ ): CalcResponse {
+ execution.cancelAndJoin()
+ withContext(NonCancellable) {
+ withTimeoutOrNull(CANCEL_TIMEOUT_MS) { transport.cancel(request.id) }
+ transport.forceStop()
+ }
+ return CalcResponse.failed(
+ request.id,
+ CalcFailure(
+ if (preparing) {
+ CalcFailureCode.PREPARATION_TIMEOUT
+ } else {
+ CalcFailureCode.EXECUTION_TIMEOUT
+ },
+ if (preparing) CalcFailureStage.PREPARATION else CalcFailureStage.EXECUTION,
+ if (preparing) {
+ "Maxima 引擎准备超时"
+ } else {
+ "Maxima 计算超时"
+ },
+ "limitMs=${if (preparing) prepareTimeoutMs else executionTimeoutMs}",
+ ),
+ )
+ }
+
+ private sealed interface PrepareOutcome {
+ data class Completed(val response: CalcResponse) : PrepareOutcome
+ data object Running : PrepareOutcome
+ }
+
+ private companion object {
+ const val PREPARE_TIMEOUT_MS = 90_000L
+ const val EXECUTION_TIMEOUT_MS = 130_000L
+ const val CANCEL_TIMEOUT_MS = 5_000L
+ const val MAX_DETAILS = 16 * 1024
+ }
+}
+
+/** Android Messenger adapter. Binder failures become terminal values instead of disappearing. */
+private class MessengerEngineTransport(private val context: Context) : EngineTransport {
+
+ override suspend fun execute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse = exchange(
+ action = CalcIpc.ACTION_RUN,
+ requestId = request.id,
+ requestJson = request.toJson(),
+ onEvent = onEvent,
+ ).let { event ->
+ when (event) {
+ is CalcEvent.Done -> event.response
+ is CalcEvent.Failure -> CalcResponse.failed(request.id, event.failure)
+ is CalcEvent.Preparing,
+ is CalcEvent.Running,
+ -> CalcResponse.failed(
+ request.id,
+ CalcFailure(
+ CalcFailureCode.PROTOCOL_ERROR,
+ CalcFailureStage.RESPONSE,
+ "Maxima 服务缺少终态",
+ ),
+ )
}
+ }
- private suspend fun computeHeavy(request: CalcRequest): CalcResponse =
- suspendCancellableCoroutine { cont ->
- val serviceIntent = Intent(context, EngineService::class.java)
- var connection: ServiceConnection? = null
+ override suspend fun cancel(requestId: String): CalcEvent = exchange(
+ action = CalcIpc.ACTION_CANCEL,
+ requestId = requestId,
+ requestJson = null,
+ onEvent = {},
+ )
+
+ override fun forceStop() {
+ runCatching { context.stopService(Intent(context, EngineService::class.java)) }
+ }
- fun cleanup() {
- connection?.let { context.unbindService(it) }
- connection = null
+ private suspend fun exchange(
+ action: String,
+ requestId: String,
+ requestJson: String?,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcEvent = suspendCancellableCoroutine { continuation ->
+ val terminal = AtomicBoolean(false)
+ val bound = AtomicBoolean(false)
+ var connection: ServiceConnection? = null
+
+ fun cleanup() {
+ val current = connection
+ connection = null
+ if (current != null && bound.compareAndSet(true, false)) {
+ runCatching { context.unbindService(current) }
}
+ }
- val replyMessenger = Messenger(HandlerProxy { msg ->
- val json = msg.data.getString("json") ?: return@HandlerProxy
- val response = CalcResponse.fromJson(json)
- if (cont.isActive) cont.resume(response)
- cleanup()
- })
-
- cont.invokeOnCancellation {
- cleanup()
- try {
- context.startService(
- Intent(context, EngineService::class.java)
- .setAction(EngineService.ACTION_CANCEL)
- )
- } catch (_: Exception) {
+ fun finish(event: CalcEvent) {
+ if (!terminal.compareAndSet(false, true)) return
+ if (continuation.isActive) continuation.resume(event)
+ cleanup()
+ }
+
+ fun failure(code: CalcFailureCode, stage: CalcFailureStage, message: String, details: String? = null) {
+ finish(CalcEvent.Failure(requestId, CalcFailure(code, stage, message, details)))
+ }
+
+ val reply = Messenger(Handler(Looper.getMainLooper()) { message ->
+ val raw = message.data.getString(CalcIpc.JSON)
+ val event = runCatching {
+ requireNotNull(raw) { "Missing Maxima event payload" }
+ CalcEvent.fromJson(raw).also {
+ require(it.requestId == requestId) { "Maxima event request ID mismatch" }
}
+ }.getOrElse { error ->
+ failure(
+ CalcFailureCode.PROTOCOL_ERROR,
+ CalcFailureStage.RESPONSE,
+ "Maxima 服务返回了无效响应",
+ error.message,
+ )
+ return@Handler true
+ }
+ when (event) {
+ is CalcEvent.Preparing,
+ is CalcEvent.Running,
+ -> if (!terminal.get()) runCatching { onEvent(event) }
+ is CalcEvent.Done,
+ is CalcEvent.Failure,
+ -> finish(event)
}
+ true
+ })
- connection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
- val messenger = Messenger(binder!!)
- val msg = Message.obtain()
- val b = Bundle()
- b.putString("action", "eval")
- b.putString("json", request.toJson())
- msg.data = b
- msg.replyTo = replyMessenger
- try {
- messenger.send(msg)
- } catch (e: Exception) {
- if (cont.isActive) {
- cont.resume(CalcResponse(request.id, ok = false, error = e.message ?: "引擎连接失败"))
- }
- cleanup()
- }
+ connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ if (binder == null) {
+ failure(
+ CalcFailureCode.NULL_BINDER,
+ CalcFailureStage.BIND,
+ "Maxima 服务返回了空 Binder",
+ )
+ return
}
-
- override fun onServiceDisconnected(name: ComponentName?) {
- if (cont.isActive) {
- cont.resume(CalcResponse(request.id, ok = false, error = "引擎进程已断开"))
+ val message = Message.obtain().apply {
+ data = Bundle().apply {
+ putString(CalcIpc.ACTION, action)
+ putString(CalcIpc.REQUEST_ID, requestId)
+ requestJson?.let { putString(CalcIpc.JSON, it) }
}
- cleanup()
+ replyTo = reply
}
+ runCatching { Messenger(binder).send(message) }
+ .onFailure { error ->
+ failure(
+ CalcFailureCode.IPC_ERROR,
+ CalcFailureStage.BIND,
+ "无法向 Maxima 服务发送请求",
+ error.message,
+ )
+ }
}
- val bound = context.bindService(serviceIntent, connection!!, Context.BIND_AUTO_CREATE)
- if (!bound) {
- if (cont.isActive) {
- cont.resume(CalcResponse(request.id, ok = false, error = "无法绑定引擎服务"))
- }
- cleanup()
+ override fun onServiceDisconnected(name: ComponentName?) {
+ failure(
+ CalcFailureCode.SERVICE_DISCONNECTED,
+ CalcFailureStage.RESPONSE,
+ "Maxima 服务已断开",
+ )
+ }
+
+ override fun onBindingDied(name: ComponentName?) {
+ failure(
+ CalcFailureCode.SERVICE_DISCONNECTED,
+ CalcFailureStage.BIND,
+ "Maxima 服务绑定已失效",
+ )
}
+
+ override fun onNullBinding(name: ComponentName?) {
+ failure(
+ CalcFailureCode.NULL_BINDER,
+ CalcFailureStage.BIND,
+ "Maxima 服务不可绑定",
+ )
+ }
+ }
+
+ continuation.invokeOnCancellation {
+ terminal.compareAndSet(false, true)
+ cleanup()
}
- private class HandlerProxy(val onMessage: (Message) -> Unit) : android.os.Handler(
- android.os.Looper.getMainLooper()
- ) {
- override fun handleMessage(msg: Message) = onMessage(msg)
+ val accepted = runCatching {
+ context.bindService(
+ Intent(context, EngineService::class.java),
+ requireNotNull(connection),
+ Context.BIND_AUTO_CREATE,
+ )
+ }.getOrElse { error ->
+ failure(
+ CalcFailureCode.BIND_FAILED,
+ CalcFailureStage.BIND,
+ "无法绑定 Maxima 服务",
+ error.message,
+ )
+ false
+ }
+ if (accepted) {
+ bound.set(true)
+ if (terminal.get()) cleanup()
+ } else if (!terminal.get()) {
+ failure(
+ CalcFailureCode.BIND_FAILED,
+ CalcFailureStage.BIND,
+ "无法绑定 Maxima 服务",
+ )
+ }
}
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt
index 819a948..59c545e 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineInstaller.kt
@@ -1,159 +1,440 @@
package com.paruh.maxmath.engine
+import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import java.io.File
-import java.io.FileNotFoundException
import java.io.IOException
+import java.nio.file.Files
+import java.security.MessageDigest
+import java.util.UUID
+import java.util.zip.ZipInputStream
-/**
- * 引擎安装:把 assets/engine 解压到应用私有目录、选择 ABI 对应的 Maxima
- * 二进制并生成 init.lisp。负责安装相关的全部文件操作,避免 MaximaEngine
- * 同时承担安装、执行与绘图多个职责。
- */
+/** Transactional installer for the immutable Maxima/ECL runtime archive. */
object EngineInstaller {
+ enum class InstallStage { ABI, MANIFEST, INSTALL, INTEGRITY, NATIVE }
+
sealed interface InstallResult {
data class Success(
val maximaPath: String,
+ val runtimeDir: String,
val workDir: String,
+ val userDir: String,
val initLispPath: String,
- /** libecl.so 所在目录,恒为 nativeLibraryDir。 */
val libDir: String,
- /** ECL 运行文件目录(.fas/encodings),传给子进程设置 ECLDIR。 */
val eclDataDir: String,
+ val runtimeId: String,
) : InstallResult
- data class Failure(val message: String) : InstallResult
+ data class Failure(
+ val message: String,
+ val details: String? = null,
+ val stage: InstallStage = InstallStage.INSTALL,
+ ) : InstallResult
}
+ @SuppressLint("UsableSpace")
fun install(context: Context): InstallResult {
- val dir = File(context.filesDir, "engine").apply { mkdirs() }
- val hasEngine = File(dir, "share/maxima").isDirectory
- val marker = File(dir, ENGINE_MARKER)
- val currentVersion = if (marker.exists()) marker.readText() else ""
- if (!hasEngine || currentVersion != ENGINE_VERSION || !File(dir, INIT_TEMPLATE).exists()) {
- // 整目录重建而不是覆盖解压:解压只会新增/改写文件,旧版本里
- // 已不再打包的内容(MoA 的 maxima.pie/additions、与 jniLibs 重复
- // 的 binary-ecl 与 libecl.so)会永久留在 filesDir 里白占空间。
- dir.deleteRecursively()
- dir.mkdirs()
- extractAssets(context, dir)
- marker.writeText(ENGINE_VERSION)
- }
- File(dir, "tmp").mkdirs()
- File(dir, "user").mkdirs()
-
- Build.SUPPORTED_ABIS.firstOrNull { it in SUPPORTED_ABIS }
- ?: return InstallResult.Failure("不支持的 ABI:${Build.SUPPORTED_ABIS.joinToString()}")
- // Android 10+ 禁止 execve filesDir 下的文件(W^X 策略),因此可执行
- // 文件只能来自 APK 的 nativeLibraryDir(jniLibs,安装时解压,带可执行
- // SELinux 标签)。assets 里再放一份既执行不了,又要多占一倍空间,
- // 所以这里不再保留 assets 回退路径——找不到就是打包错误。
+ if (Build.SUPPORTED_ABIS.none { it == EngineRuntimeManifest.SUPPORTED_ABI }) {
+ return InstallResult.Failure(
+ "不支持的 ABI:${Build.SUPPORTED_ABIS.joinToString()}",
+ stage = InstallStage.ABI,
+ )
+ }
+ val manifestJson = try {
+ context.assets.open(MANIFEST_ASSET).bufferedReader().use { it.readText() }
+ } catch (error: Exception) {
+ return InstallResult.Failure(
+ "Maxima 运行时清单未打包",
+ error.describe(),
+ InstallStage.MANIFEST,
+ )
+ }
+ val manifest = try {
+ EngineRuntimeManifest.parse(manifestJson)
+ } catch (error: Exception) {
+ return InstallResult.Failure(
+ "Maxima 运行时清单无效",
+ error.describe(),
+ InstallStage.MANIFEST,
+ )
+ }
+
+ val root = File(context.filesDir, ROOT_DIR)
+ val runtime = File(root, RUNTIME_DIR)
+ val work = File(root, WORK_DIR)
+ val user = File(root, USER_DIR)
+ val tmp = File(work, TMP_DIR)
+ val out = File(work, OUT_DIR)
+ try {
+ listOf(root, work, user, tmp, out).forEach(::ensureDirectory)
+ recoverInterruptedInstall(root, runtime)
+ if (!isCurrentRuntime(runtime, manifest)) {
+ val required = manifest.totalBytes + INSTALL_SPACE_MARGIN_BYTES
+ val usable = root.usableSpace
+ if (usable > 0L && usable < required) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "存储空间不足,无法安装 Maxima 运行时",
+ IOException("required=$required usable=$usable"),
+ )
+ }
+ replaceRuntime(context, root, runtime, work, manifest, manifestJson)
+ }
+ removeLegacyRuntime(root)
+ } catch (error: RuntimeInstallException) {
+ return InstallResult.Failure(error.userMessage, error.describe(), error.stage)
+ } catch (error: Exception) {
+ return InstallResult.Failure(
+ if (error.isNoSpaceFailure()) {
+ "存储空间不足,无法安装 Maxima 运行时"
+ } else {
+ "Maxima 运行时安装失败"
+ },
+ error.describe(),
+ InstallStage.INSTALL,
+ )
+ }
+
val nativeLibDir = context.applicationInfo.nativeLibraryDir
- ?.takeIf { it.isNotBlank() }
- ?.let { File(it) }
- ?: return InstallResult.Failure("nativeLibraryDir 不可用,引擎二进制无处可执行")
- val maxima = File(nativeLibDir, "libmaxima.so").takeIf { it.exists() }
- ?: File(nativeLibDir, "maxima").takeIf { it.exists() }
- ?: return InstallResult.Failure(
- "Maxima 引擎二进制未打包(${File(nativeLibDir, "libmaxima.so").absolutePath})",
+ ?.takeIf(String::isNotBlank)
+ ?.let(::File)
+ ?: return InstallResult.Failure("nativeLibraryDir 不可用", stage = InstallStage.NATIVE)
+ validateNativeRuntime(nativeLibDir, manifest)?.let { return it }
+ val maxima = File(nativeLibDir, EXECUTABLE)
+ if (!maxima.canExecute()) maxima.setExecutable(true, false)
+ if (!maxima.canExecute()) {
+ return InstallResult.Failure(
+ "Maxima 引擎二进制无执行权限:${maxima.absolutePath}",
+ stage = InstallStage.NATIVE,
)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !maxima.canExecute()) {
- return InstallResult.Failure("引擎二进制无执行权限:${maxima.absolutePath}")
}
- maxima.setExecutable(true, false)
- val libDir = nativeLibDir.absolutePath
- if (!File(libDir, "libecl.so").exists()) {
- return InstallResult.Failure("libecl.so 未找到($libDir)")
+ val eclDir = findEclDataDir(runtime)
+ if (eclDir.isBlank() || !File(eclDir, "sb-bsd-sockets.fas").isFile) {
+ return InstallResult.Failure(
+ "ECL 运行文件未找到",
+ eclDir,
+ InstallStage.INTEGRITY,
+ )
}
- val initLisp = writeInitLisp(dir)
- val eclDir = findEclDataDir(dir)
- if (eclDir.isBlank() || !File(eclDir, "sb-bsd-sockets.fas").exists()) {
- return InstallResult.Failure("ECL 运行文件未找到($eclDir)")
+ val init = try {
+ writeInitLisp(runtime, work, user)
+ } catch (error: Exception) {
+ return InstallResult.Failure(
+ "Maxima 初始化文件生成失败",
+ error.describe(),
+ InstallStage.INSTALL,
+ )
}
return InstallResult.Success(
maximaPath = maxima.absolutePath,
- workDir = dir.absolutePath,
- initLispPath = initLisp.absolutePath,
- libDir = libDir,
+ runtimeDir = runtime.absolutePath,
+ workDir = work.absolutePath,
+ userDir = user.absolutePath,
+ initLispPath = init.absolutePath,
+ libDir = nativeLibDir.absolutePath,
eclDataDir = eclDir,
+ runtimeId = manifest.runtimeId,
)
}
- /** ECL 运行文件目录(sb-bsd-sockets.fas 等),用于设置 ECLDIR。 */
- internal fun findEclDataDir(dir: File): String {
- val lib = File(dir, "lib")
- val versionDir = lib.listFiles { f -> f.isDirectory && f.name.startsWith("ecl-") }
- ?.maxByOrNull { it.name }
- ?: return ""
- return versionDir.absolutePath
- }
-
- internal fun writeInitLisp(dir: File): File {
- val template = File(dir, INIT_TEMPLATE)
- // 标准 autoconf prefix 布局:share/maxima/ 就在引擎根目录下。
- val maximaDir = dir.absolutePath
- val content = if (template.exists()) {
- template.readText()
- .replace("__MAXIMA_DIR__", maximaDir)
- .replace("__TEMP_DIR__", File(dir, "tmp").absolutePath)
- .replace("__USER_DIR__", File(dir, "user").absolutePath)
- } else {
- ""
- }
- val init = File(dir, "init.lisp")
- init.writeText(content)
- return init
- }
-
- private fun extractAssets(context: Context, target: File) {
- copyAssetDir(context, "engine", target)
- }
-
- private fun copyAssetDir(context: Context, path: String, target: File) {
- // 真机 AOSP:AssetManager.list() 对“文件路径”抛 FileNotFoundException
- // (OpenDir 返回 null),对目录返回子项;Robolectric 则对文件返回空数组。
- // 必须先按 list 是否抛异常区分文件/目录,否则真机解压会在第一个文件处
- // 中断,导致引擎数据不完整、每次计算都秒出错误。
- val list = try {
- context.assets.list(path)
- } catch (_: IOException) {
- null
- }
- if (list == null) {
- // list 抛异常 => 这是文件(AOSP 真机行为),直接复制。
- copyAssetFile(context, path, target)
+ private fun replaceRuntime(
+ context: Context,
+ root: File,
+ runtime: File,
+ work: File,
+ manifest: EngineRuntimeManifest,
+ manifestJson: String,
+ ) {
+ val staging = File(root, ".$RUNTIME_DIR-staging-${UUID.randomUUID()}")
+ val previous = File(root, ".$RUNTIME_DIR-previous")
+ val archive = File(work, ".${manifest.runtimeId.removePrefix("sha256:")}.zip.tmp")
+ listOf(staging, previous, archive).forEach { ensureChild(root, it) }
+ if (staging.exists() && !staging.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理 Maxima 安装暂存目录")
+ }
+ ensureDirectory(staging)
+ try {
+ copyAsset(context, "$ASSET_ROOT/${manifest.archive.path}", archive)
+ if (archive.length() != manifest.archive.size || sha256(archive) != manifest.archive.sha256) {
+ throw RuntimeInstallException(InstallStage.INTEGRITY, "Maxima 运行时归档校验失败")
+ }
+ extractArchive(archive, staging, manifest)
+ File(staging, MANIFEST_NAME).writeText(manifestJson)
+ verifyRuntime(staging, manifest)
+
+ if (previous.exists() && !previous.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理上一次 Maxima 运行时备份")
+ }
+ val hadRuntime = runtime.exists()
+ if (hadRuntime && !runtime.renameTo(previous)) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法备份当前 Maxima 运行时")
+ }
+ if (!staging.renameTo(runtime)) {
+ val rolledBack = !hadRuntime || previous.renameTo(runtime)
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ if (rolledBack) {
+ "无法启用新的 Maxima 运行时,已回滚"
+ } else {
+ "无法启用新的 Maxima 运行时,且回滚失败"
+ },
+ )
+ }
+ if (previous.exists()) previous.deleteRecursively()
+ } finally {
+ archive.delete()
+ if (staging.exists()) staging.deleteRecursively()
+ }
+ }
+
+ private fun extractArchive(
+ archive: File,
+ staging: File,
+ manifest: EngineRuntimeManifest,
+ ) {
+ val expected = manifest.files.associateBy { it.path }
+ val extracted = HashSet(expected.size)
+ ZipInputStream(archive.inputStream().buffered()).use { zip ->
+ while (true) {
+ val entry = zip.nextEntry ?: break
+ val path = EngineRuntimeManifest.normalizeRelativePath(entry.name)
+ val target = resolveRuntimeFile(staging, path)
+ if (entry.isDirectory) {
+ ensureDirectory(target)
+ zip.closeEntry()
+ continue
+ }
+ val record = expected[path]
+ ?: throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时归档包含未知文件:$path",
+ )
+ if (!extracted.add(path)) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时归档包含重复文件:$path",
+ )
+ }
+ target.parentFile?.let(::ensureDirectory)
+ target.outputStream().use { output ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var written = 0L
+ while (true) {
+ val count = zip.read(buffer)
+ if (count < 0) break
+ written += count
+ if (written > record.size) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时归档文件超出清单大小:$path",
+ )
+ }
+ output.write(buffer, 0, count)
+ }
+ }
+ zip.closeEntry()
+ }
+ }
+ if (extracted != expected.keys) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时文件集合不完整",
+ IllegalStateException("missing=${(expected.keys - extracted).take(8)}"),
+ )
+ }
+ }
+
+ private fun recoverInterruptedInstall(root: File, runtime: File) {
+ root.listFiles()
+ ?.filter { it.name.startsWith(".$RUNTIME_DIR-staging-") }
+ ?.forEach { orphan ->
+ ensureChild(root, orphan)
+ if (Files.isSymbolicLink(orphan.toPath()) ||
+ orphan.parentFile?.canonicalFile != root.canonicalFile ||
+ !orphan.deleteRecursively()
+ ) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "无法安全清理中断的 Maxima 安装暂存目录",
+ )
+ }
+ }
+ val previous = File(root, ".$RUNTIME_DIR-previous")
+ ensureChild(root, previous)
+ if (!runtime.exists() && isSelfConsistentRuntime(previous)) {
+ if (!previous.renameTo(runtime)) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法恢复上一次 Maxima 运行时")
+ }
return
}
- if (list.isEmpty()) {
- // 可能是空目录(AOSP),也可能是文件(Robolectric/部分实现):
- // 先按文件 open(),失败则视为空目录。
- try {
- copyAssetFile(context, path, target)
- return
- } catch (_: FileNotFoundException) {
- target.mkdirs()
- return
+ if (isSelfConsistentRuntime(runtime) && previous.exists()) previous.deleteRecursively()
+ }
+
+ private fun isSelfConsistentRuntime(runtime: File): Boolean {
+ val manifest = runCatching {
+ EngineRuntimeManifest.parse(File(runtime, MANIFEST_NAME).readText())
+ }.getOrNull() ?: return false
+ return isCurrentRuntime(runtime, manifest)
+ }
+
+ private fun isCurrentRuntime(runtime: File, expected: EngineRuntimeManifest): Boolean {
+ if (!runtime.isDirectory) return false
+ val installed = runCatching {
+ EngineRuntimeManifest.parse(File(runtime, MANIFEST_NAME).readText())
+ }.getOrNull() ?: return false
+ if (installed != expected) return false
+ return runCatching {
+ verifyRuntime(runtime, expected)
+ true
+ }.getOrDefault(false)
+ }
+
+ private fun verifyRuntime(runtime: File, manifest: EngineRuntimeManifest) {
+ val expected = manifest.files.associateBy { it.path }
+ val actual = runtime.walkTopDown()
+ .filter(File::isFile)
+ .map { it.relativeTo(runtime).invariantSeparatorsPath }
+ .filter { it != MANIFEST_NAME }
+ .toSet()
+ if (actual != expected.keys) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时文件集合不完整",
+ IllegalStateException(
+ "missing=${(expected.keys - actual).take(8)} extra=${(actual - expected.keys).take(8)}",
+ ),
+ )
+ }
+ expected.values.forEach { record ->
+ val file = resolveRuntimeFile(runtime, record.path)
+ if (!file.isFile || file.length() != record.size || sha256(file) != record.sha256) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Maxima 运行时文件损坏:${record.path}",
+ )
}
}
- target.mkdirs()
- list.forEach { name ->
- copyAssetDir(context, "$path/$name", File(target, name))
+ }
+
+ private fun validateNativeRuntime(
+ nativeLibDir: File,
+ manifest: EngineRuntimeManifest,
+ ): InstallResult.Failure? {
+ manifest.jniFiles.forEach { record ->
+ val file = resolveRuntimeFile(nativeLibDir, record.path)
+ if (!file.isFile || file.length() != record.size || sha256(file) != record.sha256) {
+ return InstallResult.Failure(
+ "Maxima 原生运行时与清单不一致:${record.path}",
+ "expectedSize=${record.size} actualSize=${file.takeIf(File::exists)?.length()}",
+ InstallStage.NATIVE,
+ )
+ }
+ }
+ return null
+ }
+
+ internal fun findEclDataDir(runtime: File): String = File(runtime, "lib")
+ .listFiles { file -> file.isDirectory && file.name.startsWith("ecl-") }
+ ?.maxByOrNull(File::getName)
+ ?.absolutePath
+ .orEmpty()
+
+ internal fun writeInitLisp(runtime: File, work: File, user: File): File {
+ val template = File(runtime, INIT_TEMPLATE)
+ require(template.isFile) { "Missing $INIT_TEMPLATE" }
+ val tmp = File(work, TMP_DIR).also(::ensureDirectory)
+ val content = template.readText()
+ .replace("__MAXIMA_DIR__", runtime.absolutePath)
+ .replace("__TEMP_DIR__", tmp.absolutePath)
+ .replace("__USER_DIR__", user.absolutePath)
+ return File(work, "init.lisp").apply { writeText(content) }
+ }
+
+ private fun removeLegacyRuntime(root: File) {
+ listOf("bin", "lib", "share", "tmp", "out").forEach { name ->
+ val legacy = File(root, name)
+ ensureChild(root, legacy)
+ if (legacy.exists()) legacy.deleteRecursively()
+ }
+ listOf("init.lisp", INIT_TEMPLATE, "engine_version").forEach { name ->
+ File(root, name).delete()
}
}
- private fun copyAssetFile(context: Context, path: String, target: File) {
- target.parentFile?.mkdirs()
- // 流式拷贝:readBytes() 会把整个文件读进内存,share 树里有若干 MB
- // 级条目,逐个全量装载会在首次安装时造成明显的内存尖峰。
+ private fun copyAsset(context: Context, path: String, target: File) {
+ target.parentFile?.let(::ensureDirectory)
context.assets.open(path).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
}
- private const val ENGINE_MARKER = "engine_version"
- private const val ENGINE_VERSION = "maxima-5.49.0-arm64-jnilib-autoconf-matplotlib-slim"
+ private fun resolveRuntimeFile(root: File, relativePath: String): File {
+ val safe = EngineRuntimeManifest.normalizeRelativePath(relativePath)
+ val file = File(root, safe.replace('/', File.separatorChar))
+ ensureChild(root, file)
+ return file
+ }
+
+ private fun ensureDirectory(directory: File) {
+ if (!directory.isDirectory && !directory.mkdirs()) {
+ throw IOException("无法创建目录:${directory.absolutePath}")
+ }
+ }
+
+ private fun ensureChild(root: File, target: File) {
+ val rootPath = root.canonicalFile.toPath()
+ val targetPath = target.canonicalFile.toPath()
+ require(targetPath.startsWith(rootPath) && targetPath != rootPath) {
+ "Unsafe Maxima runtime path: $target"
+ }
+ }
+
+ private fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (count > 0) digest.update(buffer, 0, count)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ private class RuntimeInstallException(
+ val stage: InstallStage,
+ val userMessage: String,
+ cause: Throwable? = null,
+ ) : IOException(userMessage, cause)
+
+ private fun Throwable.describe(): String = buildString {
+ append(javaClass.simpleName)
+ message?.takeIf(String::isNotBlank)?.let { append(": ").append(it) }
+ cause?.message?.takeIf(String::isNotBlank)?.let { append("; cause=").append(it) }
+ }.take(MAX_DETAILS)
+
+ private fun Throwable.isNoSpaceFailure(): Boolean = generateSequence(this) { it.cause }
+ .mapNotNull { it.message }
+ .any { message ->
+ message.contains("ENOSPC", true) ||
+ message.contains("No space left", true) ||
+ message.contains("空间不足")
+ }
+
+ private const val ROOT_DIR = "engine"
+ private const val RUNTIME_DIR = "runtime"
+ private const val WORK_DIR = "work"
+ private const val USER_DIR = "user"
+ private const val TMP_DIR = "tmp"
+ private const val OUT_DIR = "out"
+ private const val ASSET_ROOT = "engine"
+ private const val MANIFEST_NAME = "runtime-manifest.json"
+ private const val MANIFEST_ASSET = "$ASSET_ROOT/$MANIFEST_NAME"
private const val INIT_TEMPLATE = "init.lisp.template"
- private val SUPPORTED_ABIS = setOf("arm64-v8a", "armeabi-v7a", "armeabi", "x86_64", "x86")
+ private const val EXECUTABLE = "libmaxima.so"
+ private const val MAX_DETAILS = 16 * 1024
+ private const val INSTALL_SPACE_MARGIN_BYTES = 16L * 1024L * 1024L
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/EngineRuntimeManifest.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineRuntimeManifest.kt
new file mode 100644
index 0000000..d54bab7
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineRuntimeManifest.kt
@@ -0,0 +1,130 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONObject
+
+internal data class EngineRuntimeFile(
+ val path: String,
+ val size: Long,
+ val sha256: String,
+)
+
+/** Deterministic contract shared by the Maxima packager, installer and APK gate. */
+internal data class EngineRuntimeManifest(
+ val runtimeId: String,
+ val maximaVersion: String,
+ val compiledMaximaVersion: String,
+ val eclVersion: String,
+ val abi: String,
+ val archive: EngineRuntimeFile,
+ val files: List,
+ val jniFiles: List,
+) {
+ val totalBytes: Long get() = archive.size + files.sumOf { it.size }
+
+ companion object {
+ private val SHA256 = Regex("[0-9a-f]{64}")
+ private val RUNTIME_ID = Regex("sha256:[0-9a-f]{64}")
+
+ fun parse(json: String): EngineRuntimeManifest {
+ val obj = JSONObject(json)
+ requireInteger(obj.get("schemaVersion"), SCHEMA_VERSION.toLong(), "schemaVersion")
+ val runtimeId = obj.getString("runtimeId")
+ require(RUNTIME_ID.matches(runtimeId)) { "Invalid Maxima runtimeId" }
+ val maximaVersion = obj.getString("maximaVersion")
+ val compiledMaximaVersion = obj.getString("compiledMaximaVersion")
+ val eclVersion = obj.getString("eclVersion")
+ val abi = obj.getString("abi")
+ require(maximaVersion == MAXIMA_VERSION) {
+ "Unsupported Maxima version: $maximaVersion"
+ }
+ require(compiledMaximaVersion == maximaVersion) {
+ "Maxima compiled/runtime version mismatch: $compiledMaximaVersion != $maximaVersion"
+ }
+ require(eclVersion.isNotBlank()) { "Missing ECL version" }
+ require(abi == SUPPORTED_ABI) { "Unsupported Maxima ABI: $abi" }
+
+ val archive = parseFile(obj.getJSONObject("archive"))
+ require(archive.path == ARCHIVE_NAME) { "Unexpected Maxima archive path" }
+ val files = parseFiles(obj, "files")
+ val jniFiles = parseFiles(obj, "jniFiles")
+ require(files.isNotEmpty()) { "Maxima runtime manifest has no files" }
+ require(jniFiles.isNotEmpty()) { "Maxima runtime manifest has no JNI files" }
+ REQUIRED_JNI.forEach { required ->
+ require(jniFiles.any { it.path == required }) { "Missing Maxima JNI record: $required" }
+ }
+ REQUIRED_RUNTIME.forEach { required ->
+ require(files.any { it.path == required }) { "Missing Maxima runtime record: $required" }
+ }
+ return EngineRuntimeManifest(
+ runtimeId,
+ maximaVersion,
+ compiledMaximaVersion,
+ eclVersion,
+ abi,
+ archive,
+ files,
+ jniFiles,
+ )
+ }
+
+ private fun parseFiles(obj: JSONObject, field: String): List {
+ val array = obj.getJSONArray(field)
+ val seen = HashSet(array.length())
+ return buildList(array.length()) {
+ for (index in 0 until array.length()) {
+ val record = parseFile(array.getJSONObject(index))
+ require(seen.add(record.path)) { "Duplicate Maxima runtime file: ${record.path}" }
+ add(record)
+ }
+ }
+ }
+
+ private fun parseFile(obj: JSONObject): EngineRuntimeFile {
+ val path = normalizeRelativePath(obj.getString("path"))
+ val rawSize = obj.get("size")
+ val sizeDouble = (rawSize as? Number)?.toDouble()
+ require(
+ sizeDouble != null &&
+ sizeDouble.isFinite() &&
+ sizeDouble % 1.0 == 0.0 &&
+ sizeDouble in 0.0..MAX_SAFE_JSON_INTEGER.toDouble()
+ ) { "Invalid Maxima runtime file size: $path" }
+ val sha256 = obj.getString("sha256").lowercase()
+ require(SHA256.matches(sha256)) { "Invalid Maxima SHA-256: $path" }
+ return EngineRuntimeFile(path, sizeDouble.toLong(), sha256)
+ }
+
+ private fun requireInteger(value: Any, expected: Long, name: String) {
+ val number = value as? Number
+ val double = number?.toDouble()
+ require(double != null && double.isFinite() && double == expected.toDouble()) {
+ "Unsupported Maxima runtime $name"
+ }
+ }
+
+ internal fun normalizeRelativePath(raw: String): String {
+ require(raw.isNotBlank()) { "Empty Maxima runtime path" }
+ val normalized = raw.replace('\\', '/')
+ require(!normalized.startsWith('/') && !Regex("^[A-Za-z]:").containsMatchIn(normalized)) {
+ "Absolute Maxima runtime path is not allowed: $raw"
+ }
+ val parts = normalized.split('/')
+ require(parts.none { it.isBlank() || it == "." || it == ".." }) {
+ "Unsafe Maxima runtime path: $raw"
+ }
+ return parts.joinToString("/")
+ }
+
+ const val SCHEMA_VERSION = 1
+ const val MAXIMA_VERSION = "5.49.0"
+ const val SUPPORTED_ABI = "arm64-v8a"
+ const val ARCHIVE_NAME = "runtime.zip"
+ private const val MAX_SAFE_JSON_INTEGER = 9_007_199_254_740_991L
+ private val REQUIRED_JNI = setOf("libmaxima.so", "libecl.so")
+ private val REQUIRED_RUNTIME = setOf(
+ "init.lisp.template",
+ "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp",
+ )
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt b/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt
index ce99923..b0a323a 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/EngineService.kt
@@ -1,131 +1,294 @@
package com.paruh.maxmath.engine
import android.app.Service
-import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
+import android.os.Looper
import android.os.Message
import android.os.Messenger
-import android.os.Process
-/**
- * 独立进程中的引擎服务(android:process=":engine")。
- * 重计算通过 Messenger 转发到这里执行;取消即终止该进程。
- */
+/** Single-flight Maxima service with request-scoped phases, cancellation and terminal delivery. */
class EngineService : Service() {
-
- private lateinit var thread: HandlerThread
- private lateinit var handler: Handler
+ private lateinit var workerThread: HandlerThread
+ private lateinit var worker: Handler
private var messenger: Messenger? = null
+ private val lifecycleLock = Any()
+ private var activeRequestId: String? = null
+ private var cancellingRequestId: String? = null
+ private var cancelledRequestId: String? = null
+ @Volatile private var stopping = false
- /**
- * 空闲计时器。客户端每次请求后都会 unbind,若无其他绑定,服务连同
- * :engine 进程会立刻销毁——常驻 Maxima 进程也就无从常驻,每个请求还要
- * 重付进程创建、System.loadLibrary 和 Python/Matplotlib 启动的代价。
- * 因此这里在最后一次请求后多留 [IDLE_TIMEOUT_MS],然后主动收摊。
- */
private val idleStop = Runnable {
- MaximaEngine.stop()
- stopSelf()
+ val stop = synchronized(lifecycleLock) {
+ if (activeRequestId == null && !stopping) {
+ stopping = true
+ true
+ } else {
+ false
+ }
+ }
+ if (stop) {
+ MaximaEngine.stop()
+ stopSelf()
+ }
}
override fun onCreate() {
super.onCreate()
- thread = HandlerThread("maxima-engine").apply { start() }
- handler = Handler(thread.looper) { msg -> handleMessage(msg) }
- messenger = Messenger(handler)
- // startService 让服务不随最后一次 unbind 立即销毁;真正的结束由
- // 空闲计时器或取消触发。后台启动受限时会抛异常,此时退化成原来的
- // “每次请求重建进程”,不能让引擎进程直接崩掉。
- try {
- startService(Intent(this, EngineService::class.java))
- } catch (_: Exception) {
- }
+ workerThread = HandlerThread("maxima-engine").apply { start() }
+ worker = Handler(workerThread.looper)
+ messenger = Messenger(Handler(Looper.getMainLooper()) { message ->
+ handleIncoming(message)
+ })
+ runCatching { startService(Intent(this, EngineService::class.java)) }
+ restartIdleTimer()
}
override fun onBind(intent: Intent?): IBinder? = messenger?.binder
+ override fun onUnbind(intent: Intent?): Boolean = true
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_CANCEL) {
- MaximaEngine.cancel()
- Process.killProcess(Process.myPid())
- return START_NOT_STICKY
+ intent.getStringExtra(CalcIpc.REQUEST_ID)?.takeIf(::validRequestId)?.let(::beginCancellation)
}
- // 不需要系统在被杀后重建:保活靠 startService + stopSelf 这一对。
return START_NOT_STICKY
}
- /** 允许重新绑定,否则复用期间的第二次 bind 拿不到 onBind 的 binder。 */
- override fun onUnbind(intent: Intent?): Boolean = true
-
override fun onDestroy() {
- handler.removeCallbacks(idleStop)
- MaximaEngine.stop()
- thread.quitSafely()
+ synchronized(lifecycleLock) { stopping = true }
+ worker.removeCallbacks(idleStop)
+ activeRequestId?.let(::beginCancellation)
+ workerThread.quitSafely()
+ messenger = null
super.onDestroy()
+ // :engine is a dedicated process. A client deadline must terminate installation/startup
+ // work as well as nativeRun; stopping only the Service leaves its HandlerThread alive.
+ // The Maxima child has PR_SET_PDEATHSIG, so it cannot become an orphan.
+ android.os.Process.killProcess(android.os.Process.myPid())
}
- private fun restartIdleTimer() {
- handler.removeCallbacks(idleStop)
- handler.postDelayed(idleStop, IDLE_TIMEOUT_MS)
+ private fun handleIncoming(message: Message): Boolean {
+ when (message.data.getString(CalcIpc.ACTION)) {
+ CalcIpc.ACTION_RUN -> enqueueRun(message.data, message.replyTo)
+ CalcIpc.ACTION_CANCEL -> handleCancel(message.data, message.replyTo)
+ }
+ return true
}
- private fun handleMessage(msg: Message): Boolean {
- val bundle = msg.data
- val reply = msg.replyTo
- when (bundle.getString("action")) {
- "eval" -> {
- // 请求期间不计空闲;回复后重新计时。
- handler.removeCallbacks(idleStop)
- val requestJson = bundle.getString("json") ?: return true
- val request = CalcRequest.fromJson(requestJson)
- // 首次解压引擎资产较慢,放在工作线程执行,避免服务主线程 ANR。
- val initError = MaximaEngine.init(applicationContext)
- if (initError != null) {
- sendReply(reply, CalcResponse(id = request.id, ok = false, error = initError).toJson())
- restartIdleTimer()
- return true
- }
- val response = try {
- MaximaEngine.run(request)
- } catch (e: Exception) {
- CalcResponse(id = request.id, ok = false, error = e.message ?: "引擎内部错误")
+ private fun enqueueRun(bundle: Bundle, reply: Messenger?) {
+ val raw = bundle.getString(CalcIpc.JSON)
+ val request = runCatching {
+ requireNotNull(raw) { "Missing calculation request" }
+ CalcRequest.fromJson(raw).also { require(validRequestId(it.id)) }
+ }.getOrElse { error ->
+ sendFailure(
+ reply,
+ bundle.getString(CalcIpc.REQUEST_ID).orEmpty().ifBlank { "invalid-request" },
+ CalcFailureCode.INVALID_REQUEST,
+ CalcFailureStage.REQUEST,
+ "计算请求无效",
+ error.message,
+ )
+ return
+ }
+ val accepted = synchronized(lifecycleLock) {
+ if (stopping || activeRequestId != null) {
+ false
+ } else {
+ activeRequestId = request.id
+ cancellingRequestId = null
+ cancelledRequestId = null
+ worker.removeCallbacks(idleStop)
+ true
+ }
+ }
+ if (!accepted) {
+ sendFailure(
+ reply,
+ request.id,
+ CalcFailureCode.INVALID_REQUEST,
+ CalcFailureStage.REQUEST,
+ if (stopping) "Maxima 服务正在重启" else "Maxima 正在执行另一个请求",
+ "activeRequestId=$activeRequestId",
+ )
+ return
+ }
+ worker.post {
+ try {
+ runRequest(request, reply)
+ } finally {
+ synchronized(lifecycleLock) {
+ if (activeRequestId == request.id) activeRequestId = null
+ if (cancellingRequestId == request.id) cancellingRequestId = null
+ if (cancelledRequestId == request.id) cancelledRequestId = null
}
- sendReply(reply, response.toJson())
restartIdleTimer()
}
- "cancel" -> {
- MaximaEngine.cancel()
- sendReply(reply, """{"id":"","ok":true,"error":null}""")
- // 独立进程被用户取消:直接结束本进程,保证重计算真正终止
- Process.killProcess(Process.myPid())
+ }
+ }
+
+ private fun runRequest(request: CalcRequest, reply: Messenger?) {
+ if (!sendEvent(reply, CalcEvent.Preparing(request.id))) {
+ beginCancellation(request.id)
+ return
+ }
+ val initError = runCatching { MaximaEngine.init(applicationContext) }
+ .getOrElse { error -> "Maxima 初始化异常:${error.message}" }
+ if (initError != null) {
+ sendFailure(
+ reply,
+ request.id,
+ CalcFailureCode.INSTALL_FAILED,
+ CalcFailureStage.PREPARATION,
+ "Maxima 引擎准备失败",
+ initError,
+ )
+ return
+ }
+ if (isCancelled(request.id)) {
+ sendFailure(
+ reply,
+ request.id,
+ CalcFailureCode.CANCELLED,
+ CalcFailureStage.CANCELLATION,
+ "计算已取消",
+ )
+ return
+ }
+ if (!sendEvent(reply, CalcEvent.Running(request.id))) {
+ beginCancellation(request.id)
+ return
+ }
+ val response = runCatching { MaximaEngine.run(request) }
+ .getOrElse { error ->
+ CalcResponse.failed(
+ request.id,
+ CalcFailure(
+ CalcFailureCode.EXECUTION_FAILED,
+ CalcFailureStage.EXECUTION,
+ "Maxima 计算失败",
+ error.stackTraceToString().take(MAX_DETAILS),
+ ),
+ )
+ }
+ .let { value ->
+ if (!value.ok && value.failure == null) {
+ value.copy(
+ failure = CalcFailure(
+ if (value.error?.contains("超时") == true) {
+ CalcFailureCode.EXECUTION_TIMEOUT
+ } else {
+ CalcFailureCode.EXECUTION_FAILED
+ },
+ CalcFailureStage.EXECUTION,
+ value.error ?: "Maxima 计算失败",
+ ),
+ )
+ } else {
+ value
+ }
}
+ sendEvent(reply, CalcEvent.Done(request.id, response.copy(id = request.id)))
+ }
+
+ private fun handleCancel(bundle: Bundle, reply: Messenger?) {
+ val requestId = bundle.getString(CalcIpc.REQUEST_ID).orEmpty()
+ if (!validRequestId(requestId)) {
+ sendFailure(
+ reply,
+ "invalid-request",
+ CalcFailureCode.INVALID_REQUEST,
+ CalcFailureStage.CANCELLATION,
+ "取消请求缺少有效 ID",
+ )
+ return
+ }
+ if (!beginCancellation(requestId)) {
+ sendFailure(
+ reply,
+ requestId,
+ CalcFailureCode.CANCEL_NOT_ACTIVE,
+ CalcFailureStage.CANCELLATION,
+ "该计算已不再运行",
+ )
+ return
+ }
+ // The acknowledgement runs after the worker exits nativeRun and proves the old process
+ // generation has been reaped before the UI admits another request.
+ worker.post {
+ sendEvent(
+ reply,
+ CalcEvent.Done(
+ requestId,
+ CalcResponse.failed(
+ requestId,
+ CalcFailure(
+ CalcFailureCode.CANCELLED,
+ CalcFailureStage.CANCELLATION,
+ "计算已取消",
+ ),
+ ),
+ ),
+ )
}
- return true
}
- private fun sendReply(reply: Messenger?, json: String) {
- if (reply == null) return
- try {
- val m = Message.obtain()
- val b = Bundle()
- b.putString("json", json)
- m.data = b
- reply.send(m)
- } catch (_: Exception) {
+ private fun beginCancellation(requestId: String): Boolean {
+ val accepted = synchronized(lifecycleLock) {
+ when {
+ cancellingRequestId == requestId -> true
+ activeRequestId == requestId -> {
+ cancellingRequestId = requestId
+ cancelledRequestId = requestId
+ true
+ }
+ else -> false
+ }
}
+ if (accepted) MaximaEngine.cancel()
+ return accepted
}
+ private fun isCancelled(requestId: String): Boolean = synchronized(lifecycleLock) {
+ cancelledRequestId == requestId
+ }
+
+ private fun sendFailure(
+ reply: Messenger?,
+ requestId: String,
+ code: CalcFailureCode,
+ stage: CalcFailureStage,
+ message: String,
+ details: String? = null,
+ ): Boolean = sendEvent(
+ reply,
+ CalcEvent.Failure(requestId, CalcFailure(code, stage, message, details)),
+ )
+
+ private fun sendEvent(reply: Messenger?, event: CalcEvent): Boolean {
+ if (reply == null) return false
+ return runCatching {
+ reply.send(Message.obtain().apply {
+ data = Bundle().apply { putString(CalcIpc.JSON, event.toJson()) }
+ })
+ }.isSuccess
+ }
+
+ private fun restartIdleTimer() {
+ if (stopping) return
+ worker.removeCallbacks(idleStop)
+ worker.postDelayed(idleStop, IDLE_TIMEOUT_MS)
+ }
+
+ private fun validRequestId(value: String): Boolean =
+ value.isNotBlank() && value.length <= 128 && value.none { it.isISOControl() }
+
companion object {
const val ACTION_CANCEL = "com.paruh.maxmath.engine.CANCEL"
-
- /**
- * 空闲多久后释放常驻 Maxima 与 :engine 进程。取值要盖住“看完结果再
- * 算下一题”的间隔,又不至于让一个几十 MB 的进程长期挂着。
- */
private const val IDLE_TIMEOUT_MS = 60_000L
+ private const val MAX_DETAILS = 16 * 1024
}
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt b/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt
index bc0134d..f16a407 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/MaximaEngine.kt
@@ -18,11 +18,7 @@ object MaximaEngine {
private const val EVAL_TIMEOUT_MS = 120_000L
- /**
- * 常驻 Maxima 的空闲上限。轻量操作在 UI 进程内执行,没有 EngineService
- * 的空闲计时器兜底,若不在这一层回收,一个几十 MB 的原生子进程会跟着
- * UI 进程一直挂到应用退出。
- */
+ /** 常驻 Maxima 子进程的空闲回收上限;所有任务均由 :engine 服务调用。 */
private const val IDLE_STOP_MS = 60_000L
private val loadLock = Any()
@@ -50,6 +46,10 @@ object MaximaEngine {
@Volatile
private var installed = false
+ /** nativeStart 已提交启动参数;包括尚在执行冷启动握手的阶段。 */
+ @Volatile
+ private var nativeConfigured = false
+
@Volatile
private var engineDir: File? = null
@@ -59,7 +59,9 @@ object MaximaEngine {
/** 保存启动参数;原生层按需惰性拉起常驻子进程。 */
private external fun nativeStart(
maximaPath: String,
+ runtimeDir: String,
workDir: String,
+ userDir: String,
initLisp: String,
libDir: String,
eclDataDir: String,
@@ -139,22 +141,58 @@ object MaximaEngine {
appContext = context.applicationContext
nativeStart(
install.maximaPath,
+ install.runtimeDir,
install.workDir,
+ install.userDir,
install.initLispPath,
install.libDir,
install.eclDataDir,
)
+ nativeConfigured = true
Log.i(
LOG_TAG,
- "engine ready: binary=${install.maximaPath} libDir=${install.libDir} " +
- "ecl=${install.eclDataDir}",
+ "engine ready: binary=${install.maximaPath} libDir=${install.libDir} " +
+ "ecl=${install.eclDataDir} runtimeId=${install.runtimeId}",
)
+ val handshake = verifyStartup(File(install.workDir))
+ if (handshake != null) {
+ nativeStop()
+ nativeConfigured = false
+ engineDir = null
+ appContext = null
+ return handshake
+ }
}
}
installed = true
null
}
+ /** Verify the real Maxima search path, not merely the presence of files on disk. */
+ private fun verifyStartup(workDir: File): String? {
+ val outFile = File(workDir, "out/startup_${System.nanoTime()}.txt")
+ outFile.parentFile?.mkdirs()
+ val escaped = outFile.absolutePath.replace("\\", "\\\\").replace("\"", "\\\"")
+ val script = """
+ |with_stdout("$escaped",
+ | errcatch(block([p,d],
+ | p: file_search("linearalgebra"),
+ | d: determinant(ident(3)),
+ | print("MAXMATH_RESULT"),
+ | print(string([p,d])),
+ | print("MAXMATH_TEX"),
+ | print(string(d)))))$
+ """.trimMargin()
+ val outcome = ScriptRunner.runScript(script, outFile, ::eval)
+ return if (outcome.ok && outcome.plain?.contains("linearalgebra") == true &&
+ outcome.plain.endsWith(",1]")
+ ) {
+ null
+ } else {
+ "Maxima 启动自检失败:${outcome.errorText ?: outcome.plain ?: "linearalgebra 不可用"}"
+ }
+ }
+
fun isReady(): Boolean = installed
fun run(request: CalcRequest): CalcResponse {
@@ -173,20 +211,18 @@ object MaximaEngine {
/**
* 中止进行中的计算。
*
- * 必须判断 [running]:CalcViewModel/PlotViewModel 在每次发起计算前都会
- * 无条件调一次 cancel,若不加判断,空闲的常驻子进程会在每次计算前被杀掉,
- * 常驻也就名存实亡。
+ * 必须判断 [running]:请求级取消不能误杀一个已完成、准备复用的空闲子进程。
*/
fun cancel() {
// 不持有 runLock:nativeRun 可能在等待子进程,取消必须立即可达。
- if (installed && running) {
+ if (nativeConfigured && running) {
nativeCancel()
}
}
fun stop() {
synchronized(runLock) {
- if (installed) {
+ if (nativeConfigured) {
nativeStop()
}
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt b/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt
index ed46372..fde3221 100644
--- a/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt
+++ b/engine/src/main/java/com/paruh/maxmath/engine/MaximaScriptBuilder.kt
@@ -76,7 +76,14 @@ object MaximaScriptBuilder {
MatrixKind.INV -> matrixBody(task, "invert(M)")
MatrixKind.TRANSPOSE -> matrixBody(task, "transpose(M)")
MatrixKind.RANK -> matrixBody(task, "rank(M)")
- MatrixKind.TRACE -> matrixBody(task, "mat_trace(M)")
+ MatrixKind.TRACE -> matrixBody(
+ task,
+ """block([i,acc:0],
+ if length(M) # length(first(M)) then
+ error("The matrix must be square"),
+ for i thru length(M) do acc: acc + M[i,i],
+ acc)""".trimIndent(),
+ )
MatrixKind.EIGEN -> matrixBody(task, "load(\"eigen\"), eigenvectors(M)")
MatrixKind.QUAD_EXPAND -> quadraticExpandCommand(task)
MatrixKind.QUAD_SIGNATURE -> matrixBody(task, "load(\"eigen\"), eigenvalues(M)")
@@ -153,8 +160,8 @@ object MaximaScriptBuilder {
CalculusKind.INDEFINITE_INTEGRATE ->
"integrate((${expr(task.expression, task.raw)}),${task.variable})"
CalculusKind.INTEGRATE -> {
- val lower = task.lower?.takeIf { it.isNotBlank() }
- val upper = task.upper?.takeIf { it.isNotBlank() }
+ val lower = task.lower?.takeIf { it.isNotBlank() }?.let { boundExpr(it, task.raw) }
+ val upper = task.upper?.takeIf { it.isNotBlank() }?.let { boundExpr(it, task.raw) }
if (lower == null && upper == null) {
"integrate((${expr(task.expression, task.raw)}),${task.variable})"
} else {
@@ -179,6 +186,11 @@ object MaximaScriptBuilder {
private fun expr(text: String, raw: Boolean): String =
if (raw) text.trim() else MathInputParser.toMaxima(text)
+ private fun boundExpr(text: String, raw: Boolean): String {
+ val trimmed = text.trim()
+ return if (!raw && trimmed.startsWith("%")) trimmed else expr(trimmed, raw)
+ }
+
private fun expr(text: String): String = MathInputParser.toMaxima(text)
}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveClient.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveClient.kt
new file mode 100644
index 0000000..beddfc3
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveClient.kt
@@ -0,0 +1,387 @@
+package com.paruh.maxmath.engine
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.IBinder
+import android.os.Message
+import android.os.Messenger
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONObject
+import kotlin.coroutines.resume
+
+/** Request-scoped Messenger client with deterministic terminal and cleanup semantics. */
+class OctaveClient(private val context: Context) : OctaveGateway {
+
+ override suspend fun run(
+ request: OctaveRequest,
+ onEvent: (OctaveEvent) -> Unit,
+ ): OctaveResponse {
+ val transport = try {
+ prepareTransport(request)
+ } catch (error: Exception) {
+ if (error is kotlinx.coroutines.CancellationException) throw error
+ val failure = OctaveFailure(
+ code = if (error is IllegalArgumentException) {
+ OctaveFailureCode.INVALID_REQUEST
+ } else {
+ OctaveFailureCode.IO_ERROR
+ },
+ stage = OctaveFailureStage.REQUEST,
+ message = "Unable to prepare Octave request",
+ details = error.message,
+ )
+ runCatching { onEvent(OctaveEvent.Failure(request.id, failure)) }
+ return OctaveResponse.failed(request.id, failure)
+ }
+ try {
+ val terminal = withTimeoutOrNull(request.timeoutMs + CLIENT_DEADLINE_GRACE_MS) {
+ awaitTerminal(
+ requestId = request.id,
+ action = OctaveIpc.ACTION_RUN,
+ payload = transport.request.toJson().toString(),
+ onEvent = onEvent,
+ )
+ } ?: OctaveEvent.Failure(
+ request.id,
+ OctaveFailure(
+ code = OctaveFailureCode.CLIENT_DEADLINE,
+ stage = OctaveFailureStage.RESPONSE,
+ message = "Timed out waiting for the Octave service",
+ details = "deadlineMs=${request.timeoutMs + CLIENT_DEADLINE_GRACE_MS}",
+ ),
+ ).also { runCatching { onEvent(it) } }
+
+ return when (terminal) {
+ is OctaveEvent.Done -> terminal.response.copy(id = request.id)
+ is OctaveEvent.Failure -> OctaveResponse.failed(request.id, terminal.failure)
+ else -> OctaveResponse.failed(
+ request.id,
+ OctaveFailure(
+ code = OctaveFailureCode.PROTOCOL_ERROR,
+ stage = OctaveFailureStage.RESPONSE,
+ message = "Octave event stream ended without a terminal event",
+ ),
+ )
+ }
+ } finally {
+ transport.file?.let { file ->
+ withContext(NonCancellable + Dispatchers.IO) { runCatching { file.delete() } }
+ }
+ }
+ }
+
+ override suspend fun cancel(requestId: String): OctaveEvent =
+ withTimeoutOrNull(CANCEL_DEADLINE_MS) {
+ awaitTerminal(
+ requestId = requestId,
+ action = OctaveIpc.ACTION_CANCEL,
+ payload = requestId,
+ onEvent = {},
+ )
+ } ?: OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ code = OctaveFailureCode.CLIENT_DEADLINE,
+ stage = OctaveFailureStage.CANCELLATION,
+ message = "Timed out waiting for cancellation confirmation",
+ details = "deadlineMs=$CANCEL_DEADLINE_MS",
+ ),
+ )
+
+ /** Compatibility convenience for non-UI callers while IPC migrates to [OctaveRequest]. */
+ suspend fun run(
+ task: OctaveTask,
+ onOutput: (String) -> Unit = {},
+ timeoutMs: Long = OctaveRequest.DEFAULT_TIMEOUT_MS,
+ ): OctaveResponse = run(
+ OctaveRequest(UUID.randomUUID().toString(), task, timeoutMs),
+ ) { event ->
+ if (event is OctaveEvent.Output) onOutput(event.text)
+ }
+
+ private suspend fun awaitTerminal(
+ requestId: String,
+ action: String,
+ payload: String,
+ onEvent: (OctaveEvent) -> Unit,
+ ): OctaveEvent = suspendCancellableCoroutine { continuation ->
+ val terminal = AtomicBoolean(false)
+ val binding = SafeBinding(context)
+ val remoteRef = AtomicReference(null)
+
+ fun failure(
+ code: OctaveFailureCode,
+ message: String,
+ details: String? = null,
+ ): OctaveEvent.Failure = OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ code = code,
+ stage = if (action == OctaveIpc.ACTION_CANCEL) {
+ OctaveFailureStage.CANCELLATION
+ } else {
+ OctaveFailureStage.BIND
+ },
+ message = message,
+ details = details,
+ ),
+ )
+
+ fun finish(event: OctaveEvent) {
+ if (!terminal.compareAndSet(false, true)) return
+ runCatching { onEvent(event) }
+ binding.cleanup()
+ if (continuation.isActive) continuation.resume(event)
+ }
+
+ fun receive(event: OctaveEvent) {
+ if (event.requestId != requestId || terminal.get()) return
+ when (event) {
+ is OctaveEvent.Started,
+ is OctaveEvent.Output,
+ -> runCatching { onEvent(event) }
+ is OctaveEvent.Done,
+ is OctaveEvent.Failure,
+ -> finish(event)
+ }
+ }
+
+ val replyMessenger = Messenger(HandlerProxy { message ->
+ val raw = message.data.getString(OctaveIpc.KEY_JSON) ?: return@HandlerProxy
+ val event = runCatching { OctaveEvent.fromJson(JSONObject(raw)) }
+ .getOrElse { error ->
+ finish(
+ failure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ "Invalid response from the Octave service",
+ error.message,
+ ),
+ )
+ return@HandlerProxy
+ }
+ receive(event)
+ })
+
+ val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ if (terminal.get()) return
+ if (binder == null) {
+ finish(failure(OctaveFailureCode.NULL_BINDER, "Octave service returned no binder"))
+ return
+ }
+ val remote = runCatching { Messenger(binder) }.getOrElse { error ->
+ finish(failure(OctaveFailureCode.NULL_BINDER, "Invalid Octave service binder", error.message))
+ return
+ }
+ remoteRef.set(remote)
+ val message = Message.obtain().apply {
+ data = Bundle().apply {
+ putString("action", action)
+ if (action == OctaveIpc.ACTION_RUN) {
+ putString(OctaveIpc.KEY_JSON, payload)
+ } else {
+ putString(OctaveIpc.KEY_REQUEST_ID, payload)
+ }
+ }
+ replyTo = replyMessenger
+ }
+ runCatching { remote.send(message) }.onFailure { error ->
+ finish(
+ failure(
+ OctaveFailureCode.BIND_FAILED,
+ "Unable to send request to the Octave service",
+ error.message,
+ ),
+ )
+ }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ finish(
+ failure(
+ OctaveFailureCode.SERVICE_DISCONNECTED,
+ "Octave service disconnected",
+ ),
+ )
+ }
+
+ override fun onBindingDied(name: ComponentName?) {
+ finish(
+ failure(
+ OctaveFailureCode.SERVICE_DISCONNECTED,
+ "Octave service binding died",
+ ),
+ )
+ }
+
+ override fun onNullBinding(name: ComponentName?) {
+ finish(failure(OctaveFailureCode.NULL_BINDER, "Octave service returned a null binding"))
+ }
+ }
+ binding.attach(connection)
+ continuation.invokeOnCancellation {
+ val ownsCancellation = terminal.compareAndSet(false, true)
+ if (ownsCancellation && action == OctaveIpc.ACTION_RUN) {
+ val sent = remoteRef.get()?.let { remote ->
+ runCatching {
+ remote.send(Message.obtain().apply {
+ data = Bundle().apply {
+ putString("action", OctaveIpc.ACTION_CANCEL)
+ putString(OctaveIpc.KEY_REQUEST_ID, requestId)
+ }
+ })
+ }.isSuccess
+ } == true
+ if (!sent) {
+ runCatching {
+ context.startService(
+ Intent(context, OctaveService::class.java)
+ .setAction(OctaveService.ACTION_CANCEL)
+ .putExtra(OctaveIpc.KEY_REQUEST_ID, requestId),
+ )
+ }
+ }
+ }
+ binding.cleanup()
+ }
+
+ val bound = runCatching {
+ context.bindService(
+ Intent(context, OctaveService::class.java),
+ connection,
+ Context.BIND_AUTO_CREATE,
+ )
+ }.getOrElse { error ->
+ binding.resolve(success = false)
+ finish(failure(OctaveFailureCode.BIND_FAILED, "Unable to bind Octave service", error.message))
+ return@suspendCancellableCoroutine
+ }
+ binding.resolve(bound)
+ if (!bound) {
+ finish(failure(OctaveFailureCode.BIND_FAILED, "Unable to bind Octave service"))
+ }
+ }
+
+ /** Handles terminal-before-bind-return and never unbinds a failed binding. */
+ private class SafeBinding(private val context: Context) {
+ private var connection: ServiceConnection? = null
+ private var resolved = false
+ private var bound = false
+ private var cleanupRequested = false
+
+ @Synchronized
+ fun attach(value: ServiceConnection) {
+ connection = value
+ }
+
+ fun resolve(success: Boolean) {
+ val toUnbind = synchronized(this) {
+ resolved = true
+ bound = success
+ if (success && cleanupRequested) takeBoundConnection() else null
+ }
+ safeUnbind(toUnbind)
+ }
+
+ fun cleanup() {
+ val toUnbind = synchronized(this) {
+ cleanupRequested = true
+ if (resolved && bound) takeBoundConnection() else null
+ }
+ safeUnbind(toUnbind)
+ }
+
+ private fun takeBoundConnection(): ServiceConnection? {
+ if (!bound) return null
+ bound = false
+ return connection
+ }
+
+ private fun safeUnbind(value: ServiceConnection?) {
+ if (value != null) runCatching { context.unbindService(value) }
+ }
+ }
+
+ private class HandlerProxy(
+ private val onMessage: (Message) -> Unit,
+ ) : android.os.Handler(android.os.Looper.getMainLooper()) {
+ override fun handleMessage(message: Message) = onMessage(message)
+ }
+
+ private suspend fun prepareTransport(request: OctaveRequest): PreparedTransport =
+ withContext(NonCancellable + Dispatchers.IO) {
+ val task = request.task
+ val source = when (task) {
+ is OctaveEvalTask -> task.command
+ is OctaveRunScriptTask -> task.script
+ else -> return@withContext PreparedTransport(request, null)
+ }
+ val sourceBytes = source.toByteArray(Charsets.UTF_8).size
+ require(sourceBytes <= MAX_SOURCE_BYTES) {
+ "Octave source exceeds $MAX_SOURCE_BYTES bytes"
+ }
+ val displayName = when (task) {
+ is OctaveRunScriptTask -> task.name
+ else -> "command.m"
+ }
+ val safeName = displayName
+ .replace(Regex("[^A-Za-z0-9_.-]"), "_")
+ .take(72)
+ .ifBlank { "command.m" }
+ val token = "src_${UUID.randomUUID().toString().replace("-", "")}_$safeName"
+ .take(180)
+ require(OctaveSourceFileTask.SAFE_TOKEN.matches(token)) { "Invalid source token" }
+ val directory = File(context.filesDir, OctaveSourceFileTask.DIRECTORY)
+ check(directory.isDirectory || directory.mkdirs()) {
+ "Unable to create Octave IPC source directory"
+ }
+ val staleBefore = System.currentTimeMillis() - STALE_SOURCE_MS
+ directory.listFiles()
+ ?.filter { it.isFile && it.lastModified() < staleBefore }
+ ?.forEach { runCatching { it.delete() } }
+ val target = File(directory, token)
+ check(target.canonicalFile.parentFile == directory.canonicalFile) {
+ "Unsafe Octave IPC source path"
+ }
+ val temporary = File(directory, ".$token.tmp")
+ try {
+ temporary.writeText(source, Charsets.UTF_8)
+ if (target.exists() && !target.delete()) {
+ error("Unable to replace Octave IPC source")
+ }
+ if (!temporary.renameTo(target)) {
+ error("Unable to publish Octave IPC source")
+ }
+ } catch (error: Exception) {
+ temporary.delete()
+ throw error
+ }
+ PreparedTransport(
+ request.copy(task = OctaveSourceFileTask(token, displayName.take(128))),
+ target,
+ )
+ }
+
+ private data class PreparedTransport(
+ val request: OctaveRequest,
+ val file: File?,
+ )
+
+ companion object {
+ private const val CLIENT_DEADLINE_GRACE_MS = 5_000L
+ private const val CANCEL_DEADLINE_MS = 15_000L
+ private const val STALE_SOURCE_MS = 24L * 60L * 60L * 1_000L
+ private const val MAX_SOURCE_BYTES = 4 * 1024 * 1024
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveEngine.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveEngine.kt
new file mode 100644
index 0000000..55b6c95
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveEngine.kt
@@ -0,0 +1,737 @@
+package com.paruh.maxmath.engine
+
+import android.content.Context
+import android.util.Log
+import com.paruh.maxmath.engine.OctaveInstaller.OctaveInstallInfo
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.locks.ReentrantLock
+
+/**
+ * Process-independent facade for the persistent Octave session.
+ *
+ * Installation and session construction are serialized, execution is single-flight, while
+ * cancellation deliberately bypasses every execution lock so it can terminate a blocked child.
+ */
+object OctaveEngine {
+ private const val LOG_TAG = "OctaveEngine"
+ private const val DEFAULT_TIMEOUT_MS = 120_000L
+
+ private val installLock = Any()
+ private val sessionLock = Any()
+ private val executeLock = ReentrantLock()
+ private val activeRequestId = AtomicReference(null)
+ private val cancelledRequestId = AtomicReference(null)
+
+ @Volatile private var appContext: Context? = null
+ @Volatile private var installInfo: OctaveInstallInfo? = null
+ @Volatile private var session: OctaveSession? = null
+
+ /** Stores application context without doing I/O; call from OctaveService.onCreate. */
+ fun configure(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ /** Compatibility entry point which also performs the startup handshake. */
+ fun init(context: Context): String? {
+ configure(context)
+ return ensureSession("__init__")?.message
+ }
+
+ fun eval(
+ task: OctaveTask,
+ id: String,
+ onStarted: () -> Unit = {},
+ onOutput: (String) -> Unit = {},
+ timeoutMs: Long = DEFAULT_TIMEOUT_MS,
+ ): OctaveResponse {
+ if (!executeLock.tryLock()) {
+ return failed(
+ id,
+ OctaveFailureCode.BUSY,
+ OctaveFailureStage.REQUEST,
+ "Octave 正在执行另一项任务",
+ )
+ }
+ activeRequestId.set(id)
+ try {
+ if (isCancelled(id)) return cancelled(id)
+ val startupFailure = ensureSession(id)
+ if (startupFailure != null) return OctaveResponse.failed(id, startupFailure)
+ if (isCancelled(id)) {
+ session?.cancel(id)
+ return cancelled(id)
+ }
+
+ val prepared = try {
+ prepareCommand(task, id)
+ } catch (e: Exception) {
+ return failed(
+ id,
+ OctaveFailureCode.IO_ERROR,
+ OctaveFailureStage.EXECUTION,
+ "无法准备 Octave 任务",
+ e.describe(),
+ )
+ }
+ val current = session ?: return failed(
+ id,
+ OctaveFailureCode.START_FAILED,
+ OctaveFailureStage.STARTUP,
+ "Octave 会话未启动",
+ )
+ onStarted()
+ val outcome = try {
+ current.execute(id, prepared.command, timeoutMs, onOutput)
+ } finally {
+ prepared.cleanupFile?.delete()
+ }
+ if (outcome.failure != null) {
+ Log.e(
+ LOG_TAG,
+ "session failure id=$id kind=${outcome.failure.kind} " +
+ "exitCode=${outcome.failure.exitCode} details=${outcome.failure.details.orEmpty()}",
+ )
+ discardSession(current)
+ return OctaveResponse.failed(
+ id = id,
+ failure = outcome.failure.toPublicFailure(),
+ output = "",
+ )
+ }
+
+ val workspace = parseWorkspace(outcome.protocolLines, id)
+ val preview = if (task is OctavePreviewTask) {
+ parsePreview(outcome.protocolLines, id)
+ } else {
+ null
+ }
+ val protocolFailure = parseProtocolFailure(outcome.protocolLines, id)
+ val displayName = (task as? OctaveSourceFileTask)?.displayName
+ val normalizedProtocolFailure = protocolFailure?.let { failure ->
+ val sourcePath = prepared.cleanupFile?.absolutePath
+ val locationPath = failure.location?.file
+ val isRequestSource = sourcePath != null && locationPath != null &&
+ (locationPath == sourcePath || File(locationPath).name == File(sourcePath).name)
+ if (displayName != null && failure.location != null && isRequestSource) {
+ failure.copy(location = failure.location.copy(file = displayName))
+ } else {
+ failure
+ }
+ }
+ val scriptLocation = normalizedProtocolFailure?.location ?: parseScriptLocation(outcome.errorText)
+ val errorText = buildString {
+ outcome.errorText?.let { append(it.trim()) }
+ scriptLocation?.let { location ->
+ if (isNotEmpty()) append('\n')
+ append("script=").append(location.file).append(" line=").append(location.line)
+ }
+ }.ifBlank { null }
+ val commandFailure = normalizedProtocolFailure ?: errorText?.let {
+ OctaveFailure(
+ code = OctaveFailureCode.EXECUTION_FAILED,
+ stage = OctaveFailureStage.EXECUTION,
+ message = it.lineSequence().firstOrNull().orEmpty().ifBlank { "Octave 执行失败" },
+ details = it,
+ )
+ } ?: if (task is OctavePreviewTask && preview == null) {
+ OctaveFailure(
+ code = OctaveFailureCode.PROTOCOL_ERROR,
+ stage = OctaveFailureStage.RESPONSE,
+ message = "Octave 变量预览响应缺失或损坏",
+ )
+ } else if (workspace == null) {
+ OctaveFailure(
+ code = OctaveFailureCode.PROTOCOL_ERROR,
+ stage = OctaveFailureStage.RESPONSE,
+ message = "Octave 工作区响应缺失或损坏",
+ )
+ } else {
+ null
+ }
+ val plot = validatePlotArtifact(outcome.plotFile, id, commandFailure == null)
+ val failure = commandFailure ?: plot.failure
+ return OctaveResponse(
+ id = id,
+ ok = failure == null,
+ output = "",
+ plotSpec = null,
+ workspace = workspace,
+ preview = preview,
+ error = failure?.message,
+ failure = failure,
+ plotPath = plot.path,
+ )
+ } catch (e: Exception) {
+ val current = session
+ discardSession(current)
+ return failed(
+ id,
+ OctaveFailureCode.UNKNOWN,
+ OctaveFailureStage.EXECUTION,
+ "Octave 引擎内部错误",
+ e.describe(),
+ )
+ } finally {
+ activeRequestId.clearOctaveRequest(id)
+ cancelledRequestId.clearOctaveRequest(id)
+ executeLock.unlock()
+ }
+ }
+
+ /** Cancels only the matching request, without acquiring [executeLock]. */
+ fun cancel(requestId: String): Boolean {
+ if (activeRequestId.get() != requestId) return false
+ cancelledRequestId.set(requestId)
+ session?.cancel(requestId)
+ return true
+ }
+
+ /** Service-only bridge for the accepted-but-not-yet-entered worker window. */
+ internal fun cancelScheduled(requestId: String): Boolean {
+ val active = activeRequestId.get()
+ if (active != null && active != requestId) return false
+ cancelledRequestId.set(requestId)
+ if (active == requestId) session?.cancel(requestId)
+ return true
+ }
+
+ /** Clears a queued cancellation after the service has reaped or skipped that request. */
+ internal fun clearScheduledCancellation(requestId: String) {
+ cancelledRequestId.clearOctaveRequest(requestId)
+ session?.clearCancellation(requestId)
+ }
+
+ fun stop() {
+ val old = synchronized(sessionLock) {
+ val value = session
+ session = null
+ value
+ }
+ old?.close()
+ activeRequestId.set(null)
+ cancelledRequestId.set(null)
+ // Runtime metadata remains valid for the lifetime of the isolated app process. Keeping
+ // it avoids re-hashing the large immutable JNI closure after every 60-second idle stop.
+ }
+
+ private fun ensureSession(requestId: String): OctaveFailure? {
+ session?.takeIf { it.isUsable() }?.let { return null }
+ synchronized(sessionLock) {
+ session?.takeIf { it.isUsable() }?.let { return null }
+ session?.close()
+ session = null
+ if (isCancelled(requestId)) {
+ return cancellationFailure()
+ }
+ ensureInstalled()?.let { failure -> return failure }
+ if (isCancelled(requestId)) return cancellationFailure()
+ val config = installInfo ?: return OctaveFailure(
+ OctaveFailureCode.START_FAILED,
+ OctaveFailureStage.STARTUP,
+ "Octave 运行时安装信息缺失",
+ )
+ cleanupPlotArtifacts(config.outDir)
+ val created = OctaveSession(config.toSessionConfig())
+ session = created // Publish before start so a matching cancel can reach boot.
+ val boot = created.start(requestId)
+ if (!boot.ok) {
+ if (session === created) session = null
+ created.close()
+ return boot.failure?.toPublicFailure(startup = true) ?: OctaveFailure(
+ OctaveFailureCode.START_FAILED,
+ OctaveFailureStage.STARTUP,
+ "Octave 启动握手失败",
+ )
+ }
+ if (isCancelled(requestId)) {
+ if (session === created) session = null
+ created.cancel(requestId)
+ created.close()
+ return cancellationFailure()
+ }
+ return null
+ }
+ }
+
+ /** Returns failure, or null after [installInfo] has been populated. */
+ private fun ensureInstalled(): OctaveFailure? = synchronized(installLock) {
+ if (installInfo != null) return@synchronized null
+ val context = appContext ?: return@synchronized OctaveFailure(
+ OctaveFailureCode.START_FAILED,
+ OctaveFailureStage.STARTUP,
+ "Octave 引擎未配置",
+ )
+ when (val result = OctaveInstaller.install(context)) {
+ is OctaveInstaller.InstallResult.Success -> {
+ installInfo = result.info
+ Log.i(
+ LOG_TAG,
+ "runtime ready: id=${result.info.runtimeId} version=${result.info.version}",
+ )
+ null
+ }
+ is OctaveInstaller.InstallResult.Failure -> OctaveFailure(
+ code = OctaveFailureCode.INSTALL_FAILED,
+ stage = OctaveFailureStage.INSTALLATION,
+ message = result.message,
+ details = buildString {
+ append("installStage=").append(result.stage.name.lowercase())
+ result.details?.let { append('\n').append(it) }
+ },
+ ).also { failure -> Log.e(LOG_TAG, failure.diagnosticText()) }
+ }
+ }
+
+ private fun prepareCommand(task: OctaveTask, requestId: String): PreparedCommand {
+ if (task is OctaveWhosTask) return PreparedCommand("")
+ val info = installInfo ?: error("Octave runtime is not installed")
+ if (task is OctaveSourceFileTask) {
+ val directory = File(info.workDir, "ipc")
+ val file = File(directory, task.token)
+ check(
+ file.isFile &&
+ file.length() <= MAX_SOURCE_BYTES &&
+ file.canonicalFile.parentFile == directory.canonicalFile
+ ) {
+ "Octave IPC source is missing or unsafe"
+ }
+ return PreparedCommand(
+ command = executeFileCommand(file, requestId),
+ cleanupFile = file,
+ )
+ }
+ val source = when (task) {
+ is OctaveEvalTask -> task.command
+ is OctaveRunScriptTask -> task.script
+ is OctavePreviewTask ->
+ "maxmath_preview('${octaveQuote(task.name)}','${octaveQuote(requestId)}')"
+ is OctaveClearTask -> if (task.name == null) {
+ "clear"
+ } else {
+ "clear('${octaveQuote(task.name)}')"
+ }
+ OctaveResetTask -> "clear; figure"
+ OctaveWhosTask -> error("handled above")
+ is OctaveSourceFileTask -> error("handled above")
+ }
+ val directory = if (task is OctaveRunScriptTask) {
+ File(info.workDir, "scripts")
+ } else {
+ File(info.workDir, "requests")
+ }.apply { check(isDirectory || mkdirs()) { "Unable to create Octave source directory" } }
+ val name = if (task is OctaveRunScriptTask) {
+ sanitizeScriptName(task.name)
+ } else {
+ "request_${requestId.replace(Regex("[^A-Za-z0-9_.-]"), "_")}.m"
+ }
+ val file = File(directory, name)
+ check(file.canonicalFile.parentFile == directory.canonicalFile) { "Unsafe Octave source path" }
+ file.writeText(source)
+ return PreparedCommand(
+ command = executeFileCommand(file, requestId),
+ cleanupFile = file.takeUnless { task is OctaveRunScriptTask },
+ )
+ }
+
+ private fun executeFileCommand(file: File, requestId: String): String =
+ "maxmath_execute_file('${octaveQuote(file.absolutePath)}','${octaveQuote(requestId)}')"
+
+ private fun octaveQuote(value: String): String = value.replace("'", "''")
+
+ private fun parseWorkspace(
+ protocolLines: List,
+ requestId: String,
+ ): List? {
+ val json = protocolLines.lastOrNull { it.startsWith(WHOS_PREFIX) }
+ ?.removePrefix(WHOS_PREFIX)
+ ?.trim()
+ ?: return null
+ return runCatching {
+ val token = org.json.JSONTokener(json).nextValue()
+ val array = when (token) {
+ is JSONObject -> {
+ requireOctaveProtocolVersion(token)
+ require(token.optString("requestId") == requestId)
+ token.getJSONArray("variables")
+ }
+ else -> error("Invalid workspace envelope")
+ }
+ buildList(array.length()) {
+ for (i in 0 until array.length()) add(OctaveVariable.fromJson(array.getJSONObject(i)))
+ }
+ }.onFailure { Log.w(LOG_TAG, "invalid workspace envelope", it) }.getOrNull()
+ }
+
+ private fun parsePreview(protocolLines: List, requestId: String): OctavePreview? {
+ val typedLine = protocolLines.lastOrNull { it.startsWith(PREVIEW_PREFIX) }
+ if (typedLine != null) {
+ return runCatching {
+ parseTypedPreview(
+ JSONObject(typedLine.removePrefix(PREVIEW_PREFIX).trim()),
+ requestId,
+ )
+ }
+ .onFailure { Log.w(LOG_TAG, "invalid typed preview envelope", it) }
+ .getOrNull()
+ }
+ return null
+ }
+
+ private fun parseTypedPreview(obj: JSONObject, requestId: String): OctavePreview {
+ requireOctaveProtocolVersion(obj)
+ require(obj.optString("requestId") == requestId)
+ val kind = obj.optString("kind")
+ val value = obj.opt("value")
+ val dimensions = obj.optJSONArray("shape")?.let { shape ->
+ IntArray(shape.length()) { shape.getInt(it) }
+ }
+ val typed = when {
+ kind == "string" || kind == "summary" -> OctavePreviewValue.Text(value?.toString().orEmpty())
+ kind == "matrix" && value is JSONArray && value.length() == 0 ->
+ OctavePreviewValue.Matrix(emptyList())
+ value == null -> null
+ else -> OctavePreviewValue.fromJsonLiteral(
+ jsonLiteral(value),
+ )
+ }
+ val valueJson = if (value == null) null else jsonLiteral(value)
+ val text = when {
+ typed != null -> typed.displayText()
+ valueJson != null -> OctaveTextFormatter.formatJson(valueJson)
+ else -> ""
+ }
+ return OctavePreview(
+ text = text,
+ valueJson = valueJson,
+ value = typed,
+ kind = kind.ifBlank { null },
+ className = obj.optString("class").ifBlank { null },
+ dims = dimensions,
+ complex = obj.optBoolean("complex", false),
+ truncated = obj.optBoolean("truncated", false),
+ estimatedBytes = if (obj.has("estimatedBytes") && !obj.isNull("estimatedBytes")) {
+ obj.getLong("estimatedBytes")
+ } else {
+ null
+ },
+ )
+ }
+
+ private fun parseScriptLocation(errorText: String?): OctaveScriptLocation? {
+ if (errorText.isNullOrBlank()) return null
+ val match = SCRIPT_LOCATION.findAll(errorText).lastOrNull() ?: return null
+ return OctaveScriptLocation(
+ file = match.groupValues[1].trim().ifBlank { "script.m" },
+ name = null,
+ line = match.groupValues[2].toIntOrNull() ?: return null,
+ column = null,
+ )
+ }
+
+ private fun parseProtocolFailure(
+ protocolLines: List,
+ requestId: String,
+ ): OctaveFailure? {
+ val raw = protocolLines.lastOrNull { it.startsWith(ERROR_PREFIX) }
+ ?.removePrefix(ERROR_PREFIX)
+ ?.trim()
+ ?: return null
+ return runCatching {
+ val obj = JSONObject(raw)
+ requireOctaveProtocolVersion(obj)
+ require(obj.optString("requestId") == requestId)
+ val stack = obj.optJSONArray("stack")
+ val location = if (stack != null) {
+ (0 until stack.length()).asSequence()
+ .map { stack.getJSONObject(it) }
+ .mapNotNull { frame ->
+ val line = frame.optInt("line", 0)
+ if (line <= 0) null else OctaveScriptLocation(
+ file = frame.optString("file").ifBlank { null },
+ name = frame.optString("name").ifBlank { null },
+ line = line,
+ column = frame.optInt("column", 0).takeIf { it > 0 },
+ )
+ }
+ .firstOrNull()
+ } else {
+ null
+ }
+ OctaveFailure(
+ code = if (obj.optString("identifier") == "MaxMath:plotWrite") {
+ OctaveFailureCode.IO_ERROR
+ } else {
+ OctaveFailureCode.EXECUTION_FAILED
+ },
+ stage = if (obj.optString("identifier") == "MaxMath:plotWrite") {
+ OctaveFailureStage.RESPONSE
+ } else {
+ OctaveFailureStage.EXECUTION
+ },
+ message = obj.optString("message").ifBlank { "Octave 执行失败" },
+ details = raw.take(MAX_FAILURE_DETAILS),
+ location = location,
+ )
+ }.getOrElse { error ->
+ Log.w(LOG_TAG, "invalid error envelope", error)
+ OctaveFailure(
+ code = OctaveFailureCode.PROTOCOL_ERROR,
+ stage = OctaveFailureStage.RESPONSE,
+ message = "Octave 错误响应损坏或请求标识不匹配",
+ details = raw.take(MAX_FAILURE_DETAILS),
+ )
+ }
+ }
+
+ private fun validatePlotArtifact(
+ file: File?,
+ requestId: String,
+ accept: Boolean,
+ ): PlotArtifact {
+ if (file == null) return PlotArtifact()
+ if (!accept || !file.isFile || file.length() > MAX_PLOT_BYTES) {
+ Log.w(LOG_TAG, "plot artifact rejected: ${file.absolutePath} (${file.length()} bytes)")
+ runCatching { file.delete() }
+ return if (accept) {
+ PlotArtifact(
+ failure = OctaveFailure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureStage.RESPONSE,
+ "Octave 绘图制品缺失或过大",
+ ),
+ )
+ } else {
+ PlotArtifact()
+ }
+ }
+ val valid = runCatching {
+ val artifact = JSONObject(file.readText())
+ runCatching { requireOctaveProtocolVersion(artifact) }.isSuccess &&
+ artifact.optString("requestId") == requestId
+ }.getOrDefault(false)
+ if (!valid) {
+ runCatching { file.delete() }
+ return PlotArtifact(
+ failure = OctaveFailure(
+ OctaveFailureCode.PROTOCOL_ERROR,
+ OctaveFailureStage.RESPONSE,
+ "Octave 绘图请求标识不匹配",
+ ),
+ )
+ }
+ return PlotArtifact(path = file.absolutePath)
+ }
+
+ private fun discardSession(expected: OctaveSession?) {
+ val old = synchronized(sessionLock) {
+ if (expected == null || session !== expected) return@synchronized null
+ session = null
+ expected
+ }
+ old?.close()
+ }
+
+ private fun cleanupPlotArtifacts(outDir: String) {
+ runCatching {
+ File(outDir).listFiles()
+ ?.filter { file ->
+ file.isFile && (file.name.startsWith("plot_") || file.name.contains(".tmp."))
+ }
+ ?.forEach { it.delete() }
+ }
+ }
+
+ private fun isCancelled(requestId: String): Boolean = cancelledRequestId.get() == requestId
+
+ private fun cancelled(id: String): OctaveResponse = OctaveResponse.failed(id, cancellationFailure())
+
+ private fun cancellationFailure(): OctaveFailure = OctaveFailure(
+ OctaveFailureCode.CANCELLED,
+ OctaveFailureStage.CANCELLATION,
+ "计算已取消",
+ )
+
+ private fun failed(
+ id: String,
+ code: OctaveFailureCode,
+ stage: OctaveFailureStage,
+ message: String,
+ details: String? = null,
+ ): OctaveResponse = OctaveResponse.failed(id, OctaveFailure(code, stage, message, details))
+
+ private fun OctaveInstallInfo.toSessionConfig() = OctaveSessionConfig(
+ execPath = execPath,
+ libDir = libDir,
+ octaveHome = octaveHome,
+ bridgeDir = bridgeDir,
+ workDir = workDir,
+ homeDir = homeDir,
+ tmpDir = tmpDir,
+ outDir = outDir,
+ )
+
+ private fun OctaveSessionFailure.toPublicFailure(startup: Boolean = false): OctaveFailure {
+ val linkerFailure = startup && kind == OctaveSessionFailureKind.EXITED &&
+ looksLikeLinkerFailure(listOfNotNull(message, details).joinToString("\n"))
+ val code = when {
+ linkerFailure -> OctaveFailureCode.LINK_FAILED
+ startup && kind == OctaveSessionFailureKind.EXITED -> OctaveFailureCode.START_FAILED
+ startup && kind == OctaveSessionFailureKind.START -> OctaveFailureCode.START_FAILED
+ startup && kind == OctaveSessionFailureKind.NOT_STARTED -> OctaveFailureCode.START_FAILED
+ startup && kind == OctaveSessionFailureKind.PROTOCOL -> OctaveFailureCode.START_FAILED
+ startup && kind == OctaveSessionFailureKind.EXECUTION -> OctaveFailureCode.START_FAILED
+ else -> when (kind) {
+ OctaveSessionFailureKind.START,
+ OctaveSessionFailureKind.NOT_STARTED,
+ -> OctaveFailureCode.START_FAILED
+ OctaveSessionFailureKind.CANCELLED -> OctaveFailureCode.CANCELLED
+ OctaveSessionFailureKind.TIMEOUT -> OctaveFailureCode.TIMEOUT
+ OctaveSessionFailureKind.MEMORY -> OctaveFailureCode.MEMORY_LIMIT
+ OctaveSessionFailureKind.EXITED -> OctaveFailureCode.PROCESS_EXITED
+ OctaveSessionFailureKind.IO -> OctaveFailureCode.IO_ERROR
+ OctaveSessionFailureKind.PROTOCOL -> OctaveFailureCode.PROTOCOL_ERROR
+ OctaveSessionFailureKind.EXECUTION -> OctaveFailureCode.EXECUTION_FAILED
+ }
+ }
+ val stage = when {
+ linkerFailure -> OctaveFailureStage.LINKING
+ startup && kind != OctaveSessionFailureKind.CANCELLED -> OctaveFailureStage.STARTUP
+ else -> when (kind) {
+ OctaveSessionFailureKind.START,
+ OctaveSessionFailureKind.NOT_STARTED,
+ -> OctaveFailureStage.STARTUP
+ OctaveSessionFailureKind.PROTOCOL -> if (startup) {
+ OctaveFailureStage.STARTUP
+ } else {
+ OctaveFailureStage.RESPONSE
+ }
+ OctaveSessionFailureKind.CANCELLED -> OctaveFailureStage.CANCELLATION
+ else -> OctaveFailureStage.EXECUTION
+ }
+ }
+ val summary = when (kind) {
+ OctaveSessionFailureKind.START -> "Octave 启动失败"
+ OctaveSessionFailureKind.NOT_STARTED -> "Octave 未启动"
+ OctaveSessionFailureKind.CANCELLED -> "计算已取消"
+ OctaveSessionFailureKind.TIMEOUT -> "计算超时"
+ OctaveSessionFailureKind.MEMORY -> "内存超限(超过 1.5GB),已终止"
+ OctaveSessionFailureKind.EXITED -> if (linkerFailure) {
+ "Octave 动态链接失败"
+ } else if (startup) {
+ "Octave 启动阶段意外退出"
+ } else {
+ "Octave 进程意外退出"
+ }
+ OctaveSessionFailureKind.IO -> "Octave 输入输出失败"
+ OctaveSessionFailureKind.PROTOCOL -> "Octave 启动握手失败"
+ OctaveSessionFailureKind.EXECUTION -> message
+ }
+ return OctaveFailure(
+ code = code,
+ stage = stage,
+ message = summary,
+ details = buildString {
+ append(message)
+ details?.takeIf { it.isNotBlank() }?.let {
+ append('\n').append(it.take(MAX_FAILURE_DETAILS))
+ }
+ }.take(MAX_FAILURE_DETAILS),
+ exitCode = exitCode,
+ )
+ }
+
+ private fun looksLikeLinkerFailure(diagnostic: String): Boolean = LINKER_FAILURE_MARKERS.any {
+ diagnostic.contains(it, ignoreCase = true)
+ }
+
+ private fun Throwable.describe(): String = buildString {
+ append(javaClass.simpleName)
+ message?.let { append(": ").append(it) }
+ }.take(MAX_FAILURE_DETAILS)
+
+ private fun jsonLiteral(value: Any): String = when (value) {
+ is String -> JSONObject.quote(value)
+ else -> value.toString()
+ }
+
+ private fun sanitizeScriptName(name: String): String {
+ val safe = name.replace(Regex("[^A-Za-z0-9_.-]"), "_").ifBlank { "script.m" }
+ return if (safe.endsWith(".m", ignoreCase = true)) safe else "$safe.m"
+ }
+
+ private data class PreparedCommand(
+ val command: String,
+ val cleanupFile: File? = null,
+ )
+
+ private data class PlotArtifact(
+ val path: String? = null,
+ val failure: OctaveFailure? = null,
+ )
+
+ private const val MAX_FAILURE_DETAILS = 16 * 1024
+ private const val MAX_PLOT_BYTES = 4L * 1024L * 1024L
+ private const val MAX_SOURCE_BYTES = 4L * 1024L * 1024L
+ private const val WHOS_PREFIX = "MAXMATH_WHOS "
+ private const val PREVIEW_PREFIX = "MAXMATH_PREVIEW "
+ private const val ERROR_PREFIX = "MAXMATH_ERROR "
+ private val LINKER_FAILURE_MARKERS = listOf(
+ "CANNOT LINK EXECUTABLE",
+ "cannot locate symbol",
+ "library not found",
+ "needed or dlopened by",
+ "dlopen failed",
+ )
+ private val SCRIPT_LOCATION = Regex("(?:called from\\s+)?([^\\n]*?)\\s+(?:at )?line\\s+(\\d+)", RegexOption.IGNORE_CASE)
+}
+
+/** Formats legacy numeric preview JSON using MATLAB-like notation. */
+object OctaveTextFormatter {
+ fun formatJson(raw: String): String = runCatching {
+ val token = org.json.JSONTokener(raw).nextValue()
+ when (token) {
+ is JSONArray -> format(token)
+ is JSONObject -> display(token)
+ is Number -> token.toString()
+ JSONObject.NULL -> "NaN"
+ else -> token.toString()
+ }
+ }.getOrDefault(raw)
+
+ fun format(arr: JSONArray): String {
+ val first = arr.optJSONArray(0)
+ if (first == null) {
+ return arr.length().let { length ->
+ if (length == 0) "[]" else buildString {
+ append("[ ")
+ for (i in 0 until length) append(display(arr.opt(i))).append(' ')
+ append(']')
+ }
+ }
+ }
+ return buildString {
+ append("[\n")
+ for (i in 0 until arr.length()) {
+ val row = arr.getJSONArray(i)
+ append(" ")
+ for (j in 0 until row.length()) append(display(row.opt(j))).append(" ")
+ append('\n')
+ }
+ append(']')
+ }
+ }
+
+ private fun display(value: Any?): String = when (value) {
+ null, JSONObject.NULL -> "NaN"
+ is JSONObject -> if (value.has("re") && value.has("im")) {
+ val imaginary = display(value.opt("im"))
+ "${display(value.opt("re"))}${if (imaginary.startsWith('-')) "" else "+"}${imaginary}i"
+ } else {
+ value.toString()
+ }
+ else -> value.toString()
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveInstaller.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveInstaller.kt
new file mode 100644
index 0000000..0a3d5c1
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveInstaller.kt
@@ -0,0 +1,498 @@
+package com.paruh.maxmath.engine
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.os.Build
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.security.MessageDigest
+import java.nio.file.Files
+import java.util.UUID
+
+/**
+ * Installs the immutable Octave asset runtime transactionally.
+ *
+ * User-owned work and home directories live beside, never inside, the replaceable runtime.
+ * A new runtime is fully extracted and verified in staging before an atomic directory swap.
+ */
+object OctaveInstaller {
+
+ data class OctaveInstallInfo(
+ val execPath: String,
+ val libDir: String,
+ val octaveHome: String,
+ val bridgeDir: String,
+ val workDir: String,
+ val homeDir: String,
+ val tmpDir: String,
+ val outDir: String,
+ val version: String,
+ val runtimeId: String,
+ )
+
+ enum class InstallStage { ABI, MANIFEST, INSTALL, INTEGRITY, NATIVE }
+
+ sealed interface InstallResult {
+ data class Success(val info: OctaveInstallInfo) : InstallResult
+ data class Failure(
+ val message: String,
+ val details: String? = null,
+ val stage: InstallStage = InstallStage.INSTALL,
+ ) : InstallResult
+ }
+
+ // This is only an early diagnostic. Transactional copying still detects ENOSPC,
+ // returns a structured failure and restores the previous runtime.
+ @SuppressLint("UsableSpace")
+ fun install(context: Context): InstallResult {
+ if (Build.SUPPORTED_ABIS.none { it == OctaveRuntimeManifest.SUPPORTED_ABI }) {
+ return InstallResult.Failure(
+ message = "不支持的 ABI:${Build.SUPPORTED_ABIS.joinToString()}",
+ stage = InstallStage.ABI,
+ )
+ }
+
+ val manifestJson = try {
+ context.assets.open(MANIFEST_ASSET).bufferedReader().use { it.readText() }
+ } catch (e: Exception) {
+ return InstallResult.Failure(
+ message = "Octave 运行时清单未打包",
+ details = e.describe(),
+ stage = InstallStage.MANIFEST,
+ )
+ }
+ val manifest = try {
+ OctaveRuntimeManifest.parse(manifestJson)
+ } catch (e: Exception) {
+ return InstallResult.Failure(
+ message = "Octave 运行时清单无效",
+ details = e.describe(),
+ stage = InstallStage.MANIFEST,
+ )
+ }
+
+ val root = File(context.filesDir, ROOT_DIR)
+ val runtime = File(root, RUNTIME_DIR)
+ val work = File(root, WORK_DIR)
+ val home = File(root, HOME_DIR)
+ val tmp = File(root, TMP_DIR)
+ val out = File(root, OUT_DIR)
+ try {
+ listOf(root, work, home, tmp, out, File(work, "scripts")).forEach { dir ->
+ if (!dir.isDirectory && !dir.mkdirs()) {
+ throw IOException("无法创建目录:${dir.absolutePath}")
+ }
+ }
+ recoverInterruptedInstall(root, runtime)
+ val current = isCurrentRuntime(runtime, manifest)
+ if (!current) {
+ val requiredBytes = manifest.totalBytes + INSTALL_SPACE_MARGIN_BYTES
+ val usableBytes = root.usableSpace
+ if (usableBytes > 0L && usableBytes < requiredBytes) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "存储空间不足,无法安装 Octave 运行时",
+ IOException("required=$requiredBytes usable=$usableBytes"),
+ )
+ }
+ replaceRuntime(context, root, runtime, manifest, manifestJson)
+ } else {
+ removeLegacyImmutableRuntime(root)
+ }
+ } catch (e: RuntimeInstallException) {
+ return InstallResult.Failure(e.userMessage, e.describe(), e.stage)
+ } catch (e: Exception) {
+ return InstallResult.Failure(
+ message = if (e.isNoSpaceFailure()) {
+ "存储空间不足,无法安装 Octave 运行时"
+ } else {
+ "Octave 运行时安装失败"
+ },
+ details = e.describe(),
+ stage = InstallStage.INSTALL,
+ )
+ }
+
+ val nativeLibDir: File
+ val exec: File
+ try {
+ nativeLibDir = context.applicationInfo.nativeLibraryDir
+ ?.takeIf { it.isNotBlank() }
+ ?.let(::File)
+ ?: return InstallResult.Failure(
+ "nativeLibraryDir 不可用",
+ stage = InstallStage.NATIVE,
+ )
+ validateNativeRuntime(nativeLibDir, manifest)?.let { return it }
+ exec = File(nativeLibDir, EXECUTABLE)
+ if (!exec.canExecute()) exec.setExecutable(true, false)
+ if (!exec.canExecute()) {
+ return InstallResult.Failure(
+ "Octave 可执行文件无执行权限:${exec.absolutePath}",
+ stage = InstallStage.NATIVE,
+ )
+ }
+ } catch (e: Exception) {
+ return InstallResult.Failure(
+ message = "Octave 原生运行时校验失败",
+ details = e.describe(),
+ stage = InstallStage.NATIVE,
+ )
+ }
+
+ return InstallResult.Success(
+ OctaveInstallInfo(
+ execPath = exec.absolutePath,
+ libDir = nativeLibDir.absolutePath,
+ octaveHome = File(runtime, "usr").absolutePath,
+ bridgeDir = File(runtime, "maxmath").absolutePath,
+ workDir = work.absolutePath,
+ homeDir = home.absolutePath,
+ tmpDir = tmp.absolutePath,
+ outDir = out.absolutePath,
+ version = manifest.octaveVersion,
+ runtimeId = manifest.runtimeId,
+ ),
+ )
+ }
+
+ private fun replaceRuntime(
+ context: Context,
+ root: File,
+ runtime: File,
+ manifest: OctaveRuntimeManifest,
+ manifestJson: String,
+ ) {
+ val staging = File(root, ".$RUNTIME_DIR-staging-${UUID.randomUUID()}")
+ val previous = File(root, ".$RUNTIME_DIR-previous")
+ ensureChild(root, staging)
+ ensureChild(root, previous)
+ if (staging.exists() && !staging.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理 Octave 安装暂存目录")
+ }
+ if (!staging.mkdirs()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法创建 Octave 安装暂存目录")
+ }
+
+ try {
+ extractAssets(context, ASSET_ROOT, staging)
+ val extractedManifest = File(staging, MANIFEST_NAME)
+ if (!extractedManifest.isFile) {
+ throw RuntimeInstallException(InstallStage.INTEGRITY, "Octave 运行时清单解压后缺失")
+ }
+ val staged = runCatching { OctaveRuntimeManifest.parse(extractedManifest.readText()) }
+ .getOrElse {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Octave 运行时清单解压后损坏",
+ it,
+ )
+ }
+ if (staged.runtimeId != manifest.runtimeId || extractedManifest.readText() != manifestJson) {
+ throw RuntimeInstallException(InstallStage.INTEGRITY, "Octave 运行时清单不一致")
+ }
+ verifyStagedRuntime(staging, manifest)
+
+ if (previous.exists() && !previous.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理上一次 Octave 运行时备份")
+ }
+ val hadRuntime = runtime.exists()
+ if (hadRuntime && !runtime.renameTo(previous)) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法备份当前 Octave 运行时")
+ }
+ if (!staging.renameTo(runtime)) {
+ val rolledBack = !hadRuntime || previous.renameTo(runtime)
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ if (rolledBack) "无法启用新的 Octave 运行时,已回滚" else "无法启用新的 Octave 运行时,且回滚失败",
+ )
+ }
+ if (previous.exists() && !previous.deleteRecursively()) {
+ // The active runtime is already valid. A stale private backup is harmless and
+ // will be retried on the next install, so do not roll back a successful switch.
+ }
+ removeLegacyImmutableRuntime(root)
+ } finally {
+ if (staging.exists()) staging.deleteRecursively()
+ }
+ }
+
+ /** Restores the last verified runtime after a crash in either rename window. */
+ private fun recoverInterruptedInstall(root: File, runtime: File) {
+ root.listFiles()
+ ?.filter { it.name.startsWith(".$RUNTIME_DIR-staging-") }
+ ?.forEach { orphan ->
+ ensureChild(root, orphan)
+ if (Files.isSymbolicLink(orphan.toPath()) || orphan.parentFile?.canonicalFile != root.canonicalFile) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "Octave 安装暂存路径不安全",
+ )
+ }
+ if (!orphan.deleteRecursively()) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "无法清理中断的 Octave 安装暂存目录",
+ )
+ }
+ }
+
+ val previous = File(root, ".$RUNTIME_DIR-previous")
+ val invalid = File(root, ".$RUNTIME_DIR-invalid")
+ ensureChild(root, previous)
+ ensureChild(root, invalid)
+ if (!runtime.exists()) {
+ if (isSelfConsistentRuntime(previous)) {
+ if (!previous.renameTo(runtime)) {
+ throw RuntimeInstallException(
+ InstallStage.INSTALL,
+ "无法恢复上一次可用的 Octave 运行时",
+ )
+ }
+ if (invalid.exists() && !invalid.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理损坏的 Octave 运行时备份")
+ }
+ }
+ return
+ }
+
+ if (isSelfConsistentRuntime(runtime)) {
+ if (previous.exists() && !previous.deleteRecursively()) {
+ // The active runtime is verified. A private stale backup is harmless and can be
+ // retried during a future upgrade; it must not block startup.
+ }
+ if (invalid.exists() && !invalid.deleteRecursively()) {
+ // Same policy for a quarantined invalid generation.
+ }
+ return
+ }
+ if (!isSelfConsistentRuntime(previous)) return
+
+ if (invalid.exists() && !invalid.deleteRecursively()) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法清理损坏的 Octave 运行时")
+ }
+ if (!runtime.renameTo(invalid)) {
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法隔离损坏的 Octave 运行时")
+ }
+ if (!previous.renameTo(runtime)) {
+ invalid.renameTo(runtime)
+ throw RuntimeInstallException(InstallStage.INSTALL, "无法回滚到上一次 Octave 运行时")
+ }
+ invalid.deleteRecursively()
+ }
+
+ private fun isSelfConsistentRuntime(runtime: File): Boolean {
+ if (!runtime.isDirectory) return false
+ val manifest = runCatching {
+ OctaveRuntimeManifest.parse(File(runtime, MANIFEST_NAME).readText())
+ }.getOrNull() ?: return false
+ return isCurrentRuntime(runtime, manifest)
+ }
+
+ private fun isCurrentRuntime(runtime: File, expected: OctaveRuntimeManifest): Boolean {
+ if (!runtime.isDirectory) return false
+ val installedManifest = File(runtime, MANIFEST_NAME)
+ val installed = runCatching {
+ OctaveRuntimeManifest.parse(installedManifest.readText())
+ }.getOrNull() ?: return false
+ if (installed != expected) return false
+ val actual = runtime.walkTopDown()
+ .filter { it.isFile }
+ .map { it.relativeTo(runtime).invariantSeparatorsPath }
+ .filter { it != MANIFEST_NAME }
+ .toSet()
+ if (actual != expected.files.mapTo(HashSet()) { it.path }) return false
+ return expected.files.all { record ->
+ val file = resolveRuntimeFile(runtime, record.path)
+ file.isFile && file.length() == record.size && sha256(file) == record.sha256
+ }
+ }
+
+ private fun verifyStagedRuntime(staging: File, manifest: OctaveRuntimeManifest) {
+ val expected = manifest.files.associateBy { it.path }
+ val actual = staging.walkTopDown()
+ .filter { it.isFile }
+ .map { it.relativeTo(staging).invariantSeparatorsPath }
+ .filter { it != MANIFEST_NAME }
+ .toSet()
+ if (actual != expected.keys) {
+ val missing = (expected.keys - actual).take(8)
+ val extra = (actual - expected.keys).take(8)
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Octave 运行时文件集合不完整",
+ IllegalStateException("missing=$missing extra=$extra"),
+ )
+ }
+ expected.values.forEach { record ->
+ val file = resolveRuntimeFile(staging, record.path)
+ if (!file.isFile || file.length() != record.size) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Octave 运行时文件损坏:${record.path}",
+ )
+ }
+ if (sha256(file) != record.sha256) {
+ throw RuntimeInstallException(
+ InstallStage.INTEGRITY,
+ "Octave 运行时校验失败:${record.path}",
+ )
+ }
+ }
+ if (!File(staging, "usr/share/octave/${manifest.octaveVersion}").isDirectory ||
+ !File(staging, "maxmath/maxmath_init.m").isFile
+ ) {
+ throw RuntimeInstallException(InstallStage.INTEGRITY, "Octave 核心运行数据缺失")
+ }
+ }
+
+ private fun validateNativeRuntime(
+ nativeLibDir: File,
+ manifest: OctaveRuntimeManifest,
+ ): InstallResult.Failure? {
+ REQUIRED_NATIVE_FILES.forEach { name ->
+ val file = File(nativeLibDir, name)
+ if (!file.isFile) {
+ return InstallResult.Failure(
+ message = "Octave 原生文件未打包:$name",
+ details = file.absolutePath,
+ stage = InstallStage.NATIVE,
+ )
+ }
+ }
+ manifest.jniFiles.forEach { record ->
+ val file = resolveRuntimeFile(nativeLibDir, record.path)
+ if (!file.isFile || file.length() != record.size || sha256(file) != record.sha256) {
+ return InstallResult.Failure(
+ message = "Octave 原生运行时与清单不一致:${record.path}",
+ details = buildString {
+ append("expectedSize=").append(record.size)
+ append(" actualSize=").append(file.takeIf { it.exists() }?.length())
+ if (file.isFile && file.length() == record.size) {
+ append(" expectedSha256=").append(record.sha256)
+ append(" actualSha256=").append(sha256(file))
+ }
+ },
+ stage = InstallStage.NATIVE,
+ )
+ }
+ }
+ return null
+ }
+
+ private fun extractAssets(context: Context, path: String, target: File) {
+ val list = try {
+ context.assets.list(path)
+ } catch (_: IOException) {
+ null
+ }
+ if (list == null) {
+ copyAssetFile(context, path, target)
+ return
+ }
+ if (list.isEmpty()) {
+ try {
+ copyAssetFile(context, path, target)
+ return
+ } catch (_: FileNotFoundException) {
+ if (!target.isDirectory && !target.mkdirs()) {
+ throw IOException("无法创建资产目录:${target.absolutePath}")
+ }
+ return
+ }
+ }
+ if (!target.isDirectory && !target.mkdirs()) {
+ throw IOException("无法创建资产目录:${target.absolutePath}")
+ }
+ list.forEach { name -> extractAssets(context, "$path/$name", File(target, name)) }
+ }
+
+ private fun copyAssetFile(context: Context, path: String, target: File) {
+ target.parentFile?.let { parent ->
+ if (!parent.isDirectory && !parent.mkdirs()) {
+ throw IOException("无法创建资产父目录:${parent.absolutePath}")
+ }
+ }
+ context.assets.open(path).use { input ->
+ target.outputStream().use { output -> input.copyTo(output) }
+ }
+ }
+
+ private fun removeLegacyImmutableRuntime(root: File) {
+ listOf("usr", "maxmath").forEach { name ->
+ val legacy = File(root, name)
+ ensureChild(root, legacy)
+ if (legacy.exists()) legacy.deleteRecursively()
+ }
+ File(root, "octave_version").delete()
+ }
+
+ private fun resolveRuntimeFile(root: File, relativePath: String): File {
+ val safe = OctaveRuntimeManifest.normalizeRelativePath(relativePath)
+ val file = File(root, safe.replace('/', File.separatorChar))
+ ensureChild(root, file)
+ return file
+ }
+
+ private fun ensureChild(root: File, target: File) {
+ val rootPath = root.canonicalFile.toPath()
+ val targetPath = target.canonicalFile.toPath()
+ require(targetPath.startsWith(rootPath) && targetPath != rootPath) {
+ "Unsafe Octave runtime path: $target"
+ }
+ }
+
+ private fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ if (count > 0) digest.update(buffer, 0, count)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ private class RuntimeInstallException(
+ val stage: InstallStage,
+ val userMessage: String,
+ cause: Throwable? = null,
+ ) : IOException(userMessage, cause)
+
+ private fun Throwable.describe(): String = buildString {
+ append(javaClass.simpleName)
+ message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
+ cause?.message?.takeIf { it.isNotBlank() }?.let { append("; cause=").append(it) }
+ }.take(MAX_DETAIL_CHARS)
+
+ private fun Throwable.isNoSpaceFailure(): Boolean = generateSequence(this) { it.cause }
+ .mapNotNull { error -> error.message }
+ .any { message ->
+ message.contains("ENOSPC", ignoreCase = true) ||
+ message.contains("No space left", ignoreCase = true) ||
+ message.contains("空间不足")
+ }
+
+ private const val ROOT_DIR = "octave"
+ private const val RUNTIME_DIR = "runtime"
+ private const val WORK_DIR = "work"
+ private const val HOME_DIR = "home"
+ private const val TMP_DIR = "tmp"
+ private const val OUT_DIR = "out"
+ private const val ASSET_ROOT = "octave"
+ private const val MANIFEST_NAME = "runtime-manifest.json"
+ private const val MANIFEST_ASSET = "$ASSET_ROOT/$MANIFEST_NAME"
+ private const val EXECUTABLE = "liboctavebin.so"
+ private const val MAX_DETAIL_CHARS = 16 * 1024
+ private const val INSTALL_SPACE_MARGIN_BYTES = 32L * 1024L * 1024L
+ private val REQUIRED_NATIVE_FILES = listOf(
+ EXECUTABLE,
+ "liboctave.so",
+ "liboctinterp.so",
+ "liboctmex.so",
+ "libc++_shared.so",
+ )
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotArtifactExpiry.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotArtifactExpiry.kt
new file mode 100644
index 0000000..e73adf8
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotArtifactExpiry.kt
@@ -0,0 +1,43 @@
+package com.paruh.maxmath.engine
+
+import java.io.File
+
+internal const val OCTAVE_PLOT_ARTIFACT_TTL_MS = 5L * 60L * 1_000L
+
+/** Deletes a delivered plot after its fallback retention period has elapsed. */
+internal fun deleteExpiredOctavePlotArtifact(
+ outDir: File,
+ artifact: File,
+ nowMs: Long = System.currentTimeMillis(),
+): Boolean {
+ val safeArtifact = safeOctavePlotArtifact(outDir, artifact) ?: return false
+ val modifiedMs = safeArtifact.lastModified()
+ if (nowMs < modifiedMs) return false
+ if (nowMs - modifiedMs < OCTAVE_PLOT_ARTIFACT_TTL_MS) return false
+ return safeArtifact.delete()
+}
+
+/** Removes expired plots left behind when a client disappeared before acknowledging them. */
+internal fun cleanupExpiredOctavePlotArtifacts(
+ outDir: File,
+ nowMs: Long = System.currentTimeMillis(),
+): Int = runCatching {
+ outDir.canonicalFile.listFiles()
+ ?.count { artifact -> deleteExpiredOctavePlotArtifact(outDir, artifact, nowMs) }
+ ?: 0
+}.getOrDefault(0)
+
+/** Immediate counterpart for failed Binder delivery; it observes the same path boundary. */
+internal fun deleteOctavePlotArtifact(outDir: File, artifact: File): Boolean =
+ safeOctavePlotArtifact(outDir, artifact)?.delete() ?: false
+
+private fun safeOctavePlotArtifact(outDir: File, artifact: File): File? = runCatching {
+ val canonicalOutDir = outDir.canonicalFile
+ val canonicalArtifact = artifact.canonicalFile
+ canonicalArtifact.takeIf {
+ it.isFile &&
+ it.parentFile == canonicalOutDir &&
+ it.name.startsWith("plot_") &&
+ it.name.endsWith(".json")
+ }
+}.getOrNull()
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotSpec.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotSpec.kt
new file mode 100644
index 0000000..5bd8df1
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctavePlotSpec.kt
@@ -0,0 +1,282 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Octave 绘图桥生成的 figure 规格(plot_spec.json)。
+ * 由 Octave 侧 maxmath_flush 输出,App 侧解析后交给 2D/GL 渲染。
+ */
+data class OctaveLine(
+ val x: DoubleArray,
+ val y: DoubleArray,
+ val style: String = "",
+)
+
+data class OctaveLine3d(
+ val x: DoubleArray,
+ val y: DoubleArray,
+ val z: DoubleArray,
+ val style: String = "",
+)
+
+data class OctaveSurface(
+ val x: DoubleArray,
+ val y: DoubleArray,
+ val z: DoubleArray,
+ val rows: Int,
+ val cols: Int,
+ val kind: String = "surf",
+)
+
+data class OctaveContour(
+ val x: DoubleArray,
+ val y: DoubleArray,
+ val z: DoubleArray,
+ val rows: Int,
+ val cols: Int,
+ val levels: DoubleArray = DoubleArray(0),
+ val filled: Boolean = false,
+)
+
+data class OctaveText(
+ val x: Double,
+ val y: Double,
+ val z: Double? = null,
+ val text: String,
+)
+
+data class OctaveAxes(
+ val position: Int = 1,
+ val type: String = "2d",
+ val title: String = "",
+ val xlabel: String = "",
+ val ylabel: String = "",
+ val zlabel: String = "",
+ val legend: List = emptyList(),
+ val grid: Boolean = false,
+ val xlim: DoubleArray? = null,
+ val ylim: DoubleArray? = null,
+ val zlim: DoubleArray? = null,
+ val axismode: String = "auto",
+ val visible: Boolean = true,
+ val lines: List = emptyList(),
+ val lines3d: List = emptyList(),
+ val surfaces: List = emptyList(),
+ val contours: List = emptyList(),
+ val texts: List = emptyList(),
+ val azimuth: Double = 60.0,
+ val elevation: Double = 30.0,
+ val colormap: String = "viridis",
+ val colorbar: Boolean = false,
+)
+
+data class OctaveFigure(
+ val layout: IntArray = intArrayOf(1, 1),
+ val axes: List = emptyList(),
+ val requestId: String? = null,
+ val protocolVersion: Int? = null,
+) {
+ val rows: Int get() = layout.getOrElse(0) { 1 }
+ val cols: Int get() = layout.getOrElse(1) { 1 }
+
+ companion object {
+ fun fromJson(json: String): OctaveFigure? = runCatching {
+ parse(JSONObject(json))
+ }.getOrNull()
+
+ private fun parse(obj: JSONObject): OctaveFigure {
+ val protocolVersion = if (obj.has("version")) {
+ requireOctaveProtocolVersion(obj)
+ } else {
+ null
+ }
+ val layout = obj.optJSONArray("layout")?.let { arr ->
+ IntArray(arr.length()) { arr.getInt(it) }
+ } ?: intArrayOf(1, 1)
+ val axes = obj.optJSONArray("axes")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ add(parseAxes(arr.getJSONObject(i)))
+ }
+ }
+ } ?: emptyList()
+ return OctaveFigure(
+ layout = layout,
+ axes = axes,
+ requestId = obj.optString("requestId").ifBlank { null },
+ protocolVersion = protocolVersion,
+ )
+ }
+
+ private fun parseAxes(obj: JSONObject): OctaveAxes {
+ val lines = obj.optJSONArray("lines")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val o = arr.getJSONObject(i)
+ add(
+ OctaveLine(
+ x = doubles(o, "x"),
+ y = doubles(o, "y"),
+ style = o.optString("style"),
+ ),
+ )
+ }
+ }
+ } ?: emptyList()
+ val lines3d = obj.optJSONArray("lines3d")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val o = arr.getJSONObject(i)
+ add(
+ OctaveLine3d(
+ x = doubles(o, "x"),
+ y = doubles(o, "y"),
+ z = doubles(o, "z"),
+ style = o.optString("style"),
+ ),
+ )
+ }
+ }
+ } ?: emptyList()
+ val surfaces = obj.optJSONArray("surfaces")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val o = arr.getJSONObject(i)
+ add(
+ OctaveSurface(
+ x = doubles(o, "x"),
+ y = doubles(o, "y"),
+ z = grid(o, "z").values,
+ rows = grid(o, "z").rows,
+ cols = grid(o, "z").cols,
+ kind = o.optString("kind", "surf"),
+ ),
+ )
+ }
+ }
+ } ?: emptyList()
+ val contours = obj.optJSONArray("contours")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val o = arr.getJSONObject(i)
+ add(
+ OctaveContour(
+ x = doubles(o, "x"),
+ y = doubles(o, "y"),
+ z = grid(o, "z").values,
+ rows = grid(o, "z").rows,
+ cols = grid(o, "z").cols,
+ levels = doubles(o, "levels"),
+ filled = bool(o, "filled"),
+ ),
+ )
+ }
+ }
+ } ?: emptyList()
+ val legend = obj.optJSONArray("legend")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) add(arr.getString(i))
+ }
+ } ?: emptyList()
+ val texts = obj.optJSONArray("texts")?.let { arr ->
+ buildList {
+ for (i in 0 until arr.length()) {
+ val text = arr.getJSONObject(i)
+ val x = text.optDouble("x", Double.NaN)
+ val y = text.optDouble("y", Double.NaN)
+ if (x.isFinite() && y.isFinite()) {
+ add(
+ OctaveText(
+ x = x,
+ y = y,
+ z = finiteDoubleOrNull(text, "z"),
+ text = text.optString("text"),
+ ),
+ )
+ }
+ }
+ }
+ } ?: emptyList()
+ val view = doubles(obj, "view")
+ return OctaveAxes(
+ position = obj.optInt("position", 1),
+ type = obj.optString("type", "2d"),
+ title = obj.optString("title"),
+ xlabel = obj.optString("xlabel"),
+ ylabel = obj.optString("ylabel"),
+ zlabel = obj.optString("zlabel"),
+ legend = legend,
+ grid = bool(obj, "grid"),
+ xlim = doubles(obj, "xlim"),
+ ylim = doubles(obj, "ylim"),
+ zlim = doubles(obj, "zlim"),
+ axismode = obj.optString("axismode", "auto"),
+ visible = bool(obj, "visible", true),
+ lines = lines,
+ lines3d = lines3d,
+ surfaces = surfaces,
+ contours = contours,
+ texts = texts,
+ azimuth = view.getOrElse(0) { 60.0 },
+ elevation = view.getOrElse(1) { 30.0 },
+ colormap = obj.optString("colormap", "viridis"),
+ colorbar = bool(obj, "colorbar"),
+ )
+ }
+
+ private fun doubles(obj: JSONObject, key: String): DoubleArray {
+ val arr = obj.optJSONArray(key) ?: return DoubleArray(0)
+ return flatten(arr)
+ }
+
+ /** 解析 2D 网格:z 形如 [[..],[..]],返回展平值 + 行/列数。 */
+ private fun grid(obj: JSONObject, key: String): Grid {
+ val arr = obj.optJSONArray(key) ?: return Grid(DoubleArray(0), 0, 0)
+ val first = arr.optJSONArray(0)
+ if (first == null) {
+ return Grid(flatten(arr), 1, arr.length())
+ }
+ val rows = arr.length()
+ val cols = first.length()
+ return Grid(flatten(arr), rows, cols)
+ }
+
+ private fun flatten(arr: JSONArray): DoubleArray {
+ if (arr.length() > 0 && arr.optJSONArray(0) != null) {
+ // 嵌套网格:逐行展平
+ val rows = arr.length()
+ val cols = arr.getJSONArray(0).length()
+ val out = DoubleArray(rows * cols)
+ for (i in 0 until rows) {
+ val row = arr.getJSONArray(i)
+ for (j in 0 until cols) {
+ out[i * cols + j] = if (row.isNull(j)) Double.NaN else row.getDouble(j)
+ }
+ }
+ return out
+ }
+ return DoubleArray(arr.length()) {
+ if (arr.isNull(it)) Double.NaN else arr.getDouble(it)
+ }
+ }
+
+ private fun bool(obj: JSONObject, key: String, default: Boolean = false): Boolean {
+ val v = obj.opt(key) ?: return default
+ return when (v) {
+ is Boolean -> v
+ is Number -> v.toInt() != 0
+ else -> default
+ }
+ }
+
+ private fun finiteDoubleOrNull(obj: JSONObject, key: String): Double? {
+ val value = obj.opt(key) ?: return null
+ if (value is JSONArray && value.length() == 0) return null
+ val number = (value as? Number)?.toDouble() ?: return null
+ return number.takeIf(Double::isFinite)
+ }
+ }
+
+ private data class Grid(val values: DoubleArray, val rows: Int, val cols: Int)
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveProtocol.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveProtocol.kt
new file mode 100644
index 0000000..ce6a269
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveProtocol.kt
@@ -0,0 +1,98 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONObject
+
+internal const val OCTAVE_PROTOCOL_VERSION = 1
+
+internal fun requireOctaveProtocolVersion(
+ obj: JSONObject,
+ allowMissing: Boolean = false,
+): Int {
+ if (allowMissing && !obj.has("version")) return OCTAVE_PROTOCOL_VERSION
+ val raw = obj.get("version") as? Number
+ ?: throw IllegalArgumentException("Octave protocol version must be numeric")
+ val value = raw.toDouble()
+ require(value.isFinite() && value % 1.0 == 0.0 && value == OCTAVE_PROTOCOL_VERSION.toDouble()) {
+ "Unsupported Octave protocol version: $raw"
+ }
+ return value.toInt()
+}
+
+/** 可被控制台 ViewModel 替换为确定性测试实现的 Octave 会话接口。 */
+interface OctaveGateway {
+ suspend fun run(
+ request: OctaveRequest,
+ onEvent: (OctaveEvent) -> Unit = {},
+ ): OctaveResponse
+
+ /** 返回与 [requestId] 对应的取消握手终态。 */
+ suspend fun cancel(requestId: String): OctaveEvent
+}
+
+/** Messenger 上传输的请求生命周期事件。 */
+sealed interface OctaveEvent {
+ val requestId: String
+
+ data class Started(override val requestId: String) : OctaveEvent
+
+ data class Output(
+ override val requestId: String,
+ val text: String,
+ ) : OctaveEvent
+
+ data class Done(
+ override val requestId: String,
+ val response: OctaveResponse,
+ ) : OctaveEvent
+
+ data class Failure(
+ override val requestId: String,
+ val failure: OctaveFailure,
+ ) : OctaveEvent
+
+ fun toJson(): JSONObject = JSONObject()
+ .put("version", OCTAVE_PROTOCOL_VERSION)
+ .put("requestId", requestId)
+ .apply {
+ when (this@OctaveEvent) {
+ is Started -> put("kind", "started")
+ is Output -> put("kind", "output").put("text", text)
+ is Done -> put("kind", "done").put("response", response.toJson())
+ is Failure -> put("kind", "failure").put("failure", failure.toJson())
+ }
+ }
+
+ companion object {
+ const val PROTOCOL_VERSION = OCTAVE_PROTOCOL_VERSION
+
+ fun fromJson(obj: JSONObject): OctaveEvent {
+ requireOctaveProtocolVersion(obj)
+ val requestId = obj.getString("requestId")
+ require(OctaveRequest.isValidId(requestId)) { "Octave 事件请求 ID 无效" }
+ return when (obj.getString("kind")) {
+ "started" -> Started(requestId)
+ "output" -> Output(requestId, obj.optString("text"))
+ "done" -> Done(
+ requestId,
+ OctaveResponse.fromJson(obj.getJSONObject("response")).also { response ->
+ require(response.id == requestId) {
+ "Octave terminal response ID does not match its event"
+ }
+ },
+ )
+ "failure" -> Failure(
+ requestId,
+ OctaveFailure.fromJson(obj.getJSONObject("failure")),
+ )
+ else -> throw IllegalArgumentException("未知 Octave 事件:${obj.getString("kind")}")
+ }
+ }
+ }
+}
+
+internal object OctaveIpc {
+ const val ACTION_RUN = "run"
+ const val ACTION_CANCEL = "cancel"
+ const val KEY_JSON = "json"
+ const val KEY_REQUEST_ID = "requestId"
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveRequestState.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveRequestState.kt
new file mode 100644
index 0000000..081c311
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveRequestState.kt
@@ -0,0 +1,33 @@
+package com.paruh.maxmath.engine
+
+import java.util.concurrent.atomic.AtomicReference
+
+/** Pure cancellation transition used by the service before it touches the engine. */
+internal enum class OctaveCancellationDecision {
+ FIRST,
+ DUPLICATE,
+ NOT_ACTIVE,
+}
+
+internal fun decideOctaveCancellation(
+ scheduledRequestId: String?,
+ cancellingRequestId: String?,
+ requestId: String,
+): OctaveCancellationDecision = when {
+ cancellingRequestId == requestId -> OctaveCancellationDecision.DUPLICATE
+ scheduledRequestId == requestId -> OctaveCancellationDecision.FIRST
+ else -> OctaveCancellationDecision.NOT_ACTIVE
+}
+
+/**
+ * Clears a request marker by String value, while using the exact observed reference for CAS.
+ * AtomicReference.compareAndSet compares object identity, so an equal String received through
+ * another Binder/JSON decode cannot safely be passed directly as the expected reference.
+ */
+internal fun AtomicReference.clearOctaveRequest(requestId: String) {
+ while (true) {
+ val current = get()
+ if (current != requestId) return
+ if (compareAndSet(current, null)) return
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveResponse.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveResponse.kt
new file mode 100644
index 0000000..1428272
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveResponse.kt
@@ -0,0 +1,540 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.json.JSONTokener
+
+/** 工作区变量摘要,对应 Octave whos() 的结构化结果。 */
+data class OctaveVariable(
+ val name: String,
+ val className: String,
+ val dims: IntArray,
+ val bytes: Long,
+ val complex: Boolean,
+ val sparse: Boolean,
+ val global: Boolean,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("name", name)
+ .put("class", className)
+ .put("dims", JSONArray(dims.toList()))
+ .put("bytes", bytes)
+ .put("complex", complex)
+ .put("sparse", sparse)
+ .put("global", global)
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctaveVariable {
+ val dimsArr = obj.getJSONArray("dims")
+ return OctaveVariable(
+ name = obj.getString("name"),
+ className = obj.getString("class"),
+ dims = IntArray(dimsArr.length()) { dimsArr.getInt(it) },
+ bytes = obj.optLong("bytes", 0L),
+ complex = bool(obj, "complex"),
+ sparse = bool(obj, "sparse"),
+ global = bool(obj, "global"),
+ )
+ }
+
+ private fun bool(obj: JSONObject, key: String): Boolean = when (val value = obj.opt(key)) {
+ is Boolean -> value
+ is Number -> value.toInt() != 0
+ else -> false
+ }
+ }
+}
+
+/** Stable machine-readable categories used by IPC, diagnostics, and UI policy. */
+enum class OctaveFailureCode(val wireValue: String) {
+ INVALID_REQUEST("invalid_request"),
+ BIND_FAILED("bind_failed"),
+ NULL_BINDER("null_binder"),
+ SERVICE_DISCONNECTED("service_disconnected"),
+ CLIENT_DEADLINE("client_deadline"),
+ PROTOCOL_ERROR("protocol_error"),
+ BUSY("busy"),
+ INSTALL_FAILED("install_failed"),
+ LINK_FAILED("link_failed"),
+ START_FAILED("start_failed"),
+ EXECUTION_FAILED("execution_failed"),
+ CANCELLED("cancelled"),
+ CANCEL_NOT_ACTIVE("cancel_not_active"),
+ TIMEOUT("timeout"),
+ MEMORY_LIMIT("memory_limit"),
+ PROCESS_EXITED("process_exited"),
+ IO_ERROR("io_error"),
+ IPC_ERROR("ipc_error"),
+ UNKNOWN("unknown");
+
+ companion object {
+ fun fromWire(value: String): OctaveFailureCode = entries.firstOrNull {
+ it.wireValue == value
+ } ?: UNKNOWN
+ }
+}
+
+enum class OctaveFailureStage(val wireValue: String) {
+ REQUEST("request"),
+ BIND("bind"),
+ INSTALLATION("installation"),
+ LINKING("linking"),
+ STARTUP("startup"),
+ EXECUTION("execution"),
+ CANCELLATION("cancellation"),
+ RESPONSE("response");
+
+ companion object {
+ fun fromWire(value: String): OctaveFailureStage = entries.firstOrNull {
+ it.wireValue == value
+ } ?: RESPONSE
+ }
+}
+
+/** Source frame for a script failure, when Octave supplied one. */
+data class OctaveScriptLocation(
+ val file: String? = null,
+ val name: String? = null,
+ val line: Int,
+ val column: Int? = null,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("line", line)
+ .apply {
+ file?.let { put("file", it) }
+ name?.let { put("name", it) }
+ column?.let { put("column", it) }
+ }
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctaveScriptLocation = OctaveScriptLocation(
+ file = obj.optString("file").ifBlank { null },
+ name = obj.optString("name").ifBlank { null },
+ line = obj.getInt("line"),
+ column = if (obj.has("column") && !obj.isNull("column")) obj.getInt("column") else null,
+ )
+ }
+}
+
+/** A structured failure safe to show in collapsed form while retaining diagnostics. */
+data class OctaveFailure(
+ val code: OctaveFailureCode,
+ val stage: OctaveFailureStage,
+ val message: String,
+ val details: String? = null,
+ val exitCode: Int? = null,
+ val location: OctaveScriptLocation? = null,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("code", code.wireValue)
+ .put("stage", stage.wireValue)
+ .put("message", message)
+ .apply {
+ details?.let { put("details", it) }
+ exitCode?.let { put("exitCode", it) }
+ location?.let { put("location", it.toJson()) }
+ }
+
+ fun diagnosticText(): String = buildString {
+ append(message)
+ append("\ncode=").append(code.wireValue)
+ append("\nstage=").append(stage.wireValue)
+ exitCode?.let { append("\nexitCode=").append(it) }
+ location?.let { source ->
+ append("\nlocation=")
+ source.file?.let { append(it).append(':') }
+ append(source.line)
+ source.column?.let { append(':').append(it) }
+ source.name?.let { append(" (").append(it).append(')') }
+ }
+ details?.takeIf { it.isNotBlank() }?.let { append("\n\n").append(it) }
+ }
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctaveFailure = OctaveFailure(
+ code = OctaveFailureCode.fromWire(obj.optString("code")),
+ stage = OctaveFailureStage.fromWire(obj.optString("stage")),
+ message = obj.optString("message").ifBlank { "Octave request failed" },
+ details = obj.optString("details").ifBlank { null },
+ exitCode = if (obj.has("exitCode") && !obj.isNull("exitCode")) {
+ obj.getInt("exitCode")
+ } else {
+ null
+ },
+ location = obj.optJSONObject("location")?.let(OctaveScriptLocation::fromJson),
+ )
+ }
+}
+
+/** A real component, including values JSON cannot represent as a number. */
+sealed interface OctaveSpecialNumber {
+ data class Finite(val value: Double) : OctaveSpecialNumber {
+ init {
+ require(value.isFinite()) { "Finite preview component cannot be NaN or infinite" }
+ }
+ }
+ data object NaN : OctaveSpecialNumber
+ data object PositiveInfinity : OctaveSpecialNumber
+ data object NegativeInfinity : OctaveSpecialNumber
+
+ fun toJsonValue(): Any = when (this) {
+ is Finite -> value
+ NaN -> "NaN"
+ PositiveInfinity -> "Inf"
+ NegativeInfinity -> "-Inf"
+ }
+
+ companion object {
+ fun fromJsonValue(value: Any?): OctaveSpecialNumber? = when (value) {
+ null, JSONObject.NULL -> NaN
+ is Number -> Finite(value.toDouble())
+ "NaN" -> NaN
+ "Inf", "+Inf" -> PositiveInfinity
+ "-Inf" -> NegativeInfinity
+ else -> null
+ }
+ }
+}
+
+/** Typed recursive preview value; strings and complex cells never masquerade as doubles. */
+sealed interface OctavePreviewValue {
+ data class Scalar(val value: OctaveSpecialNumber) : OctavePreviewValue
+ data class Logical(val value: Boolean) : OctavePreviewValue
+ data class Complex(
+ val re: OctaveSpecialNumber,
+ val im: OctaveSpecialNumber,
+ ) : OctavePreviewValue
+ data class Text(val value: String) : OctavePreviewValue
+ data class Vector(val values: List) : OctavePreviewValue
+ data class Matrix(val rows: List>) : OctavePreviewValue
+
+ fun toJson(): JSONObject = JSONObject().apply {
+ when (this@OctavePreviewValue) {
+ is Scalar -> put("kind", "scalar").put("value", value.toJsonValue())
+ is Logical -> put("kind", "logical").put("value", value)
+ is Complex -> put("kind", "complex")
+ .put("re", re.toJsonValue())
+ .put("im", im.toJsonValue())
+ is Text -> put("kind", "text").put("value", value)
+ is Vector -> put("kind", "vector").put(
+ "values",
+ JSONArray(values.map { it.toJson() }),
+ )
+ is Matrix -> put("kind", "matrix").put(
+ "rows",
+ JSONArray(rows.map { row -> JSONArray(row.map { it.toJson() }) }),
+ )
+ }
+ }
+
+ /** Compact value representation used on Binder; it never expands scalars into objects. */
+ fun toCompactJsonValue(): Any = when (this) {
+ is Scalar -> value.toJsonValue()
+ is Logical -> value
+ is Complex -> JSONObject()
+ .put("re", re.toJsonValue())
+ .put("im", im.toJsonValue())
+ is Text -> JSONObject().put("text", value)
+ is Vector -> JSONArray(values.map { it.toCompactJsonValue() })
+ is Matrix -> JSONArray(rows.map { row ->
+ JSONArray(row.map { it.toCompactJsonValue() })
+ })
+ }
+
+ fun inferredKind(): String = when (this) {
+ is Scalar, is Logical, is Complex -> "scalar"
+ is Text -> "string"
+ is Vector -> "vector"
+ is Matrix -> "matrix"
+ }
+
+ fun displayText(): String = when (this) {
+ is Scalar -> value.displayText()
+ is Logical -> value.toString()
+ is Complex -> formatComplex(re, im)
+ is Text -> value
+ is Vector -> values.joinToString(prefix = "[ ", postfix = " ]") { it.displayText() }
+ is Matrix -> rows.joinToString(prefix = "[\n", separator = "\n", postfix = "\n]") { row ->
+ row.joinToString(prefix = " ", separator = " ") { it.displayText() }
+ }
+ }
+
+ /** Returns a legacy real-array literal only when the LaTeX renderer can represent it. */
+ fun toLatexJsonLiteral(): String? {
+ return when (this) {
+ is Scalar -> value.toLatexValue()?.let { JSONArray(listOf(it)).toString() }
+ is Vector -> values.toLatexArray()?.toString()
+ is Matrix -> {
+ val arrays = rows.map { it.toLatexArray() ?: return null }
+ JSONArray(arrays).toString()
+ }
+ is Logical, is Complex, is Text -> null
+ }
+ }
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctavePreviewValue = when (obj.getString("kind")) {
+ "scalar" -> Scalar(requireNumber(obj.opt("value")))
+ "logical" -> Logical(obj.getBoolean("value"))
+ "complex" -> Complex(
+ re = requireNumber(obj.opt("re")),
+ im = requireNumber(obj.opt("im")),
+ )
+ "text" -> Text(obj.optString("value"))
+ "vector" -> Vector(values(obj.getJSONArray("values")))
+ "matrix" -> Matrix(buildList {
+ val rows = obj.getJSONArray("rows")
+ for (index in 0 until rows.length()) {
+ add(values(rows.getJSONArray(index)))
+ }
+ })
+ else -> throw IllegalArgumentException("Unknown Octave preview value kind")
+ }
+
+ fun fromJsonLiteral(raw: String): OctavePreviewValue? = runCatching {
+ fromJsonValue(JSONTokener(raw).nextValue())
+ }.getOrNull()
+
+ private fun fromJsonValue(value: Any?): OctavePreviewValue {
+ return when (value) {
+ is Number, JSONObject.NULL -> Scalar(requireNumber(value))
+ is Boolean -> Logical(value)
+ is String -> OctaveSpecialNumber.fromJsonValue(value)?.let(::Scalar) ?: Text(value)
+ is JSONObject -> {
+ if (value.has("kind")) return fromJson(value)
+ if (value.has("text")) return Text(value.getString("text"))
+ Complex(
+ re = requireNumber(value.opt("re")),
+ im = requireNumber(value.opt("im")),
+ )
+ }
+ is JSONArray -> fromArray(value)
+ else -> throw IllegalArgumentException("Unsupported Octave preview JSON value")
+ }
+ }
+
+ private fun fromArray(array: JSONArray): OctavePreviewValue {
+ if (array.length() == 0) return Vector(emptyList())
+ val nested = (0 until array.length()).all { array.opt(it) is JSONArray }
+ return if (nested) {
+ Matrix(buildList {
+ for (index in 0 until array.length()) {
+ val row = array.getJSONArray(index)
+ add(buildList {
+ for (column in 0 until row.length()) add(fromJsonValue(row.opt(column)))
+ })
+ }
+ })
+ } else {
+ Vector(buildList {
+ for (index in 0 until array.length()) add(fromJsonValue(array.opt(index)))
+ })
+ }
+ }
+
+ private fun values(array: JSONArray): List = buildList {
+ for (index in 0 until array.length()) {
+ val raw = array.opt(index)
+ add(if (raw is JSONObject && raw.has("kind")) fromJson(raw) else fromJsonValue(raw))
+ }
+ }
+
+ private fun requireNumber(value: Any?): OctaveSpecialNumber =
+ requireNotNull(OctaveSpecialNumber.fromJsonValue(value)) {
+ "Invalid Octave numeric component"
+ }
+ }
+}
+
+private fun OctaveSpecialNumber.toLatexValue(): Double? = when (this) {
+ is OctaveSpecialNumber.Finite -> value
+ else -> null
+}
+
+private fun List.toLatexArray(): JSONArray? {
+ val values = map { value ->
+ val scalar = value as? OctavePreviewValue.Scalar ?: return null
+ scalar.value.toLatexValue() ?: return null
+ }
+ return JSONArray(values)
+}
+
+private fun OctaveSpecialNumber.displayText(): String = when (this) {
+ is OctaveSpecialNumber.Finite -> value.toString()
+ OctaveSpecialNumber.NaN -> "NaN"
+ OctaveSpecialNumber.PositiveInfinity -> "Inf"
+ OctaveSpecialNumber.NegativeInfinity -> "-Inf"
+}
+
+private fun formatComplex(
+ real: OctaveSpecialNumber,
+ imaginary: OctaveSpecialNumber,
+): String {
+ val imaginaryText = imaginary.displayText()
+ val negative = imaginaryText.startsWith('-')
+ val magnitude = if (negative) imaginaryText.removePrefix("-") else imaginaryText
+ return real.displayText() + (if (negative) "-" else "+") + magnitude + "i"
+}
+
+/** Typed variable preview. [valueJson] is retained only for the current engine adapter. */
+data class OctavePreview(
+ val text: String,
+ val valueJson: String? = null,
+ val value: OctavePreviewValue? = valueJson?.let(OctavePreviewValue::fromJsonLiteral),
+ val kind: String? = null,
+ val className: String? = null,
+ val dims: IntArray? = null,
+ val complex: Boolean = false,
+ val truncated: Boolean = false,
+ val estimatedBytes: Long? = null,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .apply {
+ val parsedLegacy = valueJson?.let { raw ->
+ runCatching { JSONTokener(raw).nextValue() }.getOrNull()
+ }
+ val compactValue = value?.toCompactJsonValue() ?: parsedLegacy
+ if (compactValue == null) {
+ put("text", text)
+ }
+ compactValue?.let { put("value", it) }
+ if (compactValue == null) valueJson?.let { put("valueJson", it) }
+ (kind ?: value?.inferredKind())?.let { put("kind", it) }
+ className?.let { put("class", it) }
+ dims?.let { put("dims", JSONArray(it.toList())) }
+ put("complex", complex)
+ put("truncated", truncated)
+ estimatedBytes?.let { put("estimatedBytes", it) }
+ }
+
+ fun latexValueJson(): String? = if (value != null) {
+ value.toLatexJsonLiteral()
+ } else {
+ valueJson
+ }
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctavePreview {
+ val legacyJson = obj.optString("valueJson").ifBlank { null }
+ val kind = obj.optString("kind").ifBlank { null }
+ val compact = obj.opt("value").takeUnless { it == null || it === JSONObject.NULL }
+ val compactJson = compact?.let { jsonLiteral(it) }
+ val typedValue = when {
+ compact == null -> legacyJson?.let(OctavePreviewValue::fromJsonLiteral)
+ kind == "string" || kind == "summary" -> OctavePreviewValue.Text(
+ if (compact is JSONObject && compact.has("text")) {
+ compact.getString("text")
+ } else {
+ compact.toString()
+ },
+ )
+ kind == "matrix" && compact is JSONArray && compact.length() == 0 ->
+ OctavePreviewValue.Matrix(emptyList())
+ compact is JSONObject && compact.has("kind") -> OctavePreviewValue.fromJson(compact)
+ else -> compactJson?.let(OctavePreviewValue::fromJsonLiteral)
+ }
+ return OctavePreview(
+ text = if (obj.has("text")) {
+ obj.optString("text")
+ } else {
+ typedValue?.displayText().orEmpty()
+ },
+ valueJson = legacyJson ?: compactJson,
+ value = typedValue,
+ kind = kind,
+ className = obj.optString("class").ifBlank { null },
+ dims = obj.optJSONArray("dims")?.let { dimensions ->
+ IntArray(dimensions.length()) { dimensions.getInt(it) }
+ },
+ complex = obj.optBoolean("complex", false),
+ truncated = obj.optBoolean("truncated", false),
+ estimatedBytes = if (obj.has("estimatedBytes") && !obj.isNull("estimatedBytes")) {
+ obj.getLong("estimatedBytes")
+ } else {
+ null
+ },
+ )
+ }
+
+ private fun jsonLiteral(value: Any): String = when (value) {
+ is String -> JSONObject.quote(value)
+ else -> value.toString()
+ }
+ }
+}
+
+/** Complete terminal response. Streaming output is delivered separately as events. */
+data class OctaveResponse(
+ val id: String,
+ val ok: Boolean,
+ val output: String,
+ val plotSpec: String? = null,
+ val plotPath: String? = null,
+ val workspace: List? = null,
+ val preview: OctavePreview? = null,
+ val error: String? = null,
+ val failure: OctaveFailure? = null,
+) {
+ fun toJson(): JSONObject = JSONObject()
+ .put("id", id)
+ .put("ok", ok)
+ .put("output", output)
+ .apply {
+ plotSpec?.let { put("plotSpec", it) }
+ plotPath?.let { put("plotPath", it) }
+ workspace?.let { variables ->
+ put("workspace", JSONArray(variables.map(OctaveVariable::toJson)))
+ }
+ preview?.let { put("preview", it.toJson()) }
+ error?.let { put("error", it) }
+ failure?.let { put("failure", it.toJson()) }
+ }
+
+ fun effectiveFailure(): OctaveFailure? = failure ?: if (!ok) {
+ OctaveFailure(
+ code = OctaveFailureCode.EXECUTION_FAILED,
+ stage = OctaveFailureStage.EXECUTION,
+ message = error?.ifBlank { null } ?: "Octave request failed",
+ details = output.takeIf { it.isNotBlank() },
+ )
+ } else {
+ null
+ }
+
+ companion object {
+ fun failed(
+ id: String,
+ failure: OctaveFailure,
+ output: String = "",
+ ): OctaveResponse = OctaveResponse(
+ id = id,
+ ok = false,
+ output = output,
+ error = failure.message,
+ failure = failure,
+ )
+
+ fun fromJson(json: String): OctaveResponse = fromJson(JSONObject(json))
+
+ fun fromJson(obj: JSONObject): OctaveResponse {
+ val workspace = obj.optJSONArray("workspace")?.let { array ->
+ buildList {
+ for (index in 0 until array.length()) {
+ add(OctaveVariable.fromJson(array.getJSONObject(index)))
+ }
+ }
+ }
+ return OctaveResponse(
+ id = obj.getString("id"),
+ ok = obj.optBoolean("ok"),
+ output = obj.optString("output"),
+ plotSpec = obj.optString("plotSpec").ifBlank { null },
+ plotPath = obj.optString("plotPath").ifBlank { null },
+ workspace = workspace,
+ preview = obj.optJSONObject("preview")?.let(OctavePreview::fromJson),
+ error = obj.optString("error").ifBlank { null },
+ failure = obj.optJSONObject("failure")?.let(OctaveFailure::fromJson),
+ )
+ }
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveRuntimeManifest.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveRuntimeManifest.kt
new file mode 100644
index 0000000..d5dc964
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveRuntimeManifest.kt
@@ -0,0 +1,190 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONObject
+
+internal data class OctaveSourcePackage(
+ val name: String,
+ val version: String,
+ val filename: String,
+ val sha256: String,
+)
+
+internal data class OctaveCxxRuntime(
+ val path: String,
+ val sha256: String,
+ val packageVersion: String,
+ val compilerMarkers: List,
+)
+
+internal data class OctaveRuntimeFile(
+ val path: String,
+ val size: Long,
+ val sha256: String,
+)
+
+internal data class OctaveRuntimeManifest(
+ val runtimeId: String,
+ val octaveVersion: String,
+ val bridgeVersion: Int,
+ val abi: String,
+ val sourcePackages: List,
+ val cxxRuntime: OctaveCxxRuntime,
+ val files: List,
+ val jniFiles: List,
+) {
+ val totalBytes: Long get() = files.sumOf { it.size }
+
+ companion object {
+ private val SHA256 = Regex("[0-9a-f]{64}")
+ private val RUNTIME_ID = Regex("sha256:[0-9a-f]{64}")
+
+ fun parse(json: String): OctaveRuntimeManifest {
+ val obj = JSONObject(json)
+ val schemaVersion = obj.get("schemaVersion")
+ require(schemaVersion is Number && schemaVersion.toDouble() == SCHEMA_VERSION.toDouble()) {
+ "Unsupported Octave runtime manifest schema"
+ }
+ val runtimeId = obj.getString("runtimeId")
+ require(runtimeId.isNotBlank() && RUNTIME_ID.matches(runtimeId)) {
+ "Invalid Octave runtimeId"
+ }
+ val octaveVersion = obj.getString("octaveVersion")
+ val rawBridgeVersion = obj.get("bridgeVersion")
+ require(rawBridgeVersion is Number) { "Invalid Octave bridgeVersion type" }
+ val bridgeDouble = rawBridgeVersion.toDouble()
+ require(
+ bridgeDouble.isFinite() &&
+ bridgeDouble % 1.0 == 0.0 &&
+ bridgeDouble in 1.0..Int.MAX_VALUE.toDouble()
+ ) { "Invalid Octave bridgeVersion" }
+ val bridgeVersion = rawBridgeVersion.toInt()
+ require(bridgeVersion == SUPPORTED_BRIDGE_VERSION) {
+ "Unsupported Octave bridgeVersion: $bridgeVersion"
+ }
+ val abi = obj.getString("abi")
+ require(octaveVersion == OCTAVE_VERSION) {
+ "Unsupported Octave runtime version: $octaveVersion"
+ }
+ require(abi == SUPPORTED_ABI) { "Unsupported Octave runtime ABI: $abi" }
+ val files = parseFiles(obj, "files")
+ require(files.isNotEmpty()) { "Octave runtime manifest has no assets" }
+ val jniFiles = parseFiles(obj, "jniFiles")
+ require(jniFiles.isNotEmpty()) { "Octave runtime manifest has no JNI files" }
+ val sourcePackages = parseSourcePackages(obj)
+ require(
+ sourcePackages.singleOrNull { it.name == "octave" }?.version == OCTAVE_PACKAGE_VERSION
+ ) { "Octave source package version is missing or inconsistent" }
+ require(sourcePackages.any { it.name == "libc++" }) { "libc++ source package is missing" }
+ val cxxRuntime = parseCxxRuntime(obj)
+ require(sourcePackages.single { it.name == "libc++" }.version == cxxRuntime.packageVersion) {
+ "C++ runtime package provenance does not match sourcePackages"
+ }
+ val cxxRecord = jniFiles.singleOrNull { it.path == cxxRuntime.path }
+ require(cxxRecord?.sha256 == cxxRuntime.sha256) {
+ "C++ runtime provenance does not match jniFiles"
+ }
+ return OctaveRuntimeManifest(
+ runtimeId = runtimeId,
+ octaveVersion = octaveVersion,
+ bridgeVersion = bridgeVersion,
+ abi = abi,
+ sourcePackages = sourcePackages,
+ cxxRuntime = cxxRuntime,
+ files = files,
+ jniFiles = jniFiles,
+ )
+ }
+
+ private fun parseSourcePackages(obj: JSONObject): List {
+ val array = obj.getJSONArray("sourcePackages")
+ require(array.length() > 0) { "Octave runtime manifest has no source packages" }
+ val seen = HashSet(array.length())
+ return buildList(array.length()) {
+ for (index in 0 until array.length()) {
+ val item = array.getJSONObject(index)
+ val name = item.getString("name")
+ val version = item.getString("version")
+ val filename = item.getString("filename")
+ val sha256 = item.getString("sha256").lowercase()
+ require(name.isNotBlank() && version.isNotBlank() && filename.isNotBlank()) {
+ "Invalid Octave source package provenance"
+ }
+ require(seen.add(name)) { "Duplicate Octave source package: $name" }
+ require(SHA256.matches(sha256)) { "Invalid package SHA-256 for $name" }
+ add(OctaveSourcePackage(name, version, filename, sha256))
+ }
+ }
+ }
+
+ private fun parseCxxRuntime(obj: JSONObject): OctaveCxxRuntime {
+ val cxx = obj.getJSONObject("cxxRuntime")
+ val path = normalizeRelativePath(cxx.getString("path"))
+ require(path == "libc++_shared.so") { "Unexpected C++ runtime path" }
+ val sha256 = cxx.getString("sha256").lowercase()
+ require(SHA256.matches(sha256)) { "Invalid C++ runtime SHA-256" }
+ val rawPackageVersion = cxx.get("packageVersion")
+ require(rawPackageVersion is String) { "Invalid C++ runtime package version type" }
+ val packageVersion = rawPackageVersion
+ require(packageVersion.isNotBlank()) { "Missing C++ runtime package version" }
+ val markers = cxx.getJSONArray("compilerMarkers")
+ val compilerMarkers = buildList(markers.length()) {
+ for (index in 0 until markers.length()) {
+ add(markers.getString(index).also { require(it.isNotBlank()) })
+ }
+ }
+ require(compilerMarkers.isNotEmpty()) { "Missing C++ compiler provenance" }
+ return OctaveCxxRuntime(path, sha256, packageVersion, compilerMarkers)
+ }
+
+ private fun parseFiles(
+ obj: JSONObject,
+ field: String,
+ ): List {
+ val arr = obj.optJSONArray(field)
+ require(arr != null) { "Octave runtime manifest is missing $field" }
+ val seen = HashSet(arr.length())
+ return buildList(arr.length()) {
+ for (i in 0 until arr.length()) {
+ val item = arr.getJSONObject(i)
+ val path = normalizeRelativePath(item.getString("path"))
+ val rawSize = item.get("size")
+ val sizeDouble = (rawSize as? Number)?.toDouble()
+ require(
+ sizeDouble != null &&
+ sizeDouble.isFinite() &&
+ sizeDouble % 1.0 == 0.0 &&
+ sizeDouble in 0.0..MAX_SAFE_JSON_INTEGER.toDouble()
+ ) {
+ "Invalid runtime file size: $path"
+ }
+ val size = sizeDouble.toLong()
+ val sha256 = item.getString("sha256").lowercase()
+ require(size >= 0L) { "Negative runtime file size: $path" }
+ require(SHA256.matches(sha256)) { "Invalid SHA-256 for $path" }
+ require(seen.add(path)) { "Duplicate runtime file: $path" }
+ add(OctaveRuntimeFile(path, size, sha256))
+ }
+ }
+ }
+
+ internal fun normalizeRelativePath(raw: String): String {
+ require(raw.isNotBlank()) { "Empty runtime file path" }
+ val normalized = raw.replace('\\', '/')
+ require(!normalized.startsWith('/') && !Regex("^[A-Za-z]:").containsMatchIn(normalized)) {
+ "Absolute runtime path is not allowed: $raw"
+ }
+ val parts = normalized.split('/')
+ require(parts.none { it.isBlank() || it == "." || it == ".." }) {
+ "Unsafe runtime path: $raw"
+ }
+ return parts.joinToString("/")
+ }
+
+ const val OCTAVE_VERSION = "11.3.0"
+ const val OCTAVE_PACKAGE_VERSION = "2:11.3.0"
+ const val SUPPORTED_ABI = "arm64-v8a"
+ const val SCHEMA_VERSION = 1
+ const val SUPPORTED_BRIDGE_VERSION = 1
+ private const val MAX_SAFE_JSON_INTEGER = 9_007_199_254_740_991L
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveService.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveService.kt
new file mode 100644
index 0000000..908dff2
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveService.kt
@@ -0,0 +1,433 @@
+package com.paruh.maxmath.engine
+
+import android.app.Service
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.atomic.AtomicReference
+
+/** Isolated Octave service. Every emitted event is scoped to the client's request ID. */
+class OctaveService : Service() {
+ private lateinit var workerThread: HandlerThread
+ private lateinit var worker: Handler
+ private lateinit var plotExpiryHandler: Handler
+ private var messenger: Messenger? = null
+ private val scheduledRequestId = AtomicReference(null)
+ private val cancellingRequestId = AtomicReference(null)
+ private val cancelledRequestId = AtomicReference(null)
+ private val lifecycleLock = Any()
+ @Volatile private var stopping = false
+
+ private val idleStop = Runnable {
+ val shouldStop = synchronized(lifecycleLock) {
+ if (scheduledRequestId.get() == null && cancellingRequestId.get() == null && !stopping) {
+ stopping = true
+ true
+ } else {
+ false
+ }
+ }
+ if (shouldStop) {
+ OctaveEngine.stop()
+ stopSelf()
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ OctaveEngine.configure(applicationContext)
+ workerThread = HandlerThread("octave-engine").apply { start() }
+ worker = Handler(workerThread.looper)
+ // These callbacks deliberately outlive the service's one-minute idle window. They only
+ // retain File values, so an old Service instance is not kept alive for the five-minute TTL.
+ plotExpiryHandler = Handler(Looper.getMainLooper())
+ worker.post { cleanupExpiredOctavePlotArtifacts(plotOutDir()) }
+ // Binder ingress stays off the blocking execution thread so cancel can be acknowledged
+ // while a long-running eval owns the worker.
+ messenger = Messenger(Handler(Looper.getMainLooper()) { message ->
+ handleIncoming(message)
+ })
+ // Keep the bound service alive until the explicit idle timer expires.
+ runCatching { startService(Intent(this, OctaveService::class.java)) }
+ restartIdleTimer()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = messenger?.binder
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_CANCEL) {
+ intent.getStringExtra(OctaveIpc.KEY_REQUEST_ID)?.let { requestId ->
+ if (OctaveRequest.isValidId(requestId)) {
+ beginCancellation(requestId)
+ }
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean = true
+
+ override fun onDestroy() {
+ synchronized(lifecycleLock) { stopping = true }
+ worker.removeCallbacks(idleStop)
+ scheduledRequestId.get()?.let(OctaveEngine::cancelScheduled)
+ worker.post {
+ OctaveEngine.stop()
+ workerThread.quitSafely()
+ }
+ messenger = null
+ super.onDestroy()
+ }
+
+ private fun restartIdleTimer() {
+ if (stopping) return
+ worker.removeCallbacks(idleStop)
+ worker.postDelayed(idleStop, IDLE_TIMEOUT_MS)
+ }
+
+ private fun handleIncoming(message: Message): Boolean {
+ val reply = message.replyTo
+ when (message.data.getString("action")) {
+ OctaveIpc.ACTION_RUN -> enqueueRun(message.data, reply)
+ OctaveIpc.ACTION_CANCEL -> handleCancel(message.data, reply)
+ }
+ return true
+ }
+
+ private fun enqueueRun(bundle: Bundle, reply: Messenger?) {
+ val raw = bundle.getString(OctaveIpc.KEY_JSON)
+ val requestId = runCatching {
+ requireNotNull(raw) { "Missing request payload" }
+ org.json.JSONObject(raw).getString("id").also { id ->
+ require(OctaveRequest.isValidId(id)) { "Invalid request ID" }
+ }
+ }.getOrElse { error ->
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ "invalid-request",
+ OctaveFailure(
+ OctaveFailureCode.INVALID_REQUEST,
+ OctaveFailureStage.REQUEST,
+ "Invalid Octave request",
+ error.message,
+ ),
+ ),
+ )
+ return
+ }
+ val accepted = synchronized(lifecycleLock) {
+ if (stopping) {
+ false
+ } else if (cancellingRequestId.get() != null) {
+ false
+ } else if (scheduledRequestId.compareAndSet(null, requestId)) {
+ // Remove the stop callback at ingress. Waiting until the worker starts would
+ // allow an already queued idle stop to win the race.
+ worker.removeCallbacks(idleStop)
+ true
+ } else {
+ false
+ }
+ }
+ if (!accepted) {
+ val isStopping = stopping
+ val currentRequestId = scheduledRequestId.get() ?: cancellingRequestId.get()
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ if (isStopping) {
+ OctaveFailureCode.SERVICE_DISCONNECTED
+ } else {
+ OctaveFailureCode.BUSY
+ },
+ OctaveFailureStage.REQUEST,
+ if (isStopping) {
+ "Octave service is restarting"
+ } else {
+ "Octave is already running another request"
+ },
+ "activeRequestId=$currentRequestId",
+ ),
+ ),
+ )
+ return
+ }
+ val requestBundle = Bundle(bundle)
+ worker.post {
+ try {
+ handleRun(requestBundle, reply)
+ } finally {
+ releaseScheduled(requestId)
+ clearCancelled(requestId)
+ clearCancelling(requestId)
+ // Clear the engine marker only after the service no longer accepts cancellation
+ // for this ID; otherwise a late cancel could recreate the tombstone behind us.
+ OctaveEngine.clearScheduledCancellation(requestId)
+ restartIdleTimer()
+ }
+ }
+ }
+
+ private fun handleRun(bundle: Bundle, reply: Messenger?) {
+ worker.removeCallbacks(idleStop)
+ val raw = bundle.getString(OctaveIpc.KEY_JSON)
+ val request = runCatching {
+ requireNotNull(raw) { "Missing request payload" }
+ OctaveRequest.fromJson(org.json.JSONObject(raw))
+ }.getOrElse { error ->
+ val requestId = runCatching {
+ raw?.let { org.json.JSONObject(it) }?.optString("id")
+ }.getOrNull().orEmpty().ifBlank { "invalid-request" }
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ code = OctaveFailureCode.INVALID_REQUEST,
+ stage = OctaveFailureStage.REQUEST,
+ message = "Invalid Octave request",
+ details = error.message,
+ ),
+ ),
+ )
+ return
+ }
+ if (request.task is OctaveEvalTask || request.task is OctaveRunScriptTask) {
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ request.id,
+ OctaveFailure(
+ OctaveFailureCode.INVALID_REQUEST,
+ OctaveFailureStage.REQUEST,
+ "Inline Octave source is not allowed over IPC",
+ ),
+ ),
+ )
+ return
+ }
+ if (cancelledRequestId.get() == request.id) {
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ request.id,
+ OctaveFailure(
+ OctaveFailureCode.CANCELLED,
+ OctaveFailureStage.CANCELLATION,
+ "Octave request was cancelled before execution",
+ ),
+ ),
+ )
+ return
+ }
+
+ var deliveryFailed = false
+ val response = runCatching {
+ OctaveEngine.eval(
+ task = request.task,
+ id = request.id,
+ onStarted = {
+ if (!deliveryFailed && !sendEvent(reply, OctaveEvent.Started(request.id))) {
+ deliveryFailed = true
+ OctaveEngine.cancel(request.id)
+ }
+ },
+ onOutput = { text ->
+ if (!deliveryFailed && !sendEvent(reply, OctaveEvent.Output(request.id, text))) {
+ deliveryFailed = true
+ OctaveEngine.cancel(request.id)
+ }
+ },
+ timeoutMs = request.timeoutMs,
+ )
+ }.getOrElse { error ->
+ OctaveResponse.failed(
+ request.id,
+ OctaveFailure(
+ code = OctaveFailureCode.UNKNOWN,
+ stage = OctaveFailureStage.EXECUTION,
+ message = "Octave service failed",
+ details = error.stackTraceToString().take(MAX_DIAGNOSTIC_CHARS),
+ ),
+ )
+ }
+ if (deliveryFailed) {
+ deletePlotArtifact(response)
+ } else if (response.ok) {
+ if (sendEvent(reply, OctaveEvent.Done(request.id, response.copy(id = request.id)))) {
+ schedulePlotExpiry(response)
+ } else {
+ deletePlotArtifact(response)
+ }
+ } else {
+ // Engine failures still travel as a response so workspace/typed metadata from the
+ // same command frame is not discarded. Failure events are reserved for transport
+ // and request-level failures which have no OctaveResponse payload.
+ sendEvent(reply, OctaveEvent.Done(request.id, response.copy(id = request.id)))
+ }
+ }
+
+ private fun handleCancel(bundle: Bundle, reply: Messenger?) {
+ val requestId = bundle.getString(OctaveIpc.KEY_REQUEST_ID).orEmpty()
+ if (!OctaveRequest.isValidId(requestId)) {
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ "invalid-request",
+ OctaveFailure(
+ OctaveFailureCode.INVALID_REQUEST,
+ OctaveFailureStage.CANCELLATION,
+ "Missing cancellation request ID",
+ ),
+ ),
+ )
+ return
+ }
+ val cancelled = beginCancellation(requestId)
+ if (cancelled) {
+ // Queue the acknowledgement behind the active worker call. Reaching it proves the
+ // child has exited and the old generation has been closed, so the UI may safely
+ // admit the next request without racing stale output.
+ worker.post {
+ try {
+ sendEvent(
+ reply,
+ OctaveEvent.Done(
+ requestId,
+ OctaveResponse(
+ id = requestId,
+ ok = true,
+ output = "",
+ ),
+ ),
+ )
+ } finally {
+ clearCancelled(requestId)
+ clearCancelling(requestId)
+ }
+ }
+ } else {
+ sendEvent(
+ reply,
+ OctaveEvent.Failure(
+ requestId,
+ OctaveFailure(
+ OctaveFailureCode.CANCEL_NOT_ACTIVE,
+ OctaveFailureStage.CANCELLATION,
+ "Octave request is no longer active",
+ ),
+ ),
+ )
+ }
+ }
+
+ private fun sendEvent(reply: Messenger?, event: OctaveEvent): Boolean {
+ if (reply == null) return false
+ val original = event.toJson().toString()
+ val oversized = original.toByteArray(StandardCharsets.UTF_8).size > MAX_EVENT_BYTES
+ val payload = if (oversized) {
+ OctaveEvent.Failure(
+ event.requestId,
+ OctaveFailure(
+ OctaveFailureCode.IPC_ERROR,
+ OctaveFailureStage.RESPONSE,
+ "Octave response exceeded the IPC limit",
+ "limitBytes=$MAX_EVENT_BYTES",
+ ),
+ ).toJson().toString()
+ } else {
+ original
+ }
+ val delivered = runCatching {
+ reply.send(Message.obtain().apply {
+ data = Bundle().apply {
+ putString(OctaveIpc.KEY_JSON, payload)
+ }
+ })
+ }.isSuccess
+ return delivered && !oversized
+ }
+
+ private fun deletePlotArtifact(response: OctaveResponse) {
+ response.plotPath?.let { path ->
+ deleteOctavePlotArtifact(plotOutDir(), File(path))
+ }
+ }
+
+ private fun schedulePlotExpiry(response: OctaveResponse) {
+ val artifact = response.plotPath?.let(::File) ?: return
+ val outDir = plotOutDir()
+ plotExpiryHandler.postDelayed(
+ { deleteExpiredOctavePlotArtifact(outDir, artifact) },
+ OCTAVE_PLOT_ARTIFACT_TTL_MS + PLOT_EXPIRY_SCHEDULING_MARGIN_MS,
+ )
+ }
+
+ private fun plotOutDir(): File = File(filesDir, OCTAVE_PLOT_OUT_DIRECTORY)
+
+ private fun releaseScheduled(requestId: String) {
+ synchronized(lifecycleLock) {
+ if (scheduledRequestId.get() == requestId) scheduledRequestId.set(null)
+ }
+ }
+
+ private fun beginCancellation(requestId: String): Boolean {
+ val decision = synchronized(lifecycleLock) {
+ decideOctaveCancellation(
+ scheduledRequestId = scheduledRequestId.get(),
+ cancellingRequestId = cancellingRequestId.get(),
+ requestId = requestId,
+ ).also { decision ->
+ if (decision == OctaveCancellationDecision.FIRST) {
+ // Keep the single-flight slot occupied until worker reap/ack. The separate
+ // phase marker prevents duplicate cancel from targeting another request.
+ cancellingRequestId.set(requestId)
+ cancelledRequestId.set(requestId)
+ }
+ }
+ }
+ return when (decision) {
+ OctaveCancellationDecision.DUPLICATE -> true
+ OctaveCancellationDecision.NOT_ACTIVE -> false
+ OctaveCancellationDecision.FIRST -> {
+ val engineAccepted = OctaveEngine.cancelScheduled(requestId)
+ if (!engineAccepted) {
+ clearCancelled(requestId)
+ clearCancelling(requestId)
+ }
+ engineAccepted
+ }
+ }
+ }
+
+ private fun clearCancelling(requestId: String) {
+ synchronized(lifecycleLock) {
+ cancellingRequestId.clearOctaveRequest(requestId)
+ }
+ }
+
+ private fun clearCancelled(requestId: String) {
+ synchronized(lifecycleLock) {
+ cancelledRequestId.clearOctaveRequest(requestId)
+ }
+ }
+
+ companion object {
+ const val ACTION_CANCEL = "com.paruh.maxmath.octave.CANCEL"
+ private const val IDLE_TIMEOUT_MS = 60_000L
+ private const val MAX_DIAGNOSTIC_CHARS = 16 * 1024
+ private const val MAX_EVENT_BYTES = 384 * 1024
+ private const val OCTAVE_PLOT_OUT_DIRECTORY = "octave/out"
+ private const val PLOT_EXPIRY_SCHEDULING_MARGIN_MS = 1_000L
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveSession.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveSession.kt
new file mode 100644
index 0000000..12229a9
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveSession.kt
@@ -0,0 +1,858 @@
+package com.paruh.maxmath.engine
+
+import java.io.BufferedWriter
+import java.io.File
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.OutputStream
+import java.io.OutputStreamWriter
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
+import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.thread
+import kotlin.concurrent.withLock
+import org.json.JSONObject
+
+/** Immutable launch configuration for one generation of the Octave child process. */
+internal data class OctaveSessionConfig(
+ val execPath: String,
+ val libDir: String,
+ val octaveHome: String,
+ val bridgeDir: String,
+ val workDir: String,
+ val homeDir: String,
+ val tmpDir: String,
+ val outDir: String,
+)
+
+/** The process seam used by the production adapter and deterministic JVM tests. */
+internal interface OctaveChildProcess {
+ val stdin: OutputStream
+ val stdout: InputStream
+ val isAlive: Boolean
+ val pid: Long
+ fun destroyForcibly()
+ fun waitFor(timeoutMs: Long): Boolean
+ fun exitCodeOrNull(): Int?
+}
+
+internal fun interface OctaveProcessFactory {
+ fun start(command: List, environment: Map): OctaveChildProcess
+}
+
+internal enum class OctaveSessionFailureKind {
+ NOT_STARTED,
+ START,
+ EXECUTION,
+ CANCELLED,
+ TIMEOUT,
+ MEMORY,
+ EXITED,
+ IO,
+ PROTOCOL,
+}
+
+internal data class OctaveSessionFailure(
+ val kind: OctaveSessionFailureKind,
+ val message: String,
+ val details: String? = null,
+ val exitCode: Int? = null,
+)
+
+internal data class OctaveSessionStart(
+ val ok: Boolean,
+ val failure: OctaveSessionFailure? = null,
+)
+
+internal data class OctaveSessionResult(
+ val output: String = "",
+ val protocolLines: List = emptyList(),
+ val errorText: String? = null,
+ val plotFile: File? = null,
+ val truncated: Boolean = false,
+ val failure: OctaveSessionFailure? = null,
+) {
+ val ok: Boolean get() = failure == null && errorText == null
+}
+
+/**
+ * A single generation of a persistent Octave REPL.
+ *
+ * The module deliberately exposes only start/execute/cancel/close. Process ownership,
+ * sentinel framing, reader/RSS threads, EOF signalling, output limits and cleanup all stay
+ * behind that interface. A failed generation is never reused by its caller.
+ */
+internal class OctaveSession(
+ private val config: OctaveSessionConfig,
+ private val processFactory: OctaveProcessFactory = SystemOctaveProcessFactory,
+ private val rssReader: (Long) -> Long = ::readProcessRssKb,
+) {
+ private sealed interface ReadEvent {
+ data class Line(val text: String) : ReadEvent
+ data class Eof(val exitCode: Int?) : ReadEvent
+ data class Failure(val message: String) : ReadEvent
+ }
+
+ private val executeLock = ReentrantLock()
+ private val closeLock = Any()
+ private val events = LinkedBlockingQueue(MAX_QUEUED_EVENTS)
+ private val activeRequestId = AtomicReference(null)
+ private val cancelledRequestId = AtomicReference(null)
+ private val memoryKilled = AtomicBoolean(false)
+ private val closed = AtomicBoolean(false)
+ private val commandSequence = AtomicLong(0L)
+ private val generationToken = UUID.randomUUID().toString().replace("-", "")
+
+ @Volatile private var process: OctaveChildProcess? = null
+ @Volatile private var writer: BufferedWriter? = null
+ @Volatile private var readerThread: Thread? = null
+ @Volatile private var rssThread: Thread? = null
+ @Volatile private var ready = false
+
+ fun start(requestId: String = BOOT_REQUEST_ID): OctaveSessionStart {
+ if (closed.get()) {
+ return OctaveSessionStart(
+ ok = false,
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.NOT_STARTED,
+ "Octave session generation is closed",
+ ),
+ )
+ }
+ synchronized(closeLock) {
+ if (ready && process?.isAlive == true) return OctaveSessionStart(ok = true)
+ if (process != null) {
+ return OctaveSessionStart(
+ ok = false,
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.START,
+ "Octave session is already starting",
+ ),
+ )
+ }
+ val child = try {
+ processFactory.start(buildCommand(), buildEnvironment())
+ } catch (e: Exception) {
+ resetFailedStart()
+ return OctaveSessionStart(
+ ok = false,
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.START,
+ "Unable to launch Octave",
+ e.message,
+ ),
+ )
+ }
+ try {
+ process = child
+ writer = BufferedWriter(OutputStreamWriter(child.stdin, StandardCharsets.UTF_8))
+ events.clear()
+ memoryKilled.set(false)
+ startReader(child)
+ startRssMonitor(child)
+ } catch (e: Exception) {
+ abortStartup(child)
+ return OctaveSessionStart(
+ ok = false,
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.START,
+ "Unable to initialize Octave process streams",
+ e.message,
+ child.exitCodeOrNull(),
+ ),
+ )
+ }
+ }
+
+ val boot = executeInternal(
+ requestId = requestId,
+ command = "1;",
+ timeoutMs = BOOT_TIMEOUT_MS,
+ onOutput = {},
+ )
+ if (!boot.ok || process?.isAlive != true) {
+ val failure = boot.failure ?: OctaveSessionFailure(
+ OctaveSessionFailureKind.PROTOCOL,
+ "Octave startup handshake failed",
+ boot.errorText ?: boot.output.takeLast(STARTUP_DETAIL_CHARS),
+ process?.exitCodeOrNull(),
+ )
+ close()
+ return OctaveSessionStart(ok = false, failure = failure.withStartupDetails(boot))
+ }
+ ready = true
+ return OctaveSessionStart(ok = true)
+ }
+
+ fun execute(
+ requestId: String,
+ command: String,
+ timeoutMs: Long,
+ onOutput: (String) -> Unit,
+ ): OctaveSessionResult {
+ if (!ready || process?.isAlive != true) {
+ return OctaveSessionResult(
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.NOT_STARTED,
+ "Octave session is not ready",
+ exitDetails(),
+ process?.exitCodeOrNull(),
+ ),
+ )
+ }
+ return executeInternal(requestId, command, timeoutMs, onOutput)
+ }
+
+ /** Cancels only the matching active request and never waits for the execution lock. */
+ fun cancel(requestId: String): Boolean {
+ val active = activeRequestId.get()
+ if (active != null && active != requestId) return false
+ cancelledRequestId.set(requestId)
+ if (active == requestId) process?.destroyForcibly()
+ return true
+ }
+
+ fun isUsable(): Boolean = ready && process?.isAlive == true
+
+ /** Called after the service has closed cancellation admission for this request ID. */
+ internal fun clearCancellation(requestId: String) {
+ cancelledRequestId.clearOctaveRequest(requestId)
+ }
+
+ fun close() {
+ if (!closed.compareAndSet(false, true)) return
+ val child: OctaveChildProcess?
+ val reader: Thread?
+ val rss: Thread?
+ synchronized(closeLock) {
+ ready = false
+ child = process
+ reader = readerThread
+ rss = rssThread
+ process = null
+ writer = null
+ readerThread = null
+ rssThread = null
+ }
+ runCatching { child?.stdin?.close() }
+ runCatching { child?.stdout?.close() }
+ if (child?.isAlive == true) runCatching { child.destroyForcibly() }
+ runCatching { child?.waitFor(PROCESS_STOP_TIMEOUT_MS) }
+ reader?.interrupt()
+ rss?.interrupt()
+ joinUnlessCurrent(reader)
+ joinUnlessCurrent(rss)
+ events.clear()
+ activeRequestId.set(null)
+ cancelledRequestId.set(null)
+ memoryKilled.set(false)
+ }
+
+ /** Tears down a partially initialized child without permanently closing this generation. */
+ private fun abortStartup(child: OctaveChildProcess) {
+ val reader: Thread?
+ val rss: Thread?
+ synchronized(closeLock) {
+ ready = false
+ reader = readerThread
+ rss = rssThread
+ process = null
+ writer = null
+ readerThread = null
+ rssThread = null
+ }
+ runCatching { child.stdin.close() }
+ runCatching { child.stdout.close() }
+ if (child.isAlive) runCatching { child.destroyForcibly() }
+ runCatching { child.waitFor(PROCESS_STOP_TIMEOUT_MS) }
+ reader?.interrupt()
+ rss?.interrupt()
+ joinUnlessCurrent(reader)
+ joinUnlessCurrent(rss)
+ events.clear()
+ activeRequestId.set(null)
+ cancelledRequestId.set(null)
+ memoryKilled.set(false)
+ }
+
+ private fun resetFailedStart() {
+ synchronized(closeLock) {
+ ready = false
+ process = null
+ writer = null
+ readerThread = null
+ rssThread = null
+ }
+ events.clear()
+ activeRequestId.set(null)
+ cancelledRequestId.set(null)
+ memoryKilled.set(false)
+ }
+
+ private fun executeInternal(
+ requestId: String,
+ command: String,
+ timeoutMs: Long,
+ onOutput: (String) -> Unit,
+ ): OctaveSessionResult = executeLock.withLock {
+ val child = process ?: return@withLock notStarted()
+ val output = BoundedUtf8Buffer(MAX_OUTPUT_BYTES)
+ val protocol = mutableListOf()
+ var protocolChars = 0
+ val error = StringBuilder()
+ var collectingError = false
+ val stream = BoundedStreamEmitter(
+ maxPayloadBytes = MAX_STREAM_PAYLOAD_BYTES,
+ maxChunkBytes = MAX_STREAM_CHUNK_BYTES,
+ truncationNotice = OUTPUT_TRUNCATED_NOTICE,
+ consumer = onOutput,
+ )
+ val safeId = requestId.replace(Regex("[^A-Za-z0-9_.-]"), "_")
+ val outDirectory = File(config.outDir)
+ if (!outDirectory.isDirectory && !outDirectory.mkdirs()) {
+ return@withLock OctaveSessionResult(
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.IO,
+ "Unable to create Octave plot output directory",
+ outDirectory.absolutePath,
+ ),
+ )
+ }
+ val plotFile = File(config.outDir, "plot_$safeId.json")
+ if (plotFile.exists() && !plotFile.delete()) {
+ return@withLock OctaveSessionResult(
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.IO,
+ "Unable to replace stale Octave plot artifact",
+ plotFile.absolutePath,
+ ),
+ )
+ }
+ val sentinel = "$SENTINEL_PREFIX${generationToken}_${commandSequence.incrementAndGet()}"
+ val script = buildString {
+ if (command.isNotBlank()) append(command).append('\n')
+ append("try\n")
+ append("maxmath_flush('")
+ .append(octaveQuote(plotFile.absolutePath))
+ .append("','")
+ .append(octaveQuote(requestId))
+ .append("')\n")
+ append("maxmath_whos('")
+ .append(octaveQuote(requestId))
+ .append("')\n")
+ append("catch __maxmath_finalize_error\n")
+ append("maxmath_emit_error(__maxmath_finalize_error,'")
+ .append(octaveQuote(requestId))
+ .append("')\n")
+ append("end_try_catch\n")
+ append("clear('__maxmath_finalize_error')\n")
+ append("__maxmath_print_count = printf('\\n")
+ .append(sentinel)
+ .append("\\n');\n")
+ append("__maxmath_flush_status = fflush(stdout);\n")
+ append("clear __maxmath_print_count __maxmath_flush_status\n")
+ }
+ activeRequestId.set(requestId)
+ if (cancelledRequestId.get() == requestId) {
+ activeRequestId.clearOctaveRequest(requestId)
+ return@withLock OctaveSessionResult(
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.CANCELLED,
+ "Octave request was cancelled before execution",
+ ),
+ )
+ }
+
+ try {
+ val currentWriter = writer ?: return@withLock notStarted()
+ currentWriter.write(script)
+ currentWriter.flush()
+ } catch (e: Exception) {
+ val failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.IO,
+ "Unable to write to Octave",
+ e.message,
+ child.exitCodeOrNull(),
+ )
+ close()
+ return@withLock OctaveSessionResult(failure = failure)
+ }
+
+ val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs.coerceAtLeast(1L))
+ try {
+ while (true) {
+ if (cancelledRequestId.get() == requestId) {
+ val result = terminalFailure(
+ kind = OctaveSessionFailureKind.CANCELLED,
+ message = "Octave request was cancelled",
+ output = output,
+ protocol = protocol,
+ error = error,
+ child = child,
+ )
+ close()
+ return@withLock result
+ }
+ val remainingNanos = deadline - System.nanoTime()
+ if (remainingNanos <= 0L) {
+ val result = terminalFailure(
+ kind = OctaveSessionFailureKind.TIMEOUT,
+ message = "Octave request timed out after ${timeoutMs}ms",
+ output = output,
+ protocol = protocol,
+ error = error,
+ child = child,
+ )
+ close()
+ return@withLock result
+ }
+ when (val event = events.poll(
+ minOf(TimeUnit.NANOSECONDS.toMillis(remainingNanos).coerceAtLeast(1L), POLL_MS),
+ TimeUnit.MILLISECONDS,
+ )) {
+ null -> {
+ stream.flush()
+ if (!child.isAlive) {
+ val kind = if (memoryKilled.get()) {
+ OctaveSessionFailureKind.MEMORY
+ } else {
+ OctaveSessionFailureKind.EXITED
+ }
+ val result = terminalFailure(
+ kind,
+ if (kind == OctaveSessionFailureKind.MEMORY) {
+ "Octave exceeded the 1.5 GB memory limit"
+ } else {
+ "Octave process exited unexpectedly"
+ },
+ output,
+ protocol,
+ error,
+ child,
+ )
+ close()
+ return@withLock result
+ }
+ }
+ is ReadEvent.Line -> {
+ val line = event.text
+ if (line == sentinel) {
+ stream.flush()
+ break
+ }
+ val protocolPrefix = PROTOCOL_PREFIXES.firstOrNull { prefix ->
+ line.startsWith(prefix)
+ }
+ if (protocolPrefix != null && protocolLineMatchesRequest(
+ line,
+ protocolPrefix,
+ requestId,
+ )
+ ) {
+ if (protocolChars + line.length <= MAX_PROTOCOL_CHARS) {
+ protocol += line
+ protocolChars += line.length
+ } else {
+ val result = terminalFailure(
+ OctaveSessionFailureKind.PROTOCOL,
+ "Structured Octave response exceeded 320 KiB",
+ output,
+ protocol,
+ error,
+ child,
+ )
+ close()
+ return@withLock result
+ }
+ if (line.startsWith(ERROR_PREFIX) && line.length <= MAX_PROTOCOL_CHARS) {
+ collectingError = true
+ error.append(line.removePrefix(ERROR_PREFIX)).append('\n')
+ }
+ continue
+ }
+ if (line.startsWith("error:")) {
+ collectingError = true
+ if (error.length < MAX_ERROR_CHARS) error.append(line).append('\n')
+ } else if (collectingError && isErrorContinuation(line)) {
+ if (error.length < MAX_ERROR_CHARS) error.append(line).append('\n')
+ } else {
+ collectingError = false
+ output.appendLine(line)
+ stream.append(line + "\n")
+ }
+ }
+ is ReadEvent.Eof -> {
+ val kind = when {
+ cancelledRequestId.get() == requestId -> OctaveSessionFailureKind.CANCELLED
+ memoryKilled.get() -> OctaveSessionFailureKind.MEMORY
+ else -> OctaveSessionFailureKind.EXITED
+ }
+ val result = terminalFailure(
+ kind = kind,
+ message = when (kind) {
+ OctaveSessionFailureKind.CANCELLED -> "Octave request was cancelled"
+ OctaveSessionFailureKind.MEMORY -> "Octave exceeded the 1.5 GB memory limit"
+ else -> "Octave process exited unexpectedly"
+ },
+ output = output,
+ protocol = protocol,
+ error = error,
+ child = child,
+ exitCode = event.exitCode ?: child.exitCodeOrNull(),
+ )
+ close()
+ return@withLock result
+ }
+ is ReadEvent.Failure -> {
+ val result = terminalFailure(
+ OctaveSessionFailureKind.IO,
+ "Unable to read Octave output",
+ output,
+ protocol,
+ error,
+ child,
+ details = event.message,
+ )
+ close()
+ return@withLock result
+ }
+ }
+ }
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ val result = terminalFailure(
+ OctaveSessionFailureKind.CANCELLED,
+ "Octave request was interrupted",
+ output,
+ protocol,
+ error,
+ child,
+ details = e.message,
+ )
+ close()
+ return@withLock result
+ } finally {
+ stream.flush()
+ activeRequestId.clearOctaveRequest(requestId)
+ cancelledRequestId.clearOctaveRequest(requestId)
+ }
+
+ val errorText = error.toString().trim().ifBlank { null }
+ if (errorText != null) runCatching { plotFile.delete() }
+ OctaveSessionResult(
+ output = output.toString(),
+ protocolLines = protocol,
+ errorText = errorText,
+ plotFile = plotFile.takeIf { errorText == null && it.isFile && it.length() > 0L },
+ truncated = output.truncated,
+ )
+ }
+
+ private fun startReader(child: OctaveChildProcess) {
+ readerThread = thread(name = "octave-reader-$generationToken", isDaemon = true) {
+ try {
+ InputStreamReader(child.stdout, StandardCharsets.UTF_8).use { reader ->
+ val line = StringBuilder()
+ val buffer = CharArray(READER_BUFFER_CHARS)
+ while (!Thread.currentThread().isInterrupted) {
+ val count = reader.read(buffer)
+ if (count < 0) break
+ for (index in 0 until count) {
+ val char = buffer[index]
+ if (char == '\n') {
+ events.put(ReadEvent.Line(line.removeTrailingCarriageReturn()))
+ line.clear()
+ } else {
+ line.append(char)
+ if (line.length >= MAX_LINE_CHARS) {
+ events.put(ReadEvent.Line(line.toString()))
+ line.clear()
+ }
+ }
+ }
+ }
+ if (line.isNotEmpty()) {
+ events.put(ReadEvent.Line(line.removeTrailingCarriageReturn()))
+ }
+ }
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ } catch (e: Exception) {
+ events.offer(ReadEvent.Failure(e.message ?: e.javaClass.simpleName))
+ } finally {
+ events.offer(ReadEvent.Eof(child.exitCodeOrNull()))
+ }
+ }
+ }
+
+ private fun isErrorContinuation(line: String): Boolean =
+ line.isBlank() || line.startsWith(" ") || line.startsWith("called from", ignoreCase = true)
+
+ /** Only a valid v1 JSON envelope for this request may consume a reserved stdout prefix. */
+ private fun protocolLineMatchesRequest(
+ line: String,
+ prefix: String,
+ requestId: String,
+ ): Boolean = runCatching {
+ val payload = line.removePrefix(prefix).trim()
+ val obj = JSONObject(payload)
+ requireOctaveProtocolVersion(obj)
+ obj.optString("requestId") == requestId
+ }.getOrDefault(false)
+
+ private fun startRssMonitor(child: OctaveChildProcess) {
+ rssThread = thread(name = "octave-rss-$generationToken", isDaemon = true) {
+ try {
+ while (child.isAlive && !Thread.currentThread().isInterrupted) {
+ if (rssReader(child.pid) > RSS_CAP_KB) {
+ memoryKilled.set(true)
+ child.destroyForcibly()
+ break
+ }
+ Thread.sleep(RSS_POLL_MS)
+ }
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ }
+
+ private fun buildCommand(): List = listOf(
+ config.execPath,
+ "--no-gui",
+ "--quiet",
+ "--no-init-file",
+ "--no-window-system",
+ "--no-history",
+ "--no-line-editing",
+ "--persist",
+ "--eval",
+ "PS1('');PS2('');PS4('');" +
+ "addpath('${octaveQuote(config.bridgeDir)}');" +
+ "addpath('${octaveQuote(config.bridgeDir)}/private');" +
+ "maxmath_init('${octaveQuote(config.bridgeDir)}','${octaveQuote(config.workDir)}')",
+ )
+
+ private fun buildEnvironment(): Map = mapOf(
+ "LD_LIBRARY_PATH" to config.libDir,
+ "OCTAVE_HOME" to config.octaveHome,
+ "OCTAVE_EXEC_HOME" to config.octaveHome,
+ "OCTAVE_HISTFILE" to "/dev/null",
+ "HOME" to config.homeDir,
+ "TMPDIR" to config.tmpDir,
+ "PAGER" to "cat",
+ "TERM" to "dumb",
+ "LANG" to "C.UTF-8",
+ "LC_ALL" to "C.UTF-8",
+ )
+
+ private fun terminalFailure(
+ kind: OctaveSessionFailureKind,
+ message: String,
+ output: BoundedUtf8Buffer,
+ protocol: List,
+ error: StringBuilder,
+ child: OctaveChildProcess,
+ exitCode: Int? = child.exitCodeOrNull(),
+ details: String? = null,
+ ): OctaveSessionResult {
+ val errorText = error.toString().trim().ifBlank { null }
+ val diagnostic = details ?: buildString {
+ errorText?.let { append(it).append('\n') }
+ append(output.toString().takeLast(STARTUP_DETAIL_CHARS))
+ }.trim().takeLast(STARTUP_DETAIL_CHARS).ifBlank { exitDetails() }
+ return OctaveSessionResult(
+ output = output.toString(),
+ protocolLines = protocol,
+ errorText = errorText,
+ truncated = output.truncated,
+ failure = OctaveSessionFailure(kind, message, diagnostic, exitCode),
+ )
+ }
+
+ private fun OctaveSessionFailure.withStartupDetails(result: OctaveSessionResult): OctaveSessionFailure =
+ copy(
+ details = buildString {
+ details?.let { append(it).append('\n') }
+ result.errorText?.let { append(it).append('\n') }
+ append(result.output.takeLast(STARTUP_DETAIL_CHARS))
+ }.trim().takeLast(STARTUP_DETAIL_CHARS).takeIf { it.isNotBlank() },
+ exitCode = exitCode ?: process?.exitCodeOrNull(),
+ )
+
+ private fun notStarted() = OctaveSessionResult(
+ failure = OctaveSessionFailure(
+ OctaveSessionFailureKind.NOT_STARTED,
+ "Octave session is not ready",
+ exitDetails(),
+ process?.exitCodeOrNull(),
+ ),
+ )
+
+ private fun exitDetails(): String = process?.exitCodeOrNull()?.let { "exitCode=$it" } ?: ""
+
+ private fun joinUnlessCurrent(target: Thread?) {
+ if (target != null && target !== Thread.currentThread()) {
+ runCatching { target.join(THREAD_JOIN_TIMEOUT_MS) }
+ }
+ }
+
+ companion object {
+ internal const val MAX_OUTPUT_BYTES = 1024 * 1024
+ internal const val MAX_STREAM_CHUNK_BYTES = 32 * 1024
+ private const val OUTPUT_TRUNCATED_NOTICE = "[Octave output truncated after 1 MiB]\n"
+ private val MAX_STREAM_PAYLOAD_BYTES = MAX_OUTPUT_BYTES -
+ OUTPUT_TRUNCATED_NOTICE.toByteArray(StandardCharsets.UTF_8).size
+ private const val MAX_PROTOCOL_CHARS = 320 * 1024
+ private const val MAX_ERROR_CHARS = 64 * 1024
+ private const val STARTUP_DETAIL_CHARS = 16 * 1024
+ private const val BOOT_REQUEST_ID = "__boot__"
+ private const val BOOT_TIMEOUT_MS = 60_000L
+ private const val PROCESS_STOP_TIMEOUT_MS = 2_000L
+ private const val THREAD_JOIN_TIMEOUT_MS = 2_000L
+ private const val POLL_MS = 250L
+ private const val MAX_QUEUED_EVENTS = 16
+ private const val READER_BUFFER_CHARS = 8 * 1024
+ private const val MAX_LINE_CHARS = MAX_PROTOCOL_CHARS + 4 * 1024
+ private const val RSS_POLL_MS = 2_000L
+ private const val RSS_CAP_KB = 1_500_000L
+ private const val SENTINEL_PREFIX = "__MAXMATH_DONE__"
+ private const val ERROR_PREFIX = "MAXMATH_ERROR "
+ private val PROTOCOL_PREFIXES = listOf(
+ "MAXMATH_WHOS ",
+ "MAXMATH_PREVIEW ",
+ "MAXMATH_ERROR ",
+ )
+ }
+}
+
+private fun StringBuilder.removeTrailingCarriageReturn(): String {
+ if (isNotEmpty() && this[lastIndex] == '\r') setLength(length - 1)
+ return toString()
+}
+
+private object SystemOctaveProcessFactory : OctaveProcessFactory {
+ override fun start(command: List, environment: Map): OctaveChildProcess {
+ val builder = ProcessBuilder(command)
+ .directory(File(environment.getValue("HOME")).parentFile)
+ .redirectErrorStream(true)
+ builder.environment().putAll(environment)
+ return SystemOctaveChildProcess(builder.start())
+ }
+}
+
+private class SystemOctaveChildProcess(private val delegate: Process) : OctaveChildProcess {
+ override val stdin: OutputStream get() = delegate.outputStream
+ override val stdout: InputStream get() = delegate.inputStream
+ override val isAlive: Boolean get() = delegate.isAlive
+ override val pid: Long get() = runCatching {
+ delegate.javaClass.getMethod("pid").invoke(delegate) as Long
+ }.getOrDefault(-1L)
+
+ override fun destroyForcibly() {
+ delegate.destroyForcibly()
+ }
+
+ override fun waitFor(timeoutMs: Long): Boolean = delegate.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
+
+ override fun exitCodeOrNull(): Int? = if (delegate.isAlive) null else runCatching {
+ delegate.exitValue()
+ }.getOrNull()
+}
+
+private class BoundedUtf8Buffer(private val maxBytes: Int) {
+ private val text = StringBuilder()
+ private var bytes = 0
+ var truncated: Boolean = false
+ private set
+
+ fun appendLine(line: String) {
+ append(line)
+ append("\n")
+ }
+
+ private fun append(value: String) {
+ var offset = 0
+ while (offset < value.length) {
+ val codePoint = value.codePointAt(offset)
+ val chars = String(Character.toChars(codePoint))
+ val encoded = chars.toByteArray(StandardCharsets.UTF_8)
+ if (bytes + encoded.size > maxBytes) {
+ truncated = true
+ return
+ }
+ text.append(chars)
+ bytes += encoded.size
+ offset += Character.charCount(codePoint)
+ }
+ }
+
+ override fun toString(): String = text.toString()
+}
+
+private class BoundedStreamEmitter(
+ private val maxPayloadBytes: Int,
+ private val maxChunkBytes: Int,
+ private val truncationNotice: String,
+ private val consumer: (String) -> Unit,
+) {
+ private val chunk = StringBuilder()
+ private var chunkBytes = 0
+ private var payloadBytes = 0
+ private var truncated = false
+
+ fun append(value: String) {
+ if (value.isEmpty() || truncated) return
+ var offset = 0
+ while (offset < value.length) {
+ val codePoint = value.codePointAt(offset)
+ val chars = String(Character.toChars(codePoint))
+ val size = chars.toByteArray(StandardCharsets.UTF_8).size
+ if (payloadBytes + size > maxPayloadBytes) {
+ notifyTruncated()
+ return
+ }
+ if (chunkBytes + size > maxChunkBytes && chunk.isNotEmpty()) flush()
+ chunk.append(chars)
+ chunkBytes += size
+ payloadBytes += size
+ offset += Character.charCount(codePoint)
+ }
+ }
+
+ fun flush() {
+ if (chunk.isEmpty()) return
+ consumer(chunk.toString())
+ chunk.clear()
+ chunkBytes = 0
+ }
+
+ private fun notifyTruncated() {
+ if (truncated) return
+ truncated = true
+ flush()
+ consumer(truncationNotice)
+ }
+}
+
+private fun octaveQuote(value: String): String = value.replace("'", "''")
+
+private fun readProcessRssKb(pid: Long): Long {
+ if (pid <= 0L) return 0L
+ return try {
+ File("/proc/$pid/status").useLines { lines ->
+ lines.firstOrNull { it.startsWith("VmRSS:") }
+ ?.split(Regex("\\s+"))
+ ?.getOrNull(1)
+ ?.toLongOrNull()
+ ?: 0L
+ }
+ } catch (_: Exception) {
+ 0L
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OctaveTask.kt b/engine/src/main/java/com/paruh/maxmath/engine/OctaveTask.kt
new file mode 100644
index 0000000..a012b39
--- /dev/null
+++ b/engine/src/main/java/com/paruh/maxmath/engine/OctaveTask.kt
@@ -0,0 +1,167 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONObject
+
+/**
+ * Octave 控制台任务:命令求值、脚本运行与工作区管理。
+ * 与 [MathTask] 一样用密封类型 + 显式 JSON,避免字符串操作名漂移。
+ */
+sealed interface OctaveTask {
+
+ fun toJson(): JSONObject
+
+ companion object {
+ fun fromJson(obj: JSONObject): OctaveTask = when (obj.getString("kind")) {
+ "eval" -> OctaveEvalTask(command = obj.getString("command"))
+ "runScript" -> OctaveRunScriptTask(
+ script = obj.getString("script"),
+ name = obj.optString("name", "script.m"),
+ )
+ "whos" -> OctaveWhosTask
+ "preview" -> OctavePreviewTask(name = obj.getString("name"))
+ "clear" -> OctaveClearTask(name = obj.optString("name").ifBlank { null })
+ "reset" -> OctaveResetTask
+ "sourceFile" -> OctaveSourceFileTask(
+ token = obj.getString("token"),
+ displayName = obj.optString("displayName").ifBlank { null },
+ )
+ else -> throw IllegalArgumentException("未知 Octave 任务:${obj.getString("kind")}")
+ }
+ }
+}
+
+/**
+ * 一次 Octave IPC 请求。请求标识由客户端生成,并在整个事件流中保持不变。
+ */
+data class OctaveRequest(
+ val id: String,
+ val task: OctaveTask,
+ val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
+) {
+ init {
+ require(isValidId(id)) {
+ "Octave 请求 ID 无效"
+ }
+ require(timeoutMs in 1..MAX_TIMEOUT_MS) { "Octave 请求超时超出允许范围" }
+ }
+
+ fun toJson(): JSONObject = JSONObject()
+ .put("version", OCTAVE_PROTOCOL_VERSION)
+ .put("id", id)
+ .put("task", task.toJson())
+ .put("timeoutMs", timeoutMs)
+
+ companion object {
+ const val DEFAULT_TIMEOUT_MS = 120_000L
+ const val MAX_TIMEOUT_MS = 15L * 60L * 1_000L
+ private val REQUEST_ID = Regex("[A-Za-z0-9_.-]{1,128}")
+
+ internal fun isValidId(id: String): Boolean = REQUEST_ID.matches(id)
+
+ fun fromJson(obj: JSONObject): OctaveRequest = OctaveRequest(
+ id = obj.getString("id"),
+ task = OctaveTask.fromJson(obj.getJSONObject("task")),
+ timeoutMs = parseTimeout(obj),
+ ).also { requireOctaveProtocolVersion(obj, allowMissing = true) }
+
+ private fun parseTimeout(obj: JSONObject): Long {
+ if (!obj.has("timeoutMs")) return DEFAULT_TIMEOUT_MS
+ val raw = obj.get("timeoutMs") as? Number
+ ?: throw IllegalArgumentException("Octave timeout must be numeric")
+ val value = raw.toDouble()
+ require(value.isFinite() && value % 1.0 == 0.0 && value in 1.0..MAX_TIMEOUT_MS.toDouble()) {
+ "Octave request timeout is invalid"
+ }
+ return value.toLong()
+ }
+ }
+}
+
+/** 控制台输入:可以是一次提交的多行命令(含 ... 续行)。 */
+data class OctaveEvalTask(val command: String) : OctaveTask {
+ init {
+ require(command.length <= MAX_INLINE_CHARS) { "Octave command is too large" }
+ }
+ override fun toJson(): JSONObject = JSONObject()
+ .put("kind", "eval")
+ .put("command", command)
+
+ companion object { private const val MAX_INLINE_CHARS = 4 * 1024 * 1024 }
+}
+
+/** 运行脚本文件;脚本内容由 UI 写入沙箱后以 source() 执行。 */
+data class OctaveRunScriptTask(
+ val script: String,
+ val name: String = "script.m",
+) : OctaveTask {
+ init {
+ require(script.length <= MAX_INLINE_CHARS) { "Octave script is too large" }
+ require(name.length <= MAX_NAME_CHARS) { "Octave script name is too long" }
+ }
+ override fun toJson(): JSONObject = JSONObject()
+ .put("kind", "runScript")
+ .put("script", script)
+ .put("name", name)
+
+ companion object {
+ private const val MAX_INLINE_CHARS = 4 * 1024 * 1024
+ private const val MAX_NAME_CHARS = 128
+ }
+}
+
+/** 刷新工作区变量列表。 */
+data object OctaveWhosTask : OctaveTask {
+ override fun toJson(): JSONObject = JSONObject().put("kind", "whos")
+}
+
+/** 预览单个变量的值(结构化 JSON,供文本与 LaTeX 两种渲染)。 */
+data class OctavePreviewTask(val name: String) : OctaveTask {
+ init {
+ require(NAME.matches(name)) { "Invalid Octave variable name" }
+ }
+ override fun toJson(): JSONObject = JSONObject()
+ .put("kind", "preview")
+ .put("name", name)
+
+ companion object { private val NAME = Regex("[A-Za-z][A-Za-z0-9_]{0,127}") }
+}
+
+/** 删除一个变量(name=null 时清空工作区)。 */
+data class OctaveClearTask(val name: String? = null) : OctaveTask {
+ init {
+ require(name == null || NAME.matches(name)) { "Invalid Octave variable name" }
+ }
+ override fun toJson(): JSONObject = JSONObject()
+ .put("kind", "clear")
+ .put("name", name ?: "")
+
+ companion object { private val NAME = Regex("[A-Za-z][A-Za-z0-9_]{0,127}") }
+}
+
+/** 重置工作区并清空绘图状态。 */
+data object OctaveResetTask : OctaveTask {
+ override fun toJson(): JSONObject = JSONObject().put("kind", "reset")
+}
+
+/** Same-UID file transport used internally so arbitrary source never crosses Binder. */
+internal data class OctaveSourceFileTask(
+ val token: String,
+ val displayName: String? = null,
+) : OctaveTask {
+ init {
+ require(SAFE_TOKEN.matches(token)) { "Invalid Octave source token" }
+ require(displayName == null || displayName.length <= 128) {
+ "Octave source display name is too long"
+ }
+ }
+
+ override fun toJson(): JSONObject = JSONObject()
+ .put("kind", "sourceFile")
+ .put("token", token)
+ .apply { displayName?.let { put("displayName", it) } }
+
+ companion object {
+ val SAFE_TOKEN = Regex("[A-Za-z0-9_.-]{1,180}")
+ const val DIRECTORY = "octave/work/ipc"
+ }
+}
diff --git a/engine/src/main/java/com/paruh/maxmath/engine/OpRegistry.kt b/engine/src/main/java/com/paruh/maxmath/engine/OpRegistry.kt
deleted file mode 100644
index 388bda7..0000000
--- a/engine/src/main/java/com/paruh/maxmath/engine/OpRegistry.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.paruh.maxmath.engine
-
-/** 轻重分类:轻操作在 UI 进程执行,重操作转发到 :engine 独立进程。 */
-object OpRegistry {
-
- fun isHeavy(task: MathTask): Boolean = when (task) {
- is SimplifyTask, is ExpandTask, is EvaluateTask -> false
- is CalculusTask -> task.kind != CalculusKind.DIFF
- is MatrixTask -> when (task.kind) {
- MatrixKind.DET,
- MatrixKind.INV,
- MatrixKind.TRANSPOSE,
- MatrixKind.RANK,
- MatrixKind.TRACE,
- -> task.raw && !task.matrix.rawText.isNullOrBlank() || task.matrix.cells.size > 4
-
- else -> true
- }
- else -> true
- }
-}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineClientDeadlineTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineClientDeadlineTest.kt
new file mode 100644
index 0000000..d5033b2
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineClientDeadlineTest.kt
@@ -0,0 +1,129 @@
+package com.paruh.maxmath.engine
+
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class EngineClientDeadlineTest {
+
+ @Test
+ fun transportWhichNeverSendsATerminalCannotLeaveTheClientComputingForever() = runBlocking {
+ val cancelled = AtomicBoolean(false)
+ val transport = object : EngineTransport {
+ override suspend fun execute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Preparing(request.id))
+ awaitCancellation()
+ }
+
+ override suspend fun cancel(requestId: String): CalcEvent {
+ cancelled.set(true)
+ return CalcEvent.Done(requestId, CalcResponse(requestId, ok = false))
+ }
+ }
+ val client = EngineClient(
+ transport = transport,
+ prepareTimeoutMs = 50,
+ executionTimeoutMs = 50,
+ )
+
+ val response = client.compute(CalcRequest("deadline", SimplifyTask("x+x"))) { }
+
+ assertEquals(CalcFailureCode.PREPARATION_TIMEOUT, response.failure?.code)
+ assertTrue("deadline must actively cancel the orphan request", cancelled.get())
+ }
+
+ @Test
+ fun runningRequestWhichLosesItsTerminalEndsAtTheExecutionDeadline() = runBlocking {
+ val cancelled = AtomicBoolean(false)
+ val transport = object : EngineTransport {
+ override suspend fun execute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Preparing(request.id))
+ onEvent(CalcEvent.Running(request.id))
+ awaitCancellation()
+ }
+
+ override suspend fun cancel(requestId: String): CalcEvent {
+ cancelled.set(true)
+ return CalcEvent.Done(requestId, CalcResponse(requestId, ok = false))
+ }
+ }
+ val client = EngineClient(transport, prepareTimeoutMs = 50, executionTimeoutMs = 50)
+
+ val response = client.compute(CalcRequest("execution-deadline", SimplifyTask("x+x"))) { }
+
+ assertEquals(CalcFailureCode.EXECUTION_TIMEOUT, response.failure?.code)
+ assertTrue(cancelled.get())
+ }
+
+ @Test
+ fun serviceDisconnectIsReturnedAsTheSingleTerminalFailure() = runBlocking {
+ val events = mutableListOf()
+ val transport = object : EngineTransport {
+ override suspend fun execute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Preparing(request.id))
+ return CalcResponse.failed(
+ request.id,
+ CalcFailure(
+ CalcFailureCode.SERVICE_DISCONNECTED,
+ CalcFailureStage.RESPONSE,
+ "Maxima 服务已断开",
+ ),
+ )
+ }
+
+ override suspend fun cancel(requestId: String): CalcEvent =
+ error("completed requests must not be cancelled")
+ }
+ val client = EngineClient(transport, prepareTimeoutMs = 100, executionTimeoutMs = 100)
+
+ val response = client.compute(CalcRequest("disconnect", SimplifyTask("x")), events::add)
+
+ assertEquals(CalcFailureCode.SERVICE_DISCONNECTED, response.failure?.code)
+ assertEquals(1, events.filterIsInstance().size)
+ }
+
+ @Test
+ fun coroutineCancellationCancelsTheMatchingRemoteRequestExactlyOnce() = runBlocking {
+ val started = CompletableDeferred()
+ val cancelCalls = AtomicInteger()
+ val transport = object : EngineTransport {
+ override suspend fun execute(
+ request: CalcRequest,
+ onEvent: (CalcEvent) -> Unit,
+ ): CalcResponse {
+ onEvent(CalcEvent.Running(request.id))
+ started.complete(Unit)
+ awaitCancellation()
+ }
+
+ override suspend fun cancel(requestId: String): CalcEvent {
+ cancelCalls.incrementAndGet()
+ return CalcEvent.Done(requestId, CalcResponse(requestId, ok = false))
+ }
+ }
+ val client = EngineClient(transport, prepareTimeoutMs = 1_000, executionTimeoutMs = 1_000)
+ val request = CalcRequest("cancel-one", SimplifyTask("x"))
+ val job = async { client.compute(request) { } }
+ started.await()
+
+ job.cancelAndJoin()
+
+ assertEquals(1, cancelCalls.get())
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineRuntimeManifestTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineRuntimeManifestTest.kt
new file mode 100644
index 0000000..22f6948
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/EngineRuntimeManifestTest.kt
@@ -0,0 +1,58 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class EngineRuntimeManifestTest {
+
+ @Test
+ fun `accepts matching compiled and packaged Maxima versions`() {
+ val manifest = EngineRuntimeManifest.parse(manifestJson("5.49.0"))
+
+ assertEquals("5.49.0", manifest.maximaVersion)
+ assertEquals("5.49.0", manifest.compiledMaximaVersion)
+ }
+
+ @Test
+ fun `rejects binary compiled for a different Maxima search directory`() {
+ val error = assertThrows(IllegalArgumentException::class.java) {
+ EngineRuntimeManifest.parse(manifestJson("v1.1.0_2_g084c646_dirty"))
+ }
+
+ assertTrue(error.message!!.contains("compiled/runtime version mismatch"))
+ }
+
+ private fun manifestJson(compiledVersion: String): String {
+ fun record(path: String) = JSONObject()
+ .put("path", path)
+ .put("size", 1)
+ .put("sha256", "a".repeat(64))
+ return JSONObject()
+ .put("schemaVersion", 1)
+ .put("runtimeId", "sha256:${"b".repeat(64)}")
+ .put("maximaVersion", "5.49.0")
+ .put("compiledMaximaVersion", compiledVersion)
+ .put("eclVersion", "26.3.27")
+ .put("abi", "arm64-v8a")
+ .put("archive", record("runtime.zip"))
+ .put(
+ "files",
+ JSONArray(
+ listOf(
+ record("init.lisp.template"),
+ record("share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac"),
+ record("share/maxima/5.49.0/share/lisp-utils/defsystem.lisp"),
+ ),
+ ),
+ )
+ .put(
+ "jniFiles",
+ JSONArray(listOf(record("libmaxima.so"), record("libecl.so"))),
+ )
+ .toString()
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt
index c6abf8a..5d86123 100644
--- a/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/MaximaScriptBuilderTest.kt
@@ -30,6 +30,24 @@ class MaximaScriptBuilderTest {
assertTrue(script.contains("determinant(M)"))
}
+ @Test
+ fun `matrix trace stays on the fast self contained path`() {
+ val task = MatrixTask(
+ kind = MatrixKind.TRACE,
+ matrix = MatrixInput(cells = listOf(
+ listOf("1", "2", "3"),
+ listOf("4", "5", "6"),
+ listOf("7", "8", "9"),
+ )),
+ )
+
+ val script = MaximaScriptBuilder.build(request(task).task, "/tmp/out.txt")
+
+ assertFalse("迹不应触发 linearalgebra 的慢速自动加载", script.contains("mat_trace"))
+ assertFalse("迹不应加载 linearalgebra", script.contains("load(\"linearalgebra\")"))
+ assertTrue("迹应直接遍历主对角线", script.contains("for i thru length(M) do"))
+ }
+
@Test
fun `raw matrix passes through`() {
val task = MatrixTask(
@@ -84,6 +102,21 @@ class MaximaScriptBuilderTest {
assertTrue(script.contains("integrate((sin(x)),x,0,%pi)"))
}
+ @Test
+ fun `sinc definite integral from screenshot reaches Maxima without losing the quotient`() {
+ val task = CalculusTask(
+ kind = CalculusKind.INTEGRATE,
+ expression = "sin(x)/x",
+ variable = "x",
+ lower = "0",
+ upper = "pi",
+ )
+
+ val script = MaximaScriptBuilder.build(request(task).task, "/tmp/out.txt")
+
+ assertTrue(script, script.contains("integrate(((sin(x)/x)),x,0,%pi)"))
+ }
+
@Test
fun `indefinite integral command`() {
val task = CalculusTask(
@@ -118,29 +151,6 @@ class MaximaScriptBuilderTest {
assertTrue(script.contains("pts: errcatch("))
}
- @Test
- fun `op registry classifies heavy`() {
- assertTrue(OpRegistry.isHeavy(MatrixTask(MatrixKind.EIGEN, MatrixInput())))
- assertTrue(
- OpRegistry.isHeavy(
- PlotTask(PlotKind.PLOT_2D, xMin = "-3", xMax = "3", yMin = "-2", yMax = "2")
- )
- )
- assertFalse(OpRegistry.isHeavy(SimplifyTask("x")))
- assertFalse(OpRegistry.isHeavy(CalculusTask(CalculusKind.DIFF, "x", "x")))
- assertTrue(OpRegistry.isHeavy(CalculusTask(CalculusKind.LIMIT, "x", "x", point = "0")))
- assertFalse(
- OpRegistry.isHeavy(
- MatrixTask(MatrixKind.DET, MatrixInput(listOf(listOf("1", "2"), listOf("3", "4"))))
- )
- )
- assertTrue(
- OpRegistry.isHeavy(
- MatrixTask(MatrixKind.DET, MatrixInput(List(5) { List(2) { "" } }))
- )
- )
- }
-
@Test
fun `calc request json roundtrip`() {
val task = CalculusTask(
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotArtifactExpiryTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotArtifactExpiryTest.kt
new file mode 100644
index 0000000..25fec1d
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotArtifactExpiryTest.kt
@@ -0,0 +1,62 @@
+package com.paruh.maxmath.engine
+
+import java.io.File
+import java.nio.file.Files
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class OctavePlotArtifactExpiryTest {
+
+ @Test
+ fun cleanupDeletesOnlyExpiredPlotJsonArtifacts() = withPlotDirectory { root, outDir ->
+ val expired = createFile(outDir, "plot_expired.json", lastModifiedMs = 1_000L)
+ val recent = createFile(outDir, "plot_recent.json", lastModifiedMs = 2_000L)
+ val unrelated = createFile(outDir, "preview.json", lastModifiedMs = 1_000L)
+
+ val deleted = cleanupExpiredOctavePlotArtifacts(
+ outDir = outDir,
+ nowMs = 301_000L,
+ )
+
+ assertEquals(1, deleted)
+ assertFalse(expired.exists())
+ assertTrue(recent.exists())
+ assertTrue(unrelated.exists())
+ assertTrue(root.exists())
+ }
+
+ @Test
+ fun expiryRejectsAPlotOutsideTheCanonicalOutputDirectory() =
+ withPlotDirectory { root, outDir ->
+ val escaped = createFile(root, "plot_escape.json", lastModifiedMs = 1_000L)
+
+ val deleted = deleteExpiredOctavePlotArtifact(
+ outDir = outDir,
+ artifact = File(outDir, "../plot_escape.json"),
+ nowMs = 301_000L,
+ )
+
+ assertFalse(deleted)
+ assertTrue(escaped.exists())
+ }
+
+ private fun withPlotDirectory(block: (root: File, outDir: File) -> Unit) {
+ val root = Files.createTempDirectory("maxmath-plot-expiry").toFile()
+ try {
+ val outDir = File(root, "out").apply {
+ check(mkdirs())
+ }
+ block(root, outDir)
+ } finally {
+ root.deleteRecursively()
+ }
+ }
+
+ private fun createFile(directory: File, name: String, lastModifiedMs: Long): File =
+ File(directory, name).apply {
+ writeText("{}")
+ check(setLastModified(lastModifiedMs))
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotSpecTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotSpecTest.kt
new file mode 100644
index 0000000..4c1b113
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotSpecTest.kt
@@ -0,0 +1,120 @@
+package com.paruh.maxmath.engine
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class OctavePlotSpecTest {
+
+ @Test
+ fun `plot artifact keeps request identity`() {
+ val figure = OctaveFigure.fromJson(
+ """{"version":1,"requestId":"plot-request","layout":[1,1],"axes":[]}""",
+ )
+
+ assertEquals("plot-request", figure?.requestId)
+ assertEquals(1, figure?.protocolVersion)
+ }
+
+ @Test
+ fun parses2dLineFigure() {
+ val spec = OctaveFigure.fromJson(
+ """
+ {
+ "layout": [1,1],
+ "axes": [{
+ "position": 1, "type": "2d", "title": "demo",
+ "xlabel": "t", "ylabel": "sin",
+ "legend": ["a","b"], "grid": 1,
+ "xlim": [], "ylim": [], "zlim": [],
+ "lines": [{"x":[0,1,2],"y":[0,1,0],"style":"r--"}],
+ "lines3d": [], "surfaces": [], "contours": []
+ }]
+ }
+ """.trimIndent(),
+ )!!
+
+ assertEquals(1, spec.rows)
+ assertEquals(1, spec.cols)
+ val axes = spec.axes.single()
+ assertEquals("2d", axes.type)
+ assertEquals("demo", axes.title)
+ assertEquals(listOf("a", "b"), axes.legend)
+ assertTrue(axes.grid)
+ val line = axes.lines.single()
+ assertArrayEquals(doubleArrayOf(0.0, 1.0, 2.0), line.x, 1e-9)
+ assertArrayEquals(doubleArrayOf(0.0, 1.0, 0.0), line.y, 1e-9)
+ assertEquals("r--", line.style)
+ }
+
+ @Test
+ fun parsesSurfaceAndContourGrids() {
+ val spec = OctaveFigure.fromJson(
+ """
+ {
+ "layout": [1,2],
+ "axes": [
+ {
+ "position": 1, "type": "3d",
+ "surfaces": [{"x":[[1,2],[1,2]],"y":[[1,1],[2,2]],
+ "z":[[0,1],[1,4]],"kind":"surf"}],
+ "lines": [], "lines3d": [], "contours": [],
+ "legend": [], "xlim": [], "ylim": [], "zlim": []
+ },
+ {
+ "position": 2, "type": "contour",
+ "contours": [{"x":[1,2],"y":[1,2],
+ "z":[[0,1],[1,4]],"levels":[-4,0,4],"filled":false}],
+ "lines": [], "lines3d": [], "surfaces": [],
+ "legend": [], "xlim": [], "ylim": [], "zlim": []
+ }
+ ]
+ }
+ """.trimIndent(),
+ )!!
+
+ assertEquals(2, spec.cols)
+ val surf = spec.axes[0].surfaces.single()
+ assertEquals(2, surf.rows)
+ assertEquals(2, surf.cols)
+ assertArrayEquals(doubleArrayOf(1.0, 2.0, 1.0, 2.0), surf.x, 1e-9)
+ assertArrayEquals(doubleArrayOf(0.0, 1.0, 1.0, 4.0), surf.z, 1e-9)
+
+ val contour = spec.axes[1].contours.single()
+ assertEquals(2, contour.rows)
+ assertEquals(2, contour.cols)
+ assertArrayEquals(doubleArrayOf(-4.0, 0.0, 4.0), contour.levels, 1e-9)
+ assertFalse(contour.filled)
+ }
+
+ @Test
+ fun parsesCameraAndTextAnnotations() {
+ val spec = OctaveFigure.fromJson(
+ """
+ {
+ "layout": [1,1],
+ "axes": [{
+ "position": 1, "type": "3d", "view": [45,30],
+ "texts": [{"x":50,"y":0.7,"z":[],"text":" 50.0 Hz"}],
+ "lines": [], "lines3d": [], "surfaces": [], "contours": []
+ }]
+ }
+ """.trimIndent(),
+ )!!
+
+ val axes = spec.axes.single()
+ assertEquals(45.0, axes.azimuth, 1e-9)
+ assertEquals(30.0, axes.elevation, 1e-9)
+ assertEquals(OctaveText(50.0, 0.7, null, " 50.0 Hz"), axes.texts.single())
+ }
+
+ @Test
+ fun toleratesMissingFields() {
+ val spec = OctaveFigure.fromJson("""{"layout":[1,1],"axes":[]}""")!!
+ assertEquals(0, spec.axes.size)
+ assertEquals("2d", OctaveFigure.fromJson("""{"axes":[{}]}""")!!.axes.single().type)
+ assertEquals(null, OctaveFigure.fromJson("not json"))
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveProtocolTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveProtocolTest.kt
new file mode 100644
index 0000000..0f657d1
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveProtocolTest.kt
@@ -0,0 +1,215 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+
+class OctaveProtocolTest {
+ @Test
+ fun requestRoundTripKeepsClientIdTaskAndDeadline() {
+ val request = OctaveRequest(
+ id = "request-42",
+ task = OctaveRunScriptTask("disp('ok')", "demo.m"),
+ timeoutMs = 45_000L,
+ )
+
+ val parsed = OctaveRequest.fromJson(request.toJson())
+
+ assertEquals(request, parsed)
+ }
+
+ @Test
+ fun structuredFailureEventRoundTripsDiagnosticsAndLocation() {
+ val failure = OctaveFailure(
+ code = OctaveFailureCode.PROCESS_EXITED,
+ stage = OctaveFailureStage.EXECUTION,
+ message = "Octave exited",
+ details = "stderr tail",
+ exitCode = 127,
+ location = OctaveScriptLocation(
+ file = "/work/demo.m",
+ name = "demo",
+ line = 7,
+ column = 3,
+ ),
+ )
+
+ val parsed = OctaveEvent.fromJson(
+ OctaveEvent.Failure("request-1", failure).toJson(),
+ ) as OctaveEvent.Failure
+
+ assertEquals("request-1", parsed.requestId)
+ assertEquals(failure, parsed.failure)
+ assertTrue(parsed.failure.diagnosticText().contains("/work/demo.m:7:3"))
+ }
+
+ @Test
+ fun typedPreviewRoundTripsRecursiveComplexTextAndSpecialNumbers() {
+ val value = OctavePreviewValue.Matrix(
+ listOf(
+ listOf(
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.Finite(1.5)),
+ OctavePreviewValue.Complex(
+ OctaveSpecialNumber.Finite(2.0),
+ OctaveSpecialNumber.NegativeInfinity,
+ ),
+ ),
+ listOf(
+ OctavePreviewValue.Text("hello"),
+ OctavePreviewValue.Logical(true),
+ ),
+ ),
+ )
+ val preview = OctavePreview(text = "preview", value = value)
+
+ val parsed = OctavePreview.fromJson(preview.toJson())
+
+ assertEquals(value, parsed.value)
+ assertNull("Complex/text matrices must fall back to plain text", parsed.latexValueJson())
+ }
+
+ @Test
+ fun compactPreviewWireDoesNotExpandEveryScalarIntoAnObject() {
+ val row = List(100) {
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.Finite(it.toDouble()))
+ }
+ val preview = OctavePreview(
+ text = "matrix",
+ value = OctavePreviewValue.Matrix(List(100) { row }),
+ kind = "matrix",
+ dims = intArrayOf(100, 100),
+ )
+
+ val wire = preview.toJson().toString()
+
+ assertTrue(wire.toByteArray().size < 384 * 1024)
+ assertTrue(!wire.contains("\"kind\":\"scalar\""))
+ assertEquals(preview.value, OctavePreview.fromJson(preview.toJson()).value)
+ }
+
+ @Test
+ fun emptyMatrixKeepsItsTypedKindAcrossCompactWire() {
+ val preview = OctavePreview(
+ text = "[]",
+ value = OctavePreviewValue.Matrix(emptyList()),
+ kind = "matrix",
+ dims = intArrayOf(0, 0),
+ )
+
+ assertEquals(preview.value, OctavePreview.fromJson(preview.toJson()).value)
+ }
+
+ @Test
+ fun specialNumberWordsRemainTextInsideNestedValues() {
+ val value = OctavePreviewValue.Vector(
+ listOf(
+ OctavePreviewValue.Text("NaN"),
+ OctavePreviewValue.Text("Inf"),
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.NaN),
+ ),
+ )
+ val preview = OctavePreview(text = "mixed", value = value, kind = "vector")
+
+ assertEquals(value, OctavePreview.fromJson(preview.toJson()).value)
+ }
+
+ @Test
+ fun topLevelStringAndSummaryKeepPlainText() {
+ listOf("string", "summary").forEach { kind ->
+ val preview = OctavePreview(
+ text = "NaN",
+ value = OctavePreviewValue.Text("NaN"),
+ kind = kind,
+ truncated = kind == "summary",
+ )
+
+ val parsed = OctavePreview.fromJson(preview.toJson())
+
+ assertEquals("NaN", parsed.text)
+ assertEquals(OctavePreviewValue.Text("NaN"), parsed.value)
+ }
+ }
+
+ @Test
+ fun largeTypedTextIsNotDuplicatedOnTheBinderWire() {
+ val text = "x".repeat(192 * 1024)
+ val preview = OctavePreview(
+ text = text,
+ value = OctavePreviewValue.Text(text),
+ kind = "string",
+ )
+
+ val wire = preview.toJson().toString()
+
+ assertTrue(wire.toByteArray().size < 384 * 1024)
+ assertEquals(text, OctavePreview.fromJson(preview.toJson()).text)
+ }
+
+ @Test
+ fun legacyPreviewEnvelopeParsesScalarVectorAndNonFiniteValues() {
+ val scalar = OctavePreview("1.5", "1.5")
+ val vector = OctavePreview("values", "[1,\"Inf\",\"-Inf\",\"NaN\"]")
+ val complex = OctavePreview("1+2i", "{\"re\":1,\"im\":2}")
+
+ assertEquals(
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.Finite(1.5)),
+ scalar.value,
+ )
+ assertTrue(scalar.latexValueJson()!!.startsWith("["))
+ assertEquals(
+ OctavePreviewValue.Vector(
+ listOf(
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.Finite(1.0)),
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.PositiveInfinity),
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.NegativeInfinity),
+ OctavePreviewValue.Scalar(OctaveSpecialNumber.NaN),
+ ),
+ ),
+ vector.value,
+ )
+ assertEquals(
+ OctavePreviewValue.Complex(
+ OctaveSpecialNumber.Finite(1.0),
+ OctaveSpecialNumber.Finite(2.0),
+ ),
+ complex.value,
+ )
+ assertNull(complex.latexValueJson())
+ }
+
+ @Test
+ fun doneEventCarriesWorkspaceInTheTerminalResponse() {
+ val response = OctaveResponse(
+ id = "request-workspace",
+ ok = true,
+ output = "ans = 2",
+ workspace = listOf(
+ OctaveVariable("ans", "double", intArrayOf(1, 1), 8, false, false, false),
+ ),
+ )
+
+ val parsed = OctaveEvent.fromJson(
+ JSONObject(OctaveEvent.Done(response.id, response).toJson().toString()),
+ ) as OctaveEvent.Done
+
+ assertEquals("ans", parsed.response.workspace!!.single().name)
+ assertTrue(parsed.response.ok)
+ }
+
+ @Test
+ fun doneEventRejectsMismatchedInnerResponseId() {
+ val json = OctaveEvent.Done(
+ "outer",
+ OctaveResponse("inner", true, ""),
+ ).toJson()
+ try {
+ OctaveEvent.fromJson(json)
+ fail("mismatched response ID should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRequestStateTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRequestStateTest.kt
new file mode 100644
index 0000000..492e238
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRequestStateTest.kt
@@ -0,0 +1,39 @@
+package com.paruh.maxmath.engine
+
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class OctaveRequestStateTest {
+
+ @Test
+ fun duplicateCancellationDoesNotBecomeAFirstEngineCancellationAgain() {
+ val stored = String(charArrayOf('r', 'e', 'q', '-', '1'))
+ val decodedAgain = String(charArrayOf('r', 'e', 'q', '-', '1'))
+
+ assertEquals(
+ OctaveCancellationDecision.FIRST,
+ decideOctaveCancellation(stored, null, decodedAgain),
+ )
+ assertEquals(
+ OctaveCancellationDecision.DUPLICATE,
+ decideOctaveCancellation(stored, stored, decodedAgain),
+ )
+ }
+
+ @Test
+ fun clearedRequestIdCanBeReusedWithoutAStaleTombstone() {
+ val firstInstance = String(charArrayOf('r', 'e', 'u', 's', 'e'))
+ val equalDecodedInstance = String(charArrayOf('r', 'e', 'u', 's', 'e'))
+ val marker = AtomicReference(firstInstance)
+
+ marker.clearOctaveRequest(equalDecodedInstance)
+
+ assertNull(marker.get())
+ assertEquals(
+ OctaveCancellationDecision.FIRST,
+ decideOctaveCancellation(equalDecodedInstance, null, firstInstance),
+ )
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRuntimeManifestTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRuntimeManifestTest.kt
new file mode 100644
index 0000000..c06b7a2
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRuntimeManifestTest.kt
@@ -0,0 +1,129 @@
+package com.paruh.maxmath.engine
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+
+class OctaveRuntimeManifestTest {
+
+ @Test
+ fun parsesPinnedArm64Manifest() {
+ val manifest = OctaveRuntimeManifest.parse(
+ """
+ {
+ "schemaVersion":1,
+ "runtimeId":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "octaveVersion":"11.3.0",
+ "bridgeVersion":1,
+ "abi":"arm64-v8a",
+ "sourcePackages":[
+ {"name":"octave","version":"2:11.3.0","filename":"octave.deb","sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"},
+ {"name":"libc++","version":"29","filename":"libcxx.deb","sha256":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}
+ ],
+ "cxxRuntime":{"path":"libc++_shared.so","sha256":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","packageVersion":"29","compilerMarkers":["Android clang version 21.0.0"]},
+ "files":[
+ {"path":"usr/share/octave/11.3.0/etc/startup/octaverc","size":12,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
+ {"path":"maxmath/maxmath_init.m","size":34,"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
+ ],
+ "jniFiles":[
+ {"path":"liboctavebin.so","size":99,"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},
+ {"path":"libc++_shared.so","size":101,"sha256":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}
+ ]
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals(
+ "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ manifest.runtimeId,
+ )
+ assertEquals("11.3.0", manifest.octaveVersion)
+ assertEquals("arm64-v8a", manifest.abi)
+ assertEquals(46L, manifest.totalBytes)
+ }
+
+ @Test
+ fun rejectsTraversalAndMalformedHashes() {
+ listOf(
+ validManifestJson().replace("maxmath/maxmath_init.m", "../home/script.m"),
+ validManifestJson().replace(
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "bad",
+ ),
+ ).forEach { json ->
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("unsafe manifest should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+ }
+
+ @Test
+ fun rejectsUnknownSchemaAndMissingJniContract() {
+ listOf(
+ validManifestJson().replace("\"schemaVersion\":1", "\"schemaVersion\":2"),
+ validManifestJson().replace(
+ "\"jniFiles\":[{\"path\":\"libc++_shared.so\",\"size\":101,\"sha256\":\"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}]",
+ "\"jniFiles\":[]",
+ ),
+ ).forEach { json ->
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("unknown schema or missing JNI contract should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+ }
+
+ @Test
+ fun rejectsStringBridgeVersionInsteadOfSilentlyCoercingIt() {
+ val json = validManifestJson().replace("\"bridgeVersion\":1", "\"bridgeVersion\":\"1\"")
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("string bridgeVersion should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+
+ @Test
+ fun rejectsABridgeProtocolNewerThanTheAppUnderstands() {
+ val json = validManifestJson().replace("\"bridgeVersion\":1", "\"bridgeVersion\":2")
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("unsupported bridgeVersion should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+
+ @Test
+ fun rejectsFractionalSchema() {
+ val json = validManifestJson().replace("\"schemaVersion\":1", "\"schemaVersion\":1.5")
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("fractional schema should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+
+ @Test
+ fun rejectsFractionalAndOverflowingFileSizes() {
+ listOf("1.5", "1e100").forEach { invalidSize ->
+ val json = validManifestJson().replace("\"size\":12", "\"size\":$invalidSize")
+ try {
+ OctaveRuntimeManifest.parse(json)
+ fail("invalid file size should have been rejected: $invalidSize")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+ }
+
+ private fun validManifestJson(): String =
+ """{"schemaVersion":1,"runtimeId":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","octaveVersion":"11.3.0","bridgeVersion":1,"abi":"arm64-v8a","sourcePackages":[{"name":"octave","version":"2:11.3.0","filename":"octave.deb","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"name":"libc++","version":"29","filename":"libcxx.deb","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}],"cxxRuntime":{"path":"libc++_shared.so","sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","packageVersion":"29","compilerMarkers":["clang version 21"]},"files":[{"path":"maxmath/maxmath_init.m","size":12,"sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}],"jniFiles":[{"path":"libc++_shared.so","size":101,"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}]}"""
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt
new file mode 100644
index 0000000..ad7f52a
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt
@@ -0,0 +1,298 @@
+package com.paruh.maxmath.engine
+
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class OctaveSessionTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun bootRequiresACompleteHandshake() {
+ val process = ScriptedProcess { _, child -> child.exit(127) }
+ val session = newSession(process)
+
+ val result = session.start("request-1")
+
+ assertFalse(result.ok)
+ assertEquals(OctaveSessionFailureKind.EXITED, result.failure?.kind)
+ assertFalse(process.isAlive)
+ }
+
+ @Test
+ fun launchFailureLeavesNoDeadProcessMarkerAndCanRetry() {
+ val healthy = ScriptedProcess { script, child -> child.complete(script) }
+ var attempts = 0
+ val session = OctaveSession(
+ config = config(),
+ processFactory = OctaveProcessFactory { _, _ ->
+ attempts += 1
+ if (attempts == 1) throw java.io.IOException("exec failed")
+ healthy
+ },
+ rssReader = { 0L },
+ )
+
+ assertFalse(session.start("first").ok)
+ assertTrue(session.start("second").ok)
+ session.close()
+ }
+
+ @Test
+ fun cancellationDoesNotWaitForTheExecutingThread() {
+ val commandStarted = CountDownLatch(1)
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("1 + 1")) {
+ commandStarted.countDown()
+ } else {
+ child.complete(script)
+ }
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+ val executor = Executors.newSingleThreadExecutor()
+ val result = executor.submit {
+ session.execute("request-2", "1 + 1", 30_000L) {}
+ }
+ assertTrue(commandStarted.await(1, TimeUnit.SECONDS))
+
+ val started = System.nanoTime()
+ assertTrue(session.cancel("request-2"))
+ val response = result.get(2, TimeUnit.SECONDS)
+ val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)
+
+ assertTrue("cancel took ${elapsedMs}ms", elapsedMs < 2_000L)
+ assertEquals(OctaveSessionFailureKind.CANCELLED, response.failure?.kind)
+ executor.shutdownNow()
+ }
+
+ @Test
+ fun clearedCancellationAllowsAnEqualRequestIdInstanceToBeReused() {
+ val firstId = String(charArrayOf('r', 'e', 'u', 's', 'e'))
+ val decodedAgain = String(charArrayOf('r', 'e', 'u', 's', 'e'))
+ val process = ScriptedProcess { script, child -> child.complete(script) }
+ val session = newSession(process)
+
+ assertTrue(session.cancel(firstId))
+ session.clearCancellation(decodedAgain)
+
+ assertTrue(session.start(decodedAgain).ok)
+ session.close()
+ }
+
+ @Test
+ fun timeoutClosesTheProcess() {
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("pause(300)")) Unit else child.complete(script)
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+
+ val response = session.execute("request-3", "pause(300)", 25L) {}
+
+ assertEquals(OctaveSessionFailureKind.TIMEOUT, response.failure?.kind)
+ assertFalse(process.isAlive)
+ }
+
+ @Test
+ fun outputIsStreamedInBoundedChunksAndTranscriptIsCapped() {
+ val payload = "x".repeat(OctaveSession.MAX_OUTPUT_BYTES + 16_384)
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("emit_large_output")) {
+ child.line(payload)
+ child.complete(script)
+ } else {
+ child.complete(script)
+ }
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+ val chunks = mutableListOf()
+
+ val response = session.execute("request-4", "emit_large_output", 5_000L, chunks::add)
+
+ assertTrue(response.truncated)
+ assertTrue(response.output.toByteArray(StandardCharsets.UTF_8).size <= OctaveSession.MAX_OUTPUT_BYTES)
+ assertTrue(chunks.isNotEmpty())
+ assertTrue(chunks.all { it.toByteArray(StandardCharsets.UTF_8).size <= OctaveSession.MAX_STREAM_CHUNK_BYTES })
+ assertTrue(
+ chunks.sumOf { it.toByteArray(StandardCharsets.UTF_8).size } <= OctaveSession.MAX_OUTPUT_BYTES,
+ )
+ }
+
+ @Test
+ fun eofIsAnExplicitEventRatherThanANullQueueElement() {
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("exit")) child.exit(0) else child.complete(script)
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+
+ val response = session.execute("request-5", "exit", 1_000L) {}
+
+ assertEquals(OctaveSessionFailureKind.EXITED, response.failure?.kind)
+ assertNotNull(response.failure?.details)
+ }
+
+ @Test
+ fun aNewGenerationRunsTheNextRequestAfterTheOldProcessExits() {
+ val firstProcess = ScriptedProcess { script, child ->
+ if (script.contains("exit_now")) child.exit(0) else child.complete(script)
+ }
+ val first = newSession(firstProcess)
+ assertTrue(first.start("boot-1").ok)
+ assertEquals(
+ OctaveSessionFailureKind.EXITED,
+ first.execute("exit-request", "exit_now", 1_000L) {}.failure?.kind,
+ )
+
+ val secondProcess = ScriptedProcess { script, child -> child.complete(script) }
+ val second = newSession(secondProcess)
+ assertTrue(second.start("boot-2").ok)
+
+ assertTrue(second.execute("next-request", "1 + 1", 1_000L) {}.ok)
+ second.close()
+ }
+
+ @Test
+ fun aClosedGenerationCannotLeakReaderEventsIntoARestart() {
+ val process = ScriptedProcess { script, child -> child.complete(script) }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+
+ session.close()
+ process.line("stale output")
+ val restart = session.start("new")
+
+ assertFalse(restart.ok)
+ assertEquals(OctaveSessionFailureKind.NOT_STARTED, restart.failure?.kind)
+ }
+
+ @Test
+ fun unknownMaxmathPrefixRemainsUserOutput() {
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("emit_user_marker")) {
+ child.line("MAXMATH_HELLO user data")
+ }
+ child.complete(script)
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+ val output = mutableListOf()
+
+ val response = session.execute("marker", "emit_user_marker", 1_000L, output::add)
+
+ assertTrue(response.ok)
+ assertTrue(output.joinToString("").contains("MAXMATH_HELLO user data"))
+ }
+
+ @Test
+ fun reservedProtocolPrefixWithAnotherRequestIdRemainsUserOutput() {
+ val process = ScriptedProcess { script, child ->
+ if (script.contains("emit_spoofed_marker")) {
+ child.line("MAXMATH_ERROR {\"version\":1,\"requestId\":\"other\",\"message\":\"user\"}")
+ }
+ child.complete(script)
+ }
+ val session = newSession(process)
+ assertTrue(session.start("boot").ok)
+ val output = mutableListOf()
+
+ val response = session.execute("current", "emit_spoofed_marker", 1_000L, output::add)
+
+ assertTrue(response.ok)
+ assertTrue(output.joinToString("").contains("MAXMATH_ERROR"))
+ }
+
+ private fun config() = OctaveSessionConfig(
+ execPath = "/native/liboctavebin.so",
+ libDir = "/native",
+ octaveHome = "/runtime/usr",
+ bridgeDir = "/runtime/maxmath",
+ workDir = "/work",
+ homeDir = "/home",
+ tmpDir = "/tmp",
+ outDir = File(temporaryFolder.root, "out").absolutePath,
+ )
+
+ private fun newSession(process: ScriptedProcess): OctaveSession = OctaveSession(
+ config = config(),
+ processFactory = OctaveProcessFactory { _, _ -> process },
+ rssReader = { 0L },
+ )
+
+ private class ScriptedProcess(
+ private val responder: (String, ScriptedProcess) -> Unit,
+ ) : OctaveChildProcess {
+ private val alive = AtomicBoolean(true)
+ private val input = PipedInputStream(2 * 1024 * 1024)
+ private val childOutput = PipedOutputStream(input)
+ private val pending = ByteArrayOutputStream()
+ private var exitCode = 0
+
+ override val stdout: InputStream = input
+ override val stdin: OutputStream = object : OutputStream() {
+ override fun write(b: Int) {
+ pending.write(b)
+ }
+
+ override fun write(b: ByteArray, off: Int, len: Int) {
+ pending.write(b, off, len)
+ }
+
+ override fun flush() {
+ val script = pending.toString(StandardCharsets.UTF_8.name())
+ pending.reset()
+ responder(script, this@ScriptedProcess)
+ }
+ }
+ override val isAlive: Boolean get() = alive.get()
+ override val pid: Long = 42L
+
+ fun line(text: String) {
+ if (!alive.get()) return
+ childOutput.write(text.toByteArray(StandardCharsets.UTF_8))
+ childOutput.write('\n'.code)
+ childOutput.flush()
+ }
+
+ fun complete(script: String) {
+ val sentinel = Regex("printf\\('\\\\n([^']+)\\\\n'\\)")
+ .find(script)
+ ?.groupValues
+ ?.get(1)
+ ?: error("missing sentinel in $script")
+ line(sentinel)
+ }
+
+ fun exit(code: Int) {
+ exitCode = code
+ if (alive.compareAndSet(true, false)) childOutput.close()
+ }
+
+ override fun destroyForcibly() {
+ exit(137)
+ }
+
+ override fun waitFor(timeoutMs: Long): Boolean = !alive.get()
+
+ override fun exitCodeOrNull(): Int? = if (alive.get()) null else exitCode
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTaskTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTaskTest.kt
new file mode 100644
index 0000000..eda766b
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTaskTest.kt
@@ -0,0 +1,89 @@
+package com.paruh.maxmath.engine
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.json.JSONObject
+
+class OctaveTaskTest {
+
+ @Test
+ fun requestIdRejectsPathCharactersBeforeTheyReachArtifactNames() {
+ try {
+ OctaveRequest("../other", OctaveWhosTask)
+ fail("unsafe request ID should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+
+ @Test
+ fun requestRejectsFractionalDeadlineInsteadOfSilentlyTruncatingIt() {
+ val json = JSONObject()
+ .put("id", "request-1")
+ .put("task", OctaveWhosTask.toJson())
+ .put("timeoutMs", 1.5)
+ try {
+ OctaveRequest.fromJson(json)
+ fail("fractional timeout should have been rejected")
+ } catch (_: IllegalArgumentException) {
+ // expected
+ }
+ }
+
+ @Test
+ fun evalRoundTrip() {
+ val task = OctaveEvalTask("x = [1 2; 3 4]; inv(x)")
+ val parsed = OctaveTask.fromJson(task.toJson())
+ assertEquals(task, parsed)
+ }
+
+ @Test
+ fun runScriptRoundTrip() {
+ val task = OctaveRunScriptTask("disp('hi')", "test.m")
+ val parsed = OctaveTask.fromJson(task.toJson())
+ assertEquals(task, parsed)
+ }
+
+ @Test
+ fun clearTaskNameNullable() {
+ val all = OctaveClearTask(null)
+ val parsed = OctaveTask.fromJson(all.toJson())
+ assertEquals(OctaveClearTask(null), parsed)
+ assertNull((parsed as OctaveClearTask).name)
+ }
+
+ @Test
+ fun sourceFileTransportRoundTripsWithoutInlineSource() {
+ val task = OctaveSourceFileTask("src_123_demo.m", "demo.m")
+
+ val parsed = OctaveTask.fromJson(task.toJson())
+
+ assertEquals(task, parsed)
+ assertTrue(!task.toJson().has("script"))
+ }
+
+ @Test
+ fun responseRoundTripWithWorkspace() {
+ val response = OctaveResponse(
+ id = "r1",
+ ok = true,
+ output = "ans = 42",
+ plotSpec = """{"layout":[1,1],"axes":[]}""",
+ workspace = listOf(
+ OctaveVariable("A", "double", intArrayOf(2, 2), 32, false, false, true),
+ ),
+ preview = OctavePreview("[[1,2],[3,4]]", "[[1,2],[3,4]]"),
+ )
+ val parsed = OctaveResponse.fromJson(response.toJson().toString())
+ assertEquals("r1", parsed.id)
+ assertTrue(parsed.ok)
+ assertEquals("ans = 42", parsed.output)
+ assertEquals(1, parsed.workspace!!.size)
+ assertEquals("A", parsed.workspace[0].name)
+ assertTrue(parsed.workspace[0].global)
+ assertEquals("[[1,2],[3,4]]", parsed.preview!!.valueJson)
+ }
+}
diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTextFormatterTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTextFormatterTest.kt
new file mode 100644
index 0000000..d795395
--- /dev/null
+++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTextFormatterTest.kt
@@ -0,0 +1,36 @@
+package com.paruh.maxmath.engine
+
+import org.json.JSONArray
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class OctaveTextFormatterTest {
+
+ @Test
+ fun formatsScalarAndVector() {
+ assertEquals("[ 1.5 ]", OctaveTextFormatter.format(JSONArray("[1.5]")))
+ assertEquals("[ 1 2 3 ]", OctaveTextFormatter.format(JSONArray("[1,2,3]")))
+ }
+
+ @Test
+ fun formatsMatrixRows() {
+ val text = OctaveTextFormatter.format(JSONArray("[[1,2],[3,4]]"))
+ assertEquals("[\n 1 2 \n 3 4 \n]", text)
+ }
+
+ @Test
+ fun formatsComplexAndNonFiniteComponents() {
+ assertEquals("1-2i", OctaveTextFormatter.formatJson("""{"re":1,"im":-2}"""))
+ assertEquals(
+ "[ 1-2i NaN+NaNi ]",
+ OctaveTextFormatter.format(
+ JSONArray("""[{"re":1,"im":-2},{"re":null,"im":null}]"""),
+ ),
+ )
+ }
+
+ @Test
+ fun emptyArray() {
+ assertEquals("[]", OctaveTextFormatter.format(JSONArray("[]")))
+ }
+}
diff --git a/native/README.md b/native/README.md
index 0a05b87..ee80f82 100644
--- a/native/README.md
+++ b/native/README.md
@@ -20,15 +20,17 @@
- Linux x86_64
- JDK 17
- Android SDK
-- Android NDK r27d(27.2.12479018)
+- Android NDK r27c(27.2.12479018)
- C/C++ 工具链、autotools、curl、tar、make 与 sed
+- Octave 打包工具:Python 3、CMake、Ninja、dpkg-deb、readelf、patchelf、flock 与 sha256sum
- ECL 构建依赖:GMP、Boehm GC、libffi
Ubuntu/Debian 可安装:
~~~bash
sudo apt install build-essential autoconf automake libtool pkg-config \
- curl libgmp-dev libgc-dev libffi-dev
+ cmake curl dpkg-dev ninja-build patchelf python3 util-linux \
+ libgmp-dev libgc-dev libffi-dev
~~~
脚本当前直接使用 NDK 的 toolchains/llvm/prebuilt/linux-x86_64。macOS 或其他主机
@@ -74,19 +76,93 @@ cd ..
- ../.build/dist/ecl-host/:交叉编译时使用的宿主 ECL
- ../.build/dist/ecl-android/arm64-v8a/:Android ECL 与 libecl.so
- ../.build/dist/maxima-android/arm64-v8a/:Android Maxima
-- ../app/src/main/jniLibs/arm64-v8a/:libmaxima.so、libecl.so 和 C++ 运行库
-- ../app/src/main/assets/engine/:Maxima share 数据、ECL 运行文件与初始化模板
+- ../app/src/main/jniLibs/arm64-v8a/:Maxima/ECL、Octave 闭包和共享 C++ 运行库
+- ../app/src/main/assets/engine/:确定性的 `runtime.zip` 与逐文件哈希清单
+
+`build-maxima-android.sh` 会阻止 Maxima 的 `configure` 向上发现外层 MaxMath Git
+仓库,并在交叉编译前断言 `*autoconf-version*` 必须等于 5.49.0。输出目录同时写入
+`maxima-compiled-version.txt`;`package-engine.sh`、运行时清单和最终 APK 门禁会再次
+核对它,避免二进制搜索 `share/maxima/<错误 Git 版本>/`。
Android 10 及更高版本禁止从应用私有数据目录执行文件。Maxima 可执行文件因此以
libmaxima.so 名称放入 jniLibs,使安装器将它解压到带可执行权限的 nativeLibraryDir。
-其余 share 数据和 ECL 模块仍作为 assets 解压到应用私有目录。
+其余 share 数据和 ECL 模块写入单个 `runtime.zip`;安装器会在应用私有目录中校验、
+解压到暂存目录,再原子切换不可变的 `runtime/`。`user/` 与 `work/` 独立保留。
可执行文件只放 jniLibs 一份。assets 里不再保留 `lib/maxima//binary-ecl/maxima`
与 `lib/libecl.so`:它们与 jniLibs 逐字节相同(合计约 16MB),既进 APK 又解压到
filesDir,而 Android 10+ 根本不允许从那里执行。打包脚本同时会删掉 share 树里的
文档(PDF/texi/info/html/dem/usg/tex)和 ECL 的链接期静态库(`*.a`、help.doc、
-TAGS、ecl_min)。打包前脚本会先 `rm -rf` 整个 assets/engine,否则历史遗留文件
-会一直留在工作区并被打进 APK。
+TAGS、ecl_min)。打包脚本先在 D 盘构建候选归档和清单,验证 1768 项左右的完整
+文件集合、`linearalgebra`、`lisp-utils`、ECL 数据及两个 JNI 哈希后,才替换旧 assets。
+
+APK 构建后必须从最终制品回读归档和清单:
+
+~~~bash
+python3 ./verify-engine-apk.py ../app/build/outputs/apk/debug/app-debug.apk
+~~~
+
+该门禁核对嵌套归档的精确文件集合、逐文件大小/SHA-256、`libmaxima.so`、
+`libecl.so` 与编译/运行时 Maxima 版本,并拒绝缺少 `linearalgebra.mac` 或残留的
+散装 Maxima assets。
+
+## GNU Octave 运行时
+
+Octave 运行时同样只支持 `arm64-v8a`。`octave-termux.lock` 固定 GNU Octave
+11.3.0、全部 Termux 输入包及其 SHA-256,并单独固定最终
+`libc++_shared.so` 的文件哈希。不要手工混用 NDK libc++:当前 Termux Octave
+使用更新的 C++ ABI 符号,打包时必须让锁定的 Termux libc++ 最终胜出。
+
+~~~bash
+./download-octave-termux.sh arm64-v8a
+./build-octave-16k-overrides.sh
+./package-octave-engine.sh arm64-v8a
+~~~
+
+下载脚本每次先删除旧 stage,再只解包 lock 中的归档;缓存归档也会重新校验包名、
+版本、架构和 SHA-256。打包脚本在同级临时目录完整组装并通过门禁后才切换 JNI 与
+`assets/octave`,失败会恢复原有载荷;因此旧版本及 x86_64 `.oct` 不会残留。
+运行库只从 `octave-cli-11.3.0` 与 arm64 `.oct` 的实际
+`DT_NEEDED` 闭包收集。恢复 Maxima、Chaquopy 等已有 JNI 文件时不会覆盖新生成的
+Octave 文件,锁定的 Termux libc++ 始终优先。
+
+Termux 当前提供的 `libandroid-complex-math` 与 libwebp 二进制仍含 4 KiB
+`PT_LOAD` 段。`octave-16k-overrides.lock` 固定 Android/Google 与 WebM 官方源码
+归档及 SHA-256;覆盖构建脚本用 NDK r27 从链接阶段生成 16 KiB 兼容库。不要尝试
+事后只改 ELF header:段偏移不满足 16 KiB 同余时仍会在设备 linker 阶段失败。
+
+产物中的 `assets/octave/runtime-manifest.json` 包含稳定 `runtimeId`、Octave 与桥接
+版本、ABI、锁定源包、16 KiB 覆盖来源、C++ 编译器来源,以及 assets/JNI 闭包逐文件
+大小与 SHA-256。
+可独立重跑:
+
+~~~bash
+python3 ./verify-octave-runtime.py --root ..
+~~~
+
+验证器不执行 Android ELF,也不触发 Gradle;它检查 arm64 架构、linker 路径、
+`DT_NEEDED`、SONAME/RPATH、全部 `PT_LOAD` 的 16 KiB 对齐、强 C++ 符号解析、
+锁定 libc++ 哈希、非 arm64 残留与 manifest 一致性。构建 APK 后还必须从最终制品
+回读清单,确认 AAPT 没有过滤
+`.oct-config` 等隐藏运行时文件:
+
+~~~bash
+python3 ./verify-octave-apk.py ../app/build/outputs/apk/debug/app-debug.apk
+~~~
+
+APK 门禁逐项核对 manifest 管理的 assets/JNI 路径、大小与 SHA-256,扫描 APK 中
+每个 native ELF 的 16 KiB LOAD 对齐,并拒绝非 arm64 JNI 或清单之外的 Octave assets。
+
+宿主机已安装 Octave 时,可单独验证桥协议与数值映射(同样不运行 Gradle):
+
+~~~bash
+./test-octave.sh
+~~~
+
+真机验收时使用 `adb logcat` 筛选
+`OctaveEngine|linker|CANNOT LINK EXECUTABLE|cannot locate symbol`;冷启动、
+`pause(300)` 取消、`exit` 后自动恢复之外,还应回归 Maxima 与
+Chaquopy/Matplotlib,确保共享运行库没有引入跨引擎退化。
## 验证
@@ -108,6 +184,5 @@ test ! -e ../app/src/main/assets/engine/lib/libecl.so
## 已知限制
- 正式支持目标目前只有 arm64-v8a。
-- x86_64 分支存在于脚本中,但交叉配置和模拟器运行尚未完成验证。
- Maxima 5.49.0 交叉编译失败时可以临时设置 MAXIMA_VERSION=5.48.1;发布前必须
同步更新文档、生成资产版本和第三方声明。
diff --git a/native/build-ecl-android.sh b/native/build-ecl-android.sh
index 14be1e4..120197a 100755
--- a/native/build-ecl-android.sh
+++ b/native/build-ecl-android.sh
@@ -53,7 +53,7 @@ export AR="$TOOLCHAIN/bin/llvm-ar"
export RANLIB="$TOOLCHAIN/bin/llvm-ranlib"
export ECL_TO_RUN="$DIST/ecl-host/bin/ecl"
export CFLAGS="-O2 -fPIC"
-export LDFLAGS="-fPIC"
+export LDFLAGS="-fPIC -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384"
if [ -f "src/util/$CROSS_CONFIG" ]; then
CROSS_ARG="--with-cross-config=$PWD/src/util/$CROSS_CONFIG"
@@ -70,5 +70,8 @@ fi
make -j"$(nproc)"
make install
+"$TOOLCHAIN/bin/llvm-strip" --strip-unneeded "$OUT/lib/libecl.so"
+python3 "$ROOT/native/verify-elf-page-size.py" --page-size 16384 "$OUT/lib/libecl.so"
+
echo "Android ECL($ABI)已安装到 $OUT"
ls "$OUT/lib" | head
diff --git a/native/build-maxima-android.sh b/native/build-maxima-android.sh
index 5d731f3..3401a70 100755
--- a/native/build-maxima-android.sh
+++ b/native/build-maxima-android.sh
@@ -36,20 +36,35 @@ case "$ABI" in
esac
OUT="$DIST/maxima-android/$ABI"
+WORK="$ROOT/.build/work/maxima-android/$ABI"
rm -r -- "$OUT" 2>/dev/null || true
-mkdir -p "$OUT/bin" "$OUT/lib/maxima" "$OUT/share/maxima"
+rm -r -- "$WORK" 2>/dev/null || true
+mkdir -p "$OUT/bin" "$OUT/lib/maxima" "$OUT/share/maxima" "$WORK"
# 1) 生成 autoconf-variables.lisp(configure 会用宿主 ECL 探测,产物仅作源文件)
cd "$MAXIMA_SRC"
make distclean >/dev/null 2>&1 || true
export PATH="$TOOLCHAIN/bin:$PATH"
export MAKEINFO="$ROOT/native/fake-makeinfo.sh"
-./configure --with-ecl="$HOST_ECL" --disable-build-docs >/tmp/maxima-configure.log 2>&1
+# Maxima's configure prefers `git describe` over AC_INIT's release version.
+# The extracted tarball lives below the MaxMath worktree, so without a ceiling
+# it accidentally reads the outer repository tag (for example
+# v1.1.0-2-g...-dirty). That value becomes *autoconf-version* and makes the
+# Android binary search share/maxima// on every startup.
+ac_cv_prog_git_found=false GIT_CEILING_DIRECTORIES="$MAXIMA_SRC" \
+ ./configure --with-ecl="$HOST_ECL" --disable-build-docs >"$WORK/maxima-configure.log" 2>&1
+
+AUTOCONF_VARIABLES="$MAXIMA_SRC/src/autoconf-variables.lisp"
+COMPILED_MAXIMA_VERSION="$(sed -n 's/.*\*autoconf-version\* "\([^"]*\)".*/\1/p' "$AUTOCONF_VARIABLES")"
+if [ "$COMPILED_MAXIMA_VERSION" != "$MAXIMA_VERSION" ]; then
+ echo "Maxima 编译版本目录错误:compiled=$COMPILED_MAXIMA_VERSION expected=$MAXIMA_VERSION"
+ exit 1
+fi
# 2) 目标 ECL 的 cmpdefs 用 defvar,宿主 ECL 已有同名变量时不会覆盖,
# 必须改成 defparameter 强制生效(gist 同款处理)。
ECL_BUILD_DIR="$SRC/ecl-26.3.27/build"
-CMPDEFS_PARAM="${TMPDIR:-/tmp}/cmpdefs-param.lsp"
+CMPDEFS_PARAM="$WORK/cmpdefs-param.lsp"
sed 's/(defvar /(defparameter /g' "$ECL_BUILD_DIR/cmp/cmpdefs.lsp" > "$CMPDEFS_PARAM"
# 3) 用宿主 ECL 驱动交叉编译:Lisp -> C -> NDK clang -> Android libecl
@@ -58,16 +73,21 @@ mkdir -p binary-ecl/numerical/slatec
export ECL_ANDROID_CMPDEFS="$CMPDEFS_PARAM"
export AR="$TOOLCHAIN/bin/llvm-ar"
export RANLIB="$TOOLCHAIN/bin/llvm-ranlib"
-"$HOST_ECL" --load "$ROOT/native/maxima-cross.lisp" > /tmp/maxima-cross-build.log 2>&1
+"$HOST_ECL" --load "$ROOT/native/maxima-cross.lisp" >"$WORK/maxima-cross-build.log" 2>&1
# 4) 组装标准 prefix 布局(与 package-engine.sh 期望一致)
MAXIMA_BIN="$MAXIMA_SRC/src/binary-ecl/maxima"
cp "$MAXIMA_BIN" "$OUT/bin/maxima"
+printf '%s\n' "$COMPILED_MAXIMA_VERSION" > "$OUT/maxima-compiled-version.txt"
mkdir -p "$OUT/lib/maxima/$MAXIMA_VERSION/binary-ecl"
cp "$MAXIMA_BIN" "$OUT/lib/maxima/$MAXIMA_VERSION/binary-ecl/maxima"
cp "$ECL_ANDROID/lib/libecl.so" "$OUT/lib/libecl.so"
cp -a "$MAXIMA_SRC/share/." "$OUT/share/maxima/$MAXIMA_VERSION/"
+"$TOOLCHAIN/bin/llvm-strip" --strip-unneeded "$OUT/bin/maxima" "$OUT/lib/libecl.so"
+python3 "$ROOT/native/verify-elf-page-size.py" --page-size 16384 \
+ "$OUT/bin/maxima" "$OUT/lib/libecl.so"
+
echo "Android Maxima($ABI)已组装到 $OUT"
du -sh "$OUT"
file "$OUT/bin/maxima"
diff --git a/native/build-octave-16k-overrides.sh b/native/build-octave-16k-overrides.sh
new file mode 100644
index 0000000..617f4d0
--- /dev/null
+++ b/native/build-octave-16k-overrides.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# Rebuild the small subset of the locked Termux closure which is not compatible
+# with Android devices using a 16 KiB kernel page size.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+LOCK="$ROOT/native/octave-16k-overrides.lock"
+SOURCE_CACHE="$ROOT/.build/src/octave-16k"
+WORK="$ROOT/.build/work/octave-16k-overrides"
+OUT="$ROOT/.build/dist/octave-16k-overrides/arm64-v8a"
+
+[[ -f "$LOCK" ]] || { echo "missing override lock: $LOCK" >&2; exit 1; }
+for tool in cmake curl ninja patchelf python3 sha256sum tar; do
+ command -v "$tool" >/dev/null 2>&1 || { echo "missing required tool: $tool" >&2; exit 1; }
+done
+
+lock_value() {
+ local key="$1"
+ sed -n "s/^${key}=//p" "$LOCK"
+}
+
+[[ "$(lock_value schemaVersion)" == 1 ]] || { echo "unsupported override lock schema" >&2; exit 1; }
+[[ "$(lock_value abi)" == arm64-v8a ]] || { echo "override lock must be arm64-v8a" >&2; exit 1; }
+ANDROID_API="$(lock_value androidApi)"
+NDK_VERSION="$(lock_value ndkVersion)"
+PAGE_SIZE="$(lock_value pageSize)"
+[[ "$PAGE_SIZE" == 16384 ]] || { echo "override page size must be 16384" >&2; exit 1; }
+
+if [[ -z "${NDK_PATH:-}" ]]; then
+ if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
+ NDK_PATH="$ANDROID_SDK_ROOT/ndk/$NDK_VERSION"
+ elif [[ -d "/opt/android-sdk/ndk/$NDK_VERSION" ]]; then
+ NDK_PATH="/opt/android-sdk/ndk/$NDK_VERSION"
+ else
+ echo "set NDK_PATH or ANDROID_SDK_ROOT" >&2
+ exit 1
+ fi
+fi
+TOOLCHAIN="$NDK_PATH/toolchains/llvm/prebuilt/linux-x86_64"
+CC="$TOOLCHAIN/bin/aarch64-linux-android${ANDROID_API}-clang"
+STRIP="$TOOLCHAIN/bin/llvm-strip"
+TOOLCHAIN_FILE="$NDK_PATH/build/cmake/android.toolchain.cmake"
+[[ -x "$CC" && -x "$STRIP" && -f "$TOOLCHAIN_FILE" ]] || {
+ echo "incomplete Android NDK toolchain: $NDK_PATH" >&2
+ exit 1
+}
+
+mkdir -p "$SOURCE_CACHE" "$ROOT/.build/work" "$ROOT/.build/dist"
+
+download_locked() {
+ local url="$1" destination="$2" expected="$3" temporary
+ temporary="${destination}.part"
+ if [[ -f "$destination" ]] && echo "$expected $destination" | sha256sum -c - >/dev/null 2>&1; then
+ return
+ fi
+ rm -f "$temporary"
+ curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 --max-time 300 \
+ -o "$temporary" "$url"
+ echo "$expected $temporary" | sha256sum -c - >/dev/null || {
+ echo "source SHA-256 mismatch: $destination" >&2
+ rm -f "$temporary"
+ exit 1
+ }
+ mv "$temporary" "$destination"
+}
+
+COMPLEX_ARCHIVE="$SOURCE_CACHE/android-complex-math-$(lock_value complexMathVersion).tar.gz"
+COMPLEX_NAMESPACE_ARCHIVE="$SOURCE_CACHE/android-libm-src-$(lock_value complexMathVersion).tar.gz"
+WEBP_ARCHIVE="$SOURCE_CACHE/libwebp-v$(lock_value libwebpVersion).tar.gz"
+download_locked "$(lock_value complexMathUrl)" "$COMPLEX_ARCHIVE" "$(lock_value complexMathSha256)"
+download_locked \
+ "$(lock_value complexMathNamespaceUrl)" \
+ "$COMPLEX_NAMESPACE_ARCHIVE" \
+ "$(lock_value complexMathNamespaceSha256)"
+download_locked "$(lock_value libwebpUrl)" "$WEBP_ARCHIVE" "$(lock_value libwebpSha256)"
+
+rm -rf -- "$WORK"
+mkdir -p \
+ "$WORK/complex-src" \
+ "$WORK/complex-obj" \
+ "$WORK/src" \
+ "$WORK/webp-src" \
+ "$WORK/webp-build"
+tar xzf "$COMPLEX_ARCHIVE" -C "$WORK/complex-src"
+tar xzf "$COMPLEX_NAMESPACE_ARCHIVE" -C "$WORK/src"
+tar xzf "$WEBP_ARCHIVE" -C "$WORK/webp-src" --strip-components=1
+
+LINK_PAGE_FLAGS=(
+ "-Wl,-z,max-page-size=$PAGE_SIZE"
+ "-Wl,-z,common-page-size=$PAGE_SIZE"
+)
+
+for source in "$WORK"/complex-src/*.c; do
+ "$CC" -O2 -fPIC -D__USE_GNU -c "$source" \
+ -o "$WORK/complex-obj/$(basename "${source%.c}").o"
+done
+"$CC" -shared "${LINK_PAGE_FLAGS[@]}" -Wl,-soname,libandroid-complex-math.so \
+ -o "$WORK/libandroid-complex-math.so" "$WORK"/complex-obj/*.o -lm
+
+cmake -S "$WORK/webp-src" -B "$WORK/webp-build" -G Ninja \
+ -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN_FILE" \
+ -DANDROID_ABI=arm64-v8a \
+ -DANDROID_PLATFORM="android-$ANDROID_API" \
+ -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_SHARED_LIBS=ON \
+ -DWEBP_BUILD_ANIM_UTILS=OFF \
+ -DWEBP_BUILD_CWEBP=OFF \
+ -DWEBP_BUILD_DWEBP=OFF \
+ -DWEBP_BUILD_EXTRAS=OFF \
+ -DWEBP_BUILD_GIF2WEBP=OFF \
+ -DWEBP_BUILD_IMG2WEBP=OFF \
+ -DWEBP_BUILD_VWEBP=OFF \
+ -DWEBP_BUILD_WEBPINFO=OFF \
+ -DWEBP_BUILD_WEBPMUX=OFF \
+ -DCMAKE_SHARED_LINKER_FLAGS="${LINK_PAGE_FLAGS[*]}"
+cmake --build "$WORK/webp-build" --target sharpyuv webp webpdemux libwebpmux
+
+rm -rf -- "$OUT"
+mkdir -p "$OUT"
+cp "$WORK/libandroid-complex-math.so" "$OUT/libandroid-complex-math.so"
+for name in sharpyuv webp webpdemux webpmux; do
+ source="$(find "$WORK/webp-build" -type f -name "lib${name}.so*" -print | sort | head -n 1)"
+ [[ -n "$source" ]] || { echo "missing built lib${name}" >&2; exit 1; }
+ cp "$source" "$OUT/lib${name}.so"
+ patchelf --page-size "$PAGE_SIZE" --set-soname "lib${name}.so" "$OUT/lib${name}.so"
+done
+
+"$STRIP" --strip-unneeded "$OUT"/*.so
+sha256sum "$LOCK" | awk '{print $1}' > "$OUT/octave-16k-overrides.lock.sha256"
+python3 "$ROOT/native/verify-elf-page-size.py" --page-size "$PAGE_SIZE" "$OUT"
+echo "16 KiB Octave overrides ready: $OUT"
+sha256sum "$OUT"/*.so
diff --git a/native/download-octave-termux.sh b/native/download-octave-termux.sh
new file mode 100644
index 0000000..98f1f53
--- /dev/null
+++ b/native/download-octave-termux.sh
@@ -0,0 +1,166 @@
+#!/usr/bin/env bash
+#
+# Download the exactly locked Termux aarch64 packages and create a clean Octave
+# staging tree. This script never resolves a mutable "latest" package set.
+
+set -euo pipefail
+
+ABI="${1:-arm64-v8a}"
+if [[ $# -gt 1 || "$ABI" != "arm64-v8a" ]]; then
+ echo "usage: $0 [arm64-v8a] (Octave runtime is arm64-only)" >&2
+ exit 2
+fi
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+LOCK="$ROOT/native/octave-termux.lock"
+STAGE_ROOT="$ROOT/.build/octave-stage/arm64-v8a"
+STAGE="$STAGE_ROOT/usr"
+DEBS="$ROOT/.build/octave-debs/aarch64"
+PACKAGES_TSV="$DEBS/locked-packages.tsv"
+LOCK_FILE="$ROOT/.build/octave-runtime.lock"
+
+[[ -f "$LOCK" ]] || { echo "missing lock file: $LOCK" >&2; exit 1; }
+for tool in curl dpkg-deb flock python3 sha256sum; do
+ command -v "$tool" >/dev/null 2>&1 || { echo "missing required tool: $tool" >&2; exit 1; }
+done
+mkdir -p "$ROOT/.build"
+exec 9>"$LOCK_FILE"
+flock -n 9 || { echo "another Octave download/package operation is active" >&2; exit 1; }
+
+lock_value() {
+ local key="$1"
+ sed -n "s/^${key}=//p" "$LOCK"
+}
+
+[[ "$(lock_value schemaVersion)" == "1" ]] || { echo "unsupported lock schema" >&2; exit 1; }
+[[ "$(lock_value abi)" == "arm64-v8a" ]] || { echo "lock is not arm64-v8a" >&2; exit 1; }
+[[ "$(lock_value termuxArch)" == "aarch64" ]] || { echo "lock is not aarch64" >&2; exit 1; }
+[[ "$(lock_value octaveVersion)" == "11.3.0" ]] || { echo "Octave must remain pinned to 11.3.0" >&2; exit 1; }
+[[ "$(lock_value octavePackageVersion)" == "2:11.3.0" ]] || { echo "unexpected Octave package version" >&2; exit 1; }
+
+REPOSITORY="$(lock_value repository)"
+MIRROR="$(lock_value mirror)"
+LIBCXX_RUNTIME_SHA256="$(lock_value libcxxRuntimeSha256)"
+
+mkdir -p "$DEBS"
+python3 - "$LOCK" "$PACKAGES_TSV" <<'PYEOF'
+import re
+import sys
+from pathlib import Path
+
+lock_path, output_path = map(Path, sys.argv[1:])
+metadata = {}
+packages = []
+in_packages = False
+for line_number, raw in enumerate(lock_path.read_text(encoding="utf-8").splitlines(), 1):
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line == "packages:":
+ in_packages = True
+ continue
+ if not in_packages:
+ if "=" not in line:
+ raise SystemExit(f"{lock_path}:{line_number}: expected key=value")
+ key, value = line.split("=", 1)
+ if key in metadata:
+ raise SystemExit(f"{lock_path}:{line_number}: duplicate key {key}")
+ metadata[key] = value
+ continue
+ fields = raw.split("\t")
+ if len(fields) != 4:
+ raise SystemExit(f"{lock_path}:{line_number}: expected four tab-separated fields")
+ name, version, filename, sha256 = fields
+ if not re.fullmatch(r"[0-9a-f]{64}", sha256):
+ raise SystemExit(f"{lock_path}:{line_number}: invalid SHA-256 for {name}")
+ packages.append(fields)
+
+if int(metadata.get("packageCount", "-1")) != len(packages):
+ raise SystemExit(f"{lock_path}: packageCount does not match package rows")
+names = [row[0] for row in packages]
+if len(names) != len(set(names)):
+ raise SystemExit(f"{lock_path}: duplicate package names")
+required = {"octave", "libc++"}
+if not required.issubset(names):
+ raise SystemExit(f"{lock_path}: octave and libc++ must both be locked")
+octave = next(row for row in packages if row[0] == "octave")
+if octave[1] != metadata.get("octavePackageVersion"):
+ raise SystemExit(f"{lock_path}: Octave package metadata mismatch")
+
+output_path.write_text(
+ "".join("\t".join(row) + "\n" for row in packages),
+ encoding="utf-8",
+)
+PYEOF
+
+echo "==> Fetching $(wc -l < "$PACKAGES_TSV") locked Termux aarch64 packages"
+download_locked() {
+ local filename="$1" destination="$2" temporary base
+ temporary="${destination}.part"
+ rm -f "$temporary"
+ for base in "$REPOSITORY" "$MIRROR"; do
+ echo " GET $base/$filename"
+ if curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 --max-time 300 \
+ -o "$temporary" "$base/$filename"; then
+ mv "$temporary" "$destination"
+ return 0
+ fi
+ rm -f "$temporary"
+ done
+ return 1
+}
+
+while IFS=$'\t' read -r name version filename expected_sha; do
+ deb="$DEBS/$(basename "$filename")"
+ if [[ -f "$deb" ]] && ! echo "$expected_sha $deb" | sha256sum -c - >/dev/null 2>&1; then
+ echo " discarding stale/corrupt cache entry: $deb"
+ rm -f "$deb"
+ fi
+ if [[ ! -f "$deb" ]]; then
+ download_locked "$filename" "$deb" || { echo "download failed: $filename" >&2; exit 1; }
+ fi
+ echo "$expected_sha $deb" | sha256sum -c - >/dev/null || {
+ echo "SHA-256 mismatch: $deb" >&2
+ exit 1
+ }
+ actual_name="$(dpkg-deb -f "$deb" Package)"
+ actual_version="$(dpkg-deb -f "$deb" Version)"
+ actual_arch="$(dpkg-deb -f "$deb" Architecture)"
+ if [[ "$actual_name" != "$name" || "$actual_version" != "$version" || "$actual_arch" != "aarch64" ]]; then
+ echo "package metadata mismatch: $deb" >&2
+ echo " expected: $name $version aarch64" >&2
+ echo " actual: $actual_name $actual_version $actual_arch" >&2
+ exit 1
+ fi
+done < "$PACKAGES_TSV"
+
+# Always reconstruct staging from only the locked rows. Old packages, removed
+# files and partial extraction state must not leak into a subsequent runtime.
+echo "==> Rebuilding clean stage: $STAGE"
+rm -rf "$STAGE_ROOT"
+EXTRACT="$STAGE_ROOT/extract"
+mkdir -p "$EXTRACT" "$STAGE"
+while IFS=$'\t' read -r _name _version filename _sha; do
+ dpkg-deb -x "$DEBS/$(basename "$filename")" "$EXTRACT"
+done < "$PACKAGES_TSV"
+
+SRC="$EXTRACT/data/data/com.termux/files/usr"
+[[ -d "$SRC" ]] || { echo "unexpected Termux package layout" >&2; exit 1; }
+cp -a "$SRC/." "$STAGE/"
+rm -rf "$EXTRACT"
+
+[[ -x "$STAGE/bin/octave-cli-11.3.0" ]] || {
+ echo "locked Octave CLI entry is missing: $STAGE/bin/octave-cli-11.3.0" >&2
+ exit 1
+}
+[[ -d "$STAGE/share/octave/11.3.0" ]] || { echo "locked Octave share tree is missing" >&2; exit 1; }
+[[ -f "$STAGE/lib/libc++_shared.so" ]] || { echo "locked Termux libc++ is missing" >&2; exit 1; }
+echo "$LIBCXX_RUNTIME_SHA256 $STAGE/lib/libc++_shared.so" | sha256sum -c - >/dev/null || {
+ echo "extracted Termux libc++ does not match libcxxRuntimeSha256" >&2
+ exit 1
+}
+
+sha256sum "$LOCK" | awk '{print $1}' > "$STAGE_ROOT/octave-termux.lock.sha256"
+cp "$PACKAGES_TSV" "$STAGE_ROOT/locked-packages.tsv"
+echo "==> Locked Octave 11.3.0 stage ready: $STAGE"
+du -sh "$STAGE"
diff --git a/native/octave-16k-overrides.lock b/native/octave-16k-overrides.lock
new file mode 100644
index 0000000..bb590d5
--- /dev/null
+++ b/native/octave-16k-overrides.lock
@@ -0,0 +1,15 @@
+# Reproducible sources for Termux libraries which are rebuilt because the
+# official binary packages currently contain 4 KiB-aligned PT_LOAD segments.
+schemaVersion=1
+abi=arm64-v8a
+androidApi=26
+ndkVersion=27.2.12479018
+pageSize=16384
+complexMathVersion=android-8.1.0_r81
+complexMathUrl=https://android.googlesource.com/platform/bionic/+archive/android-8.1.0_r81/libm/upstream-netbsd/lib/libm/complex.tar.gz
+complexMathSha256=89a625a8cf53a6c6a462a46109a5776ae0db273daedcfffc5931c5cdda808a57
+complexMathNamespaceUrl=https://android.googlesource.com/platform/bionic/+archive/android-8.1.0_r81/libm/upstream-netbsd/lib/libm/src.tar.gz
+complexMathNamespaceSha256=cd87146c515954c97d0f5508dbbfd332792b19443cbd1ffac6943cf8ec768f46
+libwebpVersion=1.6.0-rc1
+libwebpUrl=https://codeload.github.com/webmproject/libwebp/tar.gz/refs/tags/v1.6.0-rc1
+libwebpSha256=a8822fbd36e43fa1e5a83a7104d86c5be8692cee1e323d57030b5562ef884a8a
diff --git a/native/octave-m/PROTOCOL.md b/native/octave-m/PROTOCOL.md
new file mode 100644
index 0000000..4397a36
--- /dev/null
+++ b/native/octave-m/PROTOCOL.md
@@ -0,0 +1,58 @@
+# MaxMath Octave bridge protocol
+
+The bridge is line-oriented. Every machine-readable record occupies one UTF-8
+line and starts with a fixed marker followed by JSON.
+
+## Entry points
+
+The Android client writes each command or script to
+`work/requests/.m` and sends only a quoted file path and request
+ID to the REPL. This keeps arbitrary user source out of the stdin protocol.
+
+Protocol v1 adds request correlation and structured values:
+
+- `maxmath_execute_file(path, request_id)` sources the request file in the base
+ workspace. On failure it emits `MAXMATH_ERROR {json}` and returns without
+ aborting the REPL, so the caller can still flush and print its sentinel.
+- `maxmath_execute(command, request_id)` provides the same error boundary for
+ trusted host tools and compatibility callers; Android does not use it for
+ arbitrary user source.
+- `maxmath_flush(path, request_id)` atomically replaces `plot_spec.json`; the
+ JSON object includes `version` and `requestId`. The one-argument form remains valid and
+ writes an empty request ID.
+- `maxmath_preview(name, request_id)` emits one `MAXMATH_PREVIEW {json}` record.
+- `maxmath_whos(request_id)` emits a `MAXMATH_WHOS` envelope containing
+ `version`, `requestId`, and the `variables` array.
+
+Plot axes may additionally contain `view: [azimuth,elevation]` and `texts`, an
+array of `{x,y,z,text}` annotations. The bridge shadows Octave's `view()` and
+`text()` so these common calls never enter unavailable native handle graphics.
+
+## Typed preview v1
+
+`MAXMATH_PREVIEW` has these fields:
+
+- `version`: `1`
+- `requestId`: the caller's request ID
+- `kind`: `scalar`, `vector`, `matrix`, `array`, `string`, or `summary`
+- `class`: the Octave class name
+- `shape`: the full Octave dimensions
+- `complex`: JSON boolean
+- `truncated`: JSON boolean
+- `value`: row-major JSON arrays for matrices; a complex element is
+ `{"re": value, "im": value}`
+
+Finite real numbers remain JSON numbers. `NaN`, positive infinity, and negative
+infinity are encoded as the strings `"NaN"`, `"Inf"`, and `"-Inf"` so their
+meaning is not lost through JSON `null`; logical values remain JSON booleans.
+
+Structured previews are capped at 10,000 elements and a conservative 192 KiB
+wire-size estimate. Larger values emit `kind: "summary"`, `truncated: true`,
+their class/shape/estimated size, and a short text summary; the bridge does not
+serialize the full value or emit a legacy `MAXMATH_VALUE` line.
+
+## Structured error v1
+
+`MAXMATH_ERROR` contains `version`, `kind`, `requestId`, `identifier`,
+`message`, and `stack`. `stack` is always a JSON array; every frame contains
+`file`, `name`, `line`, and `column`.
diff --git a/native/octave-m/axis.m b/native/octave-m/axis.m
new file mode 100644
index 0000000..2315303
--- /dev/null
+++ b/native/octave-m/axis.m
@@ -0,0 +1,39 @@
+function varargout = axis(varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin >= 1
+ v = varargin{1};
+ if isnumeric(v) && numel(v) >= 4
+ a.xlim = [v(1) v(2)];
+ a.ylim = [v(3) v(4)];
+ if numel(v) >= 6
+ a.zlim = [v(5) v(6)];
+ end
+ a.axismode = 'manual';
+ elseif ischar(v)
+ switch lower(v)
+ case 'on'
+ a.visible = true;
+ case 'off'
+ a.visible = false;
+ case {'equal', 'square'}
+ a.axismode = 'equal';
+ case 'tight'
+ a.axismode = 'tight';
+ case {'auto', 'normal'}
+ a.axismode = 'auto';
+ end
+ end
+ end
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ if numel(a.xlim) == 2 && numel(a.ylim) == 2
+ varargout{1} = [a.xlim(1) a.xlim(2) a.ylim(1) a.ylim(2)];
+ else
+ varargout{1} = [];
+ end
+ end
+end
diff --git a/native/octave-m/cla.m b/native/octave-m/cla.m
new file mode 100644
index 0000000..58e97de
--- /dev/null
+++ b/native/octave-m/cla.m
@@ -0,0 +1,6 @@
+function cla()
+ s = mm_state();
+ s.axes{s.current} = mm_new_axes();
+ s.dirty = true;
+ mm_set_state(s);
+end
diff --git a/native/octave-m/clf.m b/native/octave-m/clf.m
new file mode 100644
index 0000000..71114c2
--- /dev/null
+++ b/native/octave-m/clf.m
@@ -0,0 +1,8 @@
+function clf()
+ s = mm_state();
+ s.layout = [1 1];
+ s.axes = {mm_new_axes()};
+ s.current = 1;
+ s.dirty = true;
+ mm_set_state(s);
+end
diff --git a/native/octave-m/close.m b/native/octave-m/close.m
new file mode 100644
index 0000000..a77f231
--- /dev/null
+++ b/native/octave-m/close.m
@@ -0,0 +1,8 @@
+function close(varargin)
+ s = mm_state();
+ s.layout = [1 1];
+ s.axes = {mm_new_axes()};
+ s.current = 1;
+ s.dirty = true;
+ mm_set_state(s);
+end
diff --git a/native/octave-m/colorbar.m b/native/octave-m/colorbar.m
new file mode 100644
index 0000000..646d737
--- /dev/null
+++ b/native/octave-m/colorbar.m
@@ -0,0 +1,10 @@
+function colorbar(varargin)
+ s = mm_state();
+ if nargin >= 1 && ischar(varargin{1}) && strcmpi(varargin{1}, 'off')
+ s.colorbar = false;
+ else
+ s.colorbar = true;
+ end
+ s.dirty = true;
+ mm_set_state(s);
+end
diff --git a/native/octave-m/colormap.m b/native/octave-m/colormap.m
new file mode 100644
index 0000000..45f33f9
--- /dev/null
+++ b/native/octave-m/colormap.m
@@ -0,0 +1,36 @@
+function colormap(varargin)
+ s = mm_state();
+ if nargin < 1
+ return
+ end
+ name = '';
+ value = varargin{1};
+ if ischar(value)
+ name = lower(value);
+ elseif isnumeric(value) && ismatrix(value) && columns(value) == 3 ...
+ && rows(value) >= 1
+ name = mm_colormap_name(value);
+ end
+ if ~isempty(name)
+ s.colormap = name;
+ s.dirty = true;
+ mm_set_state(s);
+ end
+end
+
+function name = mm_colormap_name(value)
+ name = '';
+ supported = {'jet', 'hot', 'gray', 'autumn', 'cool', 'hsv'};
+ for i = 1:numel(supported)
+ try
+ candidate = feval(supported{i}, rows(value));
+ if isequal(size(candidate), size(value)) ...
+ && max(abs(double(candidate(:)) - double(value(:)))) < 1e-10
+ name = supported{i};
+ return
+ end
+ catch
+ % Ignore colormaps unavailable in the host Octave version.
+ end
+ end
+end
diff --git a/native/octave-m/contour.m b/native/octave-m/contour.m
new file mode 100644
index 0000000..a548210
--- /dev/null
+++ b/native/octave-m/contour.m
@@ -0,0 +1,17 @@
+function h = contour(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = 'contour';
+ a = mm_contour_core(a, args, false);
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/contourf.m b/native/octave-m/contourf.m
new file mode 100644
index 0000000..31ba6d5
--- /dev/null
+++ b/native/octave-m/contourf.m
@@ -0,0 +1,17 @@
+function h = contourf(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = 'contour';
+ a = mm_contour_core(a, args, true);
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/figure.m b/native/octave-m/figure.m
new file mode 100644
index 0000000..0cfc54d
--- /dev/null
+++ b/native/octave-m/figure.m
@@ -0,0 +1,11 @@
+function h = figure(varargin)
+ s = mm_state();
+ s.layout = [1 1];
+ s.axes = {mm_new_axes()};
+ s.current = 1;
+ s.dirty = true;
+ mm_set_state(s);
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/get.m b/native/octave-m/get.m
new file mode 100644
index 0000000..8925407
--- /dev/null
+++ b/native/octave-m/get.m
@@ -0,0 +1,6 @@
+function varargout = get(varargin)
+ mm_unsupported_once('get');
+ if nargout > 0
+ varargout{1} = [];
+ end
+end
diff --git a/native/octave-m/grid.m b/native/octave-m/grid.m
new file mode 100644
index 0000000..d351981
--- /dev/null
+++ b/native/octave-m/grid.m
@@ -0,0 +1,17 @@
+function grid(varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin == 0
+ a.grid = ~a.grid;
+ elseif ischar(varargin{1})
+ if strcmpi(varargin{1}, 'on')
+ a.grid = true;
+ elseif strcmpi(varargin{1}, 'off')
+ a.grid = false;
+ end
+ end
+ mm_set_state(mm_put(s, a));
+end
diff --git a/native/octave-m/hold.m b/native/octave-m/hold.m
new file mode 100644
index 0000000..b85fb03
--- /dev/null
+++ b/native/octave-m/hold.m
@@ -0,0 +1,19 @@
+function hold(varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin == 0
+ a.hold = ~a.hold;
+ elseif nargin >= 1
+ if ischar(varargin{1})
+ if strcmpi(varargin{1}, 'on')
+ a.hold = true;
+ elseif strcmpi(varargin{1}, 'off')
+ a.hold = false;
+ end
+ end
+ end
+ mm_set_state(mm_put(s, a));
+end
diff --git a/native/octave-m/legend.m b/native/octave-m/legend.m
new file mode 100644
index 0000000..934245e
--- /dev/null
+++ b/native/octave-m/legend.m
@@ -0,0 +1,24 @@
+function legend(varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin >= 1 && ischar(varargin{1}) && strcmpi(varargin{1}, 'off')
+ a.legend = {};
+ mm_set_state(mm_put(s, a));
+ elseif nargin >= 1
+ items = {};
+ if iscell(varargin{1})
+ items = varargin{1};
+ else
+ for i = 1:nargin
+ if ischar(varargin{i})
+ items{end + 1} = varargin{i};
+ end
+ end
+ end
+ a.legend = items;
+ mm_set_state(mm_put(s, a));
+ end
+end
diff --git a/native/octave-m/maxmath_execute.m b/native/octave-m/maxmath_execute.m
new file mode 100644
index 0000000..85b0f1f
--- /dev/null
+++ b/native/octave-m/maxmath_execute.m
@@ -0,0 +1,12 @@
+function maxmath_execute(command, request_id)
+ % 字符串求值边界,保留给宿主工具与兼容调用。Android 应优先把请求写入
+ % work/requests/.m,再调用 maxmath_execute_file,避免命令转义。
+ if nargin < 2
+ request_id = '';
+ end
+ try
+ evalin('base', command);
+ catch err
+ maxmath_emit_error(err, request_id);
+ end
+end
diff --git a/native/octave-m/maxmath_execute_file.m b/native/octave-m/maxmath_execute_file.m
new file mode 100644
index 0000000..4a08bcd
--- /dev/null
+++ b/native/octave-m/maxmath_execute_file.m
@@ -0,0 +1,15 @@
+function maxmath_execute_file(path, request_id)
+ % 安全文件入口:在 base workspace 执行请求文件,并把完整错误栈编码为
+ % 单行 MAXMATH_ERROR。函数返回后调用者仍可 flush 并打印哨兵。
+ if nargin < 2
+ request_id = '';
+ end
+ try
+ if ~ischar(path) || isempty(path)
+ error('MaxMath:invalidRequestFile', '请求文件路径不能为空');
+ end
+ source(path, 'base');
+ catch err
+ maxmath_emit_error(err, request_id);
+ end
+end
diff --git a/native/octave-m/maxmath_flush.m b/native/octave-m/maxmath_flush.m
new file mode 100644
index 0000000..d07e419
--- /dev/null
+++ b/native/octave-m/maxmath_flush.m
@@ -0,0 +1,101 @@
+function maxmath_flush(outfile, request_id)
+ if nargin < 2
+ request_id = '';
+ end
+ s = mm_state();
+ if ~s.dirty
+ return
+ end
+
+ axc = cell(1, numel(s.axes));
+ for k = 1:numel(s.axes)
+ a = s.axes{k};
+ o = struct();
+ o.position = k;
+ o.type = a.type;
+ o.title = a.title;
+ o.xlabel = a.xlabel;
+ o.ylabel = a.ylabel;
+ o.zlabel = a.zlabel;
+ o.legend = a.legend;
+ o.grid = a.grid;
+ o.xlim = a.xlim;
+ o.ylim = a.ylim;
+ o.zlim = a.zlim;
+ o.axismode = a.axismode;
+ o.visible = a.visible;
+ o.lines = a.lines;
+ o.lines3d = a.lines3d;
+ o.surfaces = a.surfaces;
+ o.contours = a.contours;
+ o.texts = a.texts;
+ o.view = a.view;
+ o.colormap = s.colormap;
+ o.colorbar = s.colorbar;
+ axc{k} = o;
+ end
+ spec = struct();
+ spec.version = 1;
+ spec.requestId = request_id;
+ spec.layout = s.layout;
+ spec.axes = axc;
+
+ outdir = fileparts(outfile);
+ if isempty(outdir)
+ outdir = '.';
+ end
+ safe_request_id = regexprep(request_id, '[^A-Za-z0-9_.-]', '_');
+ if isempty(safe_request_id)
+ safe_request_id = 'legacy';
+ end
+ [~, outname, outext] = fileparts(outfile);
+ tmpprefix = [outname outext '.tmp.' safe_request_id '.'];
+ tmpfile = tempname(outdir, tmpprefix);
+ fid = fopen(tmpfile, 'w');
+ if fid < 0
+ mm_flush_error('MaxMath:plotWrite', ...
+ sprintf('无法创建 plot_spec.json 临时文件:%s', tmpfile), request_id);
+ return
+ end
+
+ try
+ written = fprintf(fid, '%s\n', mm_json(spec));
+ if written < 0
+ error('MaxMath:plotWrite', '写入 plot_spec.json 临时文件失败');
+ end
+ closed = fclose(fid);
+ fid = -1;
+ if closed ~= 0
+ error('MaxMath:plotWrite', '关闭 plot_spec.json 临时文件失败');
+ end
+ [status, message] = rename(tmpfile, outfile);
+ if status ~= 0
+ if exist(tmpfile, 'file')
+ unlink(tmpfile);
+ end
+ mm_flush_error('MaxMath:plotWrite', ...
+ sprintf('无法原子更新 plot_spec.json:%s', message), request_id);
+ return
+ end
+ catch err
+ if fid >= 0
+ fclose(fid);
+ end
+ if exist(tmpfile, 'file')
+ unlink(tmpfile);
+ end
+ maxmath_emit_error(err, request_id);
+ return
+ end
+
+ s.dirty = false;
+ mm_set_state(s);
+end
+
+function mm_flush_error(identifier, message, request_id)
+ try
+ error(identifier, '%s', message);
+ catch err
+ maxmath_emit_error(err, request_id);
+ end
+end
diff --git a/native/octave-m/maxmath_init.m b/native/octave-m/maxmath_init.m
new file mode 100644
index 0000000..20a7c7f
--- /dev/null
+++ b/native/octave-m/maxmath_init.m
@@ -0,0 +1,28 @@
+function maxmath_init(mm_dir, work_dir)
+% MaxMath 桥接初始化:加入桥接目录、切换工作目录并重置绘图状态机。
+ persistent done
+ if isequal(done, true)
+ return
+ end
+ done = true;
+
+ warning('off', 'Octave:shadowed-function');
+ warning('off', 'Octave:possible-matlab-short-circuit-operator');
+ warning('off', 'Octave:function-name-clash');
+ warning('off', 'Octave:num-to-str');
+
+ addpath(mm_dir);
+ addpath(fullfile(mm_dir, 'private'));
+
+ if nargin >= 2 && ~isempty(work_dir)
+ try
+ cd(work_dir)
+ catch
+ end
+ end
+
+ s = struct('layout', [1 1], 'axes', {{mm_new_axes()}}, ...
+ 'current', 1, 'dirty', false, ...
+ 'colormap', 'viridis', 'colorbar', false);
+ mm_set_state(s);
+end
diff --git a/native/octave-m/maxmath_preview.m b/native/octave-m/maxmath_preview.m
new file mode 100644
index 0000000..506cc54
--- /dev/null
+++ b/native/octave-m/maxmath_preview.m
@@ -0,0 +1,112 @@
+function maxmath_preview(name, request_id)
+ if nargin < 2
+ request_id = '';
+ end
+ v = evalin('base', name);
+ [oversized, estimated_bytes] = mm_preview_oversized(v);
+
+ envelope = struct();
+ envelope.version = 1;
+ envelope.requestId = request_id;
+ envelope.class = class(v);
+ envelope.shape = size(v);
+ envelope.complex = ~isreal(v);
+ if oversized
+ envelope.kind = 'summary';
+ envelope.value = sprintf('<%s %s, %d elements, preview omitted>', ...
+ mm_shape_text(size(v)), class(v), numel(v));
+ envelope.truncated = true;
+ envelope.estimatedBytes = estimated_bytes;
+ else
+ envelope.kind = mm_preview_kind(v);
+ envelope.value = mm_preview_value(v);
+ envelope.truncated = ~(ischar(v) || isnumeric(v) || islogical(v));
+ end
+ printf('MAXMATH_PREVIEW %s\n', mm_json(envelope));
+end
+
+function [oversized, estimated_bytes] = mm_preview_oversized(v)
+ if ischar(v)
+ % UTF-8 can use up to four bytes per code point, plus JSON escaping.
+ estimated_bytes = 6 * numel(v) + 1024;
+ elseif isnumeric(v) || islogical(v)
+ % 24 bytes covers a finite real token and separator. Complex values use
+ % two components plus object syntax. The estimate is conservative.
+ bytes_per_element = 24;
+ if ~isreal(v)
+ bytes_per_element = 64;
+ end
+ estimated_bytes = bytes_per_element * numel(v) + 1024;
+ else
+ estimated_bytes = 1024;
+ end
+ oversized = numel(v) > 10000 || estimated_bytes > 196608;
+end
+
+function text = mm_shape_text(shape)
+ text = strjoin(arrayfun(@num2str, shape, 'UniformOutput', false), 'x');
+end
+
+function kind = mm_preview_kind(v)
+ if ischar(v)
+ kind = 'string';
+ elseif isscalar(v)
+ kind = 'scalar';
+ elseif isvector(v)
+ kind = 'vector';
+ elseif ismatrix(v)
+ kind = 'matrix';
+ else
+ kind = 'array';
+ end
+end
+
+function value = mm_preview_value(v)
+ if ischar(v)
+ value = v;
+ return
+ end
+ if ~(isnumeric(v) || islogical(v))
+ value = '';
+ return
+ end
+ if isreal(v) && all(isfinite(v(:)))
+ value = v;
+ return
+ end
+
+ if isscalar(v)
+ if isreal(v)
+ value = mm_preview_real(v);
+ else
+ value = struct();
+ value.re = mm_preview_real(real(v));
+ value.im = mm_preview_real(imag(v));
+ end
+ return
+ end
+
+ value = cell(size(v));
+ for k = 1:numel(v)
+ if isreal(v)
+ value{k} = mm_preview_real(v(k));
+ else
+ item = struct();
+ item.re = mm_preview_real(real(v(k)));
+ item.im = mm_preview_real(imag(v(k)));
+ value{k} = item;
+ end
+ end
+end
+
+function value = mm_preview_real(v)
+ if isnan(v)
+ value = 'NaN';
+ elseif isinf(v) && v > 0
+ value = 'Inf';
+ elseif isinf(v)
+ value = '-Inf';
+ else
+ value = v;
+ end
+end
diff --git a/native/octave-m/maxmath_whos.m b/native/octave-m/maxmath_whos.m
new file mode 100644
index 0000000..3a27421
--- /dev/null
+++ b/native/octave-m/maxmath_whos.m
@@ -0,0 +1,45 @@
+function maxmath_whos(request_id)
+ if nargin < 1
+ request_id = '';
+ end
+ % Capturing evalin('base', 'whos') directly creates/overwrites base `ans`
+ % with the returned struct. Use an explicitly assigned internal global as
+ % transport so a workspace refresh is observational and never mutates user
+ % variables.
+ global __maxmath_whos_snapshot__
+ __maxmath_whos_snapshot__ = [];
+ unwind_protect
+ evalin('base', ['global __maxmath_whos_snapshot__; ' ...
+ '__maxmath_whos_snapshot__ = whos;']);
+ w = __maxmath_whos_snapshot__;
+ unwind_protect_cleanup
+ evalin('base', 'clear __maxmath_whos_snapshot__;');
+ __maxmath_whos_snapshot__ = [];
+ end_unwind_protect
+ out = struct('name', {}, 'class', {}, 'dims', {}, 'bytes', {}, ...
+ 'complex', {}, 'sparse', {}, 'global', {});
+ k = 0;
+ for i = 1:numel(w)
+ v = w(i);
+ if strncmp(v.name, '__maxmath_', 10)
+ continue
+ end
+ k = k + 1;
+ out(k).name = v.name;
+ out(k).class = v.class;
+ out(k).dims = v.size;
+ out(k).bytes = v.bytes;
+ out(k).complex = logical(v.complex);
+ out(k).sparse = logical(v.sparse);
+ out(k).global = logical(v.global);
+ end
+ items = cell(1, numel(out));
+ for i = 1:numel(out)
+ items{i} = out(i);
+ end
+ envelope = struct();
+ envelope.version = 1;
+ envelope.requestId = request_id;
+ envelope.variables = items;
+ printf('MAXMATH_WHOS %s\n', mm_json(envelope));
+end
diff --git a/native/octave-m/mesh.m b/native/octave-m/mesh.m
new file mode 100644
index 0000000..aecec66
--- /dev/null
+++ b/native/octave-m/mesh.m
@@ -0,0 +1,20 @@
+function h = mesh(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = '3d';
+ [x, y, z] = mm_surface_args(args);
+ if ~isempty(z)
+ a.surfaces{end + 1} = struct('x', x, 'y', y, 'z', z, 'kind', 'mesh');
+ end
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/plot.m b/native/octave-m/plot.m
new file mode 100644
index 0000000..82507a3
--- /dev/null
+++ b/native/octave-m/plot.m
@@ -0,0 +1,110 @@
+function h = plot(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = '2d';
+
+ i = 1;
+ n = numel(args);
+ while i <= n
+ if ~isnumeric(args{i})
+ i = mm_skip_plot_option(args, i);
+ continue
+ end
+
+ first = args{i};
+ if i + 1 <= n && isnumeric(args{i + 1})
+ x = first;
+ y = args{i + 1};
+ i = i + 2;
+ else
+ y = first;
+ x = (1:rows(y))';
+ if isvector(y)
+ x = (1:numel(y))';
+ end
+ i = i + 1;
+ end
+
+ style = '';
+ if i <= n && mm_is_linespec(args{i})
+ style = args{i};
+ i = i + 1;
+ end
+
+ % MATLAB 属性/值对属于刚解析出的序列。桥接层当前不渲染这些
+ % 属性,但必须成对跳过,不能把数值属性值或下一组数据吞掉。
+ while i <= n && mm_is_plot_property(args{i})
+ i = i + 1;
+ if i <= n
+ i = i + 1;
+ end
+ end
+
+ a = mm_add_lines(a, x, y, style);
+ end
+
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
+
+function next = mm_skip_plot_option(args, i)
+ next = i + 1;
+ if mm_is_plot_property(args{i}) && next <= numel(args)
+ next = next + 1;
+ end
+end
+
+function tf = mm_is_plot_property(value)
+ tf = false;
+ if ~ischar(value)
+ return
+ end
+ properties = {
+ 'color', 'displayname', 'linestyle', 'linewidth', ...
+ 'marker', 'markeredgecolor', 'markerfacecolor', 'markersize', ...
+ 'visible', 'clipping', 'hittest', 'pickableparts', ...
+ 'tag', 'userdata'
+ };
+ tf = any(strcmpi(value, properties));
+end
+
+function tf = mm_is_linespec(value)
+ tf = false;
+ if ~ischar(value) || isempty(value) || mm_is_plot_property(value)
+ return
+ end
+ % 颜色、线型与 marker 的紧凑组合,例如 r--、bo、k:、^。
+ tf = ~isempty(regexp(value, '^[ymcrgbwk\.ox+*sdv\^<>ph:\-]+$', 'once'));
+end
+
+function a = mm_add_lines(a, x, y, style)
+ if isvector(y)
+ y = y(:);
+ if isvector(x)
+ x = x(:);
+ end
+ if numel(x) ~= numel(y)
+ x = (1:numel(y))';
+ end
+ a.lines{end + 1} = struct('x', x, 'y', y, 'style', style);
+ elseif ismatrix(y)
+ cols = columns(y);
+ if isvector(x) && numel(x) == rows(y)
+ xv = x(:);
+ else
+ xv = (1:rows(y))';
+ end
+ for c = 1:cols
+ a.lines{end + 1} = struct('x', xv, 'y', y(:, c), 'style', style);
+ end
+ end
+end
diff --git a/native/octave-m/plot3.m b/native/octave-m/plot3.m
new file mode 100644
index 0000000..3d53ff0
--- /dev/null
+++ b/native/octave-m/plot3.m
@@ -0,0 +1,29 @@
+function h = plot3(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = '3d';
+ if numel(args) >= 3 && isnumeric(args{1}) && isnumeric(args{2}) ...
+ && isnumeric(args{3})
+ x = args{1}(:);
+ y = args{2}(:);
+ z = args{3}(:);
+ style = '';
+ if numel(args) >= 4 && ischar(args{4})
+ style = args{4};
+ end
+ n = min([numel(x), numel(y), numel(z)]);
+ a.lines3d{end + 1} = struct('x', x(1:n), 'y', y(1:n), 'z', z(1:n), ...
+ 'style', style);
+ end
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/private/maxmath_emit_error.m b/native/octave-m/private/maxmath_emit_error.m
new file mode 100644
index 0000000..e7e36d8
--- /dev/null
+++ b/native/octave-m/private/maxmath_emit_error.m
@@ -0,0 +1,32 @@
+function maxmath_emit_error(err, request_id)
+ envelope = struct();
+ envelope.version = 1;
+ envelope.kind = 'error';
+ envelope.requestId = request_id;
+ envelope.identifier = mm_error_field(err, 'identifier', '');
+ envelope.message = mm_error_field(err, 'message', 'Octave error');
+ envelope.stack = mm_error_stack(err);
+ printf('MAXMATH_ERROR %s\n', mm_json(envelope));
+end
+
+function stack = mm_error_stack(err)
+ stack = {};
+ raw = mm_error_field(err, 'stack', ...
+ struct('file', {}, 'name', {}, 'line', {}, 'column', {}));
+ for i = 1:numel(raw)
+ frame = struct();
+ frame.file = mm_error_field(raw(i), 'file', '');
+ frame.name = mm_error_field(raw(i), 'name', '');
+ frame.line = mm_error_field(raw(i), 'line', 0);
+ frame.column = mm_error_field(raw(i), 'column', 0);
+ stack{end + 1} = frame;
+ end
+end
+
+function value = mm_error_field(err, name, fallback)
+ if isstruct(err) && isfield(err, name)
+ value = err.(name);
+ else
+ value = fallback;
+ end
+end
diff --git a/native/octave-m/private/mm_contour_core.m b/native/octave-m/private/mm_contour_core.m
new file mode 100644
index 0000000..da72fe9
--- /dev/null
+++ b/native/octave-m/private/mm_contour_core.m
@@ -0,0 +1,32 @@
+function a = mm_contour_core(a, args, filled)
+ z = [];
+ levels = [];
+ x = [];
+ y = [];
+ if numel(args) >= 3 && isnumeric(args{1}) ...
+ && isnumeric(args{2}) && isnumeric(args{3})
+ x = args{1};
+ y = args{2};
+ z = args{3};
+ if numel(args) >= 4 && isnumeric(args{4})
+ levels = args{4};
+ end
+ if isvector(x) && isvector(y)
+ [x, y] = meshgrid(x, y);
+ end
+ elseif numel(args) >= 1 && isnumeric(args{1})
+ z = args{1};
+ if numel(args) >= 2 && isnumeric(args{2})
+ levels = args{2};
+ end
+ end
+ if isempty(z)
+ return
+ end
+ if isempty(x) && isempty(y)
+ [m, n] = size(z);
+ [x, y] = meshgrid(1:n, 1:m);
+ end
+ a.contours{end + 1} = struct('x', x, 'y', y, 'z', z, ...
+ 'levels', levels, 'filled', filled);
+end
diff --git a/native/octave-m/private/mm_current.m b/native/octave-m/private/mm_current.m
new file mode 100644
index 0000000..493ba69
--- /dev/null
+++ b/native/octave-m/private/mm_current.m
@@ -0,0 +1,8 @@
+function a = mm_current(s)
+ % 当前 subplot 对应的 axes 结构;没有则返回空。
+ if s.current >= 1 && numel(s.axes) >= s.current
+ a = s.axes{s.current};
+ else
+ a = [];
+ end
+end
diff --git a/native/octave-m/private/mm_json.m b/native/octave-m/private/mm_json.m
new file mode 100644
index 0000000..276714f
--- /dev/null
+++ b/native/octave-m/private/mm_json.m
@@ -0,0 +1,124 @@
+function s = mm_json(x)
+ if ischar(x)
+ s = ['"' mm_json_esc(x) '"'];
+ elseif isempty(x)
+ s = '[]';
+ elseif isnumeric(x) || islogical(x)
+ s = mm_json_num(x);
+ elseif isstruct(x)
+ if numel(x) > 1
+ parts = cell(1, numel(x));
+ for k = 1:numel(x)
+ parts{k} = mm_json(x(k));
+ end
+ s = ['[' strjoin(parts, ',') ']'];
+ return
+ end
+ f = fieldnames(x);
+ parts = cell(1, numel(f));
+ for i = 1:numel(f)
+ parts{i} = ['"' mm_json_esc(f{i}) '":' mm_json(x.(f{i}))];
+ end
+ s = ['{' strjoin(parts, ',') '}'];
+ elseif iscell(x)
+ s = mm_json_cell(x);
+ else
+ s = 'null';
+ end
+end
+
+function s = mm_json_cell(x)
+ if isempty(x)
+ s = '[]';
+ return
+ end
+ d = size(x);
+ if numel(d) == 2 && d(1) > 1 && d(2) > 1
+ rows = cell(1, d(1));
+ for i = 1:d(1)
+ values = cell(1, d(2));
+ for j = 1:d(2)
+ values{j} = mm_json(x{i, j});
+ end
+ rows{i} = ['[' strjoin(values, ',') ']'];
+ end
+ s = ['[' strjoin(rows, ',') ']'];
+ return
+ end
+ parts = cell(1, numel(x));
+ for i = 1:numel(x)
+ parts{i} = mm_json(x{i});
+ end
+ s = ['[' strjoin(parts, ',') ']'];
+end
+
+function s = mm_json_num(x)
+ if numel(x) == 1
+ s = mm_json_scalar(x);
+ return
+ end
+ d = size(x);
+ if numel(d) > 2 || d(1) == 1 || d(2) == 1
+ flat = cell(1, numel(x));
+ for k = 1:numel(x)
+ flat{k} = mm_json_scalar(x(k));
+ end
+ s = ['[' strjoin(flat, ',') ']'];
+ return
+ end
+ rows = cell(1, d(1));
+ for i = 1:d(1)
+ r = cell(1, d(2));
+ for j = 1:d(2)
+ r{j} = mm_json_scalar(x(i, j));
+ end
+ rows{i} = ['[' strjoin(r, ',') ']'];
+ end
+ s = ['[' strjoin(rows, ',') ']'];
+end
+
+function s = mm_json_scalar(x)
+ if islogical(x)
+ if x
+ s = 'true';
+ else
+ s = 'false';
+ end
+ elseif isreal(x)
+ s = mm_num(x);
+ else
+ s = mm_json_complex(x);
+ end
+end
+
+function s = mm_json_complex(x)
+ s = ['{"re":' mm_num(real(x)) ',"im":' mm_num(imag(x)) '}'];
+end
+
+function s = mm_num(x)
+ if isnan(x) || isinf(x)
+ s = 'null';
+ else
+ s = sprintf('%.15g', x);
+ end
+end
+
+function s = mm_json_esc(x)
+ parts = cell(1, numel(x));
+ for i = 1:numel(x)
+ code = double(x(i));
+ switch code
+ case 34
+ parts{i} = '\"';
+ case 92
+ parts{i} = '\\';
+ otherwise
+ if code < 32
+ parts{i} = sprintf('\\u%04x', code);
+ else
+ parts{i} = x(i);
+ end
+ end
+ end
+ s = [parts{:}];
+end
diff --git a/native/octave-m/private/mm_label.m b/native/octave-m/private/mm_label.m
new file mode 100644
index 0000000..5940f24
--- /dev/null
+++ b/native/octave-m/private/mm_label.m
@@ -0,0 +1,11 @@
+function mm_label(field, varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin >= 2 && ischar(varargin{1})
+ a.(field) = varargin{1};
+ mm_set_state(mm_put(s, a));
+ end
+end
diff --git a/native/octave-m/private/mm_limit.m b/native/octave-m/private/mm_limit.m
new file mode 100644
index 0000000..6f91bb0
--- /dev/null
+++ b/native/octave-m/private/mm_limit.m
@@ -0,0 +1,11 @@
+function mm_limit(field, varargin)
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if nargin >= 2 && isnumeric(varargin{1}) && numel(varargin{1}) == 2
+ a.(field) = [varargin{1}(1) varargin{1}(2)];
+ mm_set_state(mm_put(s, a));
+ end
+end
diff --git a/native/octave-m/private/mm_new_axes.m b/native/octave-m/private/mm_new_axes.m
new file mode 100644
index 0000000..d239ae3
--- /dev/null
+++ b/native/octave-m/private/mm_new_axes.m
@@ -0,0 +1,17 @@
+function a = mm_new_axes()
+ a = struct( ...
+ 'type', '2d', ...
+ 'lines', {{}}, ...
+ 'lines3d', {{}}, ...
+ 'surfaces', {{}}, ...
+ 'contours', {{}}, ...
+ 'texts', {{}}, ...
+ 'hold', false, ...
+ 'xlabel', '', 'ylabel', '', 'zlabel', '', 'title', '', ...
+ 'legend', {{}}, ...
+ 'grid', false, ...
+ 'xlim', [], 'ylim', [], 'zlim', [], ...
+ 'view', [60 30], ...
+ 'axismode', 'auto', ...
+ 'visible', true);
+end
diff --git a/native/octave-m/private/mm_put.m b/native/octave-m/private/mm_put.m
new file mode 100644
index 0000000..70a8468
--- /dev/null
+++ b/native/octave-m/private/mm_put.m
@@ -0,0 +1,5 @@
+function s = mm_put(s, a)
+ % 把修改后的 axes 写回当前槽位并标记图面脏(maxmath_flush 据此导出)。
+ s.axes{s.current} = a;
+ s.dirty = true;
+end
diff --git a/native/octave-m/private/mm_set_state.m b/native/octave-m/private/mm_set_state.m
new file mode 100644
index 0000000..6f06ba3
--- /dev/null
+++ b/native/octave-m/private/mm_set_state.m
@@ -0,0 +1,5 @@
+function mm_set_state(s)
+ % 写回绘图桥全局状态;所有覆写函数通过 get/set 成对访问。
+ global __maxmath_gfx__
+ __maxmath_gfx__ = s;
+end
diff --git a/native/octave-m/private/mm_state.m b/native/octave-m/private/mm_state.m
new file mode 100644
index 0000000..752a8be
--- /dev/null
+++ b/native/octave-m/private/mm_state.m
@@ -0,0 +1,10 @@
+function s = mm_state()
+ % 读取绘图桥全局状态(惰性初始化),供各覆写函数共享。
+ global __maxmath_gfx__
+ if isempty(__maxmath_gfx__)
+ __maxmath_gfx__ = struct('layout', [1 1], 'axes', {{mm_new_axes()}}, ...
+ 'current', 1, 'dirty', false, ...
+ 'colormap', 'viridis', 'colorbar', false);
+ end
+ s = __maxmath_gfx__;
+end
diff --git a/native/octave-m/private/mm_strip_axes.m b/native/octave-m/private/mm_strip_axes.m
new file mode 100644
index 0000000..3f853f2
--- /dev/null
+++ b/native/octave-m/private/mm_strip_axes.m
@@ -0,0 +1,13 @@
+function args = mm_strip_axes(args)
+ % 只有参数不少于 3 个时才把首个小整数当作 axes 句柄剥离:
+ % plot(ax,x,y) 是三个参数;plot(5) 或 plot(0,y) 这类「标量作为数据」
+ % 的合法调用不能被误判为句柄。
+ if numel(args) < 3
+ return
+ end
+ if numel(args) >= 1 && isnumeric(args{1}) && isscalar(args{1}) ...
+ && isfinite(args{1}) && args{1} == floor(args{1}) ...
+ && args{1} >= 0 && args{1} <= 64
+ args = args(2:end);
+ end
+end
diff --git a/native/octave-m/private/mm_surface_args.m b/native/octave-m/private/mm_surface_args.m
new file mode 100644
index 0000000..066c99a
--- /dev/null
+++ b/native/octave-m/private/mm_surface_args.m
@@ -0,0 +1,18 @@
+function [x, y, z] = mm_surface_args(args)
+ x = [];
+ y = [];
+ z = [];
+ if numel(args) >= 3 && isnumeric(args{1}) ...
+ && isnumeric(args{2}) && isnumeric(args{3})
+ x = args{1};
+ y = args{2};
+ z = args{3};
+ if isvector(x) && isvector(y)
+ [x, y] = meshgrid(x, y);
+ end
+ elseif numel(args) >= 1 && isnumeric(args{1})
+ z = args{1};
+ [m, n] = size(z);
+ [x, y] = meshgrid(1:n, 1:m);
+ end
+end
diff --git a/native/octave-m/private/mm_unsupported_once.m b/native/octave-m/private/mm_unsupported_once.m
new file mode 100644
index 0000000..d8e9b29
--- /dev/null
+++ b/native/octave-m/private/mm_unsupported_once.m
@@ -0,0 +1,10 @@
+function mm_unsupported_once(name)
+ persistent warned
+ if isempty(warned)
+ warned = containers.Map();
+ end
+ if ~warned.isKey(name)
+ warned(name) = true;
+ warning('MaxMath:unsupported', ['MaxMath: ' name '() 暂不支持,已忽略。']);
+ end
+end
diff --git a/native/octave-m/set.m b/native/octave-m/set.m
new file mode 100644
index 0000000..a48661a
--- /dev/null
+++ b/native/octave-m/set.m
@@ -0,0 +1,6 @@
+function varargout = set(varargin)
+ mm_unsupported_once('set');
+ if nargout > 0
+ varargout{1} = [];
+ end
+end
diff --git a/native/octave-m/subplot.m b/native/octave-m/subplot.m
new file mode 100644
index 0000000..6017d56
--- /dev/null
+++ b/native/octave-m/subplot.m
@@ -0,0 +1,20 @@
+function h = subplot(m, n, p)
+ if nargin < 2
+ return
+ end
+ if nargin < 3
+ p = 1;
+ end
+ s = mm_state();
+ s.layout = [m n];
+ if numel(s.axes) < p
+ for k = (numel(s.axes) + 1):p
+ s.axes{k} = mm_new_axes();
+ end
+ end
+ s.current = p;
+ mm_set_state(s);
+ if nargout > 0
+ h = p;
+ end
+end
diff --git a/native/octave-m/surf.m b/native/octave-m/surf.m
new file mode 100644
index 0000000..5b14cad
--- /dev/null
+++ b/native/octave-m/surf.m
@@ -0,0 +1,20 @@
+function h = surf(varargin)
+ args = mm_strip_axes(varargin);
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ return
+ end
+ if ~a.hold
+ a = mm_new_axes();
+ end
+ a.type = '3d';
+ [x, y, z] = mm_surface_args(args);
+ if ~isempty(z)
+ a.surfaces{end + 1} = struct('x', x, 'y', y, 'z', z, 'kind', 'surf');
+ end
+ mm_set_state(mm_put(s, a));
+ if nargout > 0
+ h = 1;
+ end
+end
diff --git a/native/octave-m/text.m b/native/octave-m/text.m
new file mode 100644
index 0000000..5cbcdf1
--- /dev/null
+++ b/native/octave-m/text.m
@@ -0,0 +1,94 @@
+function h = text(varargin)
+ % Capture common 2-D/3-D text annotations without entering Octave's native
+ % handle-graphics implementation. Property/value pairs are accepted and
+ % ignored by the current renderer.
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ if nargout > 0
+ h = 1;
+ end
+ return
+ end
+
+ [x, y, z, labels] = mm_text_args(varargin);
+ count = min([numel(x), numel(y), numel(labels)]);
+ for i = 1:count
+ zi = [];
+ if ~isempty(z)
+ zi = z(min(i, numel(z)));
+ end
+ a.texts{end + 1} = struct( ...
+ 'x', x(min(i, numel(x))), ...
+ 'y', y(min(i, numel(y))), ...
+ 'z', zi, ...
+ 'text', labels{i});
+ end
+ if count > 0
+ mm_set_state(mm_put(s, a));
+ end
+ if nargout > 0
+ h = 1;
+ end
+end
+
+function [x, y, z, labels] = mm_text_args(args)
+ x = [];
+ y = [];
+ z = [];
+ labels = {};
+ if numel(args) < 3 || ~isnumeric(args{1}) || ~isnumeric(args{2})
+ return
+ end
+ x = args{1}(:);
+ y = args{2}(:);
+ label_index = 3;
+ if numel(args) >= 4 && isnumeric(args{3})
+ z = args{3}(:);
+ label_index = 4;
+ end
+ if label_index > numel(args)
+ x = [];
+ y = [];
+ z = [];
+ return
+ end
+ labels = mm_text_labels(args{label_index});
+ if isempty(labels)
+ x = [];
+ y = [];
+ z = [];
+ return
+ end
+ count = max([numel(x), numel(y), numel(labels)]);
+ if numel(x) == 1 && count > 1
+ x = repmat(x, count, 1);
+ end
+ if numel(y) == 1 && count > 1
+ y = repmat(y, count, 1);
+ end
+ if numel(labels) == 1 && count > 1
+ labels = repmat(labels, count, 1);
+ end
+end
+
+function labels = mm_text_labels(value)
+ labels = {};
+ if ischar(value)
+ if rows(value) <= 1
+ labels = {value};
+ else
+ labels = cellstr(value);
+ end
+ elseif iscell(value)
+ for i = 1:numel(value)
+ if ischar(value{i})
+ labels{end + 1} = value{i};
+ end
+ end
+ elseif isnumeric(value) || islogical(value)
+ for i = 1:numel(value)
+ labels{end + 1} = num2str(value(i));
+ end
+ end
+end
diff --git a/native/octave-m/title.m b/native/octave-m/title.m
new file mode 100644
index 0000000..6ce332d
--- /dev/null
+++ b/native/octave-m/title.m
@@ -0,0 +1,3 @@
+function title(varargin)
+ mm_label('title', varargin{:});
+end
diff --git a/native/octave-m/view.m b/native/octave-m/view.m
new file mode 100644
index 0000000..238cd4b
--- /dev/null
+++ b/native/octave-m/view.m
@@ -0,0 +1,50 @@
+function result = view(varargin)
+ % Store the requested camera in the MaxMath plot specification. Calling
+ % Octave's native view() would enter gca()/axes() handle graphics, which is
+ % deliberately unavailable in the headless Android bridge.
+ s = mm_state();
+ a = mm_current(s);
+ if isempty(a)
+ if nargout > 0
+ result = [60 30];
+ end
+ return
+ end
+
+ camera = a.view;
+ args = varargin;
+ if numel(args) >= 3 && mm_is_axes_handle(args{1})
+ args = args(2:end);
+ end
+
+ if isempty(args)
+ % Query form: view().
+ elseif numel(args) == 1 && isnumeric(args{1})
+ value = args{1};
+ if isscalar(value)
+ if value == 2
+ camera = [0 90];
+ elseif value == 3
+ camera = [-37.5 30];
+ end
+ elseif numel(value) >= 2
+ camera = double(value(1:2));
+ end
+ elseif numel(args) >= 2 && isnumeric(args{1}) && isnumeric(args{2}) ...
+ && isscalar(args{1}) && isscalar(args{2})
+ camera = [double(args{1}) double(args{2})];
+ end
+
+ if numel(camera) == 2 && all(isfinite(camera))
+ a.view = reshape(camera, 1, 2);
+ mm_set_state(mm_put(s, a));
+ end
+ if nargout > 0
+ result = a.view;
+ end
+end
+
+function tf = mm_is_axes_handle(value)
+ tf = isnumeric(value) && isscalar(value) && isfinite(value) ...
+ && value == floor(value) && value >= 1 && value <= 64;
+end
diff --git a/native/octave-m/xlabel.m b/native/octave-m/xlabel.m
new file mode 100644
index 0000000..a420d91
--- /dev/null
+++ b/native/octave-m/xlabel.m
@@ -0,0 +1,3 @@
+function xlabel(varargin)
+ mm_label('xlabel', varargin{:});
+end
diff --git a/native/octave-m/xlim.m b/native/octave-m/xlim.m
new file mode 100644
index 0000000..c02a7d2
--- /dev/null
+++ b/native/octave-m/xlim.m
@@ -0,0 +1,8 @@
+function varargout = xlim(varargin)
+ mm_limit('xlim', varargin{:});
+ if nargout > 0
+ s = mm_state();
+ a = mm_current(s);
+ varargout{1} = a.xlim;
+ end
+end
diff --git a/native/octave-m/ylabel.m b/native/octave-m/ylabel.m
new file mode 100644
index 0000000..9092834
--- /dev/null
+++ b/native/octave-m/ylabel.m
@@ -0,0 +1,3 @@
+function ylabel(varargin)
+ mm_label('ylabel', varargin{:});
+end
diff --git a/native/octave-m/ylim.m b/native/octave-m/ylim.m
new file mode 100644
index 0000000..01097fd
--- /dev/null
+++ b/native/octave-m/ylim.m
@@ -0,0 +1,8 @@
+function varargout = ylim(varargin)
+ mm_limit('ylim', varargin{:});
+ if nargout > 0
+ s = mm_state();
+ a = mm_current(s);
+ varargout{1} = a.ylim;
+ end
+end
diff --git a/native/octave-m/zlabel.m b/native/octave-m/zlabel.m
new file mode 100644
index 0000000..545b16d
--- /dev/null
+++ b/native/octave-m/zlabel.m
@@ -0,0 +1,3 @@
+function zlabel(varargin)
+ mm_label('zlabel', varargin{:});
+end
diff --git a/native/octave-m/zlim.m b/native/octave-m/zlim.m
new file mode 100644
index 0000000..b706727
--- /dev/null
+++ b/native/octave-m/zlim.m
@@ -0,0 +1,8 @@
+function varargout = zlim(varargin)
+ mm_limit('zlim', varargin{:});
+ if nargout > 0
+ s = mm_state();
+ a = mm_current(s);
+ varargout{1} = a.zlim;
+ end
+end
diff --git a/native/octave-termux.lock b/native/octave-termux.lock
new file mode 100644
index 0000000..7f4ec62
--- /dev/null
+++ b/native/octave-termux.lock
@@ -0,0 +1,89 @@
+# Locked Termux aarch64 inputs for the embedded GNU Octave runtime.
+#
+# Regenerate intentionally: update every version, filename and SHA-256 from one
+# signed/official Termux repository snapshot, then rerun download + package +
+# verify. The downloader never resolves "latest" packages at build time.
+schemaVersion=1
+abi=arm64-v8a
+termuxArch=aarch64
+octaveVersion=11.3.0
+octavePackageVersion=2:11.3.0
+bridgeVersion=1
+repository=https://packages.termux.dev/apt/termux-main
+mirror=https://termux.librehat.com/apt/termux-main
+libcxxRuntimeSha256=e09c2f45cf4cf8ae574f94b6c2650d99ead0d332d5396f6613f062a2d2d73540
+packageCount=73
+packages:
+arpack-ng 2:3.9.1 pool/main/a/arpack-ng/arpack-ng_2:3.9.1_aarch64.deb d764d701e8a489e8108bfae6b4f4ce0f1b27497773a5fc0470bcb490346593b0
+brotli 1.2.0 pool/main/b/brotli/brotli_1.2.0_aarch64.deb db1502601d40fb44e6085ad8bfd9311a8b472e98db831ceec9d404c5708bb52c
+bzip2 1.0.8-8 pool/main/b/bzip2/bzip2_1.0.8-8_aarch64.deb 7b904d820c34b6c043067ca3c8cb67465413ea5eae3f529a0e05a9ae0df2b89a
+fftw 3.3.11 pool/main/f/fftw/fftw_3.3.11_aarch64.deb 0a5140986bbfc69dbfa8f939f78521fb3dc02a193b6c822b7218dae44d99ffd5
+freetype 2.14.3 pool/main/f/freetype/freetype_2.14.3_aarch64.deb 670d27d8f53ef2d887b67f8b82fa480f7132b65ac2264a39b3c5b4db637ee285
+gdk-pixbuf 2.44.7 pool/main/g/gdk-pixbuf/gdk-pixbuf_2.44.7_aarch64.deb c799fa9f08c6456d1187a35bab38ab32b8cb20a713b4eef3fbab95b094b0fc55
+giflib 5.2.2-2 pool/main/g/giflib/giflib_5.2.2-2_aarch64.deb 4d30733db0778db1aa2a24fcc39bb97597795e96e85f0ba1ed166965ad1354dc
+glib 2.88.3 pool/main/g/glib/glib_2.88.3_aarch64.deb b1bfc40c4cfde83470de9efac1e546fdf643b77a966c20a8612bddc7c59cd3dc
+glpk 5.0-2 pool/main/g/glpk/glpk_5.0-2_aarch64.deb 89774203b00783c8a289885c5bb976f52b07aae573c1da6c852a22a21f4d98ec
+graphicsmagick 1.3.48 pool/main/g/graphicsmagick/graphicsmagick_1.3.48_aarch64.deb a83cc1831686886adffb438ba9132165781f7145874457229a0840b34611a902
+libandroid-complex-math 0.2 pool/main/liba/libandroid-complex-math/libandroid-complex-math_0.2_aarch64.deb 32786717f21af152deff76b34aa3a5c94507f28d46e2ef92dcfae70021f14f48
+libandroid-glob 0.6-3 pool/main/liba/libandroid-glob/libandroid-glob_0.6-3_aarch64.deb 2276ae8adedf0db76c2f4ffc94cc4cceb2f4f5d78e021b54e2e046d1233e7826
+libandroid-posix-semaphore 0.1-4 pool/main/liba/libandroid-posix-semaphore/libandroid-posix-semaphore_0.1-4_aarch64.deb 0efa8677a0166315ba4e685863712eba0ca0a1732827492f38226e2723730c7a
+libandroid-support 29-1 pool/main/liba/libandroid-support/libandroid-support_29-1_aarch64.deb f2f145d6135ad4843ac9670153be3e3944dc1e6f1736d46d2306c28f2b86f517
+libaom 3.14.1 pool/main/liba/libaom/libaom_3.14.1_aarch64.deb 305808ea5116933b0c6b851d1189bcca4c269cbd5c2aa9be7d80a2014023238e
+libbz2 1.0.8-8 pool/main/libb/libbz2/libbz2_1.0.8-8_aarch64.deb 4335d7f060650b0aabef545d1334c2f9f280223d5962e13c24a00ec934b794ba
+libc++ 29 pool/main/libc/libc++/libc++_29_aarch64.deb bb9f12113c137aa0e8513bb51cc49fe77a5ce3ca39ab9e92c57d228ecdf00222
+libcrypt 0.2-6 pool/main/libc/libcrypt/libcrypt_0.2-6_aarch64.deb 6c283eed576b98cc3568f99638156f8588f77d979579d03bef8683d6eb8601e1
+libcurl 8.21.0 pool/main/libc/libcurl/libcurl_8.21.0_aarch64.deb ad644c7e16183d40eaf0f55df339021a69b25cc9b390e9634251e681be1ddcb8
+libdav1d 1.5.4 pool/main/libd/libdav1d/libdav1d_1.5.4_aarch64.deb 4c2ffdcbbb805727caaa2e91b56917830dbacf4245dd439cf2e1c0d61a541613
+libde265 1.1.1 pool/main/libd/libde265/libde265_1.1.1_aarch64.deb dd971fa634db60823f40da521a2a76d3fc5f0aa0be149faf79403d2e7a6b5224
+libexpat 2.8.3 pool/main/libe/libexpat/libexpat_2.8.3_aarch64.deb 86dac6db293a44dc2689d4a28fdba96e38fccd378a9c9ef17b4e840b4cf4d81c
+libffi 3.5.2 pool/main/libf/libffi/libffi_3.5.2_aarch64.deb 8c8c1d6ffb049d8496a21c1202d9b4dc9145140886fdbb45716684565f4ed3f5
+libflac 1.5.0-1 pool/main/libf/libflac/libflac_1.5.0-1_aarch64.deb d1d6fcac6fd6d15c36f34464cb01d5e2140e321d8448dfc396cd7eb1536b1f6c
+libgmp 6.3.0-2 pool/main/libg/libgmp/libgmp_6.3.0-2_aarch64.deb 5a3c1325638946ca212ddcb89bffb2c4459b4c90757d6e69f820e176534037fa
+libhdf5 1.14.4.3-1 pool/main/libh/libhdf5/libhdf5_1.14.4.3-1_aarch64.deb 5a9bf3fc3b01dcfaf8c4f9412694d2d4b8d24748846a9091bdbc02cac2a09a80
+libheif 1.23.1-1 pool/main/libh/libheif/libheif_1.23.1-1_aarch64.deb b0877a2b777820d7e2034685cfcdc8f7bc08e217605dd4ab511cfd3a73e64140
+libiconv 1.18-1 pool/main/libi/libiconv/libiconv_1.18-1_aarch64.deb b19e6f348034bb48d2a5590b5cb242769f682c476717374d134d004cc663dc84
+libicu 78.3 pool/main/libi/libicu/libicu_78.3_aarch64.deb f536403f65a08fe0df6e7304184e902d54def77d5c3bd5edfd9109d57601d276
+libidn 1.44 pool/main/libi/libidn/libidn_1.44_aarch64.deb d683287fcb512324ff08198cd74f251adaba5fd4578c32b4522fe481a8f4daa1
+libidn2 2.3.8-1 pool/main/libi/libidn2/libidn2_2.3.8-1_aarch64.deb a450a1ba25759ebf78738484a3efee316c51a1fe7bafb0b01a68e2c058a91020
+libjasper 4.2.9-1 pool/main/libj/libjasper/libjasper_4.2.9-1_aarch64.deb 37e884b7e2c84fb605ae153d2e3e3e2b10d9f71064a5e68f5d6a75b0e2c3d6fd
+libjpeg-turbo 3.2.0 pool/main/libj/libjpeg-turbo/libjpeg-turbo_3.2.0_aarch64.deb e6339075bcde28b4902d5a51f4063164834abfd2fa115e881ae637a34585e54f
+libjxl 0.12.0 pool/main/libj/libjxl/libjxl_0.12.0_aarch64.deb 3f8821c1aea697a8403e0b01746182c9babf512d47611e9b6de53ecbcc20d048
+libltdl 2.6.2 pool/main/libl/libltdl/libltdl_2.6.2_aarch64.deb 7b4643fe9d0ab42c0c3c8a1018640816bdb43d422f2268a5e4ea80675624f539
+liblzma 5.8.3 pool/main/libl/liblzma/liblzma_5.8.3_aarch64.deb 594925a313879f590fbd24050305551a78eadd9a9319f6e612389b1a521113c6
+libmp3lame 3.100-7 pool/main/libm/libmp3lame/libmp3lame_3.100-7_aarch64.deb 8c28c74209eb5c31675b4e9c1ee5193c1bf22c49ff5965612dcf9aede5d870d9
+libmpfr 4.2.1-1 pool/main/libm/libmpfr/libmpfr_4.2.1-1_aarch64.deb 5edf5a36e6f4773dc827d37ea515db27c242d3631f3030188a3324be2e00e4c6
+libmpg123 1.33.5 pool/main/libm/libmpg123/libmpg123_1.33.5_aarch64.deb 881cc85f8395a675407406ca026ac9cc81eddcf4df01f93d7f67956a63c21d5c
+libnghttp2 1.70.0 pool/main/libn/libnghttp2/libnghttp2_1.70.0_aarch64.deb ab2e0a3408fe4934ffdf774bd5049a265db2e6db62dcb651baecbecfb81e3f0b
+libnghttp3 1.18.0 pool/main/libn/libnghttp3/libnghttp3_1.18.0_aarch64.deb 0be2ee96def3608afaf24eb0b4b55f3484a37c95ea21bb1d6d85c7d543467603
+libngtcp2 1.25.0 pool/main/libn/libngtcp2/libngtcp2_1.25.0_aarch64.deb f471bad7f4329b6b0b4aedf124e0a23a35f6ac99bfbae73304136f8bfd570fc3
+libogg 1.3.6-1 pool/main/libo/libogg/libogg_1.3.6-1_aarch64.deb 4f1700a532f251ad3600f44f88cd764f1f6b0542d3343391117944b47a92802d
+libopenblas 0.3.34 pool/main/libo/libopenblas/libopenblas_0.3.34_aarch64.deb c110b2925318d49e1d95adc13b6931ad57e08661427c5adca6ff0f2af35e8d27
+libopus 1.6.1 pool/main/libo/libopus/libopus_1.6.1_aarch64.deb 005a9335ce0ec0f510bbe6e0229c901d6f920cc375fcda56461e96cef70f8ff0
+libpng 1.6.58 pool/main/libp/libpng/libpng_1.6.58_aarch64.deb e47937405c72734867513cf0c63d27f36400d462666b65dfada984667d7228c4
+libpsl 0.23.2 pool/main/libp/libpsl/libpsl_0.23.2_aarch64.deb 3538e117af2a3e38ad931c36160c0b6ca0635332204927995dfffa47fe1513fb
+librav1e 0.8.1 pool/main/libr/librav1e/librav1e_0.8.1_aarch64.deb ac7842a5896bdff1b4d4974d9c02e60f7d4a587b8618df691118a8591f8dbccf
+libsndfile 1.2.2-3 pool/main/libs/libsndfile/libsndfile_1.2.2-3_aarch64.deb 1e1fb77b14ec26f3ff6ded069e9ec816ab7535ae5d074122c6727b0f59d8aff3
+libsodium 1.0.22-1 pool/main/libs/libsodium/libsodium_1.0.22-1_aarch64.deb a9fbbc3a7a5a2a0f514474ee706d49b576ac09268b87142a3690edb9f5f3643d
+libsqlite 3.53.4 pool/main/libs/libsqlite/libsqlite_3.53.4_aarch64.deb 0e909ce0d50fe123305446cd22e0c5edf535d40344b9b065fbdcdee52f53198d
+libssh2 1.11.1-2 pool/main/libs/libssh2/libssh2_1.11.1-2_aarch64.deb 1add4e0a926b848814e7e2f1817ea28b123c54cabd5bbc4e5cfd65291cbad84e
+libtiff 4.7.2 pool/main/libt/libtiff/libtiff_4.7.2_aarch64.deb 998c583d65d7d42dcb177548a218545661f02385a51bb9f5c24f0da4fd046574
+libunistring 1.4.2 pool/main/libu/libunistring/libunistring_1.4.2_aarch64.deb 5ff75cdf3ddd4ddf5dc9705050f270c3422820295a4b26f93496d3e8e9060122
+libvorbis 1.3.7-4 pool/main/libv/libvorbis/libvorbis_1.3.7-4_aarch64.deb 6bfd70c4b570b85594bd06116b75a6f8109ec58cbc123e938b630a6eefdedf4e
+libwebp 1.6.0-rc1-0 pool/main/libw/libwebp/libwebp_1.6.0-rc1-0_aarch64.deb 80fa46bec4faad0ccfd969df5acfd501d813ca9d112b5c49f3f91390e987f3a9
+libx264 1:0.164.3191-1 pool/main/libx/libx264/libx264_1:0.164.3191-1_aarch64.deb fe98852cb06ce1c963a54284179d8cda42af2fa82afcac09f7d81e6de4d9bbc1
+libx265 4.3 pool/main/libx/libx265/libx265_4.3_aarch64.deb 8dbfd50ad2ceea5ffa2384f0f4cc16a87cbf56ac83b4861fa4f640d1beb352cd
+libxml2 2.15.3-2 pool/main/libx/libxml2/libxml2_2.15.3-2_aarch64.deb 59fbced0c60a7df9ff84faf20d248f563da5ae45a6783ed683faf33e9010fb24
+littlecms 2.19.1 pool/main/l/littlecms/littlecms_2.19.1_aarch64.deb 274f732a186563beca204f5d016d30cac8e5c24f7c4395b60881f9613eddf1a1
+ncurses 6.6.20260307+really6.5.20250830 pool/main/n/ncurses/ncurses_6.6.20260307+really6.5.20250830_aarch64.deb f44bbfdc3d42ec0217bffa978309390e59cea5a48a9a83226d4a496c42ad0b99
+octave 2:11.3.0 pool/main/o/octave/octave_2:11.3.0_aarch64.deb f80179da3202a389458e217715a61edd101c17a7856c01a3ba3c0731898f17db
+openjpeg 2.5.4 pool/main/o/openjpeg/openjpeg_2.5.4_aarch64.deb 93ad32c8caa0eb8943e2dd86b5c2eaf48a767709a3fa069a5b670d948656c7d8
+openssl 1:3.6.3 pool/main/o/openssl/openssl_1:3.6.3_aarch64.deb 86760e9ce736f463236f2c15b1eb3a3fdcfc5778d0fd7077a917448dcc90f3aa
+pcre2 10.47 pool/main/p/pcre2/pcre2_10.47_aarch64.deb 51f915d22de639bfca6ec029ae613987bbe3bc73626eede13319fd2e95f50b63
+portaudio 19.07.00-3 pool/main/p/portaudio/portaudio_19.07.00-3_aarch64.deb 4ce2ff4e859b1eb5dc40947815204d117703b50d28dceafcd51592f7a6a129cc
+qhull 8.1-alpha3-2 pool/main/q/qhull/qhull_8.1-alpha3-2_aarch64.deb 921809888dd8cad61f3024b7ea806e4378546b05bc33574def915071bb418f9c
+qrupdate-ng 2:1.1.5 pool/main/q/qrupdate-ng/qrupdate-ng_2:1.1.5_aarch64.deb 6ee18fdf9d0c98fc676f06baa33a64fcd0d189b4d1aaaea8bc58070bfd6570d5
+readline 8.3.3 pool/main/r/readline/readline_8.3.3_aarch64.deb e50fb67f40753247dbb83efb17c7fbee0ac868ffcb5b5555d44d76ec8d90b4b1
+suitesparse 2:7.13.0 pool/main/s/suitesparse/suitesparse_2:7.13.0_aarch64.deb 73ab0b00f57321ed4340a38eeb41d0031012348b205d1017f9b2102c49cd8e41
+sundials 2:7.8.0 pool/main/s/sundials/sundials_2:7.8.0_aarch64.deb b8602d029b1458a88b99134071161f5e9e050f0d50c4fd0d2e75ac577cc703cb
+zlib 1.3.2 pool/main/z/zlib/zlib_1.3.2_aarch64.deb 75e7d0af17fcc3b40004309fdc00a1ddb9ae08346dce5e269902c34ac3966ac9
+zstd 1.5.7-1 pool/main/z/zstd/zstd_1.5.7-1_aarch64.deb e1b4a5113648da8de189620ba1fce74c48b2d0833d9043391b9a1c91fb606fd3
diff --git a/native/package-engine.sh b/native/package-engine.sh
index 8a454ab..ce48dd7 100755
--- a/native/package-engine.sh
+++ b/native/package-engine.sh
@@ -2,26 +2,45 @@
set -euo pipefail
if [ $# -ne 1 ]; then
- echo "用法: $0 "
+ echo "用法: $0 "
exit 1
fi
ABI="$1"
+if [ "$ABI" != "arm64-v8a" ]; then
+ echo "Maxima 运行时仅支持 arm64-v8a:$ABI"
+ exit 1
+fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERIFY_PAGE_SIZE="$ROOT/native/verify-elf-page-size.py"
+VERIFY_RUNTIME="$ROOT/native/verify-engine-runtime.py"
DIST="$ROOT/.build/dist"
-TARGET="$ROOT/app/src/main/assets/engine"
+ASSET_TARGET="$ROOT/app/src/main/assets/engine"
JNI_TARGET="$ROOT/app/src/main/jniLibs/$ABI"
MAXIMA_VERSION="${MAXIMA_VERSION:-5.49.0}"
+PACKAGE_ROOT="$ROOT/.build/work/package-engine-$ABI"
+TARGET="$PACKAGE_ROOT/runtime"
+ASSET_CANDIDATE="$PACKAGE_ROOT/assets"
MAXIMA_DIR="$DIST/maxima-android/$ABI"
ECL_ANDROID="$DIST/ecl-android/$ABI"
+COMPILED_VERSION_FILE="$MAXIMA_DIR/maxima-compiled-version.txt"
+[ -f "$VERIFY_PAGE_SIZE" ] || { echo "缺少 ELF 页面对齐验证器($VERIFY_PAGE_SIZE)"; exit 1; }
+[ -f "$VERIFY_RUNTIME" ] || { echo "缺少 Maxima 运行时验证器($VERIFY_RUNTIME)"; exit 1; }
+for tool in patchelf python3; do
+ command -v "$tool" >/dev/null 2>&1 || { echo "缺少必需工具:$tool"; exit 1; }
+done
[ -d "$MAXIMA_DIR" ] || { echo "缺少 Maxima 产物($ABI)"; exit 1; }
[ -d "$ECL_ANDROID" ] || { echo "缺少 ECL 产物($ABI)"; exit 1; }
+[ -f "$COMPILED_VERSION_FILE" ] || { echo "缺少 Maxima 编译版本证明,请重新运行 build-maxima-android.sh"; exit 1; }
+ECL_DATA_DIR="$(find "$ECL_ANDROID/lib" -maxdepth 1 -mindepth 1 -type d -name 'ecl-*' | sort | tail -n 1)"
+[ -n "$ECL_DATA_DIR" ] || { echo "缺少 ECL 数据目录"; exit 1; }
+ECL_VERSION="${ECL_DATA_DIR##*/ecl-}"
# 先清空再打包:脚本只做增量 cp,历史遗留文件(旧版 libecl.so、
# package-moa-engine.sh 解压的 additions/qepcad)会一直留在工作区并被
# 打进 APK。必须整目录重建,assets 才等于本次产物。
-rm -rf "$TARGET"
+rm -rf "$PACKAGE_ROOT"
mkdir -p "$TARGET/bin" "$TARGET/lib" "$TARGET/share"
# 可执行文件只进 jniLibs(Android 10+ 禁止从 filesDir execve),assets 里
# 不再放 maxima 二进制,避免误导后续维护者。
@@ -32,6 +51,7 @@ rm -rf "$TARGET/lib/maxima/$MAXIMA_VERSION/binary-ecl"
# 标准 autoconf 布局:Maxima 的 file_search 指向
# /share/maxima//share/**(缺少这一层会导致共享包
# 找不到文件);lisp-utils 是部分 share 包在 ECL 下的运行时依赖。
+mkdir -p "$TARGET/share/maxima/$MAXIMA_VERSION/share"
cp -a "$MAXIMA_DIR/share/maxima/$MAXIMA_VERSION/." \
"$TARGET/share/maxima/$MAXIMA_VERSION/share/"
# 绘图已改 Matplotlib 渲染,不再打包 draw 共享包,避免 ECL 在设备端
@@ -69,6 +89,14 @@ EOF
mkdir -p "$JNI_TARGET"
cp -a "$MAXIMA_DIR/bin/maxima" "$JNI_TARGET/libmaxima.so"
cp -a "$ECL_ANDROID/lib/libecl.so" "$JNI_TARGET/libecl.so"
+# Cross-build paths must never leak into the APK. Supplying page-size here also
+# keeps any segment which patchelf needs to add compatible with 16 KiB devices.
+for elf in "$JNI_TARGET/libmaxima.so" "$JNI_TARGET/libecl.so"; do
+ patchelf --page-size 16384 --remove-rpath "$elf"
+done
+# Refuse 4 KiB-only ECL/Maxima outputs; they cannot be repaired after linking.
+python3 "$VERIFY_PAGE_SIZE" --page-size 16384 \
+ "$JNI_TARGET/libmaxima.so" "$JNI_TARGET/libecl.so"
# 其余 ECL 运行文件(module .fas、encodings、licenses)仍是数据,留在 assets。
find "$ECL_ANDROID/lib" -maxdepth 1 -mindepth 1 -not -name 'libecl.so' -exec cp -a {} "$TARGET/lib/" \;
@@ -85,5 +113,29 @@ find "$TARGET/share" \
-o -name '*.dem' -o -name '*.usg' -o -name '*.tex' -o -name '*.TEX' \) \
-delete
-echo "引擎已打包到 $TARGET(ABI: $ABI)"
-du -sh "$TARGET"
+python3 "$VERIFY_RUNTIME" \
+ --runtime "$TARGET" \
+ --jni "$JNI_TARGET" \
+ --assets "$ASSET_CANDIDATE" \
+ --maxima-version "$MAXIMA_VERSION" \
+ --compiled-version-file "$COMPILED_VERSION_FILE" \
+ --ecl-version "$ECL_VERSION" \
+ --abi "$ABI" \
+ --write
+
+# Candidate is fully built and verified before replacing the previous packaged assets.
+ASSET_BACKUP="$PACKAGE_ROOT/previous-assets"
+if [ -e "$ASSET_TARGET" ]; then
+ mv "$ASSET_TARGET" "$ASSET_BACKUP"
+fi
+if ! mv "$ASSET_CANDIDATE" "$ASSET_TARGET"; then
+ if [ -e "$ASSET_BACKUP" ]; then
+ mv "$ASSET_BACKUP" "$ASSET_TARGET" || true
+ fi
+ echo "无法启用新的 Maxima assets,已尝试恢复旧载荷"
+ exit 1
+fi
+rm -rf "$ASSET_BACKUP"
+
+echo "引擎已打包到 $ASSET_TARGET(ABI: $ABI)"
+du -sh "$ASSET_TARGET"
diff --git a/native/package-octave-engine.sh b/native/package-octave-engine.sh
new file mode 100644
index 0000000..4fd1e06
--- /dev/null
+++ b/native/package-octave-engine.sh
@@ -0,0 +1,445 @@
+#!/usr/bin/env bash
+#
+# Assemble the locked arm64 Android Octave runtime:
+# - jniLibs/arm64-v8a: CLI entry plus the actual DT_NEEDED closure
+# - assets/octave: Octave 11.3.0 data, arm64 .oct files and MaxMath bridge
+# - assets/octave/runtime-manifest.json: deterministic installer contract
+
+set -euo pipefail
+
+ABI="${1:-arm64-v8a}"
+if [[ $# -gt 1 || "$ABI" != "arm64-v8a" ]]; then
+ echo "usage: $0 [arm64-v8a] (Octave runtime is arm64-only)" >&2
+ exit 2
+fi
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+LOCK="$ROOT/native/octave-termux.lock"
+OVERRIDE_LOCK="$ROOT/native/octave-16k-overrides.lock"
+VERIFY="$ROOT/native/verify-octave-runtime.py"
+VERIFY_PAGE_SIZE="$ROOT/native/verify-elf-page-size.py"
+STAGE_ROOT="$ROOT/.build/octave-stage/arm64-v8a"
+STAGE="$STAGE_ROOT/usr"
+LOCK_FILE="$ROOT/.build/octave-runtime.lock"
+TARGET_JNI="$ROOT/app/src/main/jniLibs/arm64-v8a"
+TARGET_ASSETS="$ROOT/app/src/main/assets/octave"
+BRIDGE_SOURCE="$ROOT/native/octave-m"
+OVERRIDE_DIR="$ROOT/.build/dist/octave-16k-overrides/arm64-v8a"
+PACKAGE_ROOT=""
+JNI=""
+ASSETS=""
+KEEP_DIR=""
+OLD_OWNED=""
+TERMUX_NAMES=""
+OLD_MANIFEST_PRESENT=0
+SWAP_STARTED=0
+COMMITTED=0
+JNI_HAD_ORIGINAL=0
+ASSETS_HAD_ORIGINAL=0
+
+path_exists() {
+ [[ -e "$1" || -L "$1" ]]
+}
+
+restore_target() {
+ local label="$1" had_original="$2" candidate="$3" backup="$4" target="$5"
+
+ if [[ "$had_original" == 1 ]]; then
+ if path_exists "$backup"; then
+ if path_exists "$target" && ! rm -rf -- "$target"; then
+ echo "rollback could not remove new $label payload: $target" >&2
+ return 1
+ fi
+ if ! mv -- "$backup" "$target"; then
+ echo "rollback could not restore previous $label payload: $backup" >&2
+ return 1
+ fi
+ return 0
+ fi
+
+ # Before the backup rename completes, both the original target and the
+ # candidate still exist. Any other shape means recovery state was lost.
+ if path_exists "$target" && path_exists "$candidate"; then
+ return 0
+ fi
+ echo "rollback found inconsistent $label payload state" >&2
+ return 1
+ fi
+
+ # There was no original payload, so a target can only be a newly installed
+ # generation from this transaction.
+ if path_exists "$target" && ! rm -rf -- "$target"; then
+ echo "rollback could not remove new $label payload: $target" >&2
+ return 1
+ fi
+ return 0
+}
+
+cleanup() {
+ local status=$? rollback_ok=1
+ trap - EXIT
+ trap '' HUP INT TERM
+ set +e
+ if [[ "$SWAP_STARTED" == 1 && "$COMMITTED" == 0 ]]; then
+ restore_target \
+ "JNI" "$JNI_HAD_ORIGINAL" "$JNI" "$PACKAGE_ROOT/previous-jni" "$TARGET_JNI" || rollback_ok=0
+ restore_target \
+ "assets" "$ASSETS_HAD_ORIGINAL" "$ASSETS" "$PACKAGE_ROOT/previous-assets" "$TARGET_ASSETS" || rollback_ok=0
+ fi
+ if [[ -n "$PACKAGE_ROOT" && -d "$PACKAGE_ROOT" ]]; then
+ if [[ "$rollback_ok" == 1 ]]; then
+ rm -rf "$PACKAGE_ROOT"
+ else
+ echo "rollback incomplete; preserved recovery payload at $PACKAGE_ROOT" >&2
+ fi
+ fi
+ if [[ "$rollback_ok" != 1 ]]; then status=1; fi
+ exit "$status"
+}
+trap cleanup EXIT
+trap 'exit 129' HUP
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+for tool in flock patchelf python3 readelf sha256sum; do
+ command -v "$tool" >/dev/null 2>&1 || { echo "missing required tool: $tool" >&2; exit 1; }
+done
+mkdir -p "$ROOT/.build"
+exec 9>"$LOCK_FILE"
+flock -n 9 || { echo "another Octave download/package operation is active" >&2; exit 1; }
+[[ -f "$LOCK" && -f "$OVERRIDE_LOCK" && -f "$VERIFY" && -f "$VERIFY_PAGE_SIZE" ]] || {
+ echo "Octave lock/verifier/page-size verifier is missing" >&2
+ exit 1
+}
+[[ -d "$STAGE" ]] || { echo "missing stage: run native/download-octave-termux.sh first" >&2; exit 1; }
+
+lock_value() {
+ local key="$1"
+ sed -n "s/^${key}=//p" "$LOCK"
+}
+
+VER="$(lock_value octaveVersion)"
+[[ "$VER" == "11.3.0" ]] || { echo "Octave must remain pinned to 11.3.0" >&2; exit 1; }
+CLI="$STAGE/bin/octave-cli-$VER"
+PLUGIN_SOURCE="$STAGE/lib/octave/$VER/oct/aarch64-unknown-linux-android"
+[[ -x "$CLI" ]] || { echo "missing locked CLI entry: $CLI" >&2; exit 1; }
+[[ -d "$STAGE/share/octave/$VER" ]] || { echo "missing Octave $VER share tree" >&2; exit 1; }
+[[ -d "$PLUGIN_SOURCE" ]] || { echo "missing arm64 .oct directory: $PLUGIN_SOURCE" >&2; exit 1; }
+find "$PLUGIN_SOURCE" -maxdepth 1 -type f -name '*.oct' -print -quit | grep -q . || {
+ echo "arm64 .oct root contains no modules" >&2
+ exit 1
+}
+
+expected_lock_sha="$(sha256sum "$LOCK" | awk '{print $1}')"
+actual_lock_sha="$(cat "$STAGE_ROOT/octave-termux.lock.sha256" 2>/dev/null || true)"
+[[ "$actual_lock_sha" == "$expected_lock_sha" ]] || {
+ echo "stage was not produced from the current octave-termux.lock; download again" >&2
+ exit 1
+}
+
+PACKAGE_ROOT="$(mktemp -d "$STAGE_ROOT/package-output.XXXXXX")"
+JNI="$PACKAGE_ROOT/jni"
+ASSETS="$PACKAGE_ROOT/assets"
+KEEP_DIR="$PACKAGE_ROOT/preserve-non-octave-jni"
+OLD_OWNED="$PACKAGE_ROOT/old-octave-owned-jni.txt"
+TERMUX_NAMES="$PACKAGE_ROOT/termux-library-names.txt"
+
+# Preserve the other app runtimes before rebuilding jniLibs. A previous Octave
+# manifest is authoritative when present; the stage-name fallback handles the
+# first migration from runtimes produced before manifests existed.
+rm -rf "$KEEP_DIR"
+mkdir -p "$KEEP_DIR"
+if [[ -d "$TARGET_JNI" ]]; then
+ cp -a "$TARGET_JNI/." "$KEEP_DIR/"
+fi
+: > "$OLD_OWNED"
+if [[ -f "$TARGET_ASSETS/runtime-manifest.json" ]]; then
+ OLD_MANIFEST_PRESENT=1
+ python3 - "$TARGET_ASSETS/runtime-manifest.json" "$OLD_OWNED" <<'PYEOF'
+import json
+import sys
+from pathlib import Path
+
+manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
+names = []
+for item in manifest.get("jniFiles", []):
+ path = item.get("path")
+ if isinstance(path, str) and "/" not in path and "\\" not in path:
+ names.append(path)
+Path(sys.argv[2]).write_text("".join(name + "\n" for name in sorted(set(names))), encoding="utf-8")
+PYEOF
+fi
+
+python3 - "$STAGE" "$VER" "$TERMUX_NAMES" <<'PYEOF'
+import os
+import re
+import sys
+from pathlib import Path
+
+stage, version, output = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
+roots = (stage / "lib", stage / "lib" / "octave" / version)
+names = set()
+for root in roots:
+ if not root.is_dir():
+ continue
+ for directory, dirnames, filenames in os.walk(root):
+ dirnames.sort()
+ for filename in sorted(filenames):
+ path = Path(directory) / filename
+ try:
+ if path.read_bytes()[:4] != b"\x7fELF":
+ continue
+ except OSError:
+ continue
+ names.add(filename)
+ match = re.match(r"^(.*\.so)\.\d+(?:\.\d+)*$", filename)
+ if match:
+ names.add(match.group(1))
+output.write_text("".join(name + "\n" for name in sorted(names)), encoding="utf-8")
+PYEOF
+
+# Octave assets are always rebuilt, so stale x86_64 modules and old versions can
+# never survive an arm64 package operation.
+mkdir -p "$JNI" "$ASSETS/usr/share/octave" \
+ "$ASSETS/usr/lib/octave/$VER/oct/aarch64-unknown-linux-android" \
+ "$ASSETS/maxmath/private"
+
+cp "$CLI" "$JNI/liboctavebin.so"
+cp -a "$STAGE/share/octave/$VER/." "$ASSETS/usr/share/octave/$VER/"
+rm -rf "$ASSETS/usr/share/octave/$VER/doc" "$ASSETS/usr/share/octave/$VER/etc/tests"
+cp -a "$PLUGIN_SOURCE/." "$ASSETS/usr/lib/octave/$VER/oct/aarch64-unknown-linux-android/"
+cp "$BRIDGE_SOURCE/"*.m "$ASSETS/maxmath/"
+cp "$BRIDGE_SOURCE/private/"*.m "$ASSETS/maxmath/private/"
+
+# Traverse only the CLI and packaged .oct roots. This is the runtime closure,
+# unlike the old "all ELF files in stage" union which copied unrelated packages.
+python3 - "$STAGE" "$JNI" "$CLI" "$PLUGIN_SOURCE" "$VER" <<'PYEOF'
+import os
+import re
+import shutil
+import subprocess
+import sys
+from collections import deque
+from pathlib import Path
+
+stage, jni, cli, plugin_root = map(Path, sys.argv[1:5])
+version = sys.argv[5]
+system = {
+ "libandroid.so", "libc.so", "libdl.so", "libEGL.so", "libGLESv1_CM.so",
+ "libGLESv2.so", "libjnigraphics.so", "liblog.so", "libm.so",
+ "libOpenSLES.so", "libstdc++.so",
+}
+
+def is_elf(path: Path) -> bool:
+ try:
+ return path.open("rb").read(4) == b"\x7fELF"
+ except OSError:
+ return False
+
+def needed(path: Path) -> list[str]:
+ result = subprocess.run(["readelf", "-d", path], capture_output=True, text=True)
+ if result.returncode != 0:
+ raise SystemExit(f"readelf failed for {path}: {result.stderr.strip()}")
+ return re.findall(r"\(NEEDED\)\s+Shared library: \[([^]]+)]", result.stdout)
+
+library_roots = [stage / "lib", stage / "lib" / "octave" / version]
+by_name: dict[str, Path] = {}
+for root in library_roots:
+ if not root.is_dir():
+ continue
+ for directory, dirnames, filenames in os.walk(root):
+ dirnames.sort()
+ for filename in sorted(filenames):
+ path = Path(directory) / filename
+ if is_elf(path):
+ by_name.setdefault(filename, path)
+
+roots = [cli] + sorted(path for path in plugin_root.glob("*.oct") if is_elf(path))
+queue = deque(roots)
+visited: set[Path] = set()
+copied: dict[str, Path] = {}
+while queue:
+ elf = queue.popleft()
+ resolved = elf.resolve()
+ if resolved in visited:
+ continue
+ visited.add(resolved)
+ for name in needed(elf):
+ if name in system or name in copied:
+ continue
+ source = by_name.get(name)
+ if source is None:
+ raise SystemExit(f"missing DT_NEEDED {name} required by {elf}")
+ destination = jni / name
+ shutil.copyfile(source.resolve(), destination)
+ copied[name] = destination
+ queue.append(source)
+
+required = {"liboctave.so", "liboctinterp.so", "liboctmex.so", "libc++_shared.so"}
+missing = sorted(required - copied.keys())
+if missing:
+ raise SystemExit("entry/.oct closure misses required libraries: " + ", ".join(missing))
+print(f"==> Copied entry/.oct DT_NEEDED closure: {len(copied) + 1} JNI ELF")
+PYEOF
+
+# The official Termux packages below currently contain 4 KiB-only PT_LOAD
+# segments. Replace only those locked files with reproducibly rebuilt variants.
+expected_override_sha="$(sha256sum "$OVERRIDE_LOCK" | awk '{print $1}')"
+actual_override_sha="$(cat "$OVERRIDE_DIR/octave-16k-overrides.lock.sha256" 2>/dev/null || true)"
+[[ "$actual_override_sha" == "$expected_override_sha" ]] || {
+ echo "16 KiB overrides are missing/stale; run native/build-octave-16k-overrides.sh" >&2
+ exit 1
+}
+for name in \
+ libandroid-complex-math.so \
+ libsharpyuv.so \
+ libwebp.so \
+ libwebpdemux.so \
+ libwebpmux.so; do
+ [[ -f "$OVERRIDE_DIR/$name" ]] || {
+ echo "missing expected 16 KiB override: $name" >&2
+ exit 1
+ }
+ # libwebpdemux can be loaded dynamically by another embedded runtime and is
+ # therefore intentionally retained even when no static Octave edge reaches it.
+ cp -a "$OVERRIDE_DIR/$name" "$JNI/$name"
+done
+
+# AGP only packages names ending in .so. Materialize libX.so.N as libX.so,
+# rewrite NEEDED/SONAME, remove the versioned file, and remove Termux RPATHs.
+PATCHELF=patchelf python3 - "$JNI" "$ASSETS" <<'PYEOF'
+import os
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+jni, assets = map(Path, sys.argv[1:])
+patchelf = os.environ["PATCHELF"]
+
+def elf_files(root: Path):
+ for directory, dirnames, filenames in os.walk(root):
+ dirnames.sort()
+ for filename in sorted(filenames):
+ path = Path(directory) / filename
+ try:
+ if path.open("rb").read(4) == b"\x7fELF":
+ yield path
+ except OSError:
+ pass
+
+versioned: dict[str, Path] = {}
+for path in elf_files(jni):
+ match = re.match(r"^(.*\.so)\.\d+(?:\.\d+)*$", path.name)
+ if match:
+ plain = match.group(1)
+ if plain in versioned and versioned[plain].read_bytes() != path.read_bytes():
+ raise SystemExit(f"multiple incompatible versions normalize to {plain}")
+ versioned[plain] = path
+
+renames = {path.name: plain for plain, path in versioned.items()}
+for plain, source in versioned.items():
+ destination = jni / plain
+ if destination.exists() and destination.read_bytes() != source.read_bytes():
+ raise SystemExit(f"normalization would overwrite a different {plain}")
+ if not destination.exists():
+ shutil.copyfile(source, destination)
+for source in versioned.values():
+ source.unlink()
+
+changed = 0
+for path in list(elf_files(jni)) + list(elf_files(assets)):
+ dirty = False
+ for old, new in renames.items():
+ result = subprocess.run(
+ [patchelf, "--page-size", "16384", "--replace-needed", old, new, path],
+ capture_output=True,
+ )
+ if result.returncode == 0:
+ dirty = True
+ elif b"cannot find" not in result.stderr.lower() and b"not found" not in result.stderr.lower():
+ raise SystemExit(f"patchelf --replace-needed failed for {path}: {result.stderr.decode()}")
+ soname = subprocess.run(
+ [patchelf, "--print-soname", path], capture_output=True, text=True, check=True
+ ).stdout.strip()
+ if soname in renames:
+ subprocess.run(
+ [patchelf, "--page-size", "16384", "--set-soname", renames[soname], path],
+ check=True,
+ )
+ dirty = True
+ dynamic = subprocess.run(["readelf", "-d", path], capture_output=True, check=True).stdout
+ if b"RPATH" in dynamic or b"RUNPATH" in dynamic:
+ subprocess.run(
+ [patchelf, "--page-size", "16384", "--remove-rpath", path], check=True
+ )
+ dirty = True
+ changed += int(dirty)
+print(f"==> Normalized {len(versioned)} versioned libraries; patched {changed} ELF files")
+PYEOF
+
+# Restore only non-Termux files and never overwrite the newly packaged Octave
+# closure. In particular, the locked Termux libc++ always wins over NDK libc++.
+python3 - "$KEEP_DIR" "$JNI" "$OLD_OWNED" "$TERMUX_NAMES" "$OLD_MANIFEST_PRESENT" <<'PYEOF'
+import shutil
+import sys
+from pathlib import Path
+
+keep, destination, old_owned_path, termux_names_path = map(Path, sys.argv[1:5])
+old_manifest_present = sys.argv[5] == "1"
+old_owned = set(old_owned_path.read_text(encoding="utf-8").splitlines())
+termux_names = set(termux_names_path.read_text(encoding="utf-8").splitlines())
+# Once a manifest exists, its ownership list is authoritative. The broad
+# Termux-name fallback is only for the one-time migration from a pre-manifest
+# payload; otherwise it would drop non-Octave JNI libraries (for example GMP)
+# merely because an Octave dependency package happens to contain the same name.
+use_termux_name_fallback = not old_manifest_present
+restored = []
+if keep.is_dir():
+ for source in sorted(path for path in keep.iterdir() if path.is_file()):
+ name = source.name
+ target = destination / name
+ if (
+ name == "libc++_shared.so"
+ or name in old_owned
+ or (use_termux_name_fallback and name in termux_names)
+ or target.exists()
+ ):
+ continue
+ shutil.copy2(source, target)
+ restored.append(name)
+print(f"==> Restored {len(restored)} non-Octave JNI files without overwrites")
+PYEOF
+
+# Android 15+ can use 16 KiB pages. This is a strict check: binary rewriting
+# cannot safely repair pre-linked LOAD segments, so undersized dependencies must
+# be rebuilt with the linker flags used by build-octave-16k-overrides.sh.
+python3 "$VERIFY_PAGE_SIZE" --page-size 16384 "$JNI" "$ASSETS"
+
+python3 "$VERIFY" \
+ --root "$ROOT" \
+ --lock-file "$LOCK" \
+ --assets-dir "$ASSETS" \
+ --jni-dir "$JNI" \
+ --write-manifest
+
+# Only verified output reaches the workspace. Both previous directories remain in
+# PACKAGE_ROOT until the two-directory switch has completed, and the EXIT trap
+# restores them if either rename fails.
+mkdir -p "$(dirname "$TARGET_JNI")" "$(dirname "$TARGET_ASSETS")"
+if path_exists "$TARGET_JNI"; then JNI_HAD_ORIGINAL=1; fi
+if path_exists "$TARGET_ASSETS"; then ASSETS_HAD_ORIGINAL=1; fi
+SWAP_STARTED=1
+if [[ "$JNI_HAD_ORIGINAL" == 1 ]]; then
+ mv "$TARGET_JNI" "$PACKAGE_ROOT/previous-jni"
+fi
+mv "$JNI" "$TARGET_JNI"
+if [[ "$ASSETS_HAD_ORIGINAL" == 1 ]]; then
+ mv "$TARGET_ASSETS" "$PACKAGE_ROOT/previous-assets"
+fi
+mv "$ASSETS" "$TARGET_ASSETS"
+COMMITTED=1
+
+echo "==> Packaged locked GNU Octave $VER (arm64-v8a only)"
+du -sh "$TARGET_JNI" "$TARGET_ASSETS"
diff --git a/native/test-octave.sh b/native/test-octave.sh
new file mode 100644
index 0000000..4d75c43
--- /dev/null
+++ b/native/test-octave.sh
@@ -0,0 +1,431 @@
+#!/usr/bin/env bash
+#
+# 宿主机验证 Octave 桥接层(不需要 Android):
+# - 用宿主 Octave 启动常驻会话;
+# - 验证哨兵协议在错误后仍可继续,并对读取实施真正的非阻塞超时;
+# - 值级校验 workspace、typed preview、plot/surf/contour 与 reset;
+# - 校验 plot_spec.json 的 requestId 与原子临时文件清理。
+#
+# 用法:
+# OCTAVE_BIN=/path/to/octave-cli ./native/test-octave.sh
+# 未设置时依次尝试 octave / octave-cli。
+#
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BRIDGE_ROOT="$(mktemp -d)"
+BRIDGE="$BRIDGE_ROOT/maxmath"
+WORK="$(mktemp -d)"
+SPEC="$WORK/plot_spec.json"
+cleanup() {
+ local status=$?
+ trap - EXIT HUP INT TERM
+ if [[ -n "${PY_PID:-}" ]] && kill -0 "$PY_PID" 2>/dev/null; then
+ kill -TERM "$PY_PID" 2>/dev/null || true
+ wait "$PY_PID" 2>/dev/null || true
+ fi
+ rm -rf "$BRIDGE_ROOT" "$WORK"
+ exit "$status"
+}
+trap cleanup EXIT
+trap 'exit 129' HUP
+trap 'exit 130' INT
+trap 'exit 143' TERM
+mkdir -p "$BRIDGE/private"
+cp "$ROOT"/native/octave-m/*.m "$BRIDGE/"
+cp "$ROOT"/native/octave-m/private/*.m "$BRIDGE/private/"
+
+OCTAVE_BIN="${OCTAVE_BIN:-}"
+if [[ -z "$OCTAVE_BIN" ]]; then
+ for cand in octave octave-cli; do
+ if command -v "$cand" >/dev/null 2>&1; then
+ OCTAVE_BIN="$(command -v "$cand")"
+ break
+ fi
+ done
+fi
+[[ -n "$OCTAVE_BIN" ]] || { echo "找不到宿主 Octave,请设置 OCTAVE_BIN" >&2; exit 2; }
+
+echo "==> 使用 $OCTAVE_BIN"
+python3 - "$OCTAVE_BIN" "$BRIDGE" "$WORK" "$SPEC" <<'PYEOF' &
+import glob
+import json
+import os
+import queue
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+octave, bridge, work, spec = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
+env = os.environ.copy()
+
+
+def octave_quote(value):
+ return "'" + value.replace("'", "''") + "'"
+
+
+p = subprocess.Popen(
+ [
+ octave,
+ "--no-gui",
+ "--quiet",
+ "--no-init-file",
+ "--no-window-system",
+ "--persist",
+ "--eval",
+ (
+ f"addpath({octave_quote(bridge)});"
+ f"addpath({octave_quote(os.path.join(bridge, 'private'))});"
+ f"maxmath_init({octave_quote(bridge)},{octave_quote(work)})"
+ ),
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ env=env,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ bufsize=1,
+)
+
+events = queue.Queue()
+
+
+def read_output():
+ try:
+ for raw in p.stdout:
+ events.put(("line", raw.rstrip("\r\n")))
+ except BaseException as exc:
+ events.put(("reader_error", repr(exc)))
+ finally:
+ events.put(("eof", p.poll()))
+
+
+reader = threading.Thread(target=read_output, name="octave-smoke-reader", daemon=True)
+reader.start()
+
+
+def stop_process():
+ if p.poll() is None:
+ p.terminate()
+ try:
+ p.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ p.kill()
+ p.wait(timeout=3)
+ reader.join(timeout=1)
+
+
+def terminate_on_signal(signum, _frame):
+ raise SystemExit(128 + signum)
+
+
+signal.signal(signal.SIGTERM, terminate_on_signal)
+
+
+request_seq = 0
+requests_dir = os.path.join(work, "requests")
+os.makedirs(requests_dir, exist_ok=True)
+
+
+def run(command, timeout=60):
+ global request_seq
+ request_seq += 1
+ request_id = f"REQ_{request_seq}_{time.time_ns()}"
+ command = command.replace("__MAXMATH_REQUEST_ID__", request_id)
+ sentinel = f"SENT_{request_id}"
+ request_file = os.path.join(requests_dir, request_id + ".m")
+ with open(request_file, "w", encoding="utf-8", newline="\n") as handle:
+ handle.write(command)
+ if not command.endswith("\n"):
+ handle.write("\n")
+ wrapped = (
+ f"maxmath_execute_file({octave_quote(request_file)},{octave_quote(request_id)});"
+ )
+ wire = (
+ f"{wrapped}\n"
+ f"maxmath_flush({octave_quote(spec)},{octave_quote(request_id)});\n"
+ f"__maxmath_print_count = printf('\\n{sentinel}\\n');\n"
+ "__maxmath_flush_status = fflush(stdout);\n"
+ "clear __maxmath_print_count __maxmath_flush_status\n"
+ )
+ if p.poll() is not None:
+ raise RuntimeError(f"Octave 已退出,exit={p.returncode}")
+ try:
+ p.stdin.write(wire)
+ p.stdin.flush()
+ except (BrokenPipeError, OSError) as exc:
+ raise RuntimeError(f"写入 Octave 失败,exit={p.poll()}: {exc}") from exc
+
+ output = []
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(f"哨兵超时;输出尾部: {output[-8:]}")
+ try:
+ kind, value = events.get(timeout=min(remaining, 0.25))
+ except queue.Empty:
+ if p.poll() is not None:
+ raise RuntimeError(
+ f"Octave 在哨兵前退出,exit={p.returncode};输出尾部: {output[-8:]}"
+ )
+ continue
+ if kind == "line":
+ if value == sentinel:
+ return output, request_id
+ output.append(value)
+ elif kind == "reader_error":
+ raise RuntimeError(f"读取 Octave 输出失败: {value}")
+ elif kind == "eof":
+ raise RuntimeError(
+ f"Octave 在哨兵前 EOF,exit={value};输出尾部: {output[-8:]}"
+ )
+
+
+def marker_json(lines, marker):
+ matches = [line[len(marker):] for line in lines if line.startswith(marker)]
+ if len(matches) != 1:
+ raise AssertionError(f"期望一个 {marker!r} 标记,实际 {matches!r};输出={lines!r}")
+ return json.loads(matches[0])
+
+
+def plot_spec(request_id):
+ with open(spec, encoding="utf-8") as handle:
+ value = json.load(handle)
+ assert value["requestId"] == request_id, value
+ assert value["version"] == 1, value
+ return value
+
+
+try:
+ boot, _ = run("1;")
+ assert not any(line.startswith("MAXMATH_ERROR ") for line in boot), boot
+
+ # 工作区与空 struct JSON。
+ run("A=[1 2;3 4]; B=inv(A)")
+ whos, whos_id = run("maxmath_whos('__MAXMATH_REQUEST_ID__')")
+ whos_envelope = marker_json(whos, "MAXMATH_WHOS ")
+ assert whos_envelope["requestId"] == whos_id, whos_envelope
+ assert whos_envelope["version"] == 1, whos_envelope
+ whos_value = whos_envelope["variables"]
+ assert {item["name"] for item in whos_value} >= {"A", "B"}, whos_value
+ run("clear")
+ run("single_value=1")
+ single_whos, _ = run("maxmath_whos('__MAXMATH_REQUEST_ID__')")
+ single_value = marker_json(single_whos, "MAXMATH_WHOS ")["variables"]
+ assert [item["name"] for item in single_value] == ["single_value"], single_value
+ run("clear")
+ empty_whos, _ = run("maxmath_whos('__MAXMATH_REQUEST_ID__')")
+ assert marker_json(empty_whos, "MAXMATH_WHOS ")["variables"] == [], empty_whos
+
+ # Typed preview v1 is the sole preview frame; duplicate legacy payloads are forbidden.
+ run("scalar_value=42; matrix_value=[1 2;3 4]; complex_value=1+2i")
+ run(
+ "special_value=[NaN Inf -Inf]; text_value='hello'; "
+ "multiline_text=sprintf('first\\nsecond'); logical_value=logical([1 0])"
+ )
+
+ scalar, scalar_id = run("maxmath_preview('scalar_value','__MAXMATH_REQUEST_ID__')")
+ scalar_value = marker_json(scalar, "MAXMATH_PREVIEW ")
+ assert scalar_value == {
+ "version": 1,
+ "requestId": scalar_id,
+ "kind": "scalar",
+ "class": "double",
+ "shape": [1, 1],
+ "complex": False,
+ "value": 42,
+ "truncated": False,
+ }, scalar_value
+
+ matrix, matrix_id = run("maxmath_preview('matrix_value','__MAXMATH_REQUEST_ID__')")
+ matrix_value = marker_json(matrix, "MAXMATH_PREVIEW ")
+ assert matrix_value["kind"] == "matrix", matrix_value
+ assert matrix_value["requestId"] == matrix_id, matrix_value
+ assert matrix_value["shape"] == [2, 2], matrix_value
+ assert matrix_value["value"] == [[1, 2], [3, 4]], matrix_value
+
+ complex_out, complex_id = run("maxmath_preview('complex_value','__MAXMATH_REQUEST_ID__')")
+ complex_value = marker_json(complex_out, "MAXMATH_PREVIEW ")
+ assert complex_value["kind"] == "scalar", complex_value
+ assert complex_value["requestId"] == complex_id, complex_value
+ assert complex_value["complex"] is True, complex_value
+ assert complex_value["value"] == {"re": 1, "im": 2}, complex_value
+
+ special, special_id = run("maxmath_preview('special_value','__MAXMATH_REQUEST_ID__')")
+ special_value = marker_json(special, "MAXMATH_PREVIEW ")
+ assert special_value["kind"] == "vector", special_value
+ assert special_value["requestId"] == special_id, special_value
+ assert special_value["value"] == ["NaN", "Inf", "-Inf"], special_value
+
+ text, text_id = run("maxmath_preview('text_value','__MAXMATH_REQUEST_ID__')")
+ text_value = marker_json(text, "MAXMATH_PREVIEW ")
+ assert text_value["kind"] == "string", text_value
+ assert text_value["requestId"] == text_id, text_value
+ assert text_value["value"] == "hello", text_value
+
+ multiline, multiline_id = run(
+ "maxmath_preview('multiline_text','__MAXMATH_REQUEST_ID__')"
+ )
+ multiline_value = marker_json(multiline, "MAXMATH_PREVIEW ")
+ assert multiline_value["requestId"] == multiline_id, multiline_value
+ assert multiline_value["value"] == "first\nsecond", multiline_value
+ assert not any(line == "second" for line in multiline), multiline
+
+ logical_out, logical_id = run(
+ "maxmath_preview('logical_value','__MAXMATH_REQUEST_ID__')"
+ )
+ logical_value = marker_json(logical_out, "MAXMATH_PREVIEW ")
+ assert logical_value["requestId"] == logical_id, logical_value
+ assert logical_value["kind"] == "vector", logical_value
+ assert logical_value["value"] == [True, False], logical_value
+
+ oversized, oversized_id = run(
+ "large_preview=zeros(101,100); "
+ "maxmath_preview('large_preview','__MAXMATH_REQUEST_ID__')"
+ )
+ oversized_value = marker_json(oversized, "MAXMATH_PREVIEW ")
+ assert oversized_value["kind"] == "summary", oversized_value
+ assert oversized_value["requestId"] == oversized_id, oversized_value
+ assert oversized_value["truncated"] is True, oversized_value
+ assert oversized_value["shape"] == [101, 100], oversized_value
+ assert "10100 elements" in oversized_value["value"], oversized_value
+ oversized_line = next(line for line in oversized if line.startswith("MAXMATH_PREVIEW "))
+ assert len(oversized_line) < 2048, oversized_line
+
+ # 多序列、LineSpec 与属性/值必须互不吞噬。
+ _, request_id = run(
+ "plot([1 2],[3 4],'r--','LineWidth',2,"
+ "[5 6],[7 8],'bo','DisplayName','two')"
+ )
+ value = plot_spec(request_id)
+ lines = value["axes"][0]["lines"]
+ assert len(lines) == 2, lines
+ assert lines[0] == {"x": [1, 2], "y": [3, 4], "style": "r--"}, lines
+ assert lines[1] == {"x": [5, 6], "y": [7, 8], "style": "bo"}, lines
+
+ # 标准三参数 surf/contour 的 X/Y/Z 映射必须保持原值。
+ xyz = "X=[10 20;10 20]; Y=[1 1;2 2]; Z=[101 102;201 202]"
+ _, request_id = run(f"{xyz}; surf(X,Y,Z)")
+ surface = plot_spec(request_id)["axes"][0]["surfaces"][0]
+ assert surface["x"] == [[10, 20], [10, 20]], surface
+ assert surface["y"] == [[1, 1], [2, 2]], surface
+ assert surface["z"] == [[101, 102], [201, 202]], surface
+
+ # view() must stay inside the bridge instead of falling through to Octave's
+ # handle-graphics gca/axes path, and its camera must reach the plot artifact.
+ view_output, request_id = run(
+ f"{xyz}; figure; surf(X,Y,Z); colormap(jet); colorbar; view(45,30)"
+ )
+ assert not any(line.startswith("MAXMATH_ERROR ") for line in view_output), view_output
+ view_axes = plot_spec(request_id)["axes"][0]
+ assert view_axes["view"] == [45, 30]
+ assert view_axes["colormap"] == "jet"
+ assert view_axes["colorbar"] is True
+
+ # text() annotations have the same handle-graphics failure mode. Preserve
+ # their coordinates and formatted label in the bridge protocol.
+ text_output, request_id = run(
+ "figure; plot([1 2 3],[1 4 9]); "
+ "text(2,4,sprintf(' %.1f Hz',50))"
+ )
+ assert not any(line.startswith("MAXMATH_ERROR ") for line in text_output), text_output
+ assert plot_spec(request_id)["axes"][0]["texts"] == [
+ {"x": 2, "y": 4, "z": [], "text": " 50.0 Hz"}
+ ]
+
+ # User-reported full examples: keep the real call combinations in the
+ # smoke suite so future bridge additions cannot silently re-enter gca().
+ surface_example, request_id = run(
+ "x=linspace(-8,8,50); y=linspace(-8,8,50); "
+ "[X,Y]=meshgrid(x,y); R=sqrt(X.^2+Y.^2); Z=sin(R)./R; "
+ "Z(R==0)=1; figure; surf(X,Y,Z); colormap(jet); colorbar; "
+ "xlabel('X'); ylabel('Y'); zlabel('Z'); "
+ "title('三维曲面图: z = sin(r)/r'); view(45,30)"
+ )
+ assert not any(line.startswith("MAXMATH_ERROR ") for line in surface_example), surface_example
+ surface_axes = plot_spec(request_id)["axes"][0]
+ assert surface_axes["view"] == [45, 30], surface_axes
+ assert surface_axes["colormap"] == "jet", surface_axes
+
+ fft_example, request_id = run(
+ "clear; close all; clc; Fs=1000; T=1; N=Fs*T; t=(0:N-1)/Fs; "
+ "f1=50; f2=120; A1=0.7; A2=1.2; "
+ "signal=A1*sin(2*pi*f1*t)+A2*sin(2*pi*f2*t); "
+ "Y=fft(signal); P2=abs(Y/N); P1=P2(1:N/2+1); "
+ "P1(2:end-1)=2*P1(2:end-1); f=Fs*(0:(N/2))/N; figure; "
+ "subplot(2,1,1); plot(t,signal,'b'); xlabel('时间 (s)'); "
+ "ylabel('幅值'); title('原始时域信号'); grid on; "
+ "subplot(2,1,2); plot(f,P1,'r','LineWidth',1.5); "
+ "xlabel('频率 (Hz)'); ylabel('幅值'); title('单侧幅值频谱'); grid on; "
+ "hold on; [~,idx1]=min(abs(f-f1)); [~,idx2]=min(abs(f-f2)); "
+ "plot(f(idx1),P1(idx1),'ko','MarkerFaceColor','k'); "
+ "plot(f(idx2),P1(idx2),'ko','MarkerFaceColor','k'); "
+ "text(f(idx1)+5,P1(idx1),sprintf(' %.1f Hz',f1)); "
+ "text(f(idx2)+5,P1(idx2),sprintf(' %.1f Hz',f2)); hold off"
+ )
+ assert not any(line.startswith("MAXMATH_ERROR ") for line in fft_example), fft_example
+ fft_spec = plot_spec(request_id)
+ assert fft_spec["layout"] == [2, 1], fft_spec
+ assert len(fft_spec["axes"]) >= 2, fft_spec
+ assert [item["text"] for item in fft_spec["axes"][1]["texts"]] == [
+ " 50.0 Hz", " 120.0 Hz"
+ ], fft_spec
+
+ _, request_id = run(f"{xyz}; figure; contour(X,Y,Z,[120 180])")
+ contour = plot_spec(request_id)["axes"][0]["contours"][0]
+ assert contour["x"] == [[10, 20], [10, 20]], contour
+ assert contour["y"] == [[1, 1], [2, 2]], contour
+ assert contour["z"] == [[101, 102], [201, 202]], contour
+ assert contour["levels"] == [120, 180], contour
+
+ # figure/close/clf/reset 后 axes 必须仍是单层 cell,且可以继续绘图。
+ for reset in ("figure", "close", "clf", "clear; figure"):
+ _, request_id = run(f"{reset}; plot([1 2 3],[3 2 1])")
+ reset_spec = plot_spec(request_id)
+ assert reset_spec["axes"][0]["lines"][0]["y"] == [3, 2, 1], reset_spec
+
+ # Plot publication failures are structured and still allow the outer sentinel to complete.
+ plot_error, plot_error_id = run(
+ "figure; plot([1 2],[2 1]); "
+ f"maxmath_flush({octave_quote(work)},'__MAXMATH_REQUEST_ID__')"
+ )
+ plot_error_value = marker_json(plot_error, "MAXMATH_ERROR ")
+ assert plot_error_value["requestId"] == plot_error_id, plot_error_value
+ assert plot_error_value["identifier"] == "MaxMath:plotWrite", plot_error_value
+
+ # 结构化错误必须带 requestId 和 stack;错误后下一条命令仍能命中哨兵。
+ error_lines, request_id = run("error('boom')")
+ error_value = marker_json(error_lines, "MAXMATH_ERROR ")
+ assert error_value["requestId"] == request_id, error_value
+ assert error_value["kind"] == "error", error_value
+ assert "boom" in error_value["message"], error_value
+ assert isinstance(error_value["stack"], list), error_value
+
+ after_error, _ = run("after_error=4; after_error")
+ assert any("4" in line for line in after_error), after_error
+
+ script_error, _ = run(
+ "known_value = 1;\n"
+ "missing_script_symbol + known_value;"
+ )
+ script_value = marker_json(script_error, "MAXMATH_ERROR ")
+ assert any(
+ os.path.basename(frame.get("file", "")).startswith("REQ_")
+ and frame.get("line") == 2
+ for frame in script_value["stack"]
+ ), script_value
+
+ assert not glob.glob(spec + ".tmp.*"), glob.glob(spec + ".tmp.*")
+ print("==> Octave 桥接宿主验证通过")
+finally:
+ stop_process()
+PYEOF
+PY_PID=$!
+wait "$PY_PID"
+PY_PID=""
diff --git a/native/verify-elf-page-size.py b/native/verify-elf-page-size.py
new file mode 100644
index 0000000..8ba4d69
--- /dev/null
+++ b/native/verify-elf-page-size.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""Verify that ELF PT_LOAD segments support the requested Android page size."""
+
+from __future__ import annotations
+
+import argparse
+import struct
+import sys
+from pathlib import Path
+
+
+DEFAULT_PAGE_SIZE = 16 * 1024
+
+
+class PageSizeError(RuntimeError):
+ pass
+
+
+def load_alignments(path: Path) -> tuple[int, ...] | None:
+ with path.open("rb") as handle:
+ header = handle.read(64)
+ if len(header) < 4 or header[:4] != b"\x7fELF":
+ return None
+ if len(header) < 64 or header[4] != 2 or header[5] != 1:
+ raise PageSizeError(f"{path}: expected ELF64 little-endian")
+ program_offset = struct.unpack_from(" list[Path]:
+ candidates: set[Path] = set()
+ for path in paths:
+ if path.is_dir():
+ candidates.update(item for item in path.rglob("*") if item.is_file())
+ elif path.is_file():
+ candidates.add(path)
+ else:
+ raise PageSizeError(f"input does not exist: {path}")
+ return sorted(candidates)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("paths", nargs="+", type=Path)
+ parser.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE)
+ args = parser.parse_args()
+
+ try:
+ if args.page_size <= 0 or args.page_size & (args.page_size - 1):
+ raise PageSizeError("--page-size must be a positive power of two")
+ scanned = 0
+ errors: list[str] = []
+ for path in candidate_files(args.paths):
+ alignments = load_alignments(path)
+ if alignments is None:
+ continue
+ scanned += 1
+ if any(alignment < args.page_size for alignment in alignments):
+ rendered = ", ".join(f"0x{alignment:x}" for alignment in alignments)
+ errors.append(
+ f"{path}: PT_LOAD alignment [{rendered}] is below 0x{args.page_size:x}"
+ )
+ if errors:
+ raise PageSizeError("\n".join(errors))
+ print(
+ f"ELF page-size verification passed: scanned={scanned}, "
+ f"pageSize={args.page_size}"
+ )
+ return 0
+ except (OSError, struct.error, PageSizeError) as error:
+ print(f"ELF page-size verification failed:\n{error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/native/verify-engine-apk.py b/native/verify-engine-apk.py
new file mode 100644
index 0000000..cca0470
--- /dev/null
+++ b/native/verify-engine-apk.py
@@ -0,0 +1,232 @@
+#!/usr/bin/env python3
+"""Verify the packaged Maxima/ECL runtime at the final APK boundary."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import io
+import json
+import re
+import sys
+import tempfile
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+
+
+ABI = "arm64-v8a"
+MANIFEST_ENTRY = "assets/engine/runtime-manifest.json"
+ARCHIVE_ENTRY = "assets/engine/runtime.zip"
+SHA256 = re.compile(r"[0-9a-f]{64}")
+REQUIRED_RUNTIME = {
+ "init.lisp.template",
+ "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp",
+}
+REQUIRED_JNI = {"libmaxima.so", "libecl.so"}
+
+
+class VerificationError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class Record:
+ path: str
+ size: int
+ sha256: str
+
+
+def digest(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def record(value: object, label: str) -> Record:
+ if not isinstance(value, dict):
+ raise VerificationError(f"{label}: expected object")
+ path, size, sha = value.get("path"), value.get("size"), value.get("sha256")
+ if not isinstance(path, str) or not path:
+ raise VerificationError(f"{label}.path: expected non-empty string")
+ normalized = PurePosixPath(path)
+ if normalized.is_absolute() or ".." in normalized.parts or normalized.as_posix() != path:
+ raise VerificationError(f"{label}.path: unsafe path {path!r}")
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
+ raise VerificationError(f"{label}.size: expected non-negative integer")
+ if not isinstance(sha, str) or SHA256.fullmatch(sha) is None:
+ raise VerificationError(f"{label}.sha256: expected lowercase SHA-256")
+ return Record(path, size, sha)
+
+
+def records(manifest: dict[str, object], field: str) -> dict[str, Record]:
+ raw = manifest.get(field)
+ if not isinstance(raw, list) or not raw:
+ raise VerificationError(f"manifest {field}: expected non-empty array")
+ parsed = [record(value, f"{field}[{index}]") for index, value in enumerate(raw)]
+ result = {item.path: item for item in parsed}
+ if len(result) != len(parsed):
+ raise VerificationError(f"manifest {field}: duplicate paths")
+ return result
+
+
+def verify_value(value: bytes, expected: Record, label: str) -> list[str]:
+ errors: list[str] = []
+ if len(value) != expected.size:
+ errors.append(f"{label}: size={len(value)}, expected={expected.size}")
+ actual = digest(value)
+ if actual != expected.sha256:
+ errors.append(f"{label}: SHA-256={actual}, expected={expected.sha256}")
+ return errors
+
+
+def verify_apk(path: Path) -> tuple[str | None, list[str]]:
+ errors: list[str] = []
+ try:
+ with zipfile.ZipFile(path) as apk:
+ apk_entries = {info.filename: info for info in apk.infolist() if not info.is_dir()}
+ if MANIFEST_ENTRY not in apk_entries or ARCHIVE_ENTRY not in apk_entries:
+ raise VerificationError("Maxima runtime manifest/archive is missing from APK")
+ manifest = json.loads(apk.read(MANIFEST_ENTRY).decode("utf-8"))
+ if not isinstance(manifest, dict) or manifest.get("schemaVersion") != 1:
+ raise VerificationError("Maxima runtime manifest schema must be 1")
+ if (
+ manifest.get("abi") != ABI
+ or manifest.get("maximaVersion") != "5.49.0"
+ or manifest.get("compiledMaximaVersion") != "5.49.0"
+ ):
+ raise VerificationError("Maxima runtime version/ABI mismatch")
+ runtime_id = manifest.get("runtimeId")
+ if not isinstance(runtime_id, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", runtime_id) is None:
+ raise VerificationError("Maxima runtimeId is invalid")
+ archive_record = record(manifest.get("archive"), "archive")
+ if archive_record.path != "runtime.zip":
+ raise VerificationError("Maxima archive path must be runtime.zip")
+ archive_value = apk.read(ARCHIVE_ENTRY)
+ errors.extend(verify_value(archive_value, archive_record, ARCHIVE_ENTRY))
+
+ runtime = records(manifest, "files")
+ jni = records(manifest, "jniFiles")
+ missing_runtime = sorted(REQUIRED_RUNTIME - runtime.keys())
+ missing_jni = sorted(REQUIRED_JNI - jni.keys())
+ if missing_runtime:
+ errors.append(f"required Maxima files missing from manifest={missing_runtime}")
+ if missing_jni:
+ errors.append(f"required Maxima JNI missing from manifest={missing_jni}")
+
+ try:
+ with zipfile.ZipFile(io.BytesIO(archive_value)) as nested:
+ infos = [info for info in nested.infolist() if not info.is_dir()]
+ names = [info.filename for info in infos]
+ if len(names) != len(set(names)):
+ errors.append("Maxima runtime archive has duplicate entries")
+ actual = set(names)
+ expected = set(runtime)
+ if actual != expected:
+ errors.append(
+ "Maxima runtime archive file set mismatch: "
+ f"missing={sorted(expected - actual)[:8]} extra={sorted(actual - expected)[:8]}"
+ )
+ for name in sorted(actual & expected):
+ errors.extend(verify_value(nested.read(name), runtime[name], f"runtime.zip!/{name}"))
+ except zipfile.BadZipFile as error:
+ errors.append(f"Maxima runtime.zip is invalid: {error}")
+
+ for name, expected in jni.items():
+ entry = f"lib/{ABI}/{name}"
+ if entry not in apk_entries:
+ errors.append(f"missing={entry}")
+ else:
+ errors.extend(verify_value(apk.read(entry), expected, entry))
+ raw_engine_assets = sorted(
+ name for name in apk_entries
+ if name.startswith("assets/engine/") and name not in {MANIFEST_ENTRY, ARCHIVE_ENTRY}
+ )
+ if raw_engine_assets:
+ errors.append(f"unexpected raw Maxima assets={raw_engine_assets[:8]}")
+ return runtime_id, errors
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, zipfile.BadZipFile, VerificationError) as error:
+ return None, [str(error)]
+
+
+def fixture(
+ path: Path,
+ include_linearalgebra: bool = True,
+ corrupt_jni: bool = False,
+ compiled_version: str = "5.49.0",
+) -> None:
+ runtime = {
+ "init.lisp.template": b"template",
+ "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp": b"lisp-utils",
+ }
+ if include_linearalgebra:
+ runtime["share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac"] = b"linear"
+ nested_bytes = io.BytesIO()
+ with zipfile.ZipFile(nested_bytes, "w") as nested:
+ for name, value in runtime.items():
+ nested.writestr(name, value)
+ archive = nested_bytes.getvalue()
+ jni_values = {"libmaxima.so": b"maxima", "libecl.so": b"ecl"}
+ rec = lambda name, value: {"path": name, "size": len(value), "sha256": digest(value)}
+ manifest = {
+ "schemaVersion": 1,
+ "runtimeId": "sha256:" + "a" * 64,
+ "maximaVersion": "5.49.0",
+ "compiledMaximaVersion": compiled_version,
+ "eclVersion": "26.3.27",
+ "abi": ABI,
+ "archive": rec("runtime.zip", archive),
+ "files": [rec(name, value) for name, value in runtime.items()],
+ "jniFiles": [rec(name, value) for name, value in jni_values.items()],
+ }
+ with zipfile.ZipFile(path, "w") as apk:
+ apk.writestr(MANIFEST_ENTRY, json.dumps(manifest))
+ apk.writestr(ARCHIVE_ENTRY, archive)
+ for name, value in jni_values.items():
+ apk.writestr(f"lib/{ABI}/{name}", value + (b"bad" if corrupt_jni and name == "libecl.so" else b""))
+
+
+def self_test() -> None:
+ with tempfile.TemporaryDirectory(prefix="verify-engine-apk-") as directory:
+ good = Path(directory) / "good.apk"
+ missing = Path(directory) / "missing.apk"
+ corrupt = Path(directory) / "corrupt.apk"
+ wrong_version = Path(directory) / "wrong-version.apk"
+ fixture(good)
+ fixture(missing, include_linearalgebra=False)
+ fixture(corrupt, corrupt_jni=True)
+ fixture(wrong_version, compiled_version="v1.1.0_2_g084c646_dirty")
+ if verify_apk(good)[1]:
+ raise VerificationError(f"good fixture failed: {verify_apk(good)[1]}")
+ if not any("linearalgebra" in error for error in verify_apk(missing)[1]):
+ raise VerificationError("missing linearalgebra was not detected")
+ if not any("libecl.so" in error for error in verify_apk(corrupt)[1]):
+ raise VerificationError("corrupt JNI was not detected")
+ if not any("version/ABI mismatch" in error for error in verify_apk(wrong_version)[1]):
+ raise VerificationError("compiled Maxima version mismatch was not detected")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("apk", nargs="?", type=Path)
+ parser.add_argument("--self-test", action="store_true")
+ args = parser.parse_args()
+ if args.self_test:
+ self_test()
+ print("Maxima APK verifier self-test passed")
+ return 0
+ if args.apk is None:
+ parser.error("APK path is required unless --self-test is used")
+ runtime_id, errors = verify_apk(args.apk.resolve())
+ if errors:
+ print("Maxima APK verification failed:\n" + "\n".join(errors), file=sys.stderr)
+ return 1
+ print(f"Maxima APK verification passed: runtimeId={runtime_id}")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except VerificationError as error:
+ print(f"Maxima APK verifier failed: {error}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/native/verify-engine-runtime.py b/native/verify-engine-runtime.py
new file mode 100644
index 0000000..5570eb8
--- /dev/null
+++ b/native/verify-engine-runtime.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""Build and verify the deterministic Maxima/ECL asset archive and manifest."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import stat
+import sys
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+
+
+SCHEMA_VERSION = 1
+SUPPORTED_ABI = "arm64-v8a"
+ARCHIVE_NAME = "runtime.zip"
+MANIFEST_NAME = "runtime-manifest.json"
+REQUIRED_RUNTIME = (
+ "init.lisp.template",
+ "share/maxima/5.49.0/share/linearalgebra/linearalgebra.mac",
+ "share/maxima/5.49.0/share/lisp-utils/defsystem.lisp",
+)
+REQUIRED_JNI = ("libmaxima.so", "libecl.so")
+
+
+class VerificationError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class FileRecord:
+ path: str
+ size: int
+ sha256: str
+
+ def json(self) -> dict[str, object]:
+ return {"path": self.path, "size": self.size, "sha256": self.sha256}
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ while block := stream.read(1024 * 1024):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def safe_relative(path: Path, root: Path) -> str:
+ relative = path.relative_to(root).as_posix()
+ pure = PurePosixPath(relative)
+ if not relative or pure.is_absolute() or any(part in ("", ".", "..") for part in pure.parts):
+ raise VerificationError(f"unsafe runtime path: {relative!r}")
+ return relative
+
+
+def scan_runtime(root: Path) -> list[FileRecord]:
+ if not root.is_dir():
+ raise VerificationError(f"runtime directory is missing: {root}")
+ records: list[FileRecord] = []
+ for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()):
+ if path.is_symlink():
+ raise VerificationError(f"runtime symlink is not allowed: {path}")
+ if path.is_file():
+ relative = safe_relative(path, root)
+ records.append(FileRecord(relative, path.stat().st_size, sha256_file(path)))
+ found = {record.path for record in records}
+ missing = [path for path in REQUIRED_RUNTIME if path not in found]
+ if missing:
+ raise VerificationError(f"required Maxima runtime files are missing: {missing}")
+ return records
+
+
+def scan_jni(root: Path) -> list[FileRecord]:
+ records: list[FileRecord] = []
+ for name in REQUIRED_JNI:
+ path = root / name
+ if not path.is_file():
+ raise VerificationError(f"required Maxima JNI file is missing: {path}")
+ records.append(FileRecord(name, path.stat().st_size, sha256_file(path)))
+ return records
+
+
+def runtime_id(
+ maxima_version: str,
+ compiled_maxima_version: str,
+ ecl_version: str,
+ abi: str,
+ files: list[FileRecord],
+ jni_files: list[FileRecord],
+) -> str:
+ identity = {
+ "schemaVersion": SCHEMA_VERSION,
+ "maximaVersion": maxima_version,
+ "compiledMaximaVersion": compiled_maxima_version,
+ "eclVersion": ecl_version,
+ "abi": abi,
+ "files": [record.json() for record in files],
+ "jniFiles": [record.json() for record in jni_files],
+ }
+ canonical = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
+
+
+def write_archive(runtime: Path, records: list[FileRecord], target: Path) -> None:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ temporary = target.with_name(f".{target.name}.tmp")
+ temporary.unlink(missing_ok=True)
+ try:
+ with zipfile.ZipFile(
+ temporary,
+ "w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=9,
+ strict_timestamps=True,
+ ) as archive:
+ for record in records:
+ info = zipfile.ZipInfo(record.path, date_time=(1980, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = (stat.S_IFREG | 0o644) << 16
+ info.create_system = 3
+ archive.writestr(info, (runtime / record.path).read_bytes(), compresslevel=9)
+ os.replace(temporary, target)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def verify_archive(archive_path: Path, records: list[FileRecord]) -> None:
+ expected = {record.path: record for record in records}
+ with zipfile.ZipFile(archive_path, "r") as archive:
+ names = archive.namelist()
+ if len(names) != len(set(names)):
+ raise VerificationError("Maxima runtime archive contains duplicate entries")
+ if set(names) != set(expected):
+ raise VerificationError(
+ "Maxima runtime archive file set mismatch: "
+ f"missing={sorted(set(expected) - set(names))[:8]} "
+ f"extra={sorted(set(names) - set(expected))[:8]}"
+ )
+ for name, record in expected.items():
+ value = archive.read(name)
+ if len(value) != record.size or hashlib.sha256(value).hexdigest() != record.sha256:
+ raise VerificationError(f"Maxima runtime archive entry mismatch: {name}")
+
+
+def manifest_payload(
+ maxima_version: str,
+ compiled_maxima_version: str,
+ ecl_version: str,
+ abi: str,
+ archive_path: Path,
+ files: list[FileRecord],
+ jni_files: list[FileRecord],
+) -> dict[str, object]:
+ return {
+ "schemaVersion": SCHEMA_VERSION,
+ "runtimeId": runtime_id(
+ maxima_version,
+ compiled_maxima_version,
+ ecl_version,
+ abi,
+ files,
+ jni_files,
+ ),
+ "maximaVersion": maxima_version,
+ "compiledMaximaVersion": compiled_maxima_version,
+ "eclVersion": ecl_version,
+ "abi": abi,
+ "archive": FileRecord(
+ ARCHIVE_NAME,
+ archive_path.stat().st_size,
+ sha256_file(archive_path),
+ ).json(),
+ "files": [record.json() for record in files],
+ "jniFiles": [record.json() for record in jni_files],
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--runtime", type=Path, required=True)
+ parser.add_argument("--jni", type=Path, required=True)
+ parser.add_argument("--assets", type=Path, required=True)
+ parser.add_argument("--maxima-version", required=True)
+ parser.add_argument("--compiled-version-file", type=Path, required=True)
+ parser.add_argument("--ecl-version", required=True)
+ parser.add_argument("--abi", required=True)
+ parser.add_argument("--write", action="store_true")
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ if args.abi != SUPPORTED_ABI:
+ raise VerificationError(f"unsupported ABI: {args.abi}")
+ if args.maxima_version != "5.49.0":
+ raise VerificationError(f"unsupported Maxima version: {args.maxima_version}")
+ compiled_maxima_version = args.compiled_version_file.read_text(encoding="utf-8").strip()
+ if compiled_maxima_version != args.maxima_version:
+ raise VerificationError(
+ "compiled Maxima search-path version mismatch: "
+ f"{compiled_maxima_version!r} != {args.maxima_version!r}"
+ )
+ runtime = args.runtime.resolve()
+ jni = args.jni.resolve()
+ assets = args.assets.resolve()
+ archive_path = assets / ARCHIVE_NAME
+ manifest_path = assets / MANIFEST_NAME
+ files = scan_runtime(runtime)
+ jni_files = scan_jni(jni)
+ if args.write:
+ assets.mkdir(parents=True, exist_ok=True)
+ write_archive(runtime, files, archive_path)
+ manifest = manifest_payload(
+ args.maxima_version,
+ compiled_maxima_version,
+ args.ecl_version,
+ args.abi,
+ archive_path,
+ files,
+ jni_files,
+ )
+ manifest_path.write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ if not archive_path.is_file() or not manifest_path.is_file():
+ raise VerificationError("Maxima runtime archive/manifest is missing")
+ verify_archive(archive_path, files)
+ expected = manifest_payload(
+ args.maxima_version,
+ compiled_maxima_version,
+ args.ecl_version,
+ args.abi,
+ archive_path,
+ files,
+ jni_files,
+ )
+ actual = json.loads(manifest_path.read_text(encoding="utf-8"))
+ if actual != expected:
+ raise VerificationError("Maxima runtime manifest is stale or inconsistent")
+ print(
+ f"Maxima runtime verified: {len(files)} assets, {len(jni_files)} JNI, "
+ f"runtimeId={expected['runtimeId']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except VerificationError as error:
+ print(f"ERROR: {error}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/native/verify-octave-apk.py b/native/verify-octave-apk.py
new file mode 100644
index 0000000..257c1ed
--- /dev/null
+++ b/native/verify-octave-apk.py
@@ -0,0 +1,358 @@
+#!/usr/bin/env python3
+"""Verify that an assembled APK contains the complete Octave runtime manifest.
+
+The source-runtime verifier cannot observe AAPT filtering. This artifact-level
+gate reads the manifest back from the APK and verifies every owned asset and JNI
+file by path, size, and SHA-256.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import struct
+import sys
+import tempfile
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+
+
+EXPECTED_ABI = "arm64-v8a"
+ASSET_ROOT = "assets/octave"
+MANIFEST_ENTRY = f"{ASSET_ROOT}/runtime-manifest.json"
+SHA256 = re.compile(r"[0-9a-f]{64}")
+EXPECTED_LOAD_ALIGNMENT = 16 * 1024
+
+
+class VerificationError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class FileRecord:
+ path: str
+ size: int
+ sha256: str
+
+
+@dataclass(frozen=True)
+class VerificationSummary:
+ runtime_id: str
+ assets: int
+ jni_files: int
+ native_files: int
+
+
+def parse_record(value: object, label: str) -> FileRecord:
+ if not isinstance(value, dict):
+ raise VerificationError(f"{label}: expected an object")
+ path = value.get("path")
+ size = value.get("size")
+ digest = value.get("sha256")
+ if not isinstance(path, str) or not path:
+ raise VerificationError(f"{label}.path: expected a non-empty string")
+ normalized = PurePosixPath(path)
+ if normalized.is_absolute() or ".." in normalized.parts or normalized.as_posix() != path:
+ raise VerificationError(f"{label}.path: unsafe or non-normalized path {path!r}")
+ if isinstance(size, bool) or not isinstance(size, int) or size < 0:
+ raise VerificationError(f"{label}.size: expected a non-negative integer")
+ if not isinstance(digest, str) or SHA256.fullmatch(digest) is None:
+ raise VerificationError(f"{label}.sha256: expected 64 lowercase hexadecimal characters")
+ return FileRecord(path=path, size=size, sha256=digest)
+
+
+def parse_records(manifest: dict[str, object], field: str) -> list[FileRecord]:
+ raw = manifest.get(field)
+ if not isinstance(raw, list) or not raw:
+ raise VerificationError(f"runtime manifest {field}: expected a non-empty array")
+ records = [parse_record(value, f"{field}[{index}]") for index, value in enumerate(raw)]
+ paths = [record.path for record in records]
+ duplicates = sorted({path for path in paths if paths.count(path) > 1})
+ if duplicates:
+ raise VerificationError(f"runtime manifest {field}: duplicate paths {duplicates[:8]}")
+ return records
+
+
+def hash_entry(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> str:
+ digest = hashlib.sha256()
+ with archive.open(info, "r") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def elf_load_alignments(value: bytes, label: str) -> tuple[int, ...]:
+ if len(value) < 64 or value[:4] != b"\x7fELF":
+ raise VerificationError(f"{label}: native-library entry is not ELF")
+ if value[4] != 2 or value[5] != 1:
+ raise VerificationError(f"{label}: expected ELF64 little-endian")
+ program_offset = struct.unpack_from(" len(value):
+ raise VerificationError(f"{label}: truncated program headers")
+ alignments: list[int] = []
+ for index in range(program_count):
+ offset = program_offset + index * program_entry_size
+ kind, _, _, _, _, _, _, alignment = struct.unpack_from(" bytes:
+ value = bytearray(64 + 56)
+ value[:16] = b"\x7fELF\x02\x01\x01" + b"\0" * 9
+ struct.pack_into(" list[str]:
+ info = entries.get(entry_name)
+ if info is None:
+ return [f"missing={entry_name}"]
+ errors: list[str] = []
+ if info.file_size != record.size:
+ errors.append(f"{entry_name}: size={info.file_size}, expected={record.size}")
+ digest = hash_entry(archive, info)
+ if digest != record.sha256:
+ errors.append(f"{entry_name}: SHA-256={digest}, expected={record.sha256}")
+ return errors
+
+
+def verify_apk(apk: Path) -> tuple[VerificationSummary | None, list[str]]:
+ errors: list[str] = []
+ try:
+ with zipfile.ZipFile(apk) as archive:
+ infos = [info for info in archive.infolist() if not info.is_dir()]
+ names = [info.filename for info in infos]
+ duplicate_entries = sorted({name for name in names if names.count(name) > 1})
+ if duplicate_entries:
+ errors.append(f"duplicate APK entries={duplicate_entries[:8]}")
+ entries = {info.filename: info for info in infos}
+ manifest_info = entries.get(MANIFEST_ENTRY)
+ if manifest_info is None:
+ raise VerificationError(f"{apk}: {MANIFEST_ENTRY} is missing")
+ try:
+ manifest = json.loads(archive.read(manifest_info).decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
+ raise VerificationError(f"{MANIFEST_ENTRY}: invalid UTF-8 JSON: {error}") from error
+ if not isinstance(manifest, dict):
+ raise VerificationError(f"{MANIFEST_ENTRY}: expected a JSON object")
+ if manifest.get("abi") != EXPECTED_ABI:
+ raise VerificationError(
+ f"{MANIFEST_ENTRY}: ABI {manifest.get('abi')!r}, expected {EXPECTED_ABI!r}"
+ )
+ runtime_id = manifest.get("runtimeId")
+ if not isinstance(runtime_id, str) or not runtime_id.startswith("sha256:"):
+ raise VerificationError(f"{MANIFEST_ENTRY}: invalid runtimeId")
+
+ asset_records = parse_records(manifest, "files")
+ jni_records = parse_records(manifest, "jniFiles")
+ native_overrides = manifest.get("nativeOverrides")
+ if not isinstance(native_overrides, dict):
+ raise VerificationError("runtime manifest nativeOverrides: expected an object")
+ if native_overrides.get("pageSize") != EXPECTED_LOAD_ALIGNMENT:
+ raise VerificationError(
+ "runtime manifest nativeOverrides.pageSize must be 16384"
+ )
+ override_records = parse_records(native_overrides, "files")
+ expected_assets = {
+ f"{ASSET_ROOT}/{record.path}": record for record in asset_records
+ }
+ actual_assets = {
+ name
+ for name in entries
+ if name.startswith(f"{ASSET_ROOT}/") and name != MANIFEST_ENTRY
+ }
+ missing_assets = sorted(expected_assets.keys() - actual_assets)
+ extra_assets = sorted(actual_assets - expected_assets.keys())
+ if missing_assets:
+ errors.append(f"missing assets={missing_assets[:8]}")
+ if extra_assets:
+ errors.append(f"extra assets={extra_assets[:8]}")
+ for entry_name in sorted(expected_assets.keys() & actual_assets):
+ errors.extend(
+ verify_record(archive, entries, entry_name, expected_assets[entry_name])
+ )
+
+ for record in jni_records:
+ entry_name = f"lib/{EXPECTED_ABI}/{record.path}"
+ errors.extend(verify_record(archive, entries, entry_name, record))
+ for record in override_records:
+ entry_name = f"lib/{EXPECTED_ABI}/{record.path}"
+ errors.extend(verify_record(archive, entries, entry_name, record))
+ native_entries = sorted(
+ name for name in entries if name.startswith(f"lib/{EXPECTED_ABI}/")
+ )
+ for entry_name in native_entries:
+ try:
+ alignments = elf_load_alignments(archive.read(entries[entry_name]), entry_name)
+ if any(alignment < EXPECTED_LOAD_ALIGNMENT for alignment in alignments):
+ rendered = ", ".join(f"0x{alignment:x}" for alignment in alignments)
+ errors.append(
+ f"{entry_name}: PT_LOAD alignment [{rendered}] is below "
+ f"0x{EXPECTED_LOAD_ALIGNMENT:x} required for 16 KiB Android pages"
+ )
+ except (OSError, struct.error, VerificationError) as error:
+ errors.append(str(error))
+ forbidden_abis = sorted(
+ name
+ for name in entries
+ if name.startswith("lib/") and not name.startswith(f"lib/{EXPECTED_ABI}/")
+ )
+ if forbidden_abis:
+ errors.append(f"non-arm64 JNI entries={forbidden_abis[:8]}")
+
+ return (
+ VerificationSummary(
+ runtime_id=runtime_id,
+ assets=len(asset_records),
+ jni_files=len(jni_records),
+ native_files=len(native_entries),
+ ),
+ errors,
+ )
+ except (OSError, zipfile.BadZipFile, VerificationError) as error:
+ return None, [str(error)]
+
+
+def sha256_bytes(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def self_test() -> None:
+ asset = b"required hidden metadata\n"
+ jni = minimal_elf(EXPECTED_LOAD_ALIGNMENT)
+ bad_jni = minimal_elf(4096)
+ manifest = {
+ "schemaVersion": 1,
+ "runtimeId": "sha256:" + "a" * 64,
+ "abi": EXPECTED_ABI,
+ "files": [
+ {
+ "path": "usr/share/octave/11.3.0/m/audio/.oct-config",
+ "size": len(asset),
+ "sha256": sha256_bytes(asset),
+ }
+ ],
+ "jniFiles": [
+ {
+ "path": "liboctavebin.so",
+ "size": len(jni),
+ "sha256": sha256_bytes(jni),
+ }
+ ],
+ "nativeOverrides": {
+ "pageSize": EXPECTED_LOAD_ALIGNMENT,
+ "files": [
+ {
+ "path": "liboctavebin.so",
+ "size": len(jni),
+ "sha256": sha256_bytes(jni),
+ }
+ ],
+ },
+ }
+ with tempfile.TemporaryDirectory(prefix="verify-octave-apk-") as directory:
+ good = Path(directory) / "good.apk"
+ bad = Path(directory) / "aapt-filtered.apk"
+ bad_alignment = Path(directory) / "bad-alignment.apk"
+ for path, include_hidden in ((good, True), (bad, False)):
+ with zipfile.ZipFile(path, "w") as archive:
+ archive.writestr(MANIFEST_ENTRY, json.dumps(manifest))
+ archive.writestr(f"lib/{EXPECTED_ABI}/liboctavebin.so", jni)
+ if include_hidden:
+ archive.writestr(
+ f"{ASSET_ROOT}/usr/share/octave/11.3.0/m/audio/.oct-config",
+ asset,
+ )
+ bad_alignment_manifest = json.loads(json.dumps(manifest))
+ bad_alignment_manifest["jniFiles"][0]["size"] = len(bad_jni)
+ bad_alignment_manifest["jniFiles"][0]["sha256"] = sha256_bytes(bad_jni)
+ with zipfile.ZipFile(bad_alignment, "w") as archive:
+ archive.writestr(MANIFEST_ENTRY, json.dumps(bad_alignment_manifest))
+ archive.writestr(f"lib/{EXPECTED_ABI}/liboctavebin.so", bad_jni)
+ archive.writestr(
+ f"{ASSET_ROOT}/usr/share/octave/11.3.0/m/audio/.oct-config",
+ asset,
+ )
+ _, good_errors = verify_apk(good)
+ _, bad_errors = verify_apk(bad)
+ if good_errors:
+ raise VerificationError(f"self-test good fixture failed: {good_errors}")
+ if not any(".oct-config" in error and "missing assets=" in error for error in bad_errors):
+ raise VerificationError(f"self-test did not detect AAPT-filtered dotfile: {bad_errors}")
+ _, alignment_errors = verify_apk(bad_alignment)
+ if not any("PT_LOAD alignment" in error for error in alignment_errors):
+ raise VerificationError(
+ f"self-test did not detect 4 KiB ELF alignment: {alignment_errors}"
+ )
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("apk", nargs="?", type=Path, help="assembled APK to verify")
+ parser.add_argument(
+ "--self-test",
+ action="store_true",
+ help="run the deterministic AAPT dotfile regression fixture",
+ )
+ args = parser.parse_args()
+ if args.self_test:
+ try:
+ self_test()
+ print("Octave APK verifier self-test passed")
+ return 0
+ except VerificationError as error:
+ print(f"Octave APK verifier self-test failed:\n{error}", file=sys.stderr)
+ return 1
+ if args.apk is None:
+ parser.error("APK path is required unless --self-test is used")
+ summary, errors = verify_apk(args.apk.resolve())
+ if errors:
+ print("Octave APK verification failed:\n" + "\n".join(errors), file=sys.stderr)
+ return 1
+ assert summary is not None
+ print(
+ "Octave APK verification passed: "
+ f"{summary.assets} assets, {summary.jni_files} Octave JNI files, "
+ f"{summary.native_files} total native files, "
+ f"runtimeId={summary.runtime_id}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/native/verify-octave-runtime.py b/native/verify-octave-runtime.py
new file mode 100644
index 0000000..fa0d353
--- /dev/null
+++ b/native/verify-octave-runtime.py
@@ -0,0 +1,805 @@
+#!/usr/bin/env python3
+"""Static integrity gate for the packaged Android Octave runtime.
+
+The checker intentionally uses only the Python standard library. It verifies the
+arm64 ELF dependency graph without executing Android binaries, writes the runtime
+manifest consumed by OctaveInstaller, and catches C++ runtime ABI mismatches such
+as an unresolved libc++ floating-point from_chars implementation.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import struct
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+
+EXPECTED_ABI = "arm64-v8a"
+EXPECTED_MACHINE = 183 # EM_AARCH64
+EXPECTED_INTERPRETER = "/system/bin/linker64"
+EXPECTED_LOAD_ALIGNMENT = 16 * 1024
+MANIFEST_NAME = "runtime-manifest.json"
+OVERRIDE_LOCK_NAME = "octave-16k-overrides.lock"
+OVERRIDE_FILES = (
+ "libandroid-complex-math.so",
+ "libsharpyuv.so",
+ "libwebp.so",
+ "libwebpdemux.so",
+ "libwebpmux.so",
+)
+AAPT_DOTFILE_IGNORE_PATTERN = ".*"
+GRADLE_OWNED_JNI = {
+ "libchaquopy_java.so",
+ "libcrypto_chaquopy.so",
+ "libcrypto_python.so",
+ "libmaxmath_engine.so",
+ "libpython3.10.so",
+ "libsqlite3_chaquopy.so",
+ "libsqlite3_python.so",
+ "libssl_chaquopy.so",
+ "libssl_python.so",
+}
+
+SYSTEM_LIBRARIES = {
+ "libandroid.so",
+ "libc.so",
+ "libdl.so",
+ "libEGL.so",
+ "libGLESv1_CM.so",
+ "libGLESv2.so",
+ "libjnigraphics.so",
+ "liblog.so",
+ "libm.so",
+ "libOpenSLES.so",
+ "libstdc++.so",
+}
+
+# Android's libc, rather than libc++_shared, provides these two process-runtime
+# symbols. Every other strong C++ ABI reference must resolve inside the packaged
+# Octave closure.
+SYSTEM_CPP_SYMBOLS = {"__cxa_atexit", "__cxa_finalize"}
+CPP_SYMBOL_PREFIXES = (
+ "_Z",
+ "__cxa_",
+ "__gxx_",
+ "_Unwind_",
+ "__clang_call_terminate",
+ "__dynamic_cast",
+)
+VERSIONED_SO = re.compile(r"^.+\.so\.\d+(?:\.\d+)*$")
+PRINTABLE_RUN = re.compile(rb"[ -~]{20,}")
+
+
+class VerificationError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class LockedPackage:
+ name: str
+ version: str
+ filename: str
+ sha256: str
+
+
+@dataclass(frozen=True)
+class RuntimeLock:
+ schema_version: int
+ abi: str
+ termux_arch: str
+ octave_version: str
+ octave_package_version: str
+ bridge_version: int
+ repository: str
+ mirror: str
+ libcxx_runtime_sha256: str
+ packages: tuple[LockedPackage, ...]
+
+
+@dataclass(frozen=True)
+class ElfInfo:
+ path: Path
+ machine: int
+ interpreter: str | None
+ soname: str | None
+ needed: tuple[str, ...]
+ rpath: str | None
+ runpath: str | None
+ defined: frozenset[str]
+ undefined_strong: frozenset[str]
+ load_alignments: tuple[int, ...]
+
+
+def fail(message: str) -> None:
+ raise VerificationError(message)
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def parse_lock(path: Path) -> RuntimeLock:
+ metadata: dict[str, str] = {}
+ packages: list[LockedPackage] = []
+ in_packages = False
+ for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line == "packages:":
+ if in_packages:
+ fail(f"{path}:{line_number}: duplicate packages marker")
+ in_packages = True
+ continue
+ if not in_packages:
+ if "=" not in line:
+ fail(f"{path}:{line_number}: expected key=value")
+ key, value = line.split("=", 1)
+ if key in metadata:
+ fail(f"{path}:{line_number}: duplicate key {key}")
+ metadata[key] = value
+ continue
+ fields = raw.split("\t")
+ if len(fields) != 4:
+ fail(f"{path}:{line_number}: package rows require 4 tab-separated fields")
+ name, version, filename, digest = fields
+ if not re.fullmatch(r"[0-9a-f]{64}", digest):
+ fail(f"{path}:{line_number}: invalid SHA-256 for {name}")
+ packages.append(LockedPackage(name, version, filename, digest))
+
+ required = {
+ "schemaVersion",
+ "abi",
+ "termuxArch",
+ "octaveVersion",
+ "octavePackageVersion",
+ "bridgeVersion",
+ "repository",
+ "mirror",
+ "libcxxRuntimeSha256",
+ "packageCount",
+ }
+ missing = sorted(required - metadata.keys())
+ if missing:
+ fail(f"{path}: missing lock metadata: {', '.join(missing)}")
+ if int(metadata["packageCount"]) != len(packages):
+ fail(f"{path}: packageCount does not match package rows")
+ names = [package.name for package in packages]
+ if len(names) != len(set(names)):
+ fail(f"{path}: duplicate package names")
+ package_by_name = {package.name: package for package in packages}
+ if "octave" not in package_by_name or "libc++" not in package_by_name:
+ fail(f"{path}: octave and libc++ must both be locked")
+ if package_by_name["octave"].version != metadata["octavePackageVersion"]:
+ fail(f"{path}: octave package version does not match metadata")
+ if not re.fullmatch(r"[0-9a-f]{64}", metadata["libcxxRuntimeSha256"]):
+ fail(f"{path}: invalid libcxxRuntimeSha256")
+
+ return RuntimeLock(
+ schema_version=int(metadata["schemaVersion"]),
+ abi=metadata["abi"],
+ termux_arch=metadata["termuxArch"],
+ octave_version=metadata["octaveVersion"],
+ octave_package_version=metadata["octavePackageVersion"],
+ bridge_version=int(metadata["bridgeVersion"]),
+ repository=metadata["repository"],
+ mirror=metadata["mirror"],
+ libcxx_runtime_sha256=metadata["libcxxRuntimeSha256"],
+ packages=tuple(packages),
+ )
+
+
+def parse_override_lock(path: Path) -> dict[str, str]:
+ values: dict[str, str] = {}
+ for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ fail(f"{path}:{line_number}: expected key=value")
+ key, value = line.split("=", 1)
+ if key in values:
+ fail(f"{path}:{line_number}: duplicate key {key}")
+ values[key] = value
+ required = {
+ "schemaVersion",
+ "abi",
+ "androidApi",
+ "ndkVersion",
+ "pageSize",
+ "complexMathVersion",
+ "complexMathUrl",
+ "complexMathSha256",
+ "complexMathNamespaceUrl",
+ "complexMathNamespaceSha256",
+ "libwebpVersion",
+ "libwebpUrl",
+ "libwebpSha256",
+ }
+ missing = sorted(required - values.keys())
+ if missing:
+ fail(f"{path}: missing override metadata: {', '.join(missing)}")
+ if values["schemaVersion"] != "1" or values["abi"] != EXPECTED_ABI:
+ fail(f"{path}: unsupported schema or ABI")
+ if values["androidApi"] != "26" or values["pageSize"] != str(EXPECTED_LOAD_ALIGNMENT):
+ fail(f"{path}: overrides must target Android API 26 with 16 KiB pages")
+ for key in ("complexMathSha256", "complexMathNamespaceSha256", "libwebpSha256"):
+ if re.fullmatch(r"[0-9a-f]{64}", values[key]) is None:
+ fail(f"{path}: invalid SHA-256 in {key}")
+ for key in ("complexMathUrl", "complexMathNamespaceUrl", "libwebpUrl"):
+ if not values[key].startswith("https://"):
+ fail(f"{path}: {key} must use HTTPS")
+ return values
+
+
+def c_string(data: bytes, offset: int) -> str:
+ if offset < 0 or offset >= len(data):
+ fail(f"invalid ELF string offset {offset}")
+ end = data.find(b"\0", offset)
+ if end < 0:
+ fail("unterminated ELF string")
+ return data[offset:end].decode("utf-8", errors="strict")
+
+
+def parse_elf(path: Path) -> ElfInfo | None:
+ data = path.read_bytes()
+ if len(data) < 64 or data[:4] != b"\x7fELF":
+ return None
+ if data[4] != 2 or data[5] != 1:
+ fail(f"{path}: expected ELF64 little-endian")
+
+ machine = struct.unpack_from(" len(data):
+ fail(f"{path}: truncated program header")
+ kind, _, file_offset, virtual_address, _, file_size, _, alignment = struct.unpack_from(
+ " int:
+ for load_address, file_offset, file_size in loads:
+ if load_address <= virtual_address < load_address + file_size:
+ return file_offset + virtual_address - load_address
+ fail(f"{path}: dynamic string table is outside PT_LOAD")
+
+ needed_offsets: list[int] = []
+ string_table_address: int | None = None
+ soname_offset: int | None = None
+ rpath_offset: int | None = None
+ runpath_offset: int | None = None
+ if dynamic is not None:
+ offset, size = dynamic
+ end = min(offset + size, len(data))
+ while offset + 16 <= end:
+ tag, value = struct.unpack_from(" len(data):
+ fail(f"{path}: truncated section header")
+ _, kind, _, _, file_offset, size, link, _, _, entry_size = struct.unpack_from(
+ "= len(sections):
+ fail(f"{path}: invalid dynamic string-table link")
+ _, strings_offset, strings_size, _, _ = sections[link]
+ strings = data[strings_offset : strings_offset + strings_size]
+ entry_size = entry_size or 24
+ for symbol_offset in range(file_offset, file_offset + size, entry_size):
+ if symbol_offset + 24 > len(data):
+ fail(f"{path}: truncated dynamic symbol table")
+ name_offset, info, _, section_index, _, _ = struct.unpack_from(
+ "> 4
+ if binding not in (1, 2): # STB_GLOBAL, STB_WEAK
+ continue
+ name = c_string(strings, name_offset)
+ if not name:
+ continue
+ if section_index == 0:
+ if binding == 1:
+ undefined_strong.add(name)
+ else:
+ defined.add(name)
+
+ return ElfInfo(
+ path=path,
+ machine=machine,
+ interpreter=interpreter,
+ soname=soname,
+ needed=needed,
+ rpath=rpath,
+ runpath=runpath,
+ defined=frozenset(defined),
+ undefined_strong=frozenset(undefined_strong),
+ load_alignments=tuple(load_alignments),
+ )
+
+
+def all_files(root: Path) -> Iterable[Path]:
+ # Path ordering follows host-platform semantics (WindowsPath is
+ # case-insensitive), which made a WSL-generated manifest fail verification
+ # under Windows despite identical files. Sort explicit POSIX strings so the
+ # runtime identity is byte-for-byte stable on every supported host.
+ return sorted(
+ (path for path in root.rglob("*") if path.is_file()),
+ key=lambda path: path.relative_to(root).as_posix(),
+ )
+
+
+def file_record(path: Path, root: Path) -> dict[str, object]:
+ return {
+ "path": path.relative_to(root).as_posix(),
+ "size": path.stat().st_size,
+ "sha256": sha256_file(path),
+ }
+
+
+def canonical_json(value: object) -> bytes:
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
+ "utf-8"
+ )
+
+
+def is_cpp_symbol(symbol: str) -> bool:
+ return symbol.startswith(CPP_SYMBOL_PREFIXES)
+
+
+def build_closure(
+ entry: ElfInfo,
+ plugins: list[ElfInfo],
+ jni_by_name: dict[str, ElfInfo],
+) -> tuple[list[ElfInfo], list[str]]:
+ errors: list[str] = []
+ reachable: dict[str, ElfInfo] = {entry.path.name: entry}
+ queue: list[ElfInfo] = [entry, *plugins]
+ visited: set[Path] = set()
+ while queue:
+ elf = queue.pop(0)
+ if elf.path in visited:
+ continue
+ visited.add(elf.path)
+ for dependency in elf.needed:
+ if dependency in SYSTEM_LIBRARIES:
+ continue
+ target = jni_by_name.get(dependency)
+ if target is None:
+ errors.append(f"{elf.path}: missing DT_NEEDED {dependency}")
+ continue
+ if dependency not in reachable:
+ reachable[dependency] = target
+ queue.append(target)
+ return [reachable[name] for name in sorted(reachable)], errors
+
+
+def manifest_payload(
+ lock: RuntimeLock,
+ override_lock: dict[str, str],
+ override_lock_path: Path,
+ assets: Path,
+ jni: Path,
+ closure: list[ElfInfo],
+) -> dict[str, object]:
+ asset_records = [
+ file_record(path, assets)
+ for path in all_files(assets)
+ if path.name != MANIFEST_NAME
+ ]
+ jni_records = [file_record(elf.path, jni) for elf in closure]
+ libcxx_package = next(package for package in lock.packages if package.name == "libc++")
+ libcxx_path = jni / "libc++_shared.so"
+ compiler_markers = sorted(
+ {
+ text.decode("ascii", errors="ignore")[:512]
+ for text in PRINTABLE_RUN.findall(libcxx_path.read_bytes())
+ if b"clang version" in text.lower()
+ }
+ )
+ if not compiler_markers:
+ fail(f"{libcxx_path}: compiler provenance marker is missing")
+ override_paths = [jni / name for name in OVERRIDE_FILES]
+ missing_overrides = [path.name for path in override_paths if not path.is_file()]
+ if missing_overrides:
+ fail(f"missing 16 KiB native overrides: {', '.join(missing_overrides)}")
+ identity: dict[str, object] = {
+ "octaveVersion": lock.octave_version,
+ "bridgeVersion": lock.bridge_version,
+ "abi": lock.abi,
+ "sourcePackages": [
+ {
+ "name": package.name,
+ "version": package.version,
+ "filename": package.filename,
+ "sha256": package.sha256,
+ }
+ for package in lock.packages
+ ],
+ "cxxRuntime": {
+ "path": "libc++_shared.so",
+ "sha256": sha256_file(libcxx_path),
+ "packageVersion": libcxx_package.version,
+ "compilerMarkers": compiler_markers,
+ },
+ "nativeOverrides": {
+ "lockSha256": sha256_file(override_lock_path),
+ "androidApi": int(override_lock["androidApi"]),
+ "ndkVersion": override_lock["ndkVersion"],
+ "pageSize": int(override_lock["pageSize"]),
+ "sources": [
+ {
+ "name": "libandroid-complex-math",
+ "version": override_lock["complexMathVersion"],
+ "url": override_lock["complexMathUrl"],
+ "sha256": override_lock["complexMathSha256"],
+ "namespaceUrl": override_lock["complexMathNamespaceUrl"],
+ "namespaceSha256": override_lock["complexMathNamespaceSha256"],
+ },
+ {
+ "name": "libwebp",
+ "version": override_lock["libwebpVersion"],
+ "url": override_lock["libwebpUrl"],
+ "sha256": override_lock["libwebpSha256"],
+ },
+ ],
+ "files": [file_record(path, jni) for path in override_paths],
+ },
+ "files": asset_records,
+ "jniFiles": jni_records,
+ }
+ runtime_id = "sha256:" + hashlib.sha256(canonical_json(identity)).hexdigest()
+ return {"schemaVersion": 1, "runtimeId": runtime_id, **identity}
+
+
+def verify_manifest(path: Path, expected: dict[str, object]) -> list[str]:
+ errors: list[str] = []
+ try:
+ actual = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ return [f"{path}: cannot read runtime manifest: {error}"]
+ for field in (
+ "runtimeId",
+ "octaveVersion",
+ "bridgeVersion",
+ "abi",
+ "sourcePackages",
+ "cxxRuntime",
+ "nativeOverrides",
+ "files",
+ "jniFiles",
+ ):
+ if field not in actual:
+ errors.append(f"{path}: missing field {field}")
+ if actual != expected:
+ errors.append(f"{path}: manifest content or runtimeId is stale")
+ return errors
+
+
+def verify_android_asset_configuration(
+ root: Path,
+ asset_records: list[dict[str, object]],
+) -> list[str]:
+ """Ensure AAPT will not silently drop manifest-owned hidden runtime files."""
+ hidden_assets = sorted(
+ str(record["path"])
+ for record in asset_records
+ if any(part.startswith(".") for part in Path(str(record["path"])).parts)
+ )
+ if not hidden_assets:
+ return []
+
+ gradle_path = root / "app" / "build.gradle.kts"
+ try:
+ gradle = gradle_path.read_text(encoding="utf-8")
+ except OSError as error:
+ return [f"{gradle_path}: cannot verify AAPT asset rules: {error}"]
+
+ configured = re.findall(r'ignoreAssetsPattern\s*=\s*"([^"]*)"', gradle)
+ if not configured:
+ return [
+ f"{gradle_path}: manifest owns {len(hidden_assets)} hidden assets but "
+ "AGP's default AAPT pattern '.*' removes them"
+ ]
+ effective_patterns = configured[-1].split(":")
+ if AAPT_DOTFILE_IGNORE_PATTERN in effective_patterns:
+ return [
+ f"{gradle_path}: ignoreAssetsPattern still contains '.*' and would remove "
+ f"manifest asset {hidden_assets[0]}"
+ ]
+ return []
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=Path(__file__).resolve().parents[1],
+ help="repository root (defaults to the parent of native/)",
+ )
+ parser.add_argument(
+ "--write-manifest",
+ action="store_true",
+ help="write a deterministic runtime-manifest.json before verification",
+ )
+ parser.add_argument(
+ "--lock-file",
+ type=Path,
+ help="override octave-termux.lock (used by transactional packagers)",
+ )
+ parser.add_argument(
+ "--assets-dir",
+ type=Path,
+ help="override the packaged assets/octave directory",
+ )
+ parser.add_argument(
+ "--jni-dir",
+ type=Path,
+ help="override the packaged arm64-v8a jniLibs directory",
+ )
+ parser.add_argument(
+ "--lock-only",
+ action="store_true",
+ help="validate only octave-termux.lock (for source-only CI checkouts)",
+ )
+ args = parser.parse_args()
+
+ root = args.root.resolve()
+ lock_path = (args.lock_file or root / "native" / "octave-termux.lock").resolve()
+ assets = (args.assets_dir or root / "app" / "src" / "main" / "assets" / "octave").resolve()
+ jni = (args.jni_dir or root / "app" / "src" / "main" / "jniLibs" / EXPECTED_ABI).resolve()
+ manifest_path = assets / MANIFEST_NAME
+
+ try:
+ lock = parse_lock(lock_path)
+ override_lock_path = root / "native" / OVERRIDE_LOCK_NAME
+ override_lock = parse_override_lock(override_lock_path)
+ if lock.schema_version != 1:
+ fail(f"{lock_path}: unsupported schemaVersion {lock.schema_version}")
+ if lock.abi != EXPECTED_ABI or lock.termux_arch != "aarch64":
+ fail(f"{lock_path}: runtime must be arm64-v8a/aarch64")
+ if lock.octave_version != "11.3.0" or lock.octave_package_version != "2:11.3.0":
+ fail(f"{lock_path}: Octave must remain pinned to 11.3.0")
+ if args.lock_only:
+ print(f"Octave lock verification passed: {len(lock.packages)} packages")
+ return 0
+ if not assets.is_dir() or not jni.is_dir():
+ fail("packaged Octave assets or arm64-v8a jniLibs are missing")
+
+ asset_paths = list(all_files(assets))
+ forbidden_x86 = [path for path in assets.rglob("*") if "x86_64" in path.as_posix()]
+ if forbidden_x86:
+ fail(f"x86_64 Octave assets are forbidden: {forbidden_x86[0]}")
+ x86_jni = root / "app" / "src" / "main" / "jniLibs" / "x86_64"
+ if x86_jni.exists():
+ fail(f"x86_64 JNI directory is forbidden for the arm64-only runtime: {x86_jni}")
+ duplicate_gradle_jni = sorted(path.name for path in jni.iterdir() if path.name in GRADLE_OWNED_JNI)
+ if duplicate_gradle_jni:
+ fail(
+ "Gradle-owned JNI libraries must not also be stored in app jniLibs: "
+ + ", ".join(duplicate_gradle_jni)
+ )
+
+ jni_infos: list[ElfInfo] = []
+ for path in sorted(jni.iterdir()):
+ if not path.is_file():
+ continue
+ info = parse_elf(path)
+ if info is not None:
+ jni_infos.append(info)
+ jni_by_name = {info.path.name: info for info in jni_infos}
+ for info in jni_infos:
+ for dependency in info.needed:
+ if (
+ dependency not in SYSTEM_LIBRARIES
+ and dependency not in GRADLE_OWNED_JNI
+ and dependency not in jni_by_name
+ ):
+ fail(f"{info.path}: missing packaged DT_NEEDED {dependency}")
+ entry = jni_by_name.get("liboctavebin.so")
+ if entry is None:
+ fail(f"{jni}: liboctavebin.so is missing or not ELF")
+
+ plugins: list[ElfInfo] = []
+ for path in asset_paths:
+ if path.name == MANIFEST_NAME:
+ continue
+ info = parse_elf(path)
+ if info is not None:
+ plugins.append(info)
+ if not plugins:
+ fail(f"{assets}: no packaged .oct ELF plugins found")
+ non_oct_plugins = [info.path for info in plugins if info.path.suffix != ".oct"]
+ if non_oct_plugins:
+ fail(f"unexpected ELF asset outside .oct plugins: {non_oct_plugins[0]}")
+
+ closure, errors = build_closure(entry, plugins, jni_by_name)
+ if errors:
+ fail("\n".join(errors))
+
+ expected_manifest = manifest_payload(
+ lock,
+ override_lock,
+ override_lock_path,
+ assets,
+ jni,
+ closure,
+ )
+ if args.write_manifest:
+ manifest_path.write_text(
+ json.dumps(expected_manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+ errors = verify_manifest(manifest_path, expected_manifest)
+ errors.extend(
+ verify_android_asset_configuration(
+ root,
+ expected_manifest["files"],
+ )
+ )
+ # Validate every packaged JNI consumer, including preserved Maxima/Chaquopy libraries;
+ # the manifest still owns only the Octave-reachable closure.
+ checked_elves = [*jni_infos, *plugins]
+ for elf in checked_elves:
+ if elf.machine != EXPECTED_MACHINE:
+ errors.append(f"{elf.path}: expected AArch64 ELF (e_machine=183)")
+ if not elf.load_alignments:
+ errors.append(f"{elf.path}: ELF has no PT_LOAD segments")
+ elif any(alignment < EXPECTED_LOAD_ALIGNMENT for alignment in elf.load_alignments):
+ rendered = ", ".join(f"0x{alignment:x}" for alignment in elf.load_alignments)
+ errors.append(
+ f"{elf.path}: PT_LOAD alignment [{rendered}] is below "
+ f"0x{EXPECTED_LOAD_ALIGNMENT:x} required for 16 KiB Android pages"
+ )
+ if elf.rpath or elf.runpath:
+ errors.append(f"{elf.path}: RPATH/RUNPATH must be removed")
+ if entry.interpreter != EXPECTED_INTERPRETER:
+ errors.append(
+ f"{entry.path}: interpreter {entry.interpreter!r}, expected {EXPECTED_INTERPRETER!r}"
+ )
+ for elf in closure:
+ name = elf.path.name
+ if VERSIONED_SO.match(name):
+ errors.append(f"{elf.path}: versioned .so filename is not APK-safe")
+ if name != "liboctavebin.so" and elf.soname and elf.soname != name:
+ errors.append(f"{elf.path}: SONAME {elf.soname!r} does not match filename")
+
+ libcxx = jni_by_name.get("libc++_shared.so")
+ alternate_cxx_runtimes = sorted(
+ info.path
+ for info in jni_infos
+ if (
+ ("libc++" in info.path.name or "cxx" in info.path.name.lower())
+ and info.path.name != "libc++_shared.so"
+ )
+ )
+ if alternate_cxx_runtimes:
+ errors.append(
+ "multiple C++ runtimes are forbidden: "
+ + ", ".join(str(path) for path in alternate_cxx_runtimes)
+ )
+ alternate_cxx_needed = sorted(
+ (elf.path, needed)
+ for elf in checked_elves
+ for needed in elf.needed
+ if ("libc++" in needed or "cxx" in needed.lower())
+ and needed != "libc++_shared.so"
+ )
+ for path, needed in alternate_cxx_needed:
+ errors.append(f"{path}: alternate C++ runtime dependency {needed}")
+ if libcxx is None or libcxx not in closure:
+ errors.append("Termux libc++_shared.so is not in the reachable Octave closure")
+ elif sha256_file(libcxx.path) != lock.libcxx_runtime_sha256:
+ errors.append(
+ f"{libcxx.path}: SHA-256 does not match locked Termux libc++ runtime"
+ )
+
+ def provider_closure(consumer: ElfInfo) -> set[str]:
+ providers: set[str] = set()
+ pending = list(consumer.needed)
+ visited_names: set[str] = set()
+ while pending:
+ name = pending.pop()
+ if name in visited_names or name in SYSTEM_LIBRARIES:
+ continue
+ visited_names.add(name)
+ provider = jni_by_name.get(name)
+ if provider is None:
+ continue
+ providers.update(provider.defined)
+ pending.extend(provider.needed)
+ return providers
+
+ unresolved: list[tuple[Path, str]] = []
+ for elf in checked_elves:
+ providers = provider_closure(elf)
+ for symbol in elf.undefined_strong:
+ if (
+ is_cpp_symbol(symbol)
+ and symbol not in providers
+ and symbol not in SYSTEM_CPP_SYMBOLS
+ ):
+ unresolved.append((elf.path, symbol))
+ for path, symbol in sorted(unresolved, key=lambda item: (str(item[0]), item[1])):
+ errors.append(f"{path}: unresolved strong C++ symbol {symbol}")
+
+ if errors:
+ raise VerificationError("\n".join(errors))
+
+ print(
+ "Octave runtime verification passed: "
+ f"{len(closure)} JNI ELF, {len(plugins)} .oct ELF, "
+ f"runtimeId={expected_manifest['runtimeId']}"
+ )
+ return 0
+ except (OSError, ValueError, struct.error, VerificationError) as error:
+ print(f"Octave runtime verification failed:\n{error}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())