From b25086b8399a98bda5c535f050d4c4772b9a6442 Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Sun, 16 Aug 2026 14:34:44 +0800 Subject: [PATCH 1/2] Release MaxMath 2.0.1 with Octave console --- .github/workflows/ci.yml | 14 +- .gitignore | 3 + README.md | 63 +- README.zh-CN.md | 53 +- RELEASE_NOTES.md | 49 + THIRD_PARTY_NOTICES.md | 49 +- app/build.gradle.kts | 43 +- app/src/main/AndroidManifest.xml | 3 +- .../java/com/paruh/maxmath/ui/AppLocale.kt | 10 +- .../com/paruh/maxmath/ui/CalcViewModel.kt | 90 +- .../java/com/paruh/maxmath/ui/MaxMathApp.kt | 57 ++ .../java/com/paruh/maxmath/ui/ModeIcons.kt | 22 +- .../paruh/maxmath/ui/components/Components.kt | 33 +- .../ui/console/ConsoleCommandClassifier.kt | 19 + .../ui/console/ConsoleDetailScreens.kt | 199 ++++ .../paruh/maxmath/ui/console/ConsoleScreen.kt | 752 +++++++++++++++ .../maxmath/ui/console/ConsoleViewModel.kt | 480 ++++++++++ .../paruh/maxmath/ui/console/OctaveLatex.kt | 64 ++ .../maxmath/ui/console/OctavePlotPanel.kt | 590 ++++++++++++ .../paruh/maxmath/ui/console/PlotDataMesh.kt | 401 ++++++++ .../paruh/maxmath/ui/console/ScriptStore.kt | 71 ++ .../paruh/maxmath/ui/plot/GlGestureMath.kt | 15 +- .../paruh/maxmath/ui/plot/Plot2DPainter.kt | 3 +- .../paruh/maxmath/ui/plot/PlotViewModel.kt | 20 +- .../paruh/maxmath/ui/plot/gl/GlyphAtlas.kt | 3 +- .../paruh/maxmath/ui/screens/HomeScreen.kt | 1 + .../paruh/maxmath/ui/screens/MatrixScreen.kt | 5 +- .../paruh/maxmath/ui/screens/PlotScreen.kt | 9 +- .../maxmath/ui/screens/QuadraticFormScreen.kt | 3 +- .../maxmath/ui/screens/VectorSpaceScreen.kt | 5 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + app/src/main/res/values-en/strings.xml | 56 +- app/src/main/res/values/strings.xml | 55 +- .../maxmath/MainActivitySoftInputModeTest.kt | 31 + .../maxmath/engine/EngineInstallerTest.kt | 110 ++- .../engine/EngineInstallerUpgradeTest.kt | 226 +++++ .../maxmath/engine/OctaveInstallerTest.kt | 265 ++++++ .../com/paruh/maxmath/ui/CalcViewModelTest.kt | 11 +- .../com/paruh/maxmath/ui/ModeIconsTest.kt | 4 +- .../paruh/maxmath/ui/SystemScreenStateTest.kt | 8 +- .../console/ConsoleCommandClassifierTest.kt | 41 + .../ui/console/ConsoleImeLayoutTest.kt | 106 +++ .../console/ConsolePlotNavigationStateTest.kt | 162 ++++ .../ui/console/ConsoleViewModelTest.kt | 334 +++++++ .../maxmath/ui/console/OctaveLatexTest.kt | 39 + .../ui/console/OctavePlotCameraTest.kt | 21 + .../maxmath/ui/console/PlotDataMeshTest.kt | 77 ++ .../ui/console/ScriptCursorPositionTest.kt | 27 + .../maxmath/ui/plot/GlGestureMathTest.kt | 20 +- docs/SPEC.md | 11 + engine/build.gradle.kts | 17 +- engine/src/main/AndroidManifest.xml | 7 +- engine/src/main/cpp/CMakeLists.txt | 4 + engine/src/main/cpp/maxmath_engine.cpp | 25 +- .../com/paruh/maxmath/engine/CalcResponse.kt | 116 +++ .../com/paruh/maxmath/engine/EngineClient.kt | 374 ++++++-- .../paruh/maxmath/engine/EngineInstaller.kt | 507 ++++++++--- .../maxmath/engine/EngineRuntimeManifest.kt | 130 +++ .../com/paruh/maxmath/engine/EngineService.kt | 325 +++++-- .../com/paruh/maxmath/engine/MaximaEngine.kt | 60 +- .../maxmath/engine/MaximaScriptBuilder.kt | 18 +- .../com/paruh/maxmath/engine/OctaveClient.kt | 387 ++++++++ .../com/paruh/maxmath/engine/OctaveEngine.kt | 737 +++++++++++++++ .../paruh/maxmath/engine/OctaveInstaller.kt | 498 ++++++++++ .../engine/OctavePlotArtifactExpiry.kt | 43 + .../paruh/maxmath/engine/OctavePlotSpec.kt | 282 ++++++ .../paruh/maxmath/engine/OctaveProtocol.kt | 98 ++ .../maxmath/engine/OctaveRequestState.kt | 33 + .../paruh/maxmath/engine/OctaveResponse.kt | 540 +++++++++++ .../maxmath/engine/OctaveRuntimeManifest.kt | 190 ++++ .../com/paruh/maxmath/engine/OctaveService.kt | 433 +++++++++ .../com/paruh/maxmath/engine/OctaveSession.kt | 858 ++++++++++++++++++ .../com/paruh/maxmath/engine/OctaveTask.kt | 167 ++++ .../com/paruh/maxmath/engine/OpRegistry.kt | 21 - .../engine/EngineClientDeadlineTest.kt | 129 +++ .../engine/EngineRuntimeManifestTest.kt | 58 ++ .../maxmath/engine/MaximaScriptBuilderTest.kt | 56 +- .../engine/OctavePlotArtifactExpiryTest.kt | 62 ++ .../maxmath/engine/OctavePlotSpecTest.kt | 120 +++ .../maxmath/engine/OctaveProtocolTest.kt | 215 +++++ .../maxmath/engine/OctaveRequestStateTest.kt | 39 + .../engine/OctaveRuntimeManifestTest.kt | 129 +++ .../paruh/maxmath/engine/OctaveSessionTest.kt | 292 ++++++ .../paruh/maxmath/engine/OctaveTaskTest.kt | 89 ++ .../maxmath/engine/OctaveTextFormatterTest.kt | 36 + native/README.md | 91 +- native/build-ecl-android.sh | 5 +- native/build-maxima-android.sh | 28 +- native/build-octave-16k-overrides.sh | 134 +++ native/download-octave-termux.sh | 166 ++++ native/octave-16k-overrides.lock | 15 + native/octave-m/PROTOCOL.md | 58 ++ native/octave-m/axis.m | 39 + native/octave-m/cla.m | 6 + native/octave-m/clf.m | 8 + native/octave-m/close.m | 8 + native/octave-m/colorbar.m | 10 + native/octave-m/colormap.m | 36 + native/octave-m/contour.m | 17 + native/octave-m/contourf.m | 17 + native/octave-m/figure.m | 11 + native/octave-m/get.m | 6 + native/octave-m/grid.m | 17 + native/octave-m/hold.m | 19 + native/octave-m/legend.m | 24 + native/octave-m/maxmath_execute.m | 12 + native/octave-m/maxmath_execute_file.m | 15 + native/octave-m/maxmath_flush.m | 101 +++ native/octave-m/maxmath_init.m | 28 + native/octave-m/maxmath_preview.m | 112 +++ native/octave-m/maxmath_whos.m | 45 + native/octave-m/mesh.m | 20 + native/octave-m/plot.m | 110 +++ native/octave-m/plot3.m | 29 + native/octave-m/private/maxmath_emit_error.m | 32 + native/octave-m/private/mm_contour_core.m | 32 + native/octave-m/private/mm_current.m | 8 + native/octave-m/private/mm_json.m | 124 +++ native/octave-m/private/mm_label.m | 11 + native/octave-m/private/mm_limit.m | 11 + native/octave-m/private/mm_new_axes.m | 17 + native/octave-m/private/mm_put.m | 5 + native/octave-m/private/mm_set_state.m | 5 + native/octave-m/private/mm_state.m | 10 + native/octave-m/private/mm_strip_axes.m | 13 + native/octave-m/private/mm_surface_args.m | 18 + native/octave-m/private/mm_unsupported_once.m | 10 + native/octave-m/set.m | 6 + native/octave-m/subplot.m | 20 + native/octave-m/surf.m | 20 + native/octave-m/text.m | 94 ++ native/octave-m/title.m | 3 + native/octave-m/view.m | 50 + native/octave-m/xlabel.m | 3 + native/octave-m/xlim.m | 8 + native/octave-m/ylabel.m | 3 + native/octave-m/ylim.m | 8 + native/octave-m/zlabel.m | 3 + native/octave-m/zlim.m | 8 + native/octave-termux.lock | 89 ++ native/package-engine.sh | 62 +- native/package-octave-engine.sh | 445 +++++++++ native/test-octave.sh | 431 +++++++++ native/verify-elf-page-size.py | 91 ++ native/verify-engine-apk.py | 232 +++++ native/verify-engine-runtime.py | 256 ++++++ native/verify-octave-apk.py | 358 ++++++++ native/verify-octave-runtime.py | 805 ++++++++++++++++ 148 files changed, 15780 insertions(+), 527 deletions(-) create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/ConsoleCommandClassifier.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/ConsoleDetailScreens.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/ConsoleScreen.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/ConsoleViewModel.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/OctaveLatex.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/OctavePlotPanel.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/PlotDataMesh.kt create mode 100644 app/src/main/java/com/paruh/maxmath/ui/console/ScriptStore.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/MainActivitySoftInputModeTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/engine/EngineInstallerUpgradeTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/engine/OctaveInstallerTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleCommandClassifierTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleImeLayoutTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsolePlotNavigationStateTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/ConsoleViewModelTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/OctaveLatexTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/OctavePlotCameraTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/PlotDataMeshTest.kt create mode 100644 app/src/test/kotlin/com/paruh/maxmath/ui/console/ScriptCursorPositionTest.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/EngineRuntimeManifest.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveClient.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveEngine.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveInstaller.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctavePlotArtifactExpiry.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctavePlotSpec.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveProtocol.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveRequestState.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveResponse.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveRuntimeManifest.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveService.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveSession.kt create mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OctaveTask.kt delete mode 100644 engine/src/main/java/com/paruh/maxmath/engine/OpRegistry.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/EngineClientDeadlineTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/EngineRuntimeManifestTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotArtifactExpiryTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctavePlotSpecTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveProtocolTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRequestStateTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveRuntimeManifestTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTaskTest.kt create mode 100644 engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveTextFormatterTest.kt create mode 100644 native/build-octave-16k-overrides.sh create mode 100644 native/download-octave-termux.sh create mode 100644 native/octave-16k-overrides.lock create mode 100644 native/octave-m/PROTOCOL.md create mode 100644 native/octave-m/axis.m create mode 100644 native/octave-m/cla.m create mode 100644 native/octave-m/clf.m create mode 100644 native/octave-m/close.m create mode 100644 native/octave-m/colorbar.m create mode 100644 native/octave-m/colormap.m create mode 100644 native/octave-m/contour.m create mode 100644 native/octave-m/contourf.m create mode 100644 native/octave-m/figure.m create mode 100644 native/octave-m/get.m create mode 100644 native/octave-m/grid.m create mode 100644 native/octave-m/hold.m create mode 100644 native/octave-m/legend.m create mode 100644 native/octave-m/maxmath_execute.m create mode 100644 native/octave-m/maxmath_execute_file.m create mode 100644 native/octave-m/maxmath_flush.m create mode 100644 native/octave-m/maxmath_init.m create mode 100644 native/octave-m/maxmath_preview.m create mode 100644 native/octave-m/maxmath_whos.m create mode 100644 native/octave-m/mesh.m create mode 100644 native/octave-m/plot.m create mode 100644 native/octave-m/plot3.m create mode 100644 native/octave-m/private/maxmath_emit_error.m create mode 100644 native/octave-m/private/mm_contour_core.m create mode 100644 native/octave-m/private/mm_current.m create mode 100644 native/octave-m/private/mm_json.m create mode 100644 native/octave-m/private/mm_label.m create mode 100644 native/octave-m/private/mm_limit.m create mode 100644 native/octave-m/private/mm_new_axes.m create mode 100644 native/octave-m/private/mm_put.m create mode 100644 native/octave-m/private/mm_set_state.m create mode 100644 native/octave-m/private/mm_state.m create mode 100644 native/octave-m/private/mm_strip_axes.m create mode 100644 native/octave-m/private/mm_surface_args.m create mode 100644 native/octave-m/private/mm_unsupported_once.m create mode 100644 native/octave-m/set.m create mode 100644 native/octave-m/subplot.m create mode 100644 native/octave-m/surf.m create mode 100644 native/octave-m/text.m create mode 100644 native/octave-m/title.m create mode 100644 native/octave-m/view.m create mode 100644 native/octave-m/xlabel.m create mode 100644 native/octave-m/xlim.m create mode 100644 native/octave-m/ylabel.m create mode 100644 native/octave-m/ylim.m create mode 100644 native/octave-m/zlabel.m create mode 100644 native/octave-m/zlim.m create mode 100644 native/octave-termux.lock create mode 100644 native/package-octave-engine.sh create mode 100644 native/test-octave.sh create mode 100644 native/verify-elf-page-size.py create mode 100644 native/verify-engine-apk.py create mode 100644 native/verify-engine-runtime.py create mode 100644 native/verify-octave-apk.py create mode 100644 native/verify-octave-runtime.py 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) [![CI](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml/badge.svg)](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ParuhParhat/MaxMath?label=release)](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) [![CI](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml/badge.svg)](https://github.com/ParuhParhat/MaxMath/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ParuhParhat/MaxMath?label=release)](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..37a4818 --- /dev/null +++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt @@ -0,0 +1,292 @@ +package com.paruh.maxmath.engine + +import java.io.ByteArrayOutputStream +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.Test + +class OctaveSessionTest { + + @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 = "/out", + ) + + 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()) From 400fe6df52a04b62e487c0d6a30bf5206b754ead Mon Sep 17 00:00:00 2001 From: yueye6811 Date: Sun, 16 Aug 2026 14:55:46 +0800 Subject: [PATCH 2/2] Make Octave session tests portable --- .../kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt index 37a4818..ad7f52a 100644 --- a/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt +++ b/engine/src/test/kotlin/com/paruh/maxmath/engine/OctaveSessionTest.kt @@ -1,6 +1,7 @@ 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 @@ -14,10 +15,15 @@ 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) } @@ -223,7 +229,7 @@ class OctaveSessionTest { workDir = "/work", homeDir = "/home", tmpDir = "/tmp", - outDir = "/out", + outDir = File(temporaryFolder.root, "out").absolutePath, ) private fun newSession(process: ScriptedProcess): OctaveSession = OctaveSession(