commit db873063f808e8dc8e7ce166a525ef369c6e2f20 Author: Albrecht Degering Date: Thu Sep 24 22:49:27 2026 +0200 initial commit v 0.2.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c86a9bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto +*.kt text eol=lf +*.kts text eol=lf +*.sh text eol=lf +gradlew text eol=lf +*.bat text eol=crlf +*.ps1 text eol=crlf +*.zip binary +*.jar binary diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..1900041 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,33 @@ +name: Verify source and build Android +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + - name: Install pinned SDK components + run: sdkmanager 'platforms;android-36' 'build-tools;35.0.0' 'platform-tools' + - name: Bootstrap verified wrapper and build + run: | + chmod +x gradlew scripts/*.sh + ./scripts/verify-android.sh + - name: Preserve APK and verification reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-debug-and-reports + path: | + androidApp/build/outputs/apk/debug/*.apk + androidApp/build/reports/ + core/build/reports/tests/ + platform-jvm/build/reports/tests/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53dc6b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +.kotlin/ +**/build/ +local.properties +.idea/ +*.iml +*.jks +*.keystore +.DS_Store +out/ diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..98d9bcb --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +17 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..68683c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +## 0.2.3 — Complete SAF-D1 diagnostic source / pinned toolchain (2026-09-24) + +- Integrate the previously separate SAF-D1 diagnostics into the full 0.2.2 source; + retain the PDF lifecycle correction, folder cursor-result correction, and tests. +- Pin Gradle 8.14.5 across the wrapper and both bootstrap scripts with official hashes. +- Require a local JDK 17 via checked-in daemon criteria, compilation toolchains, + bytecode targets and settings guards; keep Kotlin 2.2.21 / AGP 8.11.1 unchanged. +- Add offline build-pin/plugin-ID checks; preserve the five unique root plugin aliases. +- Set versionName 0.2.3 / versionCode 5 and a visible release/SAF-D1 footer. +- Clarify fresh-extraction setup and that the persistent folder failure remains unresolved. +- No extra permissions, document-engine changes, JDK/SDK binaries or compiled APK. + +## 0.2.2 — Folder-listing runtime fix (2026-09-24) + +- Check the provider cursor for null before entering `use`, rather than treating + the optional EXTRA_ERROR field's absence as a missing listing. +- Preserve normal/empty/partial listings, cursor cleanup, row limits and provider messages. +- Add five Android provider regression cases and an offline host harness that tests + the actual production cursor-reading block with test doubles. +- Reproduce the original exception (9/14 host failures); fixed block passes 14/14. + Existing portable checks remain 112/112. Android build/device validation of this + patch is not claimed; the one-file patch preserves local build-tool changes. + +## 0.2.1 — Android PDF smoke-test compilation fix (2026-09-24) + +- Fixed `PdfDocument().use` in `ViewerSmokeTest.kt`: Android's PdfDocument exposes `close()` + but implements neither Closeable nor AutoCloseable; use explicit try/finally instead. +- Finish the started PDF page in finally, close the document and render client explicitly, + use a unique temporary fixture, and delete that fixture even when its creation fails. +- Add a narrowly scoped source check for the exact invalid constructor/use pattern. +- No runtime feature changes or dependency upgrades; Android/device verification remains pending. + +## 0.2.0 — Lightweight Android browser and file picker (2026-09-23) + +- Added native saved-folder browsing, breadcrumbs/Up/refresh, current-folder search, name/date/size/type + sorting, dotfile visibility, metadata, optional provider thumbnails, and Browse/Recent tabs. +- Reused the current preview pipeline; preserved browser navigation/scroll through preview and Back. +- Added explicit external-open chooser using read-only copies, including unsupported preview formats. +- Registered APP_FILES, content-directory VIEW, and a dedicated GET_CONTENT selection activity. +- Added MIME-filtered, optional multi-selection, review/confirm/cancel, LOCAL_ONLY system delegation, + narrow copied-URI results, and preserved MIME metadata for extensionless output copies. +- Separated saved tree-grant ownership from recent-file cleanup; protected concurrent viewer snapshots. +- Sanitized exported activity extras before SavedStateHandle defaults can be populated. +- Added shared portable browser policies and stream-copy budgets; 112 offline core/JVM checks pass. +- Added Android provider/picker instrumentation source and debug-only synthetic documents, not executed. +- Expanded documentation, static invariants and a repeatable Kotlin PSI syntax check. +- Android/Apple build and device verification are still pending; no APK or native Office engine bundled. + +## 0.1.0 — MVP source release + +Android read-only viewer implementation with system file integration, recent files, PDF/image views, +local reading previews, source/CSV/ZIP views, search, sharing, copies and labelled text export. +Shared Kotlin Multiplatform core; bounded JVM ZIP adapter; isolated PDF service; policy/test suite; +Apple framework targets and port plan; source-only Gradle wrapper bootstrap with pinned checksums. + +Executed validation: 75 core/JVM checks, source syntax and structural checks. Android/iOS builds, +visual checks and runtime testing not performed; no APK included. No native Office engine, editing, +full Office fidelity or iOS application claimed. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..80d9fc6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fileworkbench contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56e0b31 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# Fileworkbench — Android MVP source release 0.2.3 + +**Open files, not accounts.** A read-only, local-processing Android file browser and viewer, with a +Kotlin Multiplatform document core that can be reused by a later native iOS shell. + +> **Source release, not a verified APK.** Host checks pass: 112 core/JVM and 16 listing checks. The complete project includes the SAF-D1 diagnostics +> and the earlier PDF and cursor-result corrections. The user's continuing folder failure is +> **not diagnosed or claimed fixed**. See `docs/VALIDATION.md` for executed host checks and +> Android-build limitations. No APK, JDK, Android SDK or iOS application is bundled. + +## 0.2.3 — complete diagnostic project, Gradle 8.14.5 / JVM 17 + +No patch application is required. Extract this archive to a fresh directory and open the +contained `fileworkbench` project. Do not copy old build scripts, `.idea`, `.gradle`, or +`gradle/gradle-daemon-jvm.properties` over this release. Preserve your original checkout separately. + +- Gradle **8.14.5** is pinned in the wrapper, bootstrap downloads and official SHA-256 checksums. +- Gradle daemon criteria, Java/Kotlin toolchains and JVM bytecode targets use **17**. Install a + JDK 17 locally and select it as the IDE Gradle JVM / `JAVA_HOME`; no absolute JDK path is shipped. +- Root plugin requests and catalog IDs remain distinct: the Kotlin JVM plugin is requested once + at the root with `apply false`, then applied in its subproject. +- App version is **0.2.3 / code 5**. The Locations footer shows + **Fileworkbench 0.2.3 · Browser diagnostics SAF-D1**. +- A genuinely null directory-query cursor reports `[SAF-D1/NULL_CURSOR]`, the provider authority, + Android API and retained-read-grant status. No document URI/path or file content is included. + This is evidence for the next diagnosis, not an automatic repair or broader storage grant. + +See `docs/RELEASE-0.2.3.md` for the integrated changes and `docs/BUILD.md` for fresh-project setup. + +## What is in this release + +- Native **Browse / Recent** screens; saved folder locations, nested navigation, breadcrumbs, filename + search, folders-first sorting, size/date/type details, hidden-file toggle and opt-in provider thumbnails. +- Android **files-app category** and separate `GET_CONTENT` picker with MIME filters, single/multiple + selection, confirmation, and temporary read-only copied results. It does not replace the system picker. +- Android system document/folder picker and `Open with` / single-file `Share` intent handling. +- Private bounded read-only snapshots, recent files, retained read grants where the provider permits + them, explicit share-original / save-copy / preview-text export, details and cache/history cleanup. +- Native PDF page navigation and zoom, with a separate isolated PDF-rendering service and a bounded + raw-pixel IPC transport. Raster image view with downsampling and zoom. +- Text/source, basic Markdown, CSV/TSV tables, and ZIP index views. +- **Reading previews** for DOCX, XLSX, PPTX, ODT, ODS and ODP: text, sheet values, simple document + tables and slide sections. Search works on supported extracted text and archive names. +- Common Kotlin parsers, policy, models, search and export; JVM ZIP adapter; Apple framework targets. +- Unit/integration test sources, a local offline core test runner, Android smoke-test sources, + a build/CI configuration, debug fixture DocumentsProvider, format matrix, threat model and implementation roadmap. + +**Office reading previews are not original-layout rendering.** Images, charts, page geometry, +advanced formatting and other content can be absent. The UI and text exports state this explicitly. +No LibreOfficeKit, Collabora or third-party Office rendering binary is bundled. Legacy/macro-enabled +Office, Office editing, PDF search/forms, and full-fidelity Office viewing are not implemented. + +## Use the browser + +Choose **Browse → Add folder** once, grant access in Android's UI, and browse that folder inside +Fileworkbench. Tap a file to preview; Back / Files returns to the same browser location. **More → +Open in another app** hands off a read-only copy. Use **Open file** for single items elsewhere. + +This uses folder-scoped access, **not unrestricted storage access**. Android 11+ does not permit +bookmarking the storage root or Downloads root through this mechanism; individual Downloads files +remain available through the system picker. There is no rename/delete/move feature in this release. + +Read [`docs/BROWSER.md`](docs/BROWSER.md) for registration semantics, picker security, storage +restrictions, limits and the shared iOS-facing API. The browser's Android test plan is in +[`docs/BROWSER_ACCEPTANCE.md`](docs/BROWSER_ACCEPTANCE.md). + +## Open the project + +Gradle is pinned to **8.14.5**. Use an installed **JDK 17** for both your IDE Gradle JVM and terminal. + +Use JDK 17 and an Android Studio version supporting AGP 8.11.1. Install Android SDK platform 36 +and Build Tools 35.0.0. The Android app is configured for Android 8.0/API 26 and newer. + +The source ZIP deliberately does not contain a downloaded Gradle wrapper JAR. The included +launchers fetch the **official Gradle 8.14.5 wrapper** on first use and require its pinned SHA-256. +The Gradle distribution is separately checksum-pinned. This is build-time networking only. + +```sh +chmod +x gradlew scripts/*.sh +./gradlew --version +./gradlew :core:jvmTest :platform-jvm:test :androidApp:assembleDebug +``` + +On Windows use `gradlew.bat`; its bootstrap uses PowerShell. Before importing into Android Studio, +run `./gradlew --version` once (Windows: `gradlew.bat --version`) to materialize the wrapper JAR. +Then open this root directory in Android Studio and select the `androidApp` run configuration. + +After a **successful** Android build, the debug APK is expected at: +`androidApp/build/outputs/apk/debug/androidApp-debug.apk`. + +```sh +adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk +``` + +This installs a development build, not a production-signed distribution. Full setup and troubleshooting: +[`docs/BUILD.md`](docs/BUILD.md). + +## Modules + +```text +androidApp/ Compose browser/viewer, SAF, file picker, copies, grants, PDF service, images +core/ commonMain: document core + browser models/filter/sort/MIME/selection budgets +platform-jvm/ bounded java.util.zip adapter, local-file preview orchestration + (shared by Android and JVM tests, NOT by iOS) +ios/ Apple integration plan; native framework targets live in core/build.gradle.kts +scripts/ verified Gradle bootstrap, offline tests, structural checks, Android build checks +docs/ architecture, build instructions, feature matrix, security, roadmap, validation +examples/ small ordinary text/CSV/source examples +``` + +## Privacy defaults + +There are no network, broad-storage or account permissions in the main manifest. The app never +uploads documents or edits originals. It uses user-selected provider URIs, not all-files access. +A selected external storage provider may itself use the network. Sharing/exporting/copying selected +text are deliberate user actions outside the app's local-only boundary. + +Snapshots, saved folder names/URIs, and recent filenames/URIs are stored privately in the app sandbox; there is no additional +application-level encryption. Backup/transfer exclusions are configured. Cached data is not +promised to be securely erased from flash storage. This MVP is not security-audited. + +## Verification commands + +```sh +# With Gradle + Android SDK: +./scripts/verify-android.sh +# With a connected device/emulator: +./gradlew :androidApp:connectedDebugAndroidTest +# Without Android SDK or network, using an installed Kotlin compiler + Python 3: +./scripts/test-core.sh +python3 scripts/check-project.py +python3 scripts/test-folder-listing.py +./scripts/check-kotlin-syntax.sh +``` + +The offline runner uses the same test method bodies as the Gradle unit tests, stripping only +runner annotations. It is not a substitute for an Android build or runtime test. + +## iOS path + +On a compatible Mac/Xcode installation: + +```sh +./gradlew -PenableIos=true :core:assembleFileworkbenchCoreReleaseXCFramework +``` + +This builds only the reusable framework. An Apple file-access/ZIP adapter and native UI still need +implementation. Read [`ios/README.md`](ios/README.md) before starting an iOS port. + +## Start here for continued development + +[`docs/ROADMAP.md`](docs/ROADMAP.md) defines the next slices and acceptance gates. +[`docs/CODEX_HANDOFF.md`](docs/CODEX_HANDOFF.md) is a continuation brief for an implementation agent. +Do not represent an interface, an unexecuted test, or a configured build target as completed runtime +functionality. The immediate next gate is the first real Android build and device test. + +Working name: **Fileworkbench**. Provisional application ID: `dev.fileworkbench.android`. +New source is MIT-licensed; review distribution/licensing before adding third-party native engines. diff --git a/SOURCE-MANIFEST.sha256 b/SOURCE-MANIFEST.sha256 new file mode 100644 index 0000000..d61b68e --- /dev/null +++ b/SOURCE-MANIFEST.sha256 @@ -0,0 +1,127 @@ +cd4c11f0b2b66219bfef7a62b460adfcb298c6129591f7fcceeec35172b21977 .gitattributes +18663e9b5e75fe772d69f0e860c7013859a8f69dc6f0a95ff609872156d480c2 .github/workflows/verify.yml +6b3f54ad672d4f156186d4c977d8bdc4488cd75bd19ec4d8eef66b42b539253f .gitignore +54183f4323f377b737433a1e98229ead0fdc686f93bab057ecb612daa94002b5 .java-version +f41e4fa177de1147ecd3a288783129b93e18d6d0cea0b74fb4e07e697b5bb96d CHANGELOG.md +278f60506368160d1cc4489eca6f11a84148fa6f02a51d0941bf3d4c2e98ac75 LICENSE +6142b1a5ef62f57564d35f4f7d7e5439d7b4857d1986018c85d4e4aef3344a29 README.md +9c7c3eada443a32f6989638a6bf5a6872083290999e03466b74761b96c898f6c THIRD_PARTY_NOTICES.md +b09a9cad0f2b2975586a04d54f8bb04159108246d56c6fd3b75f5512ed53fc8a androidApp/build.gradle.kts +6f1c9a2a91066ace901be7e6264d1397bb21091a4e91e58c2c8a4b209bdfbb79 androidApp/proguard-rules.pro +a86b0bb22d58bdae87775248a88947f0ea6c523be88b74bcf9892bd72ff44905 androidApp/src/androidTest/kotlin/dev/fileworkbench/android/BrowserIntegrationTest.kt +64403f60cdb16f2a74a358e3b04b77574302a6cf2b880a6f62ab607767317c69 androidApp/src/androidTest/kotlin/dev/fileworkbench/android/ViewerSmokeTest.kt +72fc4847b5d328f5e47e7786b94714e34d965aae9d51b3d570230f1fe9749868 androidApp/src/debug/AndroidManifest.xml +03357942c0b18c2ef90753075b3a762d3c4280840e052f6bab710d0011d68904 androidApp/src/debug/kotlin/dev/fileworkbench/android/testing/FixtureDocumentsProvider.kt +d9296b8bf706e983040ccd7c4941bfe6b817101f4a60d4726bf42e5bd9d4448a androidApp/src/main/AndroidManifest.xml +3b1f6547c33587dbedab33563356dfda9a976ed5004c2272033d04139318aad9 androidApp/src/main/kotlin/dev/fileworkbench/android/FilePickerActivity.kt +7b7caad086144cd1636e9ff8ff5058834bbf78e6b3fd67f8c3cd31335b138b40 androidApp/src/main/kotlin/dev/fileworkbench/android/MainActivity.kt +dfbd0a3e42fda8ae54f1eab3c8000cda66b2c1768f5db0d5bb7f00bc0a7f4bd5 androidApp/src/main/kotlin/dev/fileworkbench/android/ViewerViewModel.kt +c3206004fb2ee76298f50565a8977f6365d85121bbde9caa040532bc05e1908a androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserThumbnails.kt +1e9a7a4fef379b5e20b151c1eeea35421db746b8487fffca2cb1c13cd47dab4a androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserViewModel.kt +b1f822189461f50872c6af9c967cbf9793426cd16f36c7b9ba67ef2dc63e7979 androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt +1d43132352e455e5590412dd038f4287cc844fc653ae659daad7753fa2c05c18 androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickerIntents.kt +f47d3724b4b7ef46907f2da57e676500761007d4a7f2ef92a2b4f08070e1a824 androidApp/src/main/kotlin/dev/fileworkbench/android/browser/ProviderIo.kt +62e27adea5a7e701e0d2c626d8332876b3f62e10113c8bdc95c3fd982697e850 androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt +739306228330c9ac9ee18aaf25a125ce6631a1d91f26d6f0af8e61bcb3eff37d androidApp/src/main/kotlin/dev/fileworkbench/android/browser/UriReadGrants.kt +aa87679e0553484a41d8430027b81ddeb453c6efc4988b4a8596e6aebde4d121 androidApp/src/main/kotlin/dev/fileworkbench/android/browser/WorkbenchFileProvider.kt +cd0a4dc769e66a83c9b3048ee887199b3845630d72838a388e21425093110114 androidApp/src/main/kotlin/dev/fileworkbench/android/data/DocumentStore.kt +2643c1d387230ea38a924d1b56d0dd239aa2ac7a9c9cca48705ebd16a35f79f7 androidApp/src/main/kotlin/dev/fileworkbench/android/render/ImageLoader.kt +b17cb9f569970feb471437434fd26d82a1a81684d6e00e3170e5c5c19ca49269 androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderClient.kt +c4d00c0e2bc2453da0c558b04db4054347460868dd2a8f1b365b88fc1db010a5 androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderService.kt +c91dc3d785c44604f744c016000f00af705b5dfe76e9101a2a69e4f07ebfa1dc androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt +e997e40c2bcddce1cde7daad3fc6d468a4ead315bb1eb97a5aeba379c5a22f42 androidApp/src/main/kotlin/dev/fileworkbench/android/ui/DocumentReader.kt +7ab33912e65b82064a8e555af2f180b5a9bfa67254ff18c297b9ec60f6c1b074 androidApp/src/main/kotlin/dev/fileworkbench/android/ui/FileworkbenchApp.kt +dfc1ded30dad959dd12d5f7239668d55f2c5c110c8e105e4481147eba3126405 androidApp/src/main/kotlin/dev/fileworkbench/android/ui/RasterViewer.kt +c162b473ac7da35182d07f46aaadd25879c1ef4b69e7827ac4c4502ed41813bf androidApp/src/main/kotlin/dev/fileworkbench/android/ui/Theme.kt +50c5c2165fde2ec087a078219fa6b535a1c5c4277bd9967566ffac3c722618e6 androidApp/src/main/res/drawable/ic_launcher.xml +6fc2d07f32c59fb5add68c93e773949fbae1f7a4866540ef996ad18f8fd8d39b androidApp/src/main/res/values/strings.xml +d663b6a8961af82a370da4d51bbf84c9996ad20a4769788856fadf6a4758cdee androidApp/src/main/res/values/styles.xml +eda9dddce1e895de1efd716fe75e244187bf8f91a3bb7444c6ea1731e51c74a8 androidApp/src/main/res/xml/data_extraction_rules.xml +d47d784b60d5d1e85d77e6d43ae32e2dbfedd7ccda89b50694d28ae28d00f236 androidApp/src/main/res/xml/file_paths.xml +4052110e7fb9f58db33bdbb91bc15fcb01196779876ece8f507030547257e8fa build.gradle.kts +5598fa57572105cf64dbe91e299ca4fa8a2573d4e2f5208ade47a720a89f4b6f core/build.gradle.kts +900388fd20269a2e669425eb4df5ee8f893eaee8dc4ae3f7549bdbd7fb35d2fa core/src/commonMain/kotlin/dev/fileworkbench/core/CoreApi.kt +f9972b7cba880b8c27a99a4f09dc6313a1177cb9fe1f57191e4ad17eb505b06e core/src/commonMain/kotlin/dev/fileworkbench/core/FileClassifier.kt +2ffc10f941d5782cd8adcdb96e66cb4d5e5a31ed066d8dabb48f1b99f3fc52cb core/src/commonMain/kotlin/dev/fileworkbench/core/Model.kt +1b86c9bbb72f11c53006077ce7ce0eb1e3a22e459984d519b906baa2359087bc core/src/commonMain/kotlin/dev/fileworkbench/core/OfficePreview.kt +cf9991d15609602d613cef584a9d8dc3af66f8e80b248b8dca46f2b12bc12bd1 core/src/commonMain/kotlin/dev/fileworkbench/core/PreviewSafety.kt +980e2bbeaa7d40fbe91c2564db74d526ed5f5cb31a0907dde3c7d12355449750 core/src/commonMain/kotlin/dev/fileworkbench/core/SafeXml.kt +b8f19912200a5cfd2d5fb90d2fcd79797013ae4c2e0acfb39687f41c884ec35b core/src/commonMain/kotlin/dev/fileworkbench/core/TextCodec.kt +b772b69dab4988310c484e1e63f3f14557821bb8670eca69793c0964b6c657fa core/src/commonMain/kotlin/dev/fileworkbench/core/TextPreview.kt +1a153afb2463a89f325e51eb0f2611c0514703a64511f1da1f89e147c5ac20c4 core/src/commonMain/kotlin/dev/fileworkbench/core/browser/BrowserModel.kt +aab18a444a6738f198bb4fea937b71e176b8a4eebc1f903fc8ea4ce1ee2f9889 core/src/commonMain/kotlin/dev/fileworkbench/core/browser/PickerRequest.kt +0857a88e0c3515f2d6455503e24b40af3327806695c938cc83d144aa70e587ba core/src/commonTest/kotlin/dev/fileworkbench/core/CoreTests.kt +693f242c4e1fdefbaf0b24fdc58ab0d3d01c0b492e5ba40a4e380dc7bf333d21 core/src/commonTest/kotlin/dev/fileworkbench/core/browser/BrowserTests.kt +08ea99c1040468dc59aba4dc09e55414557b48b39d859340e0951619837f1ffb docs/ARCHITECTURE.md +518f1437e805d200bbf2e6dfe6d21d60d2ff95b8a3019f1bd798876b43225855 docs/BROWSER.md +1648cb066f75542341852a53ab49af15dfa438e84e7ee8c1f104055f292e08ef docs/BROWSER_ACCEPTANCE.md +42b6dbe611670da3ef2c8b5d37bb5b3a7692e0e106f8ffc03a445de9c66beb59 docs/BUILD.md +6ebb9022f399f555281afc01f02e6b736877ec6ba802bee414a29d737ce3090f docs/BUILD_ATTEMPT.txt +e8c17a14ba2f0d975d29733191ff4067bc749d27d42ee9359097412e9ffe1c11 docs/CODEX_HANDOFF.md +795f68f5b41d8431af03e23204e52b74601743c4b9a2aaf3d88611915c609685 docs/ENGINE_INTEGRATION.md +20b72e0156be1c6f1459d07a5c9495de1374349332e43d048f3ba486efc1844e docs/FEATURES.md +1bed45ffd05931b90ea64147922a87ccaac988e2b2ee9bc259d9188cb7735b67 docs/HOTFIX-0.2.1.md +e87247fda0b4a077cec2cb0d85a55744b174d55038b9aaead65f1b4d001abb39 docs/HOTFIX-0.2.2.md +d0a0ef24dea5b799312919ca88372a804bb9b2f919e4b45402287abd5b0a52ca docs/RELEASE-0.2.0.md +c30335829f16e0cc664195db101cb409445ef3f7ad699225f34a692365b4b159 docs/RELEASE-0.2.3.md +8ece23033e33aefa8c72e4206806520bc394767209a5365f415248eabc2effe1 docs/ROADMAP.md +969f6cc36c52d9cb361435508746dfb5e498a3b8efebe27ea5895cc2a03f3f2e docs/SECURITY.md +eead54e00d25b5d1c72d8ed7acadde316418a14ba64503d3ddad5ed2fa6428f7 docs/SOURCES.md +3026c2d7aaccd8a7635d6578b57c371ec922aa86ca794fd4c9612735e826034b docs/SOURCE_CHECKS.txt +8f6e59726533a5cdf207ad30196dddd26de71843e96877602b7454367b6634ca docs/TEST_RESULTS.txt +386c8e011f58ef0c16a7fdde8cdc2a0caf7dfaa2d03ebd2c724750cc1df4cb41 docs/VALIDATION-0.2.0.md +7385fbd181fde1e610a5f72504e6ef668a44fb833597f7c2867beb6d313d0b3c docs/VALIDATION-0.2.1.md +65b6ac6feaf6bd3451601cc2d8369a8d62ef2edf9c2dc324a30207f54eed0e9e docs/VALIDATION-0.2.2.md +066c561e58f657e72b90a88695c4af4d40ad81bc88eeda5e9ff78a7b0fc1be3f docs/VALIDATION.md +c52dd4d2d794d6521c2f67a67f816ab0399c393dfb3085904530f24205b7d852 examples/README.md +91124ca9a95c3e6d4691010648bbaac9e57697d9fa2f01a2b9dddee9983e1c15 examples/archive.zip +e2a9fd2ce8d657ac1441582e93b0171623f450434050f12b0c272b2417cd5941 examples/config.json +a8c4fa7b0ff7b3a6aa1ee6f44433d8db67a6be4dddd573c43d6591ab91d165e6 examples/safe-source.html +09948d2c0190ba86cfd80c8eba788a65626862e85e150badd253ea570393e6f3 examples/table.csv +42301cf374e3616c1b4f1b4e7c0eedda776d84cadf4d6dabb46760a27762c4cd examples/welcome.md +8f50c61e4b02f5d748566e3e296e66e2cefc2853f6d61e1384634d19051cd4ec gradle/gradle-daemon-jvm.properties +4ac8955c8717d4339baadd76c5aac798659da55552f50e7b032c956b8f01bf87 gradle/libs.versions.toml +dbfd722314f09fe55e59ece4742ccb63ed4a75195943b2c5b48ccd3679fdde36 gradle/wrapper/gradle-wrapper.properties +47c3c2a3c564c434b4d009eb72add3b0bd5709568d1eafb61381eaa9f2d65f12 gradle.properties +fb20c2396e79d819c544bc580e77151e935e861cefa4b206a047c85f2d6815f2 gradlew +512900312c0d02d616c6cba161a9670679e43a55ef7facf2b2620e1d4f7209ad gradlew.bat +4bbfb6729f208ad41c052fd5f76140228ae3a136c367217742dd3e217604ff93 ios/README.md +7c4927468a9a3f916b193f60d6ba08616fb0971a66e099823f3e9b041eae1710 platform-jvm/build.gradle.kts +8e01457f1bc1f0ab91590e709ce6e23c0e0dde0cf485ab072e90b8350015722b platform-jvm/src/main/kotlin/dev/fileworkbench/platform/JvmZipArchive.kt +edce10469e0f0416edf44d9639587dadcd2462cd3ce08381a753259f8e9c5d98 platform-jvm/src/main/kotlin/dev/fileworkbench/platform/LocalPreviewLoader.kt +b060fa9f7d2de772574ec1e2e22153b1aedd9bd7c8261d2119eeba91beb38b9d platform-jvm/src/test/kotlin/dev/fileworkbench/platform/ZipTests.kt +b4b41265f49705e6d4c97295ef83820cd0c36195ee1cbd2a0e5b86e2e7d04ddf scripts/KotlinSyntaxCheck.kt +ffa3a0045027fe2422e637b055bb8bb3119e92d80aa308b1e8c4b17322d61731 scripts/bootstrap-wrapper.ps1 +03e5a125b31ba5c0f7253c9bc59ccb53eef6877f09b35a23df6b7bff9f770411 scripts/bootstrap-wrapper.sh +30c807becc4f2fe7a19eceb65bc835feb9c05865ce3bbbd2cf506ce525f7dd34 scripts/check-build-config.py +c1a59d08b15e2b89ee0ca02812c704f547e5cc93397bc1cdaf5bbab614f0b2d6 scripts/check-kotlin-syntax.sh +68d90639741e2f0b23dae1cfa59da55369b1812203b71702310576d2665390e1 scripts/check-project.py +a4fb1ea570c6567ae50a1b78b8c3248de54cbfda876ee3b8d8e45ac016f6d426 scripts/prepare-offline-tests.py +64fb183feebb01ae7e1c8294cfc67bdbca35623e44ce78f2d424463f38bacac0 scripts/regressions/FolderListingHarness.kt.template +9c176894bef238b27cb5ee45ae331d05868d8a8940b816496c756ab747ac30b2 scripts/test-build-config.py +8c0123f0db840d2685db3ff8ea77300f46dbf20abd870bc3b47464887c6ddced scripts/test-core.sh +30e4dbd55faea1a7d5cac8c8f61369b7217a5aaf31a6d5f91338c8407a43fc65 scripts/test-folder-listing.py +7f85cffa0cefab64598c4bedfd80ae68b4506dab78124714b36705d42b752977 scripts/verify-android.sh +bfa20b86dbe9003cc99702f4d868b66780795979c4012adff6d56177ac11ff8e settings.gradle.kts +1093581235d78eceb3ae22c492d05b314c496a8009008784f9cc5b4e00e104da validation/0.2.1/ANDROID_BUILD_ATTEMPT.txt +8f6e59726533a5cdf207ad30196dddd26de71843e96877602b7454367b6634ca validation/0.2.1/CORE_TESTS.txt +2266ed387d8ab59ae60b7028f9594f0eeb005ca438e1bf986fc7237d906cfe4a validation/0.2.1/KOTLIN_SYNTAX.txt +f2fb4c75286ba4b7d587781a8566408c507d97ff4604d63ef8d5fe53e7f6e17e validation/0.2.1/PATCH_CHECK.txt +ce911bf06515d42f5584070957864b1f51aad6e3d5700d0b81f339dfd18867f1 validation/0.2.1/REGRESSION_GUARD.txt +47648edefc431775af394291f151958b07488fd32866c5071cf14f4cdc548835 validation/0.2.1/SOURCE_CHECKS.txt +c5a5882e5b2fddc843ec31c8f1902fac7682eb23617759a20d890534377594e7 validation/0.2.2/android-build-attempt.txt +8f6e59726533a5cdf207ad30196dddd26de71843e96877602b7454367b6634ca validation/0.2.2/core-tests.txt +7f33a6321341b4440a35cdc63b1aa36468a141bcc849f39f81f4316b04807583 validation/0.2.2/folder-listing-fixed.txt +a6d323795a72f367b919998c06c2836c2fe3a033c8077fe7233b42dbf5754846 validation/0.2.2/folder-listing-original.txt +2266ed387d8ab59ae60b7028f9594f0eeb005ca438e1bf986fc7237d906cfe4a validation/0.2.2/kotlin-syntax.txt +951a35e6d41c2f739a16d24051327db1e363b38ca4fc5256cd42dda3c43214be validation/0.2.2/patch-checks.txt +47648edefc431775af394291f151958b07488fd32866c5071cf14f4cdc548835 validation/0.2.2/source-checks.txt +d1749375f3cb44e76725856023369722da9d1eb1c73624d33714842440a8b014 validation/0.2.3/android-build-attempt.txt +a5cc81444212a458318ad43da9dc0794331346d06995ca6ef66ab96c8419fff3 validation/0.2.3/archive-checks.txt +e2a4b42cbdc2f450daf247c97e05dd856481d51c796f0b6cf16f5cfbaafbdff1 validation/0.2.3/build-guard-tests.txt +8f6e59726533a5cdf207ad30196dddd26de71843e96877602b7454367b6634ca validation/0.2.3/core-tests.txt +8370aeffcdf9d52bf5d7bcfc9a44b24edc1fa1920d5098fd9db47f7d73aa41f0 validation/0.2.3/environment.txt +f213bd6c4f495d96422edda64a9e26fdff823eace970ccaa9919376e34e918d5 validation/0.2.3/folder-listing.txt +2266ed387d8ab59ae60b7028f9594f0eeb005ca438e1bf986fc7237d906cfe4a validation/0.2.3/kotlin-syntax.txt +b5a1e21c27820899e9c32607c639d8445b5c0e3e06c6b48abf6859ce6032d6db validation/0.2.3/shell-checks.txt +d9c3aaa93b3b571f5249d80616e9efdb25cb15fb5d7774a6506927d4ae4a6da5 validation/0.2.3/source-checks.txt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..57241d4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Third-party components + +New project source is licensed under `LICENSE`. External dependencies retain their own licences. +The Gradle build references Kotlin, Kotlin coroutines, AndroidX/Compose, Android platform tooling and +JUnit. The source archive does not vendor those runtime/build binaries, a LibreOffice/Collabora +engine, proprietary fonts, SDK files or signing keys. + +The bootstrap retrieves the official Gradle wrapper JAR and distribution and checks their pinned +SHA-256 digests. Retain upstream notices if you vendor or redistribute build tooling. + +Before binary distribution, generate an actual resolved dependency/licence inventory and include +all required third-party notices. Do not interpret this source-level note as a completed binary +licence audit. Any future Office native engine or font bundle needs its own review. diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts new file mode 100644 index 0000000..0b4cc86 --- /dev/null +++ b/androidApp/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} +android { + namespace = "dev.fileworkbench.android" + compileSdk = 36 + defaultConfig { + applicationId = "dev.fileworkbench.android" + minSdk = 26 + targetSdk = 36 + versionCode = 5 + versionName = "0.2.3" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } +} +kotlin { jvmToolchain(17) } +dependencies { + implementation(project(":core")) + implementation(project(":platform-jvm")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.coroutines.android) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro new file mode 100644 index 0000000..c387681 --- /dev/null +++ b/androidApp/proguard-rules.pro @@ -0,0 +1 @@ +# No app-specific shrinking rules in the read-only MVP. diff --git a/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/BrowserIntegrationTest.kt b/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/BrowserIntegrationTest.kt new file mode 100644 index 0000000..1fefa93 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/BrowserIntegrationTest.kt @@ -0,0 +1,155 @@ +package dev.fileworkbench.android + +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.DocumentsContract +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.fileworkbench.android.browser.* +import dev.fileworkbench.android.testing.FixtureDocumentsProvider +import dev.fileworkbench.core.browser.* +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** Included, not executed by the offline source checks. Requires :androidApp:connectedDebugAndroidTest. */ +@RunWith(AndroidJUnit4::class) +class BrowserIntegrationTest { + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + private val root get() = DocumentsContract.buildTreeDocumentUri("${context.packageName}.browserfixture", "root") + private fun uri(id: String) = DocumentsContract.buildDocumentUriUsingTree(root, id) + + @Test fun directoryUsesOneBoundedMetadataQuery() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + FixtureDocumentsProvider.childQueries.set(0) + val listing = store.list(location, store.rootEntry(location).id) + assertEquals(1, FixtureDocumentsProvider.childQueries.get()) + assertEquals(11, listing.entries.size) + assertNull(listing.information) + assertFalse(listing.providerLoading) + assertFalse(listing.truncated) + assertTrue(listing.entries.any { it.name == "Subfolder" && it.isDirectory }) + assertTrue(listing.entries.any { it.virtual }) + assertTrue(listing.entries.first { it.name == "sample.txt" }.size == null) + } + // Regression for 0.2.0/0.2.1: a successful cursor with no EXTRA_ERROR must + // not fall through the nullable return value of use into a "no listing" error. + @Test fun emptyDirectoryWithoutExtrasIsAValidListing() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("empty").toString()) + assertTrue(listing.entries.isEmpty()) + assertNull(listing.information) + assertFalse(listing.providerLoading) + assertFalse(listing.truncated) + } + @Test fun informationalExtraWithoutAnErrorDoesNotDiscardRows() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("info").toString()) + assertEquals(listOf("sample.txt"), listing.entries.map { it.name }) + assertEquals("Cached files", listing.information) + assertFalse(listing.providerLoading) + } + @Test fun loadingExtraWithoutAnErrorRetainsCachedRows() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("loading").toString()) + assertEquals(listOf("sample.txt"), listing.entries.map { it.name }) + assertTrue(listing.providerLoading) + assertNull(listing.information) + } + @Test fun loadingDirectoryCanInitiallyHaveNoRows() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("loading-empty").toString()) + assertTrue(listing.entries.isEmpty()) + assertTrue(listing.providerLoading) + assertNull(listing.information) + } + @Test fun providerErrorMessageIsRetainedAlongsideAvailableRows() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("provider-error").toString()) + assertEquals(listOf("sample.txt"), listing.entries.map { it.name }) + assertEquals("Some files are unavailable", listing.information) + } + @Test fun subfoldersUseOpaqueProviderIds() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("sub").toString()) + assertEquals(listOf("nested.txt"), listing.entries.map { it.name }) + } + @Test fun hugeFolderIsExplicitlyMarkedTruncated() = runBlocking { + val store = SafBrowserStore(context) + val location = store.addTree(root, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val listing = store.list(location, uri("many").toString()) + assertEquals(BrowserLimits.MAX_ENTRIES, listing.entries.size) + assertTrue(listing.truncated) + } + @Test fun singlePickReturnsOnlyATemporaryCopiedReadUri() = runBlocking { + val store = SafBrowserStore(context) + val entry = store.metadata(uri("text")) + val result = PickedFileStore(context).prepare(PickerRequest("text/*"), listOf(entry)) + assertEquals(Intent.FLAG_GRANT_READ_URI_PERMISSION, result.flags) + assertEquals("${context.packageName}.files", result.data!!.authority) + assertFalse(DocumentsContract.isTreeUri(result.data!!)) + assertEquals(1, result.clipData!!.itemCount) + assertEquals("text/plain", result.type) + assertEquals("Fixture text for the browser.\n", context.contentResolver.openInputStream(result.data!!)!!.bufferedReader().use { it.readText() }) + } + @Test fun copiedExtensionlessFileRetainsItsProviderMimeType() = runBlocking { + val store = SafBrowserStore(context) + val entry = store.metadata(uri("opaque")) + val result = PickedFileStore(context).prepare(PickerRequest("application/json"), listOf(entry)) + assertEquals("application/json", context.contentResolver.getType(result.data!!)) + } + @Test fun multiPickUsesClipDataWithoutPrefixOrWriteGrants() = runBlocking { + val store = SafBrowserStore(context) + val entries = listOf(store.metadata(uri("text")), store.metadata(uri("json"))) + val result = PickedFileStore(context).prepare(PickerRequest(allowMultiple = true), entries) + assertNull(result.data) + assertEquals(2, result.clipData!!.itemCount) + assertEquals(Intent.FLAG_GRANT_READ_URI_PERMISSION, result.flags) + for (index in 0..1) assertEquals("${context.packageName}.files", result.clipData!!.getItemAt(index).uri.authority) + } + @Test fun filePickerAndFilesCategoryResolveButSystemDocumentActionDoesNot() { + val manager = context.packageManager + fun activities(intent: Intent) = manager.queryIntentActivities(intent.setPackage(context.packageName), PackageManager.MATCH_DEFAULT_ONLY) + assertTrue(activities(Intent(Intent.ACTION_GET_CONTENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE)) + .any { it.activityInfo.name.endsWith("FilePickerActivity") }) + assertTrue(activities(Intent(Intent.ACTION_MAIN).addCategory("android.intent.category.APP_FILES")) + .any { it.activityInfo.name.endsWith("MainActivity") }) + assertTrue(activities(Intent(Intent.ACTION_OPEN_DOCUMENT).setType("*/*").addCategory(Intent.CATEGORY_OPENABLE)).isEmpty()) + } + @Test fun localOnlyAndMimeConstraintsSurviveSystemPickerDelegation() { + val request = PickerIntents.request(Intent(Intent.ACTION_GET_CONTENT).setType("image/*") + .putExtra(Intent.EXTRA_LOCAL_ONLY, true).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + .putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/png"))) + val delegated = PickerIntents.openDocument(request) + assertEquals(Intent.ACTION_OPEN_DOCUMENT, delegated.action) + assertTrue(delegated.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false)) + assertTrue(delegated.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) + assertArrayEquals(arrayOf("image/png"), delegated.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)) + } + @Test fun incomingExtrasCannotSeedRestoredSelectionsOrPreviewUris() { + val incoming = Intent(Intent.ACTION_GET_CONTENT).setType("image/*") + .putExtra(Intent.EXTRA_LOCAL_ONLY, true) + .putExtra("sourceUri", uri("text").toString()) + .putExtra("browser.selection", "malicious preselection") + .putExtra("browser.root", root.toString()) + val safe = PickerIntents.sanitizedPickerIntent(incoming, PickerIntents.request(incoming)) + assertFalse(safe.hasExtra("sourceUri")) + assertFalse(safe.hasExtra("browser.selection")) + assertFalse(safe.hasExtra("browser.root")) + assertTrue(safe.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false)) + assertFalse(PickerIntents.sanitizedViewerIntent(incoming).hasExtra("sourceUri")) + } + @Test fun providerVirtualDocumentsAreNotPickerCandidates() = runBlocking { + val entry = SafBrowserStore(context).metadata(uri("virtual")) + assertTrue(entry.virtual) + assertFalse(PickerRequest().accepts(entry)) + } +} diff --git a/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/ViewerSmokeTest.kt b/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/ViewerSmokeTest.kt new file mode 100644 index 0000000..65ca394 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/dev/fileworkbench/android/ViewerSmokeTest.kt @@ -0,0 +1,71 @@ +package dev.fileworkbench.android + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.pdf.PdfDocument +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.fileworkbench.android.render.PdfRenderClient +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** Included but NOT executed in the source-authoring environment. Requires an emulator/device. */ +@RunWith(AndroidJUnit4::class) +class ViewerSmokeTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun homeOffersAnAccountFreeOpenAction() { + compose.onNodeWithText("Files, not services.").assertIsDisplayed() + compose.onNodeWithText("Open file").assertIsDisplayed() + compose.onNodeWithText("Add folder").assertIsDisplayed() + compose.onNodeWithText("Browse").assertIsDisplayed() + compose.onNodeWithText("Recent").assertIsDisplayed() + } + + @Test fun isolatedPdfServiceRendersARealDescriptor() = runBlocking { + val context = compose.activity.applicationContext + val file = File.createTempFile("instrumentation-fixture-", ".pdf", context.cacheDir) + try { + // PdfDocument has close(), but does not implement Closeable/AutoCloseable. + // Kotlin's use() therefore cannot be called on it. + val pdf = PdfDocument() + try { + val page = pdf.startPage(PdfDocument.PageInfo.Builder(300, 400, 1).create()) + try { + page.canvas.drawColor(Color.WHITE) + page.canvas.drawText( + "Fileworkbench PDF smoke test", 16f, 50f, + Paint().apply { color = Color.BLACK; textSize = 16f } + ) + } finally { + // A started page must be finished before closing the document. + pdf.finishPage(page) + } + file.outputStream().use { pdf.writeTo(it) } + } finally { + pdf.close() + } + + val client = PdfRenderClient(context) + try { + val page = client.render(file, 0) + assertEquals(1, page.count) + assertEquals(0, page.index) + assertTrue(page.bitmap.width > 0) + assertTrue(page.bitmap.height > 0) + } finally { + client.close() + } + } finally { + // Clean up even when fixture creation or rendering fails. + file.delete() + } + } +} diff --git a/androidApp/src/debug/AndroidManifest.xml b/androidApp/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..dc86719 --- /dev/null +++ b/androidApp/src/debug/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/androidApp/src/debug/kotlin/dev/fileworkbench/android/testing/FixtureDocumentsProvider.kt b/androidApp/src/debug/kotlin/dev/fileworkbench/android/testing/FixtureDocumentsProvider.kt new file mode 100644 index 0000000..d987c92 --- /dev/null +++ b/androidApp/src/debug/kotlin/dev/fileworkbench/android/testing/FixtureDocumentsProvider.kt @@ -0,0 +1,93 @@ +package dev.fileworkbench.android.testing + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.Bundle +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.DocumentsContract.Root +import android.provider.DocumentsProvider +import java.io.File +import java.io.FileNotFoundException +import java.util.concurrent.atomic.AtomicInteger + +/** Synthetic sample documents only. This provider exists solely in the debug build for device tests. */ +class FixtureDocumentsProvider : DocumentsProvider() { + override fun onCreate() = true + + override fun queryRoots(projection: Array?): Cursor { + val columns = projection ?: arrayOf(Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS, Root.COLUMN_MIME_TYPES) + return MatrixCursor(columns).apply { + val values = mapOf(Root.COLUMN_ROOT_ID to "fixture", Root.COLUMN_DOCUMENT_ID to "root", + Root.COLUMN_TITLE to "Fileworkbench test files", Root.COLUMN_FLAGS to (Root.FLAG_SUPPORTS_IS_CHILD or Root.FLAG_LOCAL_ONLY), + Root.COLUMN_MIME_TYPES to "*/*") + addRow(columns.map { values[it] }) + } + } + override fun queryDocument(documentId: String, projection: Array?): Cursor = documents(listOf(documentId), projection) + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { + childQueries.incrementAndGet() + return documents(when (parentDocumentId) { + "root" -> listOf("sub", "text", "json", "hidden", "virtual", "many", "empty", "info", "loading", "loading-empty", "provider-error") + "sub" -> listOf("nested") + "many" -> (0..5001).map { "many-$it" } + "empty", "loading-empty" -> emptyList() + "info", "loading", "provider-error" -> listOf("text") + else -> throw FileNotFoundException("Unknown test folder") + }, projection).apply { + // Most real providers omit EXTRA_ERROR on success. All other folders retain + // the MatrixCursor default extras so that the normal path is tested too. + when (parentDocumentId) { + "info" -> setExtras(Bundle().apply { + putString(DocumentsContract.EXTRA_INFO, "Cached files") + }) + "loading", "loading-empty" -> setExtras(Bundle().apply { + putBoolean(DocumentsContract.EXTRA_LOADING, true) + }) + "provider-error" -> setExtras(Bundle().apply { + putString(DocumentsContract.EXTRA_INFO, "Cached files") + putString(DocumentsContract.EXTRA_ERROR, "Some files are unavailable") + }) + } + } + } + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = when (parentDocumentId) { + "root" -> documentId in setOf("sub", "text", "json", "hidden", "virtual", "many", "nested", "opaque", "empty", "info", "loading", "loading-empty", "provider-error") || documentId.startsWith("many-") + "sub" -> documentId == "nested" + "many" -> documentId.startsWith("many-") + "info", "loading", "provider-error" -> documentId == "text" + else -> false + } + override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor { + if (mode != "r") throw FileNotFoundException("Fixtures are read-only") + val content = when (documentId) { + "text" -> "Fixture text for the browser.\n" + "json", "opaque" -> "{\"fixture\":true}\n" + "hidden" -> "hidden=true\n" + "nested" -> "Nested document.\n" + else -> throw FileNotFoundException("Not an openable fixture file") + } + val directory = File(requireNotNull(context).cacheDir, "browser-fixtures").apply { mkdirs() } + val file = File(directory, "$documentId.txt").apply { writeText(content) } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + private fun documents(ids: List, projection: Array?): Cursor { + val columns = projection ?: arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS) + return MatrixCursor(columns).apply { + ids.forEach { id -> + val directory = id in setOf("root", "sub", "many", "empty", "info", "loading", "loading-empty", "provider-error") + val name = when (id) { "root" -> "Test root"; "sub" -> "Subfolder"; "text" -> "sample.txt"; "json" -> "data.json" + "opaque" -> "file-without-extension"; "hidden" -> ".hidden"; "virtual" -> "Virtual document"; "nested" -> "nested.txt"; "many" -> "Large folder"; else -> "$id.txt" } + val values = mapOf(Document.COLUMN_DOCUMENT_ID to id, Document.COLUMN_DISPLAY_NAME to name, + Document.COLUMN_MIME_TYPE to if (directory) Document.MIME_TYPE_DIR else if (id in setOf("json", "opaque")) "application/json" else "text/plain", + Document.COLUMN_SIZE to null, Document.COLUMN_LAST_MODIFIED to 1_700_000_000_000L, + Document.COLUMN_FLAGS to if (id == "virtual") Document.FLAG_VIRTUAL_DOCUMENT else 0) + addRow(columns.map { values[it] }) + } + } + } + companion object { val childQueries = AtomicInteger(0) } +} diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..497d6f1 --- /dev/null +++ b/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/FilePickerActivity.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/FilePickerActivity.kt new file mode 100644 index 0000000..c0ba766 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/FilePickerActivity.kt @@ -0,0 +1,33 @@ +package dev.fileworkbench.android + +import android.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import dev.fileworkbench.android.browser.BrowserViewModel +import dev.fileworkbench.android.browser.PickerIntents +import dev.fileworkbench.android.ui.FileworkbenchApp + +/** Separate standard-launch activity: an import request cannot hijack an existing viewer task/result. */ +class FilePickerActivity : ComponentActivity() { + private val viewer: ViewerViewModel by viewModels() + private val browser: BrowserViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + val request = runCatching { PickerIntents.request(intent) }.getOrNull() + if (request != null) setIntent(PickerIntents.sanitizedPickerIntent(intent, request)) + else setIntent(android.content.Intent(intent).replaceExtras(Bundle())) + super.onCreate(savedInstanceState) + setResult(Activity.RESULT_CANCELED) + if (request == null) { finish(); return } + enableEdgeToEdge() + browser.start(request) + setContent { + FileworkbenchApp(viewer, browser, + onPickerResult = { result -> setResult(Activity.RESULT_OK, result); finish() }, + onPickerCancel = { setResult(Activity.RESULT_CANCELED); finish() }) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/MainActivity.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/MainActivity.kt new file mode 100644 index 0000000..81e108e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/MainActivity.kt @@ -0,0 +1,64 @@ +package dev.fileworkbench.android + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.content.IntentCompat +import dev.fileworkbench.android.ui.FileworkbenchApp +import dev.fileworkbench.android.browser.BrowserViewModel +import dev.fileworkbench.android.browser.PickerIntents +import android.provider.DocumentsContract + +class MainActivity : ComponentActivity() { + private val viewer: ViewerViewModel by viewModels() + private val browser: BrowserViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + // Sanitize before any ViewModel can receive activity extras as SavedStateHandle defaults. + setIntent(PickerIntents.sanitizedViewerIntent(intent)) + super.onCreate(savedInstanceState) + enableEdgeToEdge() + browser.start() + setContent { FileworkbenchApp(viewer, browser) } + if (savedInstanceState == null) receive(intent) + } + + override fun onResume() { + super.onResume() + browser.refreshLocations() + viewer.refreshRecents() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + val safe = PickerIntents.sanitizedViewerIntent(intent) + setIntent(safe) + receive(safe) + } + + private fun receive(intent: Intent?) { + if (intent == null) return + if (intent.action == Intent.ACTION_MAIN) { + if (intent.hasCategory("android.intent.category.APP_FILES")) { viewer.closeDocument(); browser.locationsHome() } + return + } + if (intent.action == Intent.ACTION_VIEW && intent.type in setOf(DocumentsContract.Document.MIME_TYPE_DIR, "inode/directory")) { + viewer.closeDocument() + intent.data?.let { browser.receiveDirectory(it, intent.flags) } + ?: browser.notice("No folder location was supplied. Use Add folder.") + return + } + val uri = when (intent.action) { + Intent.ACTION_VIEW -> intent.data + Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?: intent.clipData?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.uri + else -> null + } + if (uri == null) viewer.externalOpenError() + else viewer.open(uri, persist = intent.flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ViewerViewModel.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ViewerViewModel.kt new file mode 100644 index 0000000..8a30428 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ViewerViewModel.kt @@ -0,0 +1,195 @@ +package dev.fileworkbench.android + +import android.app.Application +import android.graphics.Bitmap +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dev.fileworkbench.android.data.DocumentStore +import dev.fileworkbench.android.data.RecentDocument +import dev.fileworkbench.android.data.StagedDocument +import dev.fileworkbench.android.render.ImageLoader +import dev.fileworkbench.android.render.PdfRenderClient +import dev.fileworkbench.core.* +import dev.fileworkbench.platform.LocalPreviewLoader +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** State survives configuration changes; reopening after process death still requires a valid provider grant. */ +data class ViewerState( + val document: StagedDocument? = null, + val preview: DocumentPreview? = null, + val bitmap: Bitmap? = null, + val page: Int = 0, + val pageCount: Int = 0, + val loading: Boolean = false, + val error: String? = null, + val notice: String? = null, + val recents: List = emptyList(), +) + +class ViewerViewModel(application: Application, private val savedState: SavedStateHandle) : AndroidViewModel(application) { + private val store = DocumentStore(application) + private val pdf = PdfRenderClient(application) + private val _state = MutableStateFlow(ViewerState()) + val state = _state.asStateFlow() + private var documentJob: Job? = null + private var generation = 0 + private val processing = Mutex() + + init { + store.cleanOrphanedSessions() + _state.update { it.copy(recents = store.recents()) } + savedState.get("sourceUri")?.let { open(Uri.parse(it), restorePage = savedState["pdfPage"] ?: 0) } + } + + fun open(uri: Uri, persist: Boolean = false, restorePage: Int = 0) { + val token = ++generation + documentJob?.cancel() + documentJob = viewModelScope.launch { + processing.withLock { + var staged: StagedDocument? = null + var accepted = false + _state.update { it.copy(loading = true, error = null, notice = null) } + try { + if (persist) withContext(Dispatchers.IO) { store.persistReadGrant(uri) } + staged = store.stage(uri) + ensureActive() + if (generation != token) return@launch + val old = _state.value.document + val current = staged + savedState["sourceUri"] = current.source + savedState["pdfPage"] = restorePage + store.remember(current) + _state.value = ViewerState(document = current, loading = true, recents = store.recents()) + accepted = true + old?.file?.let(store::releaseSnapshot) + when (current.kind) { + FileKind.PDF -> { + val page = pdf.render(current.file, restorePage) + ensureActive() + _state.update { it.copy(bitmap = page.bitmap, page = page.index, pageCount = page.count, loading = false) } + } + FileKind.IMAGE -> { + val bitmap = withContext(Dispatchers.IO) { ImageLoader.decode(current.file) } + ensureActive() + _state.update { it.copy(bitmap = bitmap, loading = false) } + } + else -> { + val preview = withContext(Dispatchers.IO) { LocalPreviewLoader().load(current.file, current.displayName, current.mime) } + ensureActive() + _state.update { it.copy(preview = preview, loading = false) } + } + } + } catch (e: CancellationException) { throw e } + catch (e: Exception) { + if (generation == token) _state.update { it.copy(loading = false, error = messageFor(e)) } + } finally { + if (!accepted) staged?.file?.let(store::releaseSnapshot) + } + } + } + } + + fun goToPage(index: Int) { + val current = _state.value + val document = current.document ?: return + if (document.kind != FileKind.PDF || index !in 0 until current.pageCount || current.loading) return + documentJob?.cancel() + documentJob = viewModelScope.launch { + processing.withLock { + _state.update { it.copy(loading = true, error = null) } + try { + val page = pdf.render(document.file, index) + ensureActive() + savedState["pdfPage"] = page.index + _state.update { it.copy(bitmap = page.bitmap, page = page.index, pageCount = page.count, loading = false) } + } catch (e: CancellationException) { throw e } + catch (e: Exception) { _state.update { it.copy(loading = false, error = messageFor(e)) } } + } + } + } + + fun closeDocument() { + ++generation + documentJob?.cancel() + _state.value.document?.file?.let(store::releaseSnapshot) + savedState.remove("sourceUri") + savedState.remove("pdfPage") + _state.value = ViewerState(recents = store.recents()) + } + + fun clearHistoryAndCache() { + closeDocument() + store.clearHistoryAndCache() + _state.value = ViewerState(notice = "History and private cached copies cleared. Saved folders and original files were not changed.") + } + + fun refreshRecents() { _state.update { it.copy(recents = store.recents()) } } + + fun forget(uri: String) { store.forget(uri); _state.update { it.copy(recents = store.recents()) } } + fun dismissNotice() { _state.update { it.copy(notice = null) } } + fun notice(message: String) { _state.update { it.copy(notice = message) } } + fun externalOpenError() { _state.update { it.copy(error = "No readable file was supplied. Use Open file or share a file attachment, not a cloud-document link.") } } + + suspend fun shareOriginal(): android.content.Intent? { + val document = _state.value.document ?: return null + return try { store.shareIntent(document) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { notice(messageFor(e)); null } + } + + suspend fun openInOtherApp(): android.content.Intent? { + val document = _state.value.document ?: return null + return try { store.openExternalIntent(document) } + catch (e: CancellationException) { throw e } + catch (e: Exception) { notice(messageFor(e)); null } + } + + fun saveOriginal(uri: Uri, expectedDocumentId: String?) { + val document = _state.value.document ?: return + if (document.id != expectedDocumentId) { notice("The document changed while choosing a destination. Export was cancelled; an empty destination file may exist."); return } + viewModelScope.launch { + try { store.saveCopy(document, uri); notice("A copy of the original file was saved.") } + catch (e: CancellationException) { throw e } + catch (e: Exception) { notice(messageFor(e)) } + } + } + + fun exportPreview(uri: Uri, expectedDocumentId: String?) { + if (_state.value.document?.id != expectedDocumentId) { notice("The document changed while choosing a destination. Export was cancelled; an empty destination file may exist."); return } + val preview = _state.value.preview ?: return + viewModelScope.launch { + try { + val text = withContext(Dispatchers.Default) { + buildString { + append("Fileworkbench text preview\n\n") + preview.warnings.forEach { append("NOTICE: ").append(it).append("\n") } + append('\n').append(PreviewText.render(preview)) + } + } + store.exportText(text, uri) + notice("Preview text was exported. The original file was not changed.") + } catch (e: CancellationException) { throw e } + catch (e: Exception) { notice(messageFor(e)) } + } + } + + private fun messageFor(e: Exception): String = when (e) { + is PreviewException -> e.message ?: "The file could not be previewed." + is SecurityException, is java.io.FileNotFoundException -> "The file is no longer accessible. Select it again using Open file; temporary attachment grants can expire." + is java.util.zip.ZipException -> "This is not a readable ZIP/Office package. It may be damaged or encrypted." + else -> "The file could not be opened. It may be unsupported, damaged, or unavailable from its storage provider." + } + + override fun onCleared() { + pdf.close() + _state.value.document?.file?.let(store::releaseSnapshot) + super.onCleared() + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserThumbnails.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserThumbnails.kt new file mode 100644 index 0000000..56b6947 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserThumbnails.kt @@ -0,0 +1,39 @@ +package dev.fileworkbench.android.browser + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Point +import android.net.Uri +import android.provider.DocumentsContract +import android.util.LruCache +import dev.fileworkbench.core.browser.BrowserEntry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** Opt-in, visible-row provider thumbnails only; never parse every full document while browsing. */ +class BrowserThumbnails(context: Context) { + private val resolver = context.applicationContext.contentResolver + private val permits = Semaphore(2) + private val cache = object : LruCache(4 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + suspend fun load(entry: BrowserEntry): Bitmap? { + if (entry.isDirectory || entry.virtual || !entry.thumbnailSupported) return null + val key = "${entry.id}:${entry.modifiedMillis}:${entry.size}" + cache.get(key)?.let { return it } + return permits.withPermit { + try { + ProviderIo.read { signal -> + val bitmap = DocumentsContract.getDocumentThumbnail(resolver, Uri.parse(entry.id), Point(96, 96), signal) + if (bitmap != null && bitmap.allocationByteCount <= 1024 * 1024) cache.put(key, bitmap) + bitmap?.takeIf { it.allocationByteCount <= 1024 * 1024 } + } + } catch (_: TimeoutCancellationException) { null } + catch (e: CancellationException) { throw e } + catch (_: Exception) { null } + } + } + fun clear() { cache.evictAll() } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserViewModel.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserViewModel.kt new file mode 100644 index 0000000..f321c1b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/BrowserViewModel.kt @@ -0,0 +1,297 @@ +package dev.fileworkbench.android.browser + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import dev.fileworkbench.android.MainActivity +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.browser.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.json.JSONArray +import org.json.JSONObject + +data class BrowserState( + val locations: List = emptyList(), + val location: BrowserLocation? = null, + val trail: List = emptyList(), + val listing: BrowserListing = BrowserListing(emptyList()), + val options: BrowserOptions = BrowserOptions(), + val thumbnails: Boolean = false, + val recentsTab: Boolean = false, + val loading: Boolean = false, + val error: String? = null, + val notice: String? = null, + val picker: PickerRequest? = null, + val selected: List = emptyList(), + val exporting: Boolean = false, + val result: Intent? = null, + val outgoing: Intent? = null, +) { + val directory: BrowserEntry? get() = trail.lastOrNull() +} + +class BrowserViewModel(application: Application, private val savedState: SavedStateHandle) : AndroidViewModel(application) { + private val store = SafBrowserStore(application) + private val picked = PickedFileStore(application) + val thumbnailStore = BrowserThumbnails(application) + private val preferences = application.getSharedPreferences("browser_preferences", 0) + private val _state = MutableStateFlow(BrowserState()) + val state = _state.asStateFlow() + private var navigationJob: Job? = null + private var exportJob: Job? = null + private var started = false + + init { + val roots = store.locations() + val options = BrowserOptions( + query = savedState.get("browser.query").orEmpty(), + sort = runCatching { BrowserSort.valueOf(preferences.getString("sort", "NAME")!!) }.getOrDefault(BrowserSort.NAME), + descending = preferences.getBoolean("descending", false), showHidden = preferences.getBoolean("hidden", false)) + val location = roots.firstOrNull { it.id == savedState.get("browser.root") } + val ids = savedState.get>("browser.path.ids").orEmpty() + val names = savedState.get>("browser.path.names").orEmpty() + val trail = if (location != null && ids.isNotEmpty() && ids.size == names.size && ids.size <= BrowserLimits.MAX_DEPTH) + ids.mapIndexed { index, id -> BrowserEntry(id, names[index], DocumentsContract.Document.MIME_TYPE_DIR, isDirectory = true) } + else location?.let { listOf(store.rootEntry(it)) }.orEmpty() + _state.value = BrowserState(locations = roots, location = location, trail = trail, options = options, + thumbnails = preferences.getBoolean("thumbnails", false), recentsTab = savedState["browser.recents"] ?: false) + // Defer restoration until the host configures picker restrictions (notably LOCAL_ONLY). + } + + fun start(picker: PickerRequest? = null) { + if (started) return + started = true + // Do not persist this marker: a new VM must requery the provider after process death. + _state.update { it.copy(picker = picker, selected = restoreSelection().takeIf { picker != null }.orEmpty(), + recentsTab = if (picker != null) false else it.recentsTab) } + if (picker?.localOnly == true) { + locationsHome() + } else if (_state.value.directory != null) refresh() + if (picker != null && !picker.isValid) notice("The requesting app supplied invalid file-type filters. Cancel and try another picker.") + } + + fun addFolder(uri: Uri, flags: Int, initialDirectory: Uri? = null) { + if (_state.value.picker?.localOnly == true) { notice("Use Choose file for a local-only request."); return } + navigationJob?.cancel() + navigationJob = viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + try { + val location = store.addTree(uri, flags) + ensureActive() + val rootEntry = store.rootEntry(location) + val start = initialDirectory?.takeIf { it.toString() != rootEntry.id }?.let { store.metadata(it) } + require(start == null || start.isDirectory) { "The folder link does not point to a directory." } + val trail = listOf(rootEntry) + listOfNotNull(start) + _state.update { it.copy(locations = (store.locations() + location).distinctBy { item -> item.id }, + location = location, trail = trail, listing = BrowserListing(emptyList()), + options = it.options.copy(query = ""), recentsTab = false) } + saveNavigation() + val listing = store.list(location, trail.last().id) + ensureActive() + _state.update { it.copy(listing = listing, loading = false, + notice = if (!location.persistent) "This folder is available temporarily; the provider did not retain read access." else null) } + } catch (e: TimeoutCancellationException) { failed("The storage provider timed out. Retry or choose another location.") } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed(message(e)) } + } + } + + fun receiveDirectory(uri: Uri, flags: Int) { + locationsHome() + if (uri.scheme != "content" || !UriReadGrants.isTree(uri) || flags and Intent.FLAG_GRANT_READ_URI_PERMISSION == 0) { + notice("This folder link does not include a browsable Android tree grant. Use Add folder and select the folder once.") + return + } + val target = uri.takeIf { DocumentsContract.isDocumentUri(getApplication(), it) } + addFolder(uri, flags, target) + } + + fun refreshLocations() { + val remembered = store.locations() + val active = _state.value.location + if (active != null && active.persistent && remembered.none { it.id == active.id }) locationsHome() + val temporary = active?.takeIf { !it.persistent && remembered.none { item -> item.id == it.id } } + _state.update { it.copy(locations = remembered + listOfNotNull(temporary)) } + } + + fun enter(location: BrowserLocation) { + if (_state.value.exporting || _state.value.picker?.localOnly == true) return + _state.update { it.copy(location = location, trail = listOf(store.rootEntry(location)), listing = BrowserListing(emptyList()), + options = it.options.copy(query = ""), recentsTab = false, error = null) } + saveNavigation(); refresh() + } + fun enter(entry: BrowserEntry) { + val current = _state.value + if (!entry.isDirectory || current.location == null || current.exporting || current.loading) return + if (current.listing.entries.none { it.id == entry.id && it.isDirectory }) return + if (current.trail.size >= BrowserLimits.MAX_DEPTH || current.trail.any { it.id == entry.id }) { + notice("This folder is too deeply nested, or the provider returned a folder loop."); return + } + _state.update { it.copy(trail = it.trail + entry, listing = BrowserListing(emptyList()), options = it.options.copy(query = "")) } + saveNavigation(); refresh() + } + fun breadcrumb(index: Int) { + if (index !in _state.value.trail.indices || _state.value.exporting) return + _state.update { it.copy(trail = it.trail.take(index + 1), listing = BrowserListing(emptyList()), options = it.options.copy(query = "")) } + saveNavigation(); refresh() + } + fun up() { + if (_state.value.trail.size > 1) breadcrumb(_state.value.trail.lastIndex - 1) else locationsHome() + } + fun locationsHome() { + navigationJob?.cancel() + _state.update { it.copy(location = null, trail = emptyList(), listing = BrowserListing(emptyList()), loading = false, + error = null, recentsTab = false, options = it.options.copy(query = "")) } + saveNavigation() + } + fun recentTab(value: Boolean) { + if (_state.value.picker != null) return + _state.update { it.copy(recentsTab = value) } + savedState["browser.recents"] = value + } + fun refresh() { + val current = _state.value + val location = current.location ?: return + val directory = current.directory ?: return + navigationJob?.cancel() + navigationJob = viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + try { + val listing = store.list(location, directory.id) + ensureActive() + _state.update { it.copy(listing = listing, loading = false) } + } catch (e: TimeoutCancellationException) { failed("The storage provider timed out. Retry or choose another location.") } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed(message(e)) } + } + } + fun forget(location: BrowserLocation) { + store.forget(location) + thumbnailStore.clear() + if (_state.value.location?.id == location.id) locationsHome() + _state.update { it.copy(locations = store.locations(), selected = emptyList()) } + saveSelection() + } + fun updateOptions(options: BrowserOptions) { + val bounded = options.copy(query = options.query.take(BrowserLimits.MAX_QUERY_LENGTH)) + _state.update { it.copy(options = bounded) } + savedState["browser.query"] = bounded.query + preferences.edit().putString("sort", bounded.sort.name).putBoolean("descending", bounded.descending) + .putBoolean("hidden", bounded.showHidden).apply() + } + fun setThumbnails(value: Boolean) { + _state.update { it.copy(thumbnails = value) } + preferences.edit().putBoolean("thumbnails", value).apply() + if (!value) thumbnailStore.clear() + } + fun toggle(entry: BrowserEntry) { + if (_state.value.exporting) return + val request = _state.value.picker ?: return + try { _state.update { it.copy(selected = request.toggle(it.selected, entry)) }; saveSelection() } + catch (e: IllegalArgumentException) { notice(e.message ?: "This file cannot be selected.") } + } + fun clearSelection() { if (!_state.value.exporting) { _state.update { it.copy(selected = emptyList()) }; saveSelection() } } + + /** Called only with the result of a user-mediated ACTION_OPEN_DOCUMENT, never incoming intent extras. */ + fun selectedBySystem(uris: List) { + val request = _state.value.picker ?: return + if (_state.value.exporting) return + if (uris.size > BrowserLimits.MAX_SELECTED || (!request.allowMultiple && uris.size > 1)) { + notice("Too many files selected. Choose at most ${if (request.allowMultiple) BrowserLimits.MAX_SELECTED else 1}."); return + } + navigationJob?.cancel() + navigationJob = viewModelScope.launch { + _state.update { it.copy(loading = true) } + try { + val entries = uris.distinct().map { store.metadata(it) } + val selected = if (request.allowMultiple) (_state.value.selected + entries).distinctBy { it.id } else entries + request.validateSelection(selected) + _state.update { it.copy(selected = selected, loading = false) } + saveSelection() + } catch (e: TimeoutCancellationException) { failed("The storage provider timed out while reading file details.") } + catch (e: CancellationException) { throw e } + catch (e: Exception) { failed(message(e)) } + } + } + fun confirmSelection() { + val current = _state.value + val request = current.picker ?: return + if (current.exporting || current.loading || current.selected.isEmpty()) return + exportJob = viewModelScope.launch { + _state.update { it.copy(exporting = true, error = null) } + try { + // Revalidate MIME/directory/virtual status immediately before copying, not just against stale rows. + val entries = current.selected.map { store.metadata(Uri.parse(it.id)) } + request.validateSelection(entries) + val result = picked.prepare(request, entries) + ensureActive() + _state.update { it.copy(result = result, exporting = false) } + } catch (e: TimeoutCancellationException) { _state.update { it.copy(exporting = false, notice = "The storage provider timed out. Nothing was returned.") } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { _state.update { it.copy(exporting = false, notice = message(e)) } } + } + } + fun openInOtherApp(entry: BrowserEntry) { + if (_state.value.exporting) return + exportJob = viewModelScope.launch { + _state.update { it.copy(exporting = true) } + try { + val fresh = store.metadata(Uri.parse(entry.id)) + val result = picked.prepare(PickerRequest(), listOf(fresh)) + val view = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(result.data, fresh.mimeType) + clipData = result.clipData + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(view, "Open in another app").apply { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(getApplication(), MainActivity::class.java))) + } + _state.update { it.copy(outgoing = chooser, exporting = false) } + } catch (e: TimeoutCancellationException) { _state.update { it.copy(exporting = false, notice = "The storage provider timed out.") } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { _state.update { it.copy(exporting = false, notice = message(e)) } } + } + } + fun dismissOutgoing() { _state.update { it.copy(outgoing = null) } } + fun notice(text: String) { _state.update { it.copy(notice = text) } } + fun dismissNotice() { _state.update { it.copy(notice = null) } } + fun cancelPendingExport() { exportJob?.cancel(); _state.update { it.copy(exporting = false) } } + fun clearThumbnailCache() { thumbnailStore.clear() } + + private fun failed(text: String) { _state.update { it.copy(loading = false, error = text, listing = BrowserListing(emptyList())) } } + private fun message(e: Exception): String = when (e) { + is SecurityException, is java.io.FileNotFoundException -> "Read access is unavailable. Re-add the folder or choose the file again; the storage device or provider may be offline." + is PreviewException, is IllegalArgumentException -> e.message ?: "This operation is not supported." + else -> "The storage provider could not complete the operation. Retry, reconnect storage, or choose another location." + } + private fun saveNavigation() { + val state = _state.value + savedState["browser.root"] = state.location?.id + savedState["browser.path.ids"] = ArrayList(state.trail.map { it.id }) + savedState["browser.path.names"] = ArrayList(state.trail.map { it.name }) + savedState["browser.query"] = state.options.query + savedState["browser.recents"] = state.recentsTab + } + private fun saveSelection() { + val array = JSONArray() + _state.value.selected.forEach { array.put(JSONObject().put("id", it.id).put("name", it.name).put("mime", it.mimeType)) } + savedState["browser.selection"] = array.toString() + } + private fun restoreSelection(): List = runCatching { + val array = JSONArray(savedState.get("browser.selection") ?: "[]") + (0 until minOf(array.length(), BrowserLimits.MAX_SELECTED)).map { + val item = array.getJSONObject(it) + BrowserEntry(item.getString("id"), item.getString("name"), item.getString("mime")) + } + }.getOrDefault(emptyList()) + + override fun onCleared() { thumbnailStore.clear(); super.onCleared() } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt new file mode 100644 index 0000000..9f27a82 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt @@ -0,0 +1,76 @@ +package dev.fileworkbench.android.browser + +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.browser.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID + +/** GET_CONTENT returns read-only snapshots, never the app's saved directory/tree grants. */ +class PickedFileStore(private val context: Context) { + private val resolver = context.contentResolver + private val directory = File(context.cacheDir, "picked").apply { mkdirs() } + + suspend fun prepare(request: PickerRequest, entries: List): Intent = lock.withLock { + withContext(Dispatchers.IO) { + request.validateSelection(entries) + val now = System.currentTimeMillis() + directory.listFiles()?.filter { now - it.lastModified() > 86_400_000L }?.forEach { it.deleteRecursively() } + val existing = directory.listFiles().orEmpty() + if (existing.size >= 32) throw PreviewException("Temporary selection cache is full. Clear history and cache before sharing more copies.") + val used = directory.walkTopDown().filter { it.isFile }.sumOf { it.length() } + val session = File(directory, UUID.randomUUID().toString()).apply { mkdirs() } + val budget = SelectionCopyBudget() + try { + val uris = entries.mapIndexed { index, entry -> + currentCoroutineContext().ensureActive() + require(!entry.isDirectory && !entry.virtual) + val uri = Uri.parse(entry.id) + require(uri.scheme == "content" && uri.authority != "${context.packageName}.files") + // Separate subdirectories also safely preserve equal filenames in a multi-selection. + val parent = File(session, index.toString()).apply { mkdirs() } + val file = File(parent, safeFileLabel(entry.name)) + resolver.openInputStream(uri)?.use { input -> + file.outputStream().use { output -> + val bytes = ByteArray(32 * 1024) + budget.beginFile() + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(bytes) + if (count == -1) break + budget.consume(count) + if (used + budget.totalBytes > 256L * 1024 * 1024) + throw PreviewException("Temporary selection cache is full. Clear history and cache first.") + output.write(bytes, 0, count) + } + } + } ?: throw PreviewException("A selected file did not provide readable bytes.") + FileProvider.getUriForFile(context, "${context.packageName}.files", file).also { + SharedMimeTypes.remember(context, it, entry.mimeType) + } + } + currentCoroutineContext().ensureActive() + val clip = ClipData(ClipDescription("Selected files", entries.map { it.mimeType }.distinct().toTypedArray()), ClipData.Item(uris.first())) + uris.drop(1).forEach { clip.addItem(ClipData.Item(it)) } + Intent().apply { + if (uris.size == 1) setDataAndType(uris.first(), entries.first().mimeType) + clipData = clip + // No WRITE, PREFIX, PERSISTABLE, or original tree URIs are propagated. + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + } catch (e: Exception) { session.deleteRecursively(); throw e } + } + } + companion object { private val lock = Mutex() } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickerIntents.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickerIntents.kt new file mode 100644 index 0000000..3b996b6 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickerIntents.kt @@ -0,0 +1,59 @@ +package dev.fileworkbench.android.browser + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.core.content.IntentCompat +import dev.fileworkbench.core.browser.BrowserLimits +import dev.fileworkbench.core.browser.PickerRequest + +object PickerIntents { + fun request(intent: Intent): PickerRequest { + require(intent.action == Intent.ACTION_GET_CONTENT) { "Unsupported picker action." } + val extras = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList().orEmpty() + val request = PickerRequest(intent.type ?: "*/*", extras, + intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false), intent.getBooleanExtra(Intent.EXTRA_LOCAL_ONLY, false)) + require(request.isValid) { "Invalid picker MIME request." } + return request + } + + /** Activity intent extras become SavedStateHandle defaults. Never accept caller-supplied restore/selection keys. */ + fun sanitizedPickerIntent(original: Intent, request: PickerRequest): Intent = Intent(original).apply { + replaceExtras(Bundle()) + type = request.mimeType + putExtra(Intent.EXTRA_LOCAL_ONLY, request.localOnly) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, request.allowMultiple) + if (request.extraMimeTypes.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, request.extraMimeTypes.toTypedArray()) + } + + fun sanitizedViewerIntent(original: Intent): Intent = Intent(original).apply { + replaceExtras(Bundle()) + if (original.action == Intent.ACTION_SEND) { + runCatching { IntentCompat.getParcelableExtra(original, Intent.EXTRA_STREAM, Uri::class.java) } + .getOrNull()?.let { putExtra(Intent.EXTRA_STREAM, it) } + } + } + + /** OPEN_DOCUMENT is handled by Android itself, avoiding recursion into our GET_CONTENT activity. */ + fun openDocument(request: PickerRequest? = null): Intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = request?.mimeType ?: "*/*" + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (request != null) { + if (request.extraMimeTypes.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, request.extraMimeTypes.toTypedArray()) + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, request.allowMultiple) + putExtra(Intent.EXTRA_LOCAL_ONLY, request.localOnly) + } + } + + fun resultUris(intent: Intent?): List { + if (intent == null) return emptyList() + val uris = mutableListOf() + intent.data?.let { uris += it } + intent.clipData?.let { clip -> + // Keep one over the maximum so the caller can reject an oversized result rather than silently truncate. + for (index in 0 until minOf(clip.itemCount, BrowserLimits.MAX_SELECTED + 1)) clip.getItemAt(index).uri?.let { uris += it } + } + return uris.distinct() + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/ProviderIo.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/ProviderIo.kt new file mode 100644 index 0000000..eb8c779 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/ProviderIo.kt @@ -0,0 +1,29 @@ +package dev.fileworkbench.android.browser + +import android.os.CancellationSignal +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resumeWithException + +/** Bounded provider work, cancellable queries, and a UI timeout even if a provider stalls. */ +internal object ProviderIo { + private val executor = ThreadPoolExecutor(3, 3, 30, TimeUnit.SECONDS, ArrayBlockingQueue(32), + { runnable -> Thread(runnable, "fileworkbench-provider").apply { isDaemon = true } }, + ThreadPoolExecutor.AbortPolicy()) + + suspend fun read(block: (CancellationSignal) -> T): T = withTimeout(20_000) { + suspendCancellableCoroutine { continuation -> + val signal = CancellationSignal() + try { + val future = executor.submit { + val result = try { Result.success(block(signal)) } catch (e: Exception) { Result.failure(e) } + continuation.resumeWith(result) + } + continuation.invokeOnCancellation { signal.cancel(); future.cancel(true) } + } catch (e: Exception) { continuation.resumeWithException(e) } + } + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt new file mode 100644 index 0000000..a725973 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt @@ -0,0 +1,144 @@ +package dev.fileworkbench.android.browser + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.browser.* +import org.json.JSONArray +import org.json.JSONObject + +/** One bounded metadata query per directory. No raw paths, recursive scanning, or broad storage grant. */ +class SafBrowserStore(private val context: Context) : DirectoryReader { + private val resolver = context.contentResolver + private val preferences = context.getSharedPreferences("browser_locations", Context.MODE_PRIVATE) + + fun locations(): List = runCatching { + val values = JSONArray(preferences.getString("items", "[]")) + (0 until minOf(values.length(), BrowserLimits.MAX_LOCATIONS)).map { index -> + val value = values.getJSONObject(index) + val uri = Uri.parse(value.getString("uri")) + BrowserLocation(uri.toString(), safeFileLabel(value.getString("name")), UriReadGrants.hasPersistentRead(resolver, uri)) + }.distinctBy { it.id } + }.getOrDefault(emptyList()) + + suspend fun addTree(uri: Uri, flags: Int): BrowserLocation { + validateExternalUri(uri) + require(DocumentsContract.isTreeUri(uri)) { "Select a folder using Add folder." } + val tree = DocumentsContract.buildTreeDocumentUri(requireNotNull(uri.authority), DocumentsContract.getTreeDocumentId(uri)) + val old = locations() + require(old.any { it.id == tree.toString() } || old.size < BrowserLimits.MAX_LOCATIONS) { + "At most ${BrowserLimits.MAX_LOCATIONS} folders can be remembered. Remove an unused folder first." + } + val root = metadata(rootDocument(tree)) + require(root.isDirectory) { "The selected item is not a folder." } + if (flags and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0 && flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) { + runCatching { resolver.takePersistableUriPermission(tree, Intent.FLAG_GRANT_READ_URI_PERMISSION) } + } + val location = BrowserLocation(tree.toString(), root.label, UriReadGrants.hasPersistentRead(resolver, tree)) + // Temporary grants are usable in this session, but not misleadingly persisted as durable bookmarks. + if (location.persistent) saveLocations((old.filterNot { it.id == location.id } + location).sortedBy { it.label.lowercase() }) + return location + } + + fun forget(location: BrowserLocation) { + saveLocations(locations().filterNot { it.id == location.id }) + runCatching { resolver.releasePersistableUriPermission(Uri.parse(location.id), Intent.FLAG_GRANT_READ_URI_PERMISSION) } + } + + fun rootEntry(location: BrowserLocation) = BrowserEntry( + rootDocument(Uri.parse(location.id)).toString(), location.label, Document.MIME_TYPE_DIR, isDirectory = true) + + override suspend fun list(location: BrowserLocation, directoryId: String): BrowserListing = ProviderIo.read { signal -> + val tree = Uri.parse(location.id) + val directory = Uri.parse(directoryId) + validateExternalUri(tree) + require(DocumentsContract.isTreeUri(tree) && DocumentsContract.isTreeUri(directory) && tree.authority == directory.authority && + DocumentsContract.getTreeDocumentId(tree) == DocumentsContract.getTreeDocumentId(directory)) { "Invalid folder location." } + val children = DocumentsContract.buildChildDocumentsUriUsingTree(tree, DocumentsContract.getDocumentId(directory)) + val rows = ArrayList() + var visited = 0 + var truncated = false + var loading = false + var information: String? = null + // Check the query result itself, not use's result: optional cursor extras may be null. + val queryResult = resolver.query(children, PROJECTION, null, null, null, signal) + ?: throw PreviewException( + "[SAF-D1/NULL_CURSOR] Android returned no cursor for the folder listing.\n" + + "Provider: ${safeFileLabel(children.authority ?: "unknown").take(160)}\n" + + "Android API: ${Build.VERSION.SDK_INT}; retained read grant: " + + runCatching { UriReadGrants.hasPersistentRead(resolver, tree).toString() }.getOrDefault("unknown") + + ".\nA null cursor is not an empty-folder result. This diagnostic does not identify the cause." + ) + queryResult.use { cursor -> + while (cursor.moveToNext()) { + signal.throwIfCanceled() + if (visited++ == BrowserLimits.MAX_ENTRIES) { truncated = true; break } + val id = cursor.string(Document.COLUMN_DOCUMENT_ID) ?: continue + if (id.length > 8192) continue + val childUri = DocumentsContract.buildDocumentUriUsingTree(tree, id) + rows += entry(cursor, childUri) + } + loading = cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false) + information = cursor.extras.getString(DocumentsContract.EXTRA_INFO)?.let(::safeFileLabel) + cursor.extras.getString(DocumentsContract.EXTRA_ERROR)?.let { information = safeFileLabel(it) } + } + BrowserListing(rows.distinctBy { it.id }, truncated, loading, information) + } + + suspend fun metadata(uri: Uri): BrowserEntry = ProviderIo.read { signal -> + validateExternalUri(uri) + // General content providers need only support OpenableColumns; document providers expose richer metadata. + val document = DocumentsContract.isDocumentUri(context, uri) + val projection = if (document) PROJECTION else arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + resolver.query(uri, projection, null, null, null, signal)?.use { cursor -> + if (!cursor.moveToFirst()) throw PreviewException("The file is unavailable from its storage provider.") + val name = cursor.string(OpenableColumns.DISPLAY_NAME)?.take(512) ?: "Document" + val declared = cursor.string(Document.COLUMN_MIME_TYPE) ?: resolver.getType(uri) + entry(cursor, uri, resolvedMime(declared, name)) + } ?: throw PreviewException("The file's metadata is not available.") + } + + private fun entry(cursor: Cursor, uri: Uri, mimeOverride: String? = null): BrowserEntry { + val name = cursor.string(Document.COLUMN_DISPLAY_NAME)?.take(512) ?: "Document" + val mime = mimeOverride ?: resolvedMime(cursor.string(Document.COLUMN_MIME_TYPE), name) + val flags = cursor.long(Document.COLUMN_FLAGS)?.toInt() ?: 0 + return BrowserEntry(uri.toString(), name, mime, + isDirectory = mime == Document.MIME_TYPE_DIR || mime == "inode/directory", + size = cursor.long(Document.COLUMN_SIZE)?.takeIf { it >= 0 }, + modifiedMillis = cursor.long(Document.COLUMN_LAST_MODIFIED)?.takeIf { it > 0 }, + virtual = flags and Document.FLAG_VIRTUAL_DOCUMENT != 0, + thumbnailSupported = flags and Document.FLAG_SUPPORTS_THUMBNAIL != 0) + } + + private fun saveLocations(locations: List) { + val json = JSONArray() + locations.forEach { json.put(JSONObject().put("uri", it.id).put("name", it.label)) } + preferences.edit().putString("items", json.toString()).apply() + } + private fun validateExternalUri(uri: Uri) { + require(uri.scheme == "content" && !uri.authority.isNullOrBlank() && uri.authority != "${context.packageName}.files") { + "Only external Android content locations are accepted. Choose a folder using Add folder." + } + } + private fun rootDocument(tree: Uri): Uri = DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree)) + private fun Cursor.string(column: String): String? = getColumnIndex(column).takeIf { it >= 0 && !isNull(it) }?.let(::getString) + private fun Cursor.long(column: String): Long? = getColumnIndex(column).takeIf { it >= 0 && !isNull(it) }?.let(::getLong) + + companion object { + private val PROJECTION = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, + Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS) + fun resolvedMime(declared: String?, name: String): String { + val normalized = declared?.let(MimeTypes::normalize) + if (normalized != null && MimeTypes.validPattern(normalized) && '*' !in normalized && normalized != "application/octet-stream") return normalized + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(name.substringAfterLast('.', "").lowercase()) + ?: normalized?.takeIf { MimeTypes.validPattern(it) && '*' !in it } ?: "application/octet-stream" + } + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/UriReadGrants.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/UriReadGrants.kt new file mode 100644 index 0000000..1dc748f --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/UriReadGrants.kt @@ -0,0 +1,17 @@ +package dev.fileworkbench.android.browser + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract + +/** A saved tree grant also covers documents actually provided within that tree. */ +internal object UriReadGrants { + fun hasPersistentRead(resolver: ContentResolver, uri: Uri): Boolean { + val tree = runCatching { + if (DocumentsContract.isTreeUri(uri)) DocumentsContract.buildTreeDocumentUri( + requireNotNull(uri.authority), DocumentsContract.getTreeDocumentId(uri)) else null + }.getOrNull() + return resolver.persistedUriPermissions.any { it.isReadPermission && (it.uri == uri || it.uri == tree) } + } + fun isTree(uri: Uri): Boolean = runCatching { DocumentsContract.isTreeUri(uri) }.getOrDefault(false) +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/WorkbenchFileProvider.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/WorkbenchFileProvider.kt new file mode 100644 index 0000000..6a3a3fd --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/WorkbenchFileProvider.kt @@ -0,0 +1,32 @@ +package dev.fileworkbench.android.browser + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import dev.fileworkbench.core.browser.MimeTypes + +/** Preserve provider MIME metadata even for extensionless copied files; FileProvider still enforces paths/grants. */ +class WorkbenchFileProvider : FileProvider() { + override fun getType(uri: Uri): String? { + val fallback = super.getType(uri) // Validate against FileProvider's canonical-path allowlist first. + return context?.let { SharedMimeTypes.lookup(it, uri) } ?: fallback + } +} + +internal object SharedMimeTypes { + private const val PREFS = "shared_mime_types" + fun remember(context: Context, uri: Uri, mime: String) { + val normalized = MimeTypes.normalize(mime) + if (!MimeTypes.validPattern(normalized) || '*' in normalized) return + val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val now = System.currentTimeMillis() + val records = preferences.all.map { (key, value) -> key to (value as? String)?.substringBefore('|')?.toLongOrNull() } + val edit = preferences.edit() + records.filter { it.second == null || now - (it.second ?: 0) > 86_400_000L }.forEach { edit.remove(it.first) } + if (records.size >= 1024) records.sortedBy { it.second ?: 0 }.take(records.size - 1023).forEach { edit.remove(it.first) } + edit.putString(uri.toString(), "$now|$normalized").apply() + } + fun lookup(context: Context, uri: Uri): String? = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(uri.toString(), null)?.substringAfter('|')?.takeIf { MimeTypes.validPattern(it) && '*' !in it } + fun clear(context: Context) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/data/DocumentStore.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/data/DocumentStore.kt new file mode 100644 index 0000000..4735caa --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/data/DocumentStore.kt @@ -0,0 +1,202 @@ +package dev.fileworkbench.android.data + +import android.content.ClipData +import android.content.Context +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.content.FileProvider +import dev.fileworkbench.core.FileKind +import dev.fileworkbench.android.MainActivity +import dev.fileworkbench.android.browser.UriReadGrants +import dev.fileworkbench.android.browser.SharedMimeTypes +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.SafetyPolicy +import dev.fileworkbench.platform.LocalPreviewLoader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.UUID + +/** The original provider document is NEVER modified. Each open creates a bounded, private snapshot. */ +data class StagedDocument( + val id: String, + val source: String, + val displayName: String, + val mime: String, + val size: Long, + val kind: FileKind, + val file: File, + val persistentAccess: Boolean, +) + +data class RecentDocument(val uri: String, val name: String, val persistent: Boolean, val timestamp: Long) + +class DocumentStore(private val context: Context, private val policy: SafetyPolicy = SafetyPolicy()) { + private val resolver = context.contentResolver + private val preferences = context.getSharedPreferences("recent_documents", Context.MODE_PRIVATE) + private val sessions = File(context.cacheDir, "sessions").apply { mkdirs() } + private val shares = File(context.cacheDir, "shared").apply { mkdirs() } + private val renders = File(context.cacheDir, "renders").apply { mkdirs() } + + fun cleanOrphanedSessions() { + // MainActivity and FilePickerActivity may coexist. Never delete another live viewer's snapshot on creation. + sessions.listFiles()?.filter { it.absolutePath !in activeSnapshots && System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.delete() } + renders.listFiles()?.filter { System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.delete() } + // Previously shared snapshots expire after a day; the receiver may need the grant until then. + shares.listFiles()?.filter { System.currentTimeMillis() - it.lastModified() > 86_400_000L }?.forEach { it.deleteRecursively() } + } + + fun persistReadGrant(uri: Uri): Boolean { + runCatching { resolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } + return UriReadGrants.hasPersistentRead(resolver, uri) + } + + suspend fun stage(uri: Uri): StagedDocument = withContext(Dispatchers.IO) { + if (uri.scheme != "content" || uri.authority == "${context.packageName}.files") + throw PreviewException("Choose a document using the system file picker. Only external content URIs are accepted.") + var name = "Document" + var declaredSize: Long? = null + runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val ni = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (ni >= 0 && !cursor.isNull(ni)) name = cursor.getString(ni) + val si = cursor.getColumnIndex(OpenableColumns.SIZE) + if (si >= 0 && !cursor.isNull(si)) declaredSize = cursor.getLong(si) + } + } + } + if ((declaredSize ?: 0) > policy.maxInputBytes) throw PreviewException("File exceeds the ${policy.maxInputBytes / 1024 / 1024} MiB input limit.") + name = safeDisplayName(name) + val mime = runCatching { resolver.getType(uri) }.getOrNull()?.takeIf { it.contains('/') && it.length < 160 } + ?: "application/octet-stream" + val id = UUID.randomUUID().toString() + val snapshot = File(sessions, "$id.bin") + activeSnapshots.add(snapshot.absolutePath) + try { + resolver.openInputStream(uri)?.use { input -> + snapshot.outputStream().use { output -> + val buffer = ByteArray(32 * 1024) + var total = 0L + while (true) { + currentCoroutineContext().ensureActive() + val n = input.read(buffer) + if (n == -1) break + total += n + if (total > policy.maxInputBytes) throw PreviewException("File exceeds the input limit.") + output.write(buffer, 0, n) + } + } + } ?: throw PreviewException("The storage provider did not supply readable file bytes. Virtual cloud documents must first be exported to a supported file format.") + val kind = LocalPreviewLoader(policy).classify(snapshot, name, mime) + StagedDocument(id, uri.toString(), name, mime, snapshot.length(), kind, snapshot, + UriReadGrants.hasPersistentRead(resolver, uri)) + } catch (e: Exception) { + snapshot.delete() + activeSnapshots.remove(snapshot.absolutePath) + throw e + } + } + + fun recents(): List = runCatching { + val array = JSONArray(preferences.getString("items", "[]")) + (0 until array.length()).map { i -> + val item = array.getJSONObject(i) + RecentDocument(item.getString("uri"), item.getString("name"), item.optBoolean("persistent"), item.optLong("timestamp")) + }.take(20) + }.getOrDefault(emptyList()) + + fun remember(document: StagedDocument) { + val old = recents() + val item = RecentDocument(document.source, document.displayName, document.persistentAccess, System.currentTimeMillis()) + val retained = (listOf(item) + old.filterNot { it.uri == item.uri }).take(20) + old.filter { previous -> retained.none { it.uri == previous.uri } }.forEach { releaseGrant(it.uri) } + storeRecents(retained) + } + + fun forget(uri: String) { storeRecents(recents().filterNot { it.uri == uri }); releaseGrant(uri) } + + fun clearHistoryAndCache() { + // Folder bookmarks own their tree grants. Clearing recents must not revoke folder access. + resolver.persistedUriPermissions.filterNot { UriReadGrants.isTree(it.uri) }.forEach { releaseGrant(it.uri.toString()) } + preferences.edit().clear().apply() + SharedMimeTypes.clear(context) + File(context.cacheDir, "picked").listFiles()?.forEach { it.deleteRecursively() } + sessions.listFiles()?.forEach { it.delete() } + shares.listFiles()?.forEach { it.deleteRecursively() } + renders.listFiles()?.forEach { it.delete() } + } + + private fun releaseGrant(value: String) { + val uri = Uri.parse(value) + if (UriReadGrants.isTree(uri)) return + runCatching { resolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } + } + + private fun storeRecents(items: List) { + val json = JSONArray() + items.forEach { json.put(JSONObject().put("uri", it.uri).put("name", it.name).put("persistent", it.persistent).put("timestamp", it.timestamp)) } + preferences.edit().putString("items", json.toString()).apply() + } + + private suspend fun shareCopyUri(document: StagedDocument): Uri = withContext(Dispatchers.IO) { + // Keep at most two 64 MiB share snapshots. Sharing is an explicit user operation. + val dirs = shares.listFiles()?.sortedBy { it.lastModified() }.orEmpty() + if (dirs.size >= 2) dirs.take(dirs.size - 1).forEach { it.deleteRecursively() } + val directory = File(shares, UUID.randomUUID().toString()).apply { mkdirs() } + val copy = File(directory, document.displayName) + document.file.copyTo(copy) + FileProvider.getUriForFile(context, "${context.packageName}.files", copy).also { SharedMimeTypes.remember(context, it, document.mime) } + } + + suspend fun shareIntent(document: StagedDocument): Intent { + val sharedUri = shareCopyUri(document) + return Intent.createChooser(Intent(Intent.ACTION_SEND).apply { + type = document.mime + putExtra(Intent.EXTRA_STREAM, sharedUri) + clipData = ClipData.newRawUri("File", sharedUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }, "Share original file") + } + + suspend fun openExternalIntent(document: StagedDocument): Intent { + val uri = shareCopyUri(document) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, document.mime) + clipData = ClipData.newRawUri("File", uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + return Intent.createChooser(intent, "Open in another app").apply { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(context, MainActivity::class.java))) + } + } + + suspend fun saveCopy(document: StagedDocument, uri: Uri) = withContext(Dispatchers.IO) { + resolver.openOutputStream(uri, "w")?.use { output -> + document.file.inputStream().use { input -> input.copyTo(output) } + } ?: throw PreviewException("The destination is not writable.") + } + + suspend fun exportText(text: String, uri: Uri) = withContext(Dispatchers.IO) { + resolver.openOutputStream(uri, "w")?.bufferedWriter(Charsets.UTF_8)?.use { it.write(text) } + ?: throw PreviewException("The destination is not writable.") + } + + fun releaseSnapshot(file: File) { + activeSnapshots.remove(file.absolutePath) + file.delete() + } + companion object { + private val activeSnapshots = java.util.Collections.synchronizedSet(mutableSetOf()) + } + + private fun safeDisplayName(value: String): String = value.map { c -> + if (c.code < 32 || c == '/' || c == '\\' || c in '\u202A'..'\u202E' || c in '\u2066'..'\u2069') '_' else c + }.joinToString("").trim().take(160).takeUnless { it.isEmpty() || it == "." || it == ".." } ?: "Document" +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/render/ImageLoader.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/ImageLoader.kt new file mode 100644 index 0000000..57e98e7 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/ImageLoader.kt @@ -0,0 +1,21 @@ +package dev.fileworkbench.android.render + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import dev.fileworkbench.core.PreviewException +import java.io.File + +object ImageLoader { + fun decode(file: File): Bitmap { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || bounds.outWidth.toLong() * bounds.outHeight > 100_000_000) + throw PreviewException("Unsupported image or excessive image dimensions.") + var sample = 1 + while (bounds.outWidth.toLong() / sample * (bounds.outHeight / sample) > 3_000_000 || + bounds.outWidth / sample > 4096 || bounds.outHeight / sample > 4096) sample *= 2 + return BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { + inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 + }) ?: throw PreviewException("The image could not be decoded.") + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderClient.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderClient.kt new file mode 100644 index 0000000..5d1f04e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderClient.kt @@ -0,0 +1,117 @@ +package dev.fileworkbench.android.render + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.Bitmap +import android.os.* +import dev.fileworkbench.core.PreviewException +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.Closeable +import java.io.File +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +data class PdfPage(val bitmap: Bitmap, val index: Int, val count: Int) + +class PdfRenderClient(private val context: Context) : Closeable { + private val lock = Mutex() + private val sequence = AtomicInteger() + private var remote = CompletableDeferred() + private var bound = false + private val requests = ConcurrentHashMap>() + private val receiver = Messenger(Handler(Looper.getMainLooper()) { message -> + requests.remove(message.arg1)?.complete(message.data) + true + }) + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { remote.complete(Messenger(binder)) } + override fun onServiceDisconnected(name: ComponentName) = disconnected() + override fun onBindingDied(name: ComponentName) = disconnected() + override fun onNullBinding(name: ComponentName) = disconnected() + } + + private fun disconnected() { + val error = PreviewException("The isolated PDF renderer stopped. Reopen the file to retry.") + remote.completeExceptionally(error) + requests.values.forEach { it.completeExceptionally(error) }; requests.clear() + if (bound) runCatching { context.unbindService(connection) } + bound = false + remote = CompletableDeferred() + } + + suspend fun render(file: File, page: Int): PdfPage = lock.withLock { + withContext(Dispatchers.Main.immediate) { + if (!bound) { + bound = context.bindService(Intent(context, PdfRenderService::class.java), connection, Context.BIND_AUTO_CREATE) + if (!bound) throw PreviewException("The isolated PDF renderer could not be started.") + } + } + val id = sequence.incrementAndGet() + val result = CompletableDeferred() + val output = withContext(Dispatchers.IO) { + val directory = File(context.cacheDir, "renders").apply { mkdirs() } + File.createTempFile("page-", ".rgba", directory) + } + requests[id] = result + try { + val response = withTimeout(30_000) { + val messenger = remote.await() + withContext(Dispatchers.IO) { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { inputFd -> + ParcelFileDescriptor.open(output, ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE).use { outputFd -> + messenger.send(Message.obtain().apply { + what = PdfRenderService.REQUEST_RENDER; arg1 = id; replyTo = receiver + data = Bundle().apply { + putParcelable("input", inputFd); putParcelable("output", outputFd) + putInt("page", page); putInt("width", 1440) + } + }) + } + } + } + result.await() + } + response.getString("error")?.let { throw PreviewException(it) } + withContext(Dispatchers.IO) { + val width = response.getInt("width") + val height = response.getInt("height") + if (width !in 1..2048 || height !in 1..4096 || width.toLong() * height > PdfRenderService.MAX_PIXELS) + throw PreviewException("Invalid output from PDF renderer.") + val count = response.getInt("count") + val index = response.getInt("page") + if (count < 1 || index !in 0 until count) throw PreviewException("Invalid PDF page metadata.") + val expected = width * height * 4 + if (output.length() != expected.toLong()) throw PreviewException("Incomplete PDF render.") + val bytes = ByteArray(expected) + output.inputStream().use { input -> + var offset = 0 + while (offset < expected) { + val n = input.read(bytes, offset, expected - offset) + if (n <= 0) throw PreviewException("Incomplete PDF pixel output.") + offset += n + } + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) + PdfPage(bitmap, index, count) + } + } catch (_: TimeoutCancellationException) { + withContext(NonCancellable + Dispatchers.Main.immediate) { disconnected() } + throw PreviewException("PDF rendering exceeded its time limit.") + } finally { + requests.remove(id) + output.delete() + } + } + + override fun close() { + requests.values.forEach { it.cancel() }; requests.clear() + if (bound) runCatching { context.unbindService(connection) } + bound = false + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderService.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderService.kt new file mode 100644 index 0000000..787a22b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/render/PdfRenderService.kt @@ -0,0 +1,83 @@ +package dev.fileworkbench.android.render + +import android.app.Service +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.os.* +import java.io.FileOutputStream +import java.nio.ByteBuffer +import kotlin.math.min +import kotlin.math.sqrt + +/** Separate Android isolated UID. Only passed descriptors are accessible; no file paths or URI grants. + * The response is bounded raw pixels, not a PNG that would reintroduce an image parser into the host. + */ +class PdfRenderService : Service() { + private lateinit var thread: HandlerThread + private lateinit var messenger: Messenger + + override fun onCreate() { + super.onCreate() + thread = HandlerThread("pdf-render").apply { start() } + messenger = Messenger(Handler(thread.looper) { message -> + if (message.what == REQUEST_RENDER) { render(message); true } else false + }) + } + override fun onBind(intent: Intent): IBinder = messenger.binder + override fun onDestroy() { thread.quitSafely(); super.onDestroy() } + + @Suppress("DEPRECATION") + private fun render(message: Message) { + val reply = Message.obtain().apply { what = RESPONSE_RENDER; arg1 = message.arg1 } + val response = Bundle() + val input = message.data.getParcelable("input") + val output = message.data.getParcelable("output") + var renderer: PdfRenderer? = null + var bitmap: Bitmap? = null + try { + require(input != null && output != null) { "Missing render descriptors." } + renderer = PdfRenderer(input) + val count = renderer.pageCount + require(count > 0) { "The PDF contains no pages." } + val pageIndex = message.data.getInt("page", 0).coerceIn(0, count - 1) + renderer.openPage(pageIndex).use { page -> + require(page.width > 0 && page.height > 0) { "Invalid PDF page size." } + val desiredWidth = message.data.getInt("width", 1440).coerceIn(256, 2048) + var scale = desiredWidth.toDouble() / page.width + scale = min(scale, 4096.0 / page.height) + scale = min(scale, sqrt(MAX_PIXELS.toDouble() / (page.width.toDouble() * page.height))) + val width = (page.width * scale).toInt().coerceAtLeast(1) + val height = (page.height * scale).toInt().coerceAtLeast(1) + val rendered = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap = rendered + rendered.eraseColor(Color.WHITE) + page.render(rendered, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + require(rendered.rowBytes == width * 4) { "Unsupported bitmap stride." } + val pixels = ByteBuffer.allocate(width * height * 4) + rendered.copyPixelsToBuffer(pixels) + FileOutputStream(output.fileDescriptor).use { it.write(pixels.array()) } + response.putInt("width", width); response.putInt("height", height) + response.putInt("page", pageIndex); response.putInt("count", count) + } + } catch (_: SecurityException) { + response.putString("error", "This PDF is password-protected or uses an unsupported security scheme.") + } catch (_: Exception) { + response.putString("error", "The PDF could not be rendered. It may be damaged, unsupported, or exceed the rendering limits.") + } finally { + bitmap?.recycle() + runCatching { renderer?.close() } + if (renderer == null) runCatching { input?.close() } + runCatching { output?.close() } + } + reply.data = response + runCatching { message.replyTo?.send(reply) } + } + + companion object { + const val REQUEST_RENDER = 1 + const val RESPONSE_RENDER = 2 + const val MAX_PIXELS = 3_000_000 + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt new file mode 100644 index 0000000..d321bca --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt @@ -0,0 +1,296 @@ +package dev.fileworkbench.android.ui + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.fileworkbench.android.browser.BrowserState +import dev.fileworkbench.android.browser.BrowserThumbnails +import dev.fileworkbench.android.browser.BrowserViewModel +import dev.fileworkbench.android.data.RecentDocument +import dev.fileworkbench.core.FileClassifier +import dev.fileworkbench.core.FileKind +import dev.fileworkbench.core.browser.* +import java.text.DateFormat +import java.util.Date + +@Composable +fun BrowserScreen( + state: BrowserState, + browser: BrowserViewModel, + recents: List, + onOpenFile: () -> Unit, + onAddFolder: () -> Unit, + onPreview: (BrowserEntry) -> Unit, + onRecent: (RecentDocument) -> Unit, + onForgetRecent: (String) -> Unit, +) { + var removeLocation by remember { mutableStateOf(null) } + val visible = remember(state.listing.entries, state.options, state.picker) { + BrowserEntries.visible(state.listing.entries, state.options, state.picker) + } + Column(Modifier.fillMaxSize()) { + if (state.picker == null) TabRow(selectedTabIndex = if (state.recentsTab) 1 else 0) { + Tab(selected = !state.recentsTab, onClick = { browser.recentTab(false) }, text = { Text("Browse") }) + Tab(selected = state.recentsTab, onClick = { browser.recentTab(true) }, text = { Text("Recent") }) + } + if (state.loading || state.exporting) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.error?.let { text -> + Surface(color = MaterialTheme.colorScheme.errorContainer) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(text, style = MaterialTheme.typography.bodyMedium) + Row { + if (state.location != null) TextButton(onClick = browser::refresh) { Text("Retry") } + TextButton(onClick = onOpenFile) { Text("Choose file") } + if (state.picker?.localOnly != true) TextButton(onClick = onAddFolder) { Text("Add folder") } + } + } + } + } + when { + state.recentsTab && state.picker == null -> RecentFiles(recents, onRecent, onForgetRecent, onOpenFile) + state.location == null -> LazyColumn( + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.primaryContainer) { + Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(if (state.picker == null) "Files, not services." else "Choose files to return", + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text(if (state.picker == null) "Browse chosen folders, preview documents, and open files without an account. Originals stay unchanged." + else "Nothing is returned until you confirm. The requesting app receives read-only copies of the selected files, not access to your folders.") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onOpenFile, enabled = !state.exporting && state.picker?.isValid != false) { Text("Open file") } + if (state.picker?.localOnly != true) OutlinedButton(onClick = onAddFolder, enabled = !state.exporting) { Text("Add folder") } + } + } + } + } + if (state.picker != null) { + item { Text("Requested types: ${state.picker.mimeType}" + + if (state.picker.extraMimeTypes.isNotEmpty()) " · ${state.picker.extraMimeTypes.joinToString()}" else "", + style = MaterialTheme.typography.bodySmall) } + if (state.picker.localOnly) item { + Text("Local-only request: choose files through Android's picker. Saved provider folders are not used because their offline availability cannot be established reliably.", + style = MaterialTheme.typography.bodyMedium) + } + if (state.selected.isNotEmpty()) { + item { Text("Selected files", style = MaterialTheme.typography.titleMedium) } + items(state.selected, key = { "selected:${it.id}" }) { entry -> + BrowserFileRow(entry, false, browser.thumbnailStore, selected = true, picker = true, + enabled = !state.exporting, onClick = { browser.toggle(entry) }, onPreview = { onPreview(entry) }, + onExternal = { browser.openInOtherApp(entry) }) + } + } + } + if (state.picker?.localOnly != true) { + item { Text("Saved folders", style = MaterialTheme.typography.titleLarge) } + if (state.locations.isEmpty()) item { Text("Add a folder once to browse its files and subfolders here. Android asks which folder you allow this app to read.") } + items(state.locations, key = { "location:${it.id}" }) { location -> + OutlinedCard { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).clickable(enabled = !state.exporting, role = Role.Button) { browser.enter(location) }.padding(16.dp)) { + Text(location.label, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(if (location.persistent) "Folder read access retained" else "Read access may need renewal", style = MaterialTheme.typography.labelSmall) + } + TextButton(onClick = { removeLocation = location }, enabled = !state.exporting) { Text("Remove") } + } + } + } + } + item { + Text("Android 11+ does not permit granting the storage root or the Downloads root as a folder. Use Open file for individual downloads, or add an allowed subfolder. Other apps' private storage remains inaccessible.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + item { + Text("A chosen storage provider may use its own network connection. Thumbnails are off until enabled. Fileworkbench 0.2.3 · Browser diagnostics SAF-D1 · read-only", + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else -> { + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = browser::locationsHome, enabled = !state.exporting) { Text("Locations") } + state.trail.forEachIndexed { index, entry -> + Text("/") + TextButton(onClick = { browser.breadcrumb(index) }, enabled = !state.exporting) { Text(entry.label, maxLines = 1) } + } + } + OutlinedTextField(value = state.options.query, onValueChange = { browser.updateOptions(state.options.copy(query = it)) }, + singleLine = true, label = { Text("Search this folder") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + trailingIcon = { if (state.options.query.isNotEmpty()) TextButton(onClick = { browser.updateOptions(state.options.copy(query = "")) }) { Text("Clear") } }) + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("${visible.size} shown / ${state.listing.entries.size} loaded", style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = browser::refresh, enabled = !state.loading && !state.exporting) { Text("Refresh") } + BrowserOptionsMenu(state, browser) + } + if (state.listing.truncated || state.listing.providerLoading || state.listing.information != null) { + Text(buildString { + if (state.listing.truncated) append("Listing capped at ${BrowserLimits.MAX_ENTRIES} entries. Search and sort apply only to loaded entries; use Open file for the rest. ") + if (state.listing.providerLoading) append("The provider is still loading. Press Refresh for more results. ") + state.listing.information?.let { append(it) } + }, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(12.dp)) + } + if (visible.isEmpty() && !state.loading && state.error == null) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(if (state.listing.entries.isEmpty()) "This folder is empty." else "No files match the current search, visibility, or requested file types.") + } + } else key(state.directory?.id) { + val scroll = rememberLazyListState() + LazyColumn(Modifier.fillMaxSize(), state = scroll, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(visible, key = { it.id }) { entry -> + BrowserFileRow(entry, state.thumbnails, browser.thumbnailStore, + selected = state.selected.any { it.id == entry.id }, picker = state.picker != null, + enabled = !state.exporting && !state.loading, + onClick = { + when { + entry.isDirectory -> browser.enter(entry) + entry.virtual -> browser.notice("This is a virtual document. Export it to a normal file in its provider first.") + state.picker != null -> browser.toggle(entry) + else -> onPreview(entry) + } + }, onPreview = { onPreview(entry) }, onExternal = { browser.openInOtherApp(entry) }) + } + } + } + } + } + } + removeLocation?.let { location -> + AlertDialog(onDismissRequest = { removeLocation = null }, title = { Text("Remove saved folder?") }, + text = { Text("Remove ${location.label} and release its saved read grant. Original files are not deleted. Recent files that relied on this folder grant may need to be selected again.") }, + confirmButton = { TextButton(onClick = { browser.forget(location); removeLocation = null }) { Text("Remove") } }, + dismissButton = { TextButton(onClick = { removeLocation = null }) { Text("Cancel") } }) + } +} + +@Composable +private fun BrowserOptionsMenu(state: BrowserState, browser: BrowserViewModel) { + var open by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { open = true }) { Text("Options") } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + BrowserSort.values().forEach { sort -> + val label = when (sort) { BrowserSort.NAME -> "Name"; BrowserSort.MODIFIED -> "Modified date"; BrowserSort.SIZE -> "Size"; BrowserSort.TYPE -> "Type" } + DropdownMenuItem(text = { Text("Sort: $label${if (state.options.sort == sort) " (selected)" else ""}") }, + onClick = { browser.updateOptions(state.options.copy(sort = sort)); open = false }) + } + HorizontalDivider() + DropdownMenuItem(text = { Text(if (state.options.descending) "Use ascending order" else "Use descending order") }, + onClick = { browser.updateOptions(state.options.copy(descending = !state.options.descending)); open = false }) + DropdownMenuItem(text = { Text(if (state.options.showHidden) "Hide dotfiles" else "Show dotfiles") }, + onClick = { browser.updateOptions(state.options.copy(showHidden = !state.options.showHidden)); open = false }) + DropdownMenuItem(text = { Text(if (state.thumbnails) "Disable thumbnails" else "Enable provider thumbnails") }, + onClick = { browser.setThumbnails(!state.thumbnails); open = false }) + } + } +} + +@Composable +private fun BrowserFileRow( + entry: BrowserEntry, thumbnails: Boolean, store: BrowserThumbnails, + selected: Boolean, picker: Boolean, enabled: Boolean, + onClick: () -> Unit, onPreview: () -> Unit, onExternal: () -> Unit, +) { + var menu by remember(entry.id) { mutableStateOf(false) } + var details by remember(entry.id) { mutableStateOf(false) } + val bitmap by produceState(null, entry.id, entry.modifiedMillis, thumbnails) { + value = if (thumbnails) store.load(entry) else null + } + OutlinedCard { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.weight(1f).then( + if (picker && !entry.isDirectory) Modifier.toggleable(value = selected, enabled = enabled, role = Role.Checkbox, onValueChange = { onClick() }) + else Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick) + ).padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (picker && !entry.isDirectory) Checkbox(checked = selected, onCheckedChange = null, enabled = enabled) + else if (bitmap != null) Image(bitmap!!.asImageBitmap(), contentDescription = null, + modifier = Modifier.size(44.dp), contentScale = ContentScale.Crop) + else FileTypeBadge(entry) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(entry.label, maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium) + Text(when { + entry.isDirectory -> "Folder" + entry.virtual -> "Virtual document · export from provider first" + else -> "${entry.size?.let(::formatBytes) ?: "Unknown size"} · ${formatDate(entry.modifiedMillis)}" + }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Box { + TextButton(onClick = { menu = true }, enabled = enabled) { Text("More") } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (!entry.isDirectory) { + DropdownMenuItem(text = { Text("Preview") }, enabled = !entry.virtual, onClick = { menu = false; onPreview() }) + DropdownMenuItem(text = { Text("Open in another app") }, enabled = !entry.virtual, onClick = { menu = false; onExternal() }) + } + DropdownMenuItem(text = { Text("File details") }, onClick = { menu = false; details = true }) + } + } + } + } + if (details) AlertDialog(onDismissRequest = { details = false }, title = { Text("File details") }, text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(entry.label, fontWeight = FontWeight.SemiBold) + Text("Type: ${entry.mimeType}") + Text("Size: ${entry.size?.let(::formatBytes) ?: "Not supplied"}") + Text("Modified: ${formatDate(entry.modifiedMillis)}") + Text("Location ID: ${entry.id}", style = MaterialTheme.typography.bodySmall) + Text("Metadata comes from the storage provider. No filesystem path is inferred. Originals remain read-only.", style = MaterialTheme.typography.bodySmall) + } + }, confirmButton = { TextButton(onClick = { details = false }) { Text("Close") } }) +} + +@Composable +private fun FileTypeBadge(entry: BrowserEntry) { + val label = if (entry.isDirectory) "DIR" else when (FileClassifier.classify(entry.name, entry.mimeType)) { + FileKind.PDF -> "PDF"; FileKind.IMAGE -> "IMG"; FileKind.MARKDOWN -> "MD"; FileKind.CSV -> "CSV" + FileKind.DOCX, FileKind.ODT -> "DOC"; FileKind.XLSX, FileKind.ODS -> "SHEET" + FileKind.PPTX, FileKind.ODP -> "SLIDE"; FileKind.ZIP -> "ZIP"; FileKind.TEXT -> "TXT" + else -> "FILE" + } + Surface(shape = RoundedCornerShape(10.dp), color = if (entry.isDirectory) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant) { + Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Text(label, style = MaterialTheme.typography.labelSmall) } + } +} + +@Composable +private fun RecentFiles(recents: List, onRecent: (RecentDocument) -> Unit, onForget: (String) -> Unit, onOpen: () -> Unit) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Recent files", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + TextButton(onClick = onOpen) { Text("Open file") } + } } + if (recents.isEmpty()) item { Text("Files you preview appear here. Originals remain in their existing storage locations.") } + items(recents, key = { it.uri }) { recent -> + OutlinedCard { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).clickable(role = Role.Button) { onRecent(recent) }.padding(16.dp)) { + Text(safeFileLabel(recent.name), maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium) + Text(if (recent.persistent) "Read grant retained when last opened" else "Temporary access · may need reselection", style = MaterialTheme.typography.labelSmall) + } + TextButton(onClick = { onForget(recent.uri) }) { Text("Remove") } + } + } + } + } +} + +private fun formatDate(timestamp: Long?): String = timestamp?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } ?: "Date unknown" diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/DocumentReader.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/DocumentReader.kt new file mode 100644 index 0000000..af10382 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/DocumentReader.kt @@ -0,0 +1,168 @@ +package dev.fileworkbench.android.ui + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.fileworkbench.core.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DocumentReader(preview: DocumentPreview, searchVisible: Boolean, documentId: String) { + var selected by rememberSaveable(documentId) { mutableStateOf(0) } + var query by rememberSaveable(documentId) { mutableStateOf("") } + var warningsExpanded by rememberSaveable(documentId) { mutableStateOf(false) } + var hits by remember(documentId) { mutableStateOf(emptyList()) } + val listState = rememberLazyListState() + val horizontal = rememberScrollState() + val scope = rememberCoroutineScope() + var highlight by remember(documentId) { mutableStateOf(-1) } + var jump by remember(documentId) { mutableStateOf(null) } + val section = preview.sections.getOrNull(selected) + LaunchedEffect(preview, query) { + hits = withContext(Dispatchers.Default) { PreviewText.search(preview, query) } + } + LaunchedEffect(selected, documentId) { + listState.scrollToItem(jump ?: 0) + jump = null + } + + Column(Modifier.fillMaxSize()) { + if (preview.warnings.isNotEmpty()) { + Surface(color = MaterialTheme.colorScheme.secondaryContainer) { + Column(Modifier.fillMaxWidth().clickable { warningsExpanded = !warningsExpanded }.padding(horizontal = 16.dp, vertical = 10.dp)) { + Text(if (preview.fidelity == Fidelity.READING_PREVIEW) "Reading preview — not original layout" else "Preview information", fontWeight = FontWeight.SemiBold) + if (warningsExpanded) preview.warnings.forEach { Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 6.dp)) } + else Text("Tap to see limitations", style = MaterialTheme.typography.labelSmall) + } + } + } + if (searchVisible) { + OutlinedTextField(value = query, onValueChange = { query = it.take(256) }, label = { Text("Find in extracted text") }, + singleLine = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) + if (query.isNotBlank()) { + Text("${hits.size}${if (hits.size >= 1000) "+" else ""} matching blocks / rows", style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 180.dp)) { + items(hits) { hit -> + ListItem(headlineContent = { Text(hit.snippet, maxLines = 2) }, + supportingContent = { Text(preview.sections.getOrNull(hit.sectionIndex)?.title ?: "Archive") }, + modifier = Modifier.clickable { + if (selected == hit.sectionIndex) scope.launch { listState.scrollToItem(hit.itemIndex + if (section?.table != null) 1 else 0) } + else { + selected = hit.sectionIndex + jump = hit.itemIndex + if (preview.sections.getOrNull(hit.sectionIndex)?.table != null) 1 else 0 + } + highlight = hit.itemIndex + }) + } + } + } + } + if (preview.sections.size > 1) { + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + preview.sections.forEachIndexed { index, item -> + FilterChip(selected = index == selected, onClick = { selected = index; highlight = -1 }, label = { Text(item.title) }) + } + } + } + when { + preview.kind == FileKind.ZIP -> LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + itemsIndexed(preview.entries) { index, entry -> + ListItem(headlineContent = { Text(entry.path, fontFamily = FontFamily.Monospace) }, + supportingContent = { Text(if (entry.directory) "Directory" else "${formatBytes(entry.size)} expanded · ${formatBytes(entry.compressedSize)} compressed") }, + colors = ListItemDefaults.colors(containerColor = if (index == highlight) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent)) + HorizontalDivider() + } + if (preview.entries.isEmpty()) item { Text("Empty ZIP archive.", Modifier.padding(20.dp)) } + } + section?.table != null -> { + val table = section.table!! + val columns = table.columnCount + Box(Modifier.fillMaxSize().horizontalScroll(horizontal)) { + LazyColumn(state = listState, modifier = Modifier.width((56 + columns * 152).coerceAtLeast(240).dp).fillMaxHeight()) { + stickyHeader { + Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Row { + TableCellText("Row", 56, header = true) + repeat(columns) { TableCellText(columnLabel(it), 152, header = true) } + } + } + } + itemsIndexed(table.rows) { index, row -> + val cellMap = remember(row) { row.cells.associateBy { it.column } } + Row(Modifier.background(if (index == highlight) MaterialTheme.colorScheme.tertiaryContainer else Color.Transparent)) { + TableCellText(row.number.toString(), 56, header = true) + repeat(columns) { column -> + val cell = cellMap[column] + TableCellText(cell?.value ?: "", 152, formula = cell?.formula) + } + } + HorizontalDivider() + } + if (table.rows.isEmpty()) item { Text("No cell values in this sheet.", Modifier.padding(16.dp)) } + } + } + } + section != null -> LazyColumn(state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(20.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + itemsIndexed(section.blocks) { index, block -> + SelectionContainer { + val style = when (block.kind) { + BlockKind.HEADING -> if (block.level <= 1) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleLarge + BlockKind.CODE, BlockKind.TABLE_ROW -> MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + else -> MaterialTheme.typography.bodyLarge + } + Text(highlightedText((if (block.kind == BlockKind.LIST_ITEM) "• " else "") + block.text, if (searchVisible) query else "", + MaterialTheme.colorScheme.tertiaryContainer), style = style, + modifier = Modifier.fillMaxWidth().background(if (index == highlight) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) + .padding(vertical = if (block.kind == BlockKind.HEADING) 8.dp else 2.dp)) + } + } + if (section.blocks.isEmpty()) item { Text("No supported text was found. Images or unsupported content may still exist in the original.") } + } + else -> Text("No readable sections.", Modifier.padding(20.dp)) + } + } +} + +@Composable +private fun TableCellText(value: String, width: Int, header: Boolean = false, formula: String? = null) { + SelectionContainer { + Column(Modifier.width(width.dp).padding(8.dp)) { + Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = if (header) FontWeight.SemiBold else FontWeight.Normal) + if (formula != null) Text("Formula: $formula", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +private fun highlightedText(text: String, query: String, color: Color) = buildAnnotatedString { + append(text) + val needle = query.trim() + if (needle.isNotEmpty()) { + var index = 0 + var marks = 0 + while (index < text.length && marks < 1000) { + val next = text.indexOf(needle, startIndex = index, ignoreCase = true) + if (next < 0) break + addStyle(SpanStyle(background = color, fontWeight = FontWeight.Bold), next, next + needle.length) + index = next + needle.length + marks++ + } + } +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/FileworkbenchApp.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/FileworkbenchApp.kt new file mode 100644 index 0000000..3e01995 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/FileworkbenchApp.kt @@ -0,0 +1,255 @@ +package dev.fileworkbench.android.ui + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.fileworkbench.android.ViewerViewModel +import dev.fileworkbench.core.FileKind +import dev.fileworkbench.core.browser.BrowserEntry +import dev.fileworkbench.android.browser.* +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FileworkbenchApp( + viewer: ViewerViewModel, + browser: BrowserViewModel, + onPickerResult: ((Intent) -> Unit)? = null, + onPickerCancel: (() -> Unit)? = null, +) { + val state by viewer.state.collectAsStateWithLifecycle() + val files by browser.state.collectAsStateWithLifecycle() + val browserUiState = rememberSaveableStateHolder() + var integrationHelp by remember { mutableStateOf(false) } + var reviewSelection by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + var menu by remember { mutableStateOf(false) } + var details by remember { mutableStateOf(false) } + var clearConfirmation by remember { mutableStateOf(false) } + var search by rememberSaveable(state.document?.id) { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } + var pendingCopyId by rememberSaveable { mutableStateOf(null) } + var pendingTextId by rememberSaveable { mutableStateOf(null) } + val open = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uris = PickerIntents.resultUris(result.data) + if (files.picker != null) { + if (uris.isNotEmpty()) { viewer.closeDocument(); browser.selectedBySystem(uris) } + } else uris.firstOrNull()?.let { viewer.open(it, + persist = (result.data?.flags ?: 0) and Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION != 0) } + } + } + val addFolder = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) result.data?.data?.let { + viewer.closeDocument() + browser.addFolder(it, result.data?.flags ?: 0) + } + } + val openFile: () -> Unit = { + runCatching { open.launch(PickerIntents.openDocument(files.picker)) } + .onFailure { viewer.notice("The system file picker is unavailable on this device.") } + } + val chooseFolder: () -> Unit = { + runCatching { addFolder.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + files.directory?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(it.id)) } + }) }.onFailure { viewer.notice("The system folder picker is unavailable on this device.") } + } + val saveCopy = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) result.data?.data?.let { viewer.saveOriginal(it, pendingCopyId) } + } + val exportText = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + uri?.let { viewer.exportPreview(it, pendingTextId) } + } + BackHandler(enabled = state.document != null || state.loading) { viewer.closeDocument() } + BackHandler(enabled = state.document == null && !state.loading && files.location != null && !files.recentsTab && !files.exporting) { browser.up() } + BackHandler(enabled = files.exporting) { browser.cancelPendingExport() } + LaunchedEffect(files.result) { files.result?.let { onPickerResult?.invoke(it) } } + LaunchedEffect(files.notice) { + files.notice?.let { snackbar.showSnackbar(it); browser.dismissNotice() } + } + LaunchedEffect(files.outgoing) { + files.outgoing?.let { + runCatching { context.startActivity(it) }.onFailure { viewer.notice("No other application could open this file.") } + browser.dismissOutgoing() + } + } + LaunchedEffect(state.notice) { + state.notice?.let { snackbar.showSnackbar(it); viewer.dismissNotice() } + } + + FileworkbenchTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(state.document?.displayName ?: if (files.picker != null) "Choose files" else "Fileworkbench", maxLines = 1, overflow = TextOverflow.Ellipsis) + if (state.document != null) Text("Read-only • ${formatBytes(state.document!!.size)}", style = MaterialTheme.typography.labelMedium) + } + }, + navigationIcon = { + if (state.document != null || state.loading) TextButton(onClick = viewer::closeDocument) { Text("Files") } + else if (files.location != null && !files.recentsTab) TextButton(onClick = browser::up) { Text("Up") } + }, + actions = { + if (state.preview != null) TextButton(onClick = { search = !search }) { Text(if (search) "Done" else "Search") } + Box { + TextButton(onClick = { menu = true }) { Text("More") } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text("Open file") }, enabled = !files.exporting, onClick = { menu = false; openFile() }) + if (files.picker?.localOnly != true) DropdownMenuItem(text = { Text("Add folder") }, enabled = !files.exporting, + onClick = { menu = false; chooseFolder() }) + if (state.document != null) { + DropdownMenuItem(text = { Text("File details") }, onClick = { menu = false; details = true }) + if (files.picker == null) DropdownMenuItem(text = { Text("Open in another app") }, enabled = !state.loading, onClick = { + menu = false + scope.launch { viewer.openInOtherApp()?.let { intent -> + runCatching { context.startActivity(intent) }.onFailure { viewer.notice("No other application could open this file.") } + } } + }) + if (files.picker == null) DropdownMenuItem(text = { Text("Share original") }, enabled = !state.loading, onClick = { + menu = false + scope.launch { + viewer.shareOriginal()?.let { intent -> + runCatching { context.startActivity(intent) }.onFailure { viewer.notice("No application could receive the shared file.") } + } + } + }) + if (files.picker == null) DropdownMenuItem(text = { Text("Save a copy") }, enabled = !state.loading, onClick = { + menu = false + state.document?.let { document -> pendingCopyId = document.id; saveCopy.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = document.mime + putExtra(Intent.EXTRA_TITLE, document.displayName) + }) } + }) + if (files.picker == null && state.preview != null) DropdownMenuItem(text = { Text("Export preview as text") }, onClick = { + menu = false + pendingTextId = state.document?.id + exportText.launch((state.document?.displayName ?: "Document") + ".preview.txt") + }) + } + HorizontalDivider() + DropdownMenuItem(text = { Text("Browser integration") }, onClick = { menu = false; integrationHelp = true }) + if (files.picker == null) DropdownMenuItem(text = { Text("Clear history & cache") }, onClick = { menu = false; clearConfirmation = true }) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + bottomBar = { + if (files.picker != null) Surface(tonalElevation = 3.dp) { + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + state.document?.let { document -> + val entry = BrowserEntry(document.source, document.displayName, + SafBrowserStore.resolvedMime(document.mime, document.displayName), size = document.size) + if (files.picker!!.accepts(entry)) TextButton(onClick = { browser.toggle(entry) }, enabled = !files.exporting && !state.loading) { + Text(if (files.selected.any { it.id == entry.id }) "Deselect previewed file" else "Select previewed file") + } + } + Text(if (files.exporting) "Preparing read-only copies… Back cancels." else "${files.selected.size} selected · read-only copies · 64 MiB/file", + style = MaterialTheme.typography.labelMedium) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { browser.cancelPendingExport(); onPickerCancel?.invoke() }) { Text("Cancel") } + TextButton(onClick = { reviewSelection = true }, enabled = files.selected.isNotEmpty() && !files.exporting) { Text("Review") } + Button(onClick = browser::confirmSelection, + enabled = files.selected.isNotEmpty() && !files.loading && !files.exporting && files.picker?.isValid == true) { + Text(if (files.selected.size == 1) "Use file" else "Use files") + } + } + } + } + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (state.loading) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.error?.let { message -> + Surface(color = MaterialTheme.colorScheme.errorContainer) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text("Cannot display this file", fontWeight = FontWeight.SemiBold) + Text(message, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = { openFile() }) { Text("Choose a file") } + } + } + } + when { + state.document == null -> browserUiState.SaveableStateProvider("browser") { + BrowserScreen(files, browser, state.recents, onOpenFile = openFile, onAddFolder = chooseFolder, + onPreview = { viewer.open(Uri.parse(it.id)) }, onRecent = { viewer.open(Uri.parse(it.uri)) }, onForgetRecent = viewer::forget) + } + state.preview != null -> DocumentReader(preview = state.preview!!, searchVisible = search, documentId = state.document!!.id) + state.bitmap != null -> RasterViewer(state, onPage = viewer::goToPage) + !state.loading && state.error == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No preview available.") } + } + } + } + if (details && state.document != null) { + val document = state.document!! + AlertDialog(onDismissRequest = { details = false }, title = { Text("File details") }, text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(document.displayName, fontWeight = FontWeight.SemiBold) + Text("Size: ${formatBytes(document.size)} (${document.size} bytes)") + Text("Declared MIME type: ${document.mime}") + Text("Detected format: ${state.preview?.kind ?: document.kind}") + Text("Access: ${if (document.persistentAccess) "provider read grant retained" else "temporary; reopening may require reselection"}") + Text("Private read-only snapshot. Sharing sends the original file, not the reading preview.") + Text(when (document.kind) { + FileKind.PDF -> "PDF rendering runs in a separate isolated Android process. Text search and forms are not implemented." + FileKind.IMAGE -> "Images are downsampled for memory safety. Animation and EXIF rotation are not implemented in this version." + else -> "Reading previews use the shared Kotlin core. They are not full Office layout renderings." + }) + } + }, confirmButton = { TextButton(onClick = { details = false }) { Text("Close") } }) + } + if (integrationHelp) AlertDialog(onDismissRequest = { integrationHelp = false }, title = { Text("File browser integration") }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Fileworkbench advertises Android's files-app category and can answer Get content requests from other apps. Where a chooser is offered, select Fileworkbench.") + Text("Android's system Open document and folder-permission pickers are not replaceable by this app. There is no universal default-files-app setting guaranteed across phones.") + Text("Directory links require an accessible content tree. Other folder links ask you to add the folder instead of claiming access to raw paths.") + Text("The browser is read-only. Saved-folder removal only releases access; file picking returns temporary copies with read permission, not folder grants.") + } }, confirmButton = { TextButton(onClick = { integrationHelp = false }) { Text("Close") } }) + if (reviewSelection) AlertDialog(onDismissRequest = { reviewSelection = false }, title = { Text("Selected files") }, + text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + files.selected.forEach { entry -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(entry.label, modifier = Modifier.weight(1f)) + TextButton(onClick = { browser.toggle(entry) }) { Text("Remove") } + } + } + if (files.selected.isEmpty()) Text("No files selected.") + Text("Only these files will be copied to the requesting app. Original files remain unchanged.") + } }, confirmButton = { TextButton(onClick = { reviewSelection = false }) { Text("Done") } }) + if (clearConfirmation) AlertDialog(onDismissRequest = { clearConfirmation = false }, title = { Text("Clear history and cached copies?") }, + text = { Text("This closes the document, removes private snapshots and picker copies, and releases individual document grants. Saved folder grants stay intact; remove those under Browse. Originals are not deleted. Previously shared copies may stop being accessible.") }, + confirmButton = { TextButton(onClick = { clearConfirmation = false; viewer.clearHistoryAndCache(); browser.clearThumbnailCache() }) { Text("Clear") } }, + dismissButton = { TextButton(onClick = { clearConfirmation = false }) { Text("Cancel") } }) + } +} + +fun formatBytes(bytes: Long): String = when { + bytes < 0 -> "Unknown size" + bytes < 1024 -> "$bytes B" + bytes < 1024L * 1024 -> "${bytes / 1024} KiB" + bytes < 1024L * 1024 * 1024 -> "${String.format(java.util.Locale.getDefault(), "%.1f", bytes / (1024.0 * 1024))} MiB" + else -> "${String.format(java.util.Locale.getDefault(), "%.1f", bytes / (1024.0 * 1024 * 1024))} GiB" +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/RasterViewer.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/RasterViewer.kt new file mode 100644 index 0000000..b51e3ac --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/RasterViewer.kt @@ -0,0 +1,63 @@ +package dev.fileworkbench.android.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import dev.fileworkbench.android.ViewerState +import dev.fileworkbench.core.FileKind + +@Composable +fun RasterViewer(state: ViewerState, onPage: (Int) -> Unit) { + val bitmap = state.bitmap ?: return + var scale by remember(state.document?.id, state.page) { mutableFloatStateOf(1f) } + var offset by remember(state.document?.id, state.page) { mutableStateOf(Offset.Zero) } + var jump by remember { mutableStateOf(false) } + var pageText by rememberSaveable { mutableStateOf("") } + Column(Modifier.fillMaxSize()) { + if (state.document?.kind == FileKind.PDF) { + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(enabled = state.page > 0 && !state.loading, onClick = { onPage(state.page - 1) }) { Text("Previous") } + TextButton(onClick = { pageText = (state.page + 1).toString(); jump = true }) { Text("${state.page + 1} / ${state.pageCount}") } + TextButton(enabled = state.page + 1 < state.pageCount && !state.loading, onClick = { onPage(state.page + 1) }) { Text("Next") } + } + } + Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().background(MaterialTheme.colorScheme.surfaceVariant) + .pointerInput(state.document?.id, state.page) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + val maxX = size.width * (scale - 1) / 2f + val maxY = size.height * (scale - 1) / 2f + offset = Offset((offset.x + pan.x).coerceIn(-maxX, maxX), (offset.y + pan.y).coerceIn(-maxY, maxY)) + } + }, contentAlignment = Alignment.Center) { + Image(bitmap = bitmap.asImageBitmap(), contentDescription = if (state.document?.kind == FileKind.PDF) "PDF page ${state.page + 1}" else state.document?.displayName, + contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().graphicsLayer { + scaleX = scale; scaleY = scale; translationX = offset.x; translationY = offset.y + }) + } + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text("${(scale * 100).toInt()}% · Pinch to zoom", style = MaterialTheme.typography.labelMedium) + TextButton(onClick = { scale = 1f; offset = Offset.Zero }) { Text("Reset zoom") } + } + } + if (jump) AlertDialog(onDismissRequest = { jump = false }, title = { Text("Go to page") }, text = { + OutlinedTextField(value = pageText, onValueChange = { pageText = it.filter { c -> c.isDigit() }.take(8) }, singleLine = true, + label = { Text("Page 1–${state.pageCount}") }) + }, confirmButton = { + TextButton(enabled = pageText.toIntOrNull()?.let { it in 1..state.pageCount } == true && !state.loading, + onClick = { pageText.toIntOrNull()?.let { onPage(it - 1) }; jump = false }) { Text("Go") } + }, dismissButton = { TextButton(onClick = { jump = false }) { Text("Cancel") } }) +} diff --git a/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/Theme.kt b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/Theme.kt new file mode 100644 index 0000000..b564653 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/fileworkbench/android/ui/Theme.kt @@ -0,0 +1,23 @@ +package dev.fileworkbench.android.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val Light = lightColorScheme( + primary = Color(0xFF246A67), onPrimary = Color.White, + primaryContainer = Color(0xFFCEF0E9), onPrimaryContainer = Color(0xFF083E3D), + background = Color(0xFFF7F9F8), surface = Color(0xFFF7F9F8), + surfaceVariant = Color(0xFFE1EAE6), onSurfaceVariant = Color(0xFF414B47), +) +private val Dark = darkColorScheme( + primary = Color(0xFF9CD2C9), onPrimary = Color(0xFF003734), + primaryContainer = Color(0xFF184E48), onPrimaryContainer = Color(0xFFBEF2E8), + background = Color(0xFF101615), surface = Color(0xFF101615), + surfaceVariant = Color(0xFF303D38), onSurfaceVariant = Color(0xFFC1CEC6), +) +@Composable +fun FileworkbenchTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) Dark else Light, content = content) +} diff --git a/androidApp/src/main/res/drawable/ic_launcher.xml b/androidApp/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..860abdc --- /dev/null +++ b/androidApp/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml new file mode 100644 index 0000000..67c17dd --- /dev/null +++ b/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Fileworkbench + diff --git a/androidApp/src/main/res/values/styles.xml b/androidApp/src/main/res/values/styles.xml new file mode 100644 index 0000000..eed6131 --- /dev/null +++ b/androidApp/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + diff --git a/androidApp/src/main/res/xml/data_extraction_rules.xml b/androidApp/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..224515d --- /dev/null +++ b/androidApp/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/main/res/xml/file_paths.xml b/androidApp/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..1bcde5e --- /dev/null +++ b/androidApp/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dc54870 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..ad5c789 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,21 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework +plugins { alias(libs.plugins.kotlin.multiplatform) } +kotlin { + jvm() + jvmToolchain(17) + // Pure common Kotlin. Android consumes the JVM variant, not an Android-dependent core. + // Apple targets are opt-in so Android/Linux development never needs an Apple toolchain. + if (providers.gradleProperty("enableIos").orNull == "true") { + val xc = XCFramework("FileworkbenchCore") + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "FileworkbenchCore" + isStatic = true + xc.add(this) + } + } + } + sourceSets { + commonTest.dependencies { implementation(kotlin("test")) } + } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/CoreApi.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/CoreApi.kt new file mode 100644 index 0000000..4695e93 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/CoreApi.kt @@ -0,0 +1,21 @@ +package dev.fileworkbench.core + +/** Small public boundary usable from JVM/Android or an exported Apple framework. + * Native hosts own file access, byte limits, ZIP inflation, page/image rendering and lifecycle. + */ +class CoreApi { + fun detect(displayName: String, mimeType: String?, header: ByteArray): FileKind = FileClassifier.classify(displayName, mimeType, header) + + @Throws(PreviewException::class) + fun readText(displayName: String, mimeType: String?, bytes: ByteArray): DocumentPreview { + val kind = detect(displayName, mimeType, bytes.take(16).toByteArray()) + if (kind !in setOf(FileKind.TEXT, FileKind.MARKDOWN, FileKind.CSV)) throw PreviewException("Not a supported text format.") + return TextPreview.create(displayName, kind, bytes).also { PreviewSafety.validate(it) } + } + + @Throws(PreviewException::class) + fun readArchive(declaredKind: FileKind, container: ArchiveContainer): DocumentPreview = PreviewEngine().fromArchive(declaredKind, container) + + fun find(preview: DocumentPreview, query: String): List = PreviewText.search(preview, query) + fun export(preview: DocumentPreview): String = PreviewText.render(preview) +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/FileClassifier.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/FileClassifier.kt new file mode 100644 index 0000000..396c5af --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/FileClassifier.kt @@ -0,0 +1,72 @@ +package dev.fileworkbench.core + +object FileClassifier { + private val extensions = mapOf( + "pdf" to FileKind.PDF, + "jpg" to FileKind.IMAGE, "jpeg" to FileKind.IMAGE, "png" to FileKind.IMAGE, + "webp" to FileKind.IMAGE, "gif" to FileKind.IMAGE, "bmp" to FileKind.IMAGE, + "md" to FileKind.MARKDOWN, "markdown" to FileKind.MARKDOWN, + "csv" to FileKind.CSV, "tsv" to FileKind.CSV, + "docx" to FileKind.DOCX, "xlsx" to FileKind.XLSX, "pptx" to FileKind.PPTX, + "odt" to FileKind.ODT, "ods" to FileKind.ODS, "odp" to FileKind.ODP, + "doc" to FileKind.LEGACY_OFFICE, "xls" to FileKind.LEGACY_OFFICE, "ppt" to FileKind.LEGACY_OFFICE, + "docm" to FileKind.LEGACY_OFFICE, "xlsm" to FileKind.LEGACY_OFFICE, "pptm" to FileKind.LEGACY_OFFICE, + "rtf" to FileKind.LEGACY_OFFICE, "zip" to FileKind.ZIP, + ) + private val textExtensions = setOf("txt", "log", "json", "xml", "yaml", "yml", "toml", "ini", "cfg", "conf", + "kt", "kts", "java", "py", "sh", "bash", "js", "ts", "css", "html", "htm", "svg", "sql", "c", "h", "cpp", + "rs", "go", "php", "properties", "ics", "vcf") + private val mimeKinds = mapOf( + "application/pdf" to FileKind.PDF, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" to FileKind.DOCX, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" to FileKind.XLSX, + "application/vnd.openxmlformats-officedocument.presentationml.presentation" to FileKind.PPTX, + "application/vnd.oasis.opendocument.text" to FileKind.ODT, + "application/vnd.oasis.opendocument.spreadsheet" to FileKind.ODS, + "application/vnd.oasis.opendocument.presentation" to FileKind.ODP, + "application/zip" to FileKind.ZIP, "application/x-zip-compressed" to FileKind.ZIP, + "text/csv" to FileKind.CSV, "text/tab-separated-values" to FileKind.CSV, + "text/markdown" to FileKind.MARKDOWN, + "application/json" to FileKind.TEXT, "application/xml" to FileKind.TEXT, "image/svg+xml" to FileKind.TEXT, + "image/jpeg" to FileKind.IMAGE, "image/png" to FileKind.IMAGE, "image/webp" to FileKind.IMAGE, + "image/gif" to FileKind.IMAGE, "image/bmp" to FileKind.IMAGE, + ) + + fun classify(name: String, mime: String?, header: ByteArray = byteArrayOf()): FileKind { + if (starts(header, 0x25, 0x50, 0x44, 0x46, 0x2d)) return FileKind.PDF + if (starts(header, 0x89, 0x50, 0x4e, 0x47) || starts(header, 0xff, 0xd8, 0xff) || + starts(header, 0x47, 0x49, 0x46, 0x38)) return FileKind.IMAGE + if (starts(header, 0xd0, 0xcf, 0x11, 0xe0)) return FileKind.LEGACY_OFFICE + val ext = name.substringAfterLast('.', "").lowercase() + val declared = extensions[ext] ?: if (ext in textExtensions) FileKind.TEXT else null + if (declared == FileKind.LEGACY_OFFICE) return declared + // ZIP-family claims are checked against actual entries by PreviewEngine, not trusted here. + if (starts(header, 0x50, 0x4b, 0x03, 0x04) || starts(header, 0x50, 0x4b, 0x05, 0x06)) { + return declared?.takeIf { it in archiveKinds } ?: FileKind.ZIP + } + if (declared != null) return declared + val normalizedMime = mime?.substringBefore(';')?.trim()?.lowercase() + return mimeKinds[normalizedMime] ?: if (normalizedMime?.startsWith("text/") == true) FileKind.TEXT else FileKind.UNKNOWN + } + + val archiveKinds = setOf(FileKind.DOCX, FileKind.XLSX, FileKind.PPTX, FileKind.ODT, FileKind.ODS, FileKind.ODP, FileKind.ZIP) + + fun inspectArchive(container: ArchiveContainer, fallback: FileKind): FileKind = when { + container.entries.any { it.path.substringAfterLast('/').equals("vbaProject.bin", ignoreCase = true) } -> + throw PreviewException("Macro-enabled Office packages are not supported by this MVP.") + container.contains("word/document.xml") -> FileKind.DOCX + container.contains("xl/workbook.xml") -> FileKind.XLSX + container.contains("ppt/presentation.xml") -> FileKind.PPTX + container.contains("mimetype") -> when (TextCodec.decode(container.read("mimetype")).trim()) { + "application/vnd.oasis.opendocument.text" -> FileKind.ODT + "application/vnd.oasis.opendocument.spreadsheet" -> FileKind.ODS + "application/vnd.oasis.opendocument.presentation" -> FileKind.ODP + else -> FileKind.ZIP + } + fallback == FileKind.ZIP -> FileKind.ZIP + else -> throw PreviewException("This file does not contain the expected document package.") + } + + private fun starts(bytes: ByteArray, vararg prefix: Int): Boolean = + bytes.size >= prefix.size && prefix.indices.all { bytes[it].toInt() and 255 == prefix[it] } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/Model.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/Model.kt new file mode 100644 index 0000000..1fad73e --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/Model.kt @@ -0,0 +1,108 @@ +package dev.fileworkbench.core + +/** Policy values are resource ceilings, not declarations that every file below them will render. */ +data class SafetyPolicy( + val maxInputBytes: Long = 64L * 1024 * 1024, + val maxTextBytes: Int = 4 * 1024 * 1024, + val maxArchiveEntries: Int = 4096, + val maxEntryBytes: Int = 8 * 1024 * 1024, + val maxExpandedBytes: Long = 32L * 1024 * 1024, + val maxCompressionRatio: Int = 300, + val maxXmlDepth: Int = 96, + val maxXmlNodes: Int = 100_000, + val maxXmlAttributes: Int = 64, + val maxBlocks: Int = 20_000, + val maxBlockCharacters: Int = 32_768, + val maxTableRows: Int = 2_000, + val maxTableColumns: Int = 128, + val maxTableCells: Int = 50_000, + val maxSections: Int = 200, + val maxSearchHits: Int = 1_000, +) + +class PreviewException(message: String) : Exception(message) + +enum class FileKind { PDF, IMAGE, TEXT, MARKDOWN, CSV, DOCX, XLSX, PPTX, ODT, ODS, ODP, ZIP, LEGACY_OFFICE, UNKNOWN } +enum class Fidelity { TEXT, READING_PREVIEW, ARCHIVE_INDEX } +enum class BlockKind { PARAGRAPH, HEADING, CODE, TABLE_ROW, LIST_ITEM } + +data class TextBlock(val text: String, val kind: BlockKind = BlockKind.PARAGRAPH, val level: Int = 0) +data class TableCell(val column: Int, val value: String, val formula: String? = null) +data class TableRow(val number: Int, val cells: List) +data class TableData(val rows: List, val columnCount: Int) +data class PreviewSection(val title: String, val blocks: List = emptyList(), val table: TableData? = null) +data class ArchiveEntry(val path: String, val size: Long, val compressedSize: Long, val directory: Boolean) +data class DocumentPreview( + val kind: FileKind, + val fidelity: Fidelity, + val sections: List, + val warnings: List = emptyList(), + val entries: List = emptyList(), +) + +data class SearchHit(val sectionIndex: Int, val itemIndex: Int, val snippet: String) + +/** Native ZIP adapters enforce entry and expansion limits before exposing bytes to common code. */ +interface ArchiveContainer { + val entries: List + fun read(path: String): ByteArray + fun contains(path: String): Boolean = entries.any { it.path == path && !it.directory } +} + +interface FormatHandler { + val kinds: Set + fun preview(kind: FileKind, archive: ArchiveContainer, policy: SafetyPolicy): DocumentPreview +} + +object PreviewText { + fun render(preview: DocumentPreview): String = buildString { + preview.sections.forEachIndexed { index, section -> + if (index > 0) append("\n\n") + append(section.title).append('\n') + section.blocks.forEach { append(it.text).append('\n') } + section.table?.rows?.forEach { row -> + append(row.number).append('\t') + append(row.cells.joinToString("\t") { "${columnLabel(it.column)}: ${it.value}" }) + append('\n') + } + } + if (preview.entries.isNotEmpty()) { + preview.entries.forEach { append(it.path).append('\t').append(it.size).append(" bytes\n") } + } + } + + /** Literal, case-insensitive search. No regex supplied by the user is executed. */ + fun search(preview: DocumentPreview, query: String, maxHits: Int = 1000): List { + val needle = query.trim().take(256) + if (needle.isEmpty() || maxHits <= 0) return emptyList() + val hits = mutableListOf() + for ((s, section) in preview.sections.withIndex()) { + val lines = section.table?.rows?.map { row -> row.cells.joinToString(" | ") { it.value } } + ?: section.blocks.map { it.text } + for ((i, line) in lines.withIndex()) { + val pos = line.indexOf(needle, ignoreCase = true) + if (pos >= 0) { + hits += SearchHit(s, i, line.substring((pos - 40).coerceAtLeast(0), (pos + needle.length + 100).coerceAtMost(line.length))) + if (hits.size >= maxHits) return hits + } + } + } + if (preview.entries.isNotEmpty()) { + for ((i, entry) in preview.entries.withIndex()) { + if (entry.path.contains(needle, ignoreCase = true)) { + hits += SearchHit(0, i, entry.path) + if (hits.size >= maxHits) return hits + } + } + } + return hits + } +} + +fun columnLabel(index: Int): String { + require(index >= 0) + var n = index + 1 + var result = "" + while (n > 0) { n--; result = ('A'.code + n % 26).toChar() + result; n /= 26 } + return result +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/OfficePreview.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/OfficePreview.kt new file mode 100644 index 0000000..0ef7ae0 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/OfficePreview.kt @@ -0,0 +1,236 @@ +package dev.fileworkbench.core + +private const val OFFICE_WARNING = "Reading preview, not the original layout. Images, drawings, charts, headers, footers, notes, comments and advanced formatting may be missing. Do not use this preview to verify document completeness." + +object OfficePreview : FormatHandler { + override val kinds = setOf(FileKind.DOCX, FileKind.XLSX, FileKind.PPTX, FileKind.ODT, FileKind.ODS, FileKind.ODP) + override fun preview(kind: FileKind, archive: ArchiveContainer, policy: SafetyPolicy): DocumentPreview = when (kind) { + FileKind.DOCX -> docx(archive, policy) + FileKind.XLSX -> xlsx(archive, policy) + FileKind.PPTX -> pptx(archive, policy) + FileKind.ODT, FileKind.ODS, FileKind.ODP -> odf(kind, archive, policy) + else -> throw PreviewException("Unsupported Office format.") + } + + private fun docx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview { + val root = archive.xml("word/document.xml", p) + val body = root.children("body").firstOrNull() ?: throw PreviewException("Word document has no body.") + val blocks = mutableListOf() + fun add(block: TextBlock) { + if (blocks.size >= p.maxBlocks) throw PreviewException("Document exceeds the paragraph preview limit.") + blocks += block + } + fun visit(node: XmlNode) { + when (node.localName) { + "del", "instrText" -> Unit + "p" -> { + val style = node.children("pPr").firstOrNull()?.children("pStyle")?.firstOrNull()?.attr("val") ?: "" + val level = if (style.startsWith("Heading", true)) style.drop(7).toIntOrNull()?.coerceIn(1, 6) ?: 0 else if (style.equals("Title", true)) 1 else 0 + val list = node.children("pPr").any { it.children("numPr").isNotEmpty() } + add(TextBlock(wordText(node), if (level > 0) BlockKind.HEADING else if (list) BlockKind.LIST_ITEM else BlockKind.PARAGRAPH, level)) + } + "tbl" -> node.children("tr").forEach { row -> + add(TextBlock(row.children("tc").joinToString(" | ") { cell -> cell.descendants("p").joinToString(" / ") { wordText(it) } }, BlockKind.TABLE_ROW)) + } + else -> node.children().forEach { visit(it) } + } + } + body.children().forEach { visit(it) } + return DocumentPreview(FileKind.DOCX, Fidelity.READING_PREVIEW, listOf(PreviewSection("Document", blocks)), listOf(OFFICE_WARNING, "Tracked deletions and field instructions are omitted; inserted text and cached field results may appear. Review revisions in a full editor.")) + } + + private fun wordText(node: XmlNode): String = buildString { + fun walk(n: XmlNode) { + when (n.localName) { + "t" -> append(n.text()) + "tab" -> append('\t') + "br", "cr" -> append('\n') + "del", "instrText", "pPr", "rPr" -> Unit + else -> n.children().forEach { walk(it) } + } + } + walk(node) + } + + private fun xlsx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview { + val workbook = archive.xml("xl/workbook.xml", p) + val rels = relationships(archive, "xl/workbook.xml", p) + val strings = if (archive.contains("xl/sharedStrings.xml")) archive.xml("xl/sharedStrings.xml", p) + .children("si").map { it.descendants("t").joinToString("") { t -> t.text() } } else emptyList() + val sheetNodes = workbook.descendants("sheet") + if (sheetNodes.size > p.maxSections) throw PreviewException("Too many sheets for this preview.") + var cellCount = 0 + val sections = sheetNodes.map { sheet -> + val name = sheet.attr("name") ?: "Sheet" + val part = rels[sheet.attr("id")] ?: throw PreviewException("Missing worksheet relationship.") + val xml = archive.xml(part, p) + val rowNodes = xml.descendants("sheetData").flatMap { it.children("row") } + if (rowNodes.size > p.maxTableRows) throw PreviewException("Sheet exceeds the row preview limit.") + var lastRow = 0 + var columnCount = 0 + val rows = rowNodes.map { row -> + val rowNumber = row.attr("r")?.toIntOrNull() ?: (lastRow + 1) + if (rowNumber <= lastRow || rowNumber > 1_048_576) throw PreviewException("Invalid worksheet row order.") + lastRow = rowNumber + var lastColumn = -1 + val cells = row.children("c").map { cell -> + if (++cellCount > p.maxTableCells) throw PreviewException("Workbook exceeds the cell preview limit.") + val column = cell.attr("r")?.let { referenceColumn(it) } ?: lastColumn + 1 + if (column <= lastColumn || column >= p.maxTableColumns) throw PreviewException("Sheet is too wide or contains invalid cell ordering.") + lastColumn = column + columnCount = maxOf(columnCount, column + 1) + val raw = cell.children("v").firstOrNull()?.text() ?: "" + val formula = cell.children("f").firstOrNull()?.text() + val value = when (cell.attr("t")) { + "s" -> strings.getOrNull(raw.toIntOrNull() ?: -1) ?: throw PreviewException("Invalid shared string reference.") + "inlineStr" -> cell.children("is").flatMap { it.descendants("t") }.joinToString("") { it.text() } + "b" -> if (raw == "1") "TRUE" else if (raw == "0") "FALSE" else raw + else -> raw.ifEmpty { if (formula != null) "[no cached value]" else "" } + } + TableCell(column, value, formula) + } + TableRow(rowNumber, cells) + } + PreviewSection(name + if (sheet.attr("state") in setOf("hidden", "veryHidden")) " (hidden)" else "", table = TableData(rows, columnCount)) + } + return DocumentPreview(FileKind.XLSX, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING, + "Values are cached, not recalculated. Dates may appear as serial numbers; number formats, merged cells, filters and hidden rows/columns are not reproduced. Hidden sheets are labelled and shown.")) + } + + private fun referenceColumn(reference: String): Int { + var value = 0 + val letters = reference.takeWhile { it in 'A'..'Z' } + if (letters.isEmpty() || letters.length > 3 || reference.drop(letters.length).toIntOrNull() == null) + throw PreviewException("Invalid cell reference.") + letters.forEach { value = value * 26 + (it - 'A') + 1 } + return value - 1 + } + + private fun pptx(archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview { + val presentation = archive.xml("ppt/presentation.xml", p) + val rels = relationships(archive, "ppt/presentation.xml", p) + val ids = presentation.children("sldIdLst").flatMap { it.children("sldId") } + if (ids.size > p.maxSections) throw PreviewException("Too many slides for this preview.") + val sections = ids.mapIndexed { index, id -> + // sldId has both a numeric unqualified id and the relationship r:id. + val rid = id.attributes.entries.firstOrNull { it.key.endsWith(":id") }?.value + val part = rels[rid] ?: throw PreviewException("Missing slide relationship.") + val root = archive.xml(part, p) + val paragraphs = root.descendants("p") + if (paragraphs.size > p.maxBlocks) throw PreviewException("Too many slide paragraphs.") + val blocks = paragraphs.map { paragraph -> TextBlock(paragraph.descendants("t").joinToString("") { it.text() }) } + PreviewSection("Slide ${index + 1}", blocks) + } + return DocumentPreview(FileKind.PPTX, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING, "Slide order follows the presentation relationships. Text order follows the XML, not the visual placement of shapes.")) + } + + private fun odf(kind: FileKind, archive: ArchiveContainer, p: SafetyPolicy): DocumentPreview { + val root = archive.xml("content.xml", p) + val body = root.children("body").firstOrNull() ?: throw PreviewException("Missing OpenDocument body.") + fun paragraphBlocks(node: XmlNode): List { + val blocks = mutableListOf() + fun walk(n: XmlNode) { + if (n.localName == "p" || n.localName == "h") { + if (blocks.size >= p.maxBlocks) throw PreviewException("Too many OpenDocument paragraphs.") + blocks += TextBlock(odfText(n, p), if (n.localName == "h") BlockKind.HEADING else BlockKind.PARAGRAPH, + n.attr("outline-level")?.toIntOrNull()?.coerceIn(1, 6) ?: 1) + } else n.children().forEach { walk(it) } + } + walk(node) + return blocks + } + val sections: List = when (kind) { + FileKind.ODT -> listOf(PreviewSection("Document", paragraphBlocks(body))) + FileKind.ODP -> { + val pages = body.descendants("page") + if (pages.size > p.maxSections) throw PreviewException("Too many slides.") + pages.mapIndexed { i, page -> PreviewSection(page.attr("name") ?: "Slide ${i + 1}", paragraphBlocks(page)) } + } + FileKind.ODS -> { + val sheets = body.children("spreadsheet").flatMap { it.children("table") } + if (sheets.size > p.maxSections) throw PreviewException("Too many sheets.") + var totalCells = 0 + sheets.map { sheet -> + val rows = mutableListOf() + var rowNumber = 1 + var columns = 0 + // Do not pull rows from nested tables inside a cell. + val rowNodes = mutableListOf() + fun collectRows(n: XmlNode) { + when (n.localName) { + "table-row" -> rowNodes += n + "table", "table-row-group", "table-header-rows", "table-rows" -> n.children().forEach { collectRows(it) } + } + } + collectRows(sheet) + rowNodes.forEach { row -> + val repeatedRows = positiveCount(row.attr("number-rows-repeated")) + val cells = mutableListOf() + var column = 0 + row.children().filter { it.localName == "table-cell" || it.localName == "covered-table-cell" }.forEach { cell -> + val repeat = positiveCount(cell.attr("number-columns-repeated")) + val paragraphs = cell.children("p") + val value = if (paragraphs.isNotEmpty()) paragraphs.joinToString("\n") { odfText(it, p) } + else cell.attr("string-value") ?: cell.attr("date-value") ?: cell.attr("boolean-value") ?: cell.attr("value") ?: "" + // Empty repeated padding need not allocate; its position is still counted. + if (value.isNotEmpty() || cell.attr("formula") != null) { + if (column.toLong() + repeat > p.maxTableColumns || repeat > p.maxTableCells) throw PreviewException("Sheet exceeds the column preview limit.") + repeat(repeat) { cells += TableCell(column + it, value, cell.attr("formula")) } + } + if (column.toLong() + repeat > 16384) throw PreviewException("Invalid repeated column count.") + column += repeat + } + if (cells.isNotEmpty()) { + if (rows.size.toLong() + repeatedRows > p.maxTableRows || totalCells.toLong() + cells.size.toLong() * repeatedRows > p.maxTableCells) + throw PreviewException("Sheet exceeds the repeated row/cell preview limit.") + repeat(repeatedRows) { rows += TableRow(rowNumber + it, cells.toList()) } + totalCells += cells.size * repeatedRows + columns = maxOf(columns, (cells.maxOfOrNull { it.column } ?: -1) + 1) + } + if (rowNumber.toLong() + repeatedRows > 1_048_577) throw PreviewException("Invalid repeated row count.") + rowNumber += repeatedRows + } + PreviewSection(sheet.attr("name") ?: "Sheet", table = TableData(rows, columns)) + } + } + else -> throw PreviewException("Unsupported OpenDocument type.") + } + return DocumentPreview(kind, Fidelity.READING_PREVIEW, sections, listOf(OFFICE_WARNING) + + if (kind == FileKind.ODS) listOf("Stored cell values only; formulas are not executed. Empty padding is skipped. Hidden content and formatting are not reproduced reliably.") else emptyList()) + } + + private fun positiveCount(text: String?): Int = if (text == null) 1 else text.toIntOrNull()?.takeIf { it > 0 } + ?: throw PreviewException("Invalid repeated element count.") + + private fun odfText(node: XmlNode, p: SafetyPolicy): String = buildString { + fun walk(part: XmlPart) { + when (part) { + is XmlText -> append(part.value) + is XmlNode -> when (part.localName) { + "s" -> { + val count = positiveCount(part.attr("c")) + if (count > 10000 || length.toLong() + count > p.maxTextBytes) throw PreviewException("Too much repeated whitespace.") + repeat(count) { append(' ') } + } + "tab" -> append('\t') + "line-break" -> append('\n') + else -> part.parts.forEach { walk(it) } + } + } + if (length > p.maxTextBytes) throw PreviewException("Text exceeds preview limit.") + } + walk(node) + } +} + +class PreviewEngine(private val policy: SafetyPolicy = SafetyPolicy(), private val handlers: List = listOf(OfficePreview)) { + fun fromArchive(declaredKind: FileKind, archive: ArchiveContainer): DocumentPreview { + val kind = FileClassifier.inspectArchive(archive, declaredKind) + if (kind == FileKind.ZIP) return DocumentPreview(kind, Fidelity.ARCHIVE_INDEX, emptyList(), + listOf("Archive index only. Entries are not extracted or executed; nested archives are not opened."), archive.entries) + val result = handlers.firstOrNull { kind in it.kinds }?.preview(kind, archive, policy) + ?: throw PreviewException("No preview handler for this format.") + PreviewSafety.validate(result, policy) + return result + } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/PreviewSafety.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/PreviewSafety.kt new file mode 100644 index 0000000..acba227 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/PreviewSafety.kt @@ -0,0 +1,27 @@ +package dev.fileworkbench.core + +/** Also bounds amplification from repeated shared strings/cell values in a small compressed package. */ +object PreviewSafety { + fun validate(preview: DocumentPreview, policy: SafetyPolicy = SafetyPolicy()) { + var characters = 0L + var cells = 0 + var blocks = 0 + fun count(text: String) { + if (text.length > policy.maxBlockCharacters) throw PreviewException("A text block or cell is too long for this preview.") + characters += text.length + if (characters > policy.maxTextBytes) throw PreviewException("Expanded preview text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB limit.") + } + if (preview.sections.size > policy.maxSections) throw PreviewException("Too many preview sections.") + preview.sections.forEach { section -> + count(section.title) + section.blocks.forEach { if (++blocks > policy.maxBlocks) throw PreviewException("Too many text blocks."); count(it.text) } + section.table?.let { table -> + if (table.rows.size > policy.maxTableRows || table.columnCount > policy.maxTableColumns) throw PreviewException("Table exceeds preview limits.") + table.rows.forEach { row -> row.cells.forEach { + if (++cells > policy.maxTableCells) throw PreviewException("Too many preview cells.") + count(it.value); it.formula?.let { formula -> count(formula) } + } } + } + } + } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/SafeXml.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/SafeXml.kt new file mode 100644 index 0000000..6ae9570 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/SafeXml.kt @@ -0,0 +1,197 @@ +package dev.fileworkbench.core + +internal sealed interface XmlPart +internal data class XmlText(val value: String) : XmlPart +internal class XmlNode(val name: String, val attributes: Map, val parts: MutableList = mutableListOf()) : XmlPart { + val localName: String get() = name.substringAfter(':') + fun attr(local: String): String? = attributes.entries.firstOrNull { it.key.substringAfter(':') == local }?.value + fun children(local: String? = null): List = parts.filterIsInstance().filter { local == null || it.localName == local } + fun descendants(local: String): List = buildList { + fun visit(node: XmlNode) { + if (node.localName == local) add(node) + node.children().forEach { visit(it) } + } + visit(this@XmlNode) + } + fun text(): String = buildString { + fun visit(part: XmlPart) { + when (part) { + is XmlText -> append(part.value) + is XmlNode -> part.parts.forEach { visit(it) } + } + } + visit(this@XmlNode) + } +} + +/** Bounded XML subset for reading previews, NOT a validating general-purpose XML library. + * No DTD, no general/external entities, no XInclude, no network, no schema processing. + * It preserves mixed-content order and verifies matched tags and quoted attributes. + */ +internal object SafeXml { + fun parse(input: String, policy: SafetyPolicy): XmlNode { + if (input.length > policy.maxEntryBytes) throw PreviewException("XML exceeds the preview limit.") + val source = input.removePrefix("\uFEFF") + var pos = 0 + var nodes = 0 + val stack = mutableListOf() + var root: XmlNode? = null + fun count() { if (++nodes > policy.maxXmlNodes) throw PreviewException("Too many XML nodes.") } + fun attach(part: XmlPart) { + count() + if (stack.isEmpty()) { + if (part is XmlText) { + if (part.value.isNotBlank()) throw PreviewException("Text outside XML root.") + } else if (part is XmlNode) { + if (root != null) throw PreviewException("Multiple XML roots.") + root = part + } + } else stack.last().parts += part + } + fun skipWhitespace() { while (pos < source.length && source[pos].isWhitespace()) pos++ } + fun readName(): String { + val start = pos + if (pos >= source.length || !(source[pos].isLetter() || source[pos] == '_' || source[pos] == ':')) + throw PreviewException("Invalid XML name.") + pos++ + while (pos < source.length && (source[pos].isLetterOrDigit() || source[pos] in "_.:-")) pos++ + if (pos - start > 256) throw PreviewException("XML name too long.") + return source.substring(start, pos) + } + while (pos < source.length) { + if (source[pos] != '<') { + val end = source.indexOf('<', pos).let { if (it < 0) source.length else it } + attach(XmlText(decodeEntities(source.substring(pos, end)))) + pos = end + continue + } + when { + source.startsWith("", pos + 4) + if (end < 0) throw PreviewException("Unterminated XML comment.") + pos = end + 3 + } + source.startsWith(" { + val end = source.indexOf("]]>", pos + 9) + if (end < 0 || stack.isEmpty()) throw PreviewException("Invalid XML CDATA.") + attach(XmlText(source.substring(pos + 9, end))) + pos = end + 3 + } + source.startsWith(" throw PreviewException("DTD and XML declarations other than comments/CDATA are disabled.") + source.startsWith(" { + val end = source.indexOf("?>", pos + 2) + if (end < 0) throw PreviewException("Unterminated XML instruction.") + pos = end + 2 // Never executed. + } + source.startsWith(" { + pos += 2 + val name = readName() + skipWhitespace() + if (pos >= source.length || source[pos] != '>') throw PreviewException("Invalid XML closing tag.") + pos++ + if (stack.isEmpty() || stack.last().name != name) throw PreviewException("Mismatched XML tags.") + stack.removeAt(stack.lastIndex) + } + else -> { + pos++ + val name = readName() + val attrs = linkedMapOf() + var selfClosing = false + while (true) { + val beforeWhitespace = pos + skipWhitespace() + if (pos >= source.length) throw PreviewException("Unterminated XML tag.") + if (source[pos] == '>') { pos++; break } + if (source[pos] == '/' && source.getOrNull(pos + 1) == '>') { pos += 2; selfClosing = true; break } + if (pos == beforeWhitespace) throw PreviewException("Missing space before XML attribute.") + if (attrs.size >= policy.maxXmlAttributes) throw PreviewException("Too many XML attributes.") + val key = readName() + skipWhitespace() + if (source.getOrNull(pos) != '=') throw PreviewException("Invalid XML attribute.") + pos++; skipWhitespace() + val quote = source.getOrNull(pos) + if (quote != '\'' && quote != '"') throw PreviewException("Unquoted XML attribute.") + pos++ + val end = source.indexOf(quote, pos) + if (end < 0) throw PreviewException("Unterminated XML attribute.") + val raw = source.substring(pos, end) + if ('<' in raw) throw PreviewException("Invalid XML attribute value.") + if (attrs.containsKey(key)) throw PreviewException("Duplicate XML attribute.") + attrs[key] = decodeEntities(raw) + pos = end + 1 + } + val element = XmlNode(name, attrs) + attach(element) + if (!selfClosing) { + if (stack.size >= policy.maxXmlDepth) throw PreviewException("XML nesting exceeds preview limit.") + stack += element + } + } + } + } + if (stack.isNotEmpty()) throw PreviewException("Unclosed XML tag.") + return root ?: throw PreviewException("Empty XML document.") + } + + private fun decodeEntities(text: String): String { + if ('&' !in text) return text + return buildString { + var i = 0 + while (i < text.length) { + if (text[i] != '&') { append(text[i++]); continue } + val end = text.indexOf(';', i + 1) + if (end < 0 || end - i > 16) throw PreviewException("Invalid XML entity.") + val entity = text.substring(i + 1, end) + when (entity) { + "amp" -> append('&'); "lt" -> append('<'); "gt" -> append('>') + "quot" -> append('"'); "apos" -> append('\'') + else -> { + val code = when { + entity.startsWith("#x") -> entity.drop(2).toIntOrNull(16) + entity.startsWith('#') -> entity.drop(1).toIntOrNull() + else -> null + } ?: throw PreviewException("Custom XML entities are disabled.") + if (!(code == 9 || code == 10 || code == 13 || code in 0x20..0xd7ff || code in 0xe000..0xfffd || code in 0x10000..0x10ffff)) + throw PreviewException("Invalid XML character reference.") + if (code <= 0xffff) append(code.toChar()) else { + val value = code - 0x10000 + append((0xd800 + (value shr 10)).toChar()) + append((0xdc00 + (value and 1023)).toChar()) + } + } + } + i = end + 1 + } + } + } +} + +internal fun ArchiveContainer.xml(path: String, policy: SafetyPolicy): XmlNode = SafeXml.parse(TextCodec.decode(read(path)), policy) + +/** Resolve package-relative relationships; never access a filesystem, URI or remote target. */ +internal fun resolvePart(basePart: String, target: String): String { + if (target.isBlank() || '\\' in target || ':' in target || '?' in target || '#' in target || '%' in target || '\u0000' in target) + throw PreviewException("Unsupported package relationship.") + val segments = mutableListOf() + if (!target.startsWith('/')) segments += basePart.substringBeforeLast('/', "").split('/').filter { it.isNotEmpty() } + for (segment in target.split('/')) { + when (segment) { + "", "." -> Unit + ".." -> if (segments.isEmpty()) throw PreviewException("Package path escapes its root.") else segments.removeAt(segments.lastIndex) + else -> segments += segment + } + } + return segments.joinToString("/") +} + +internal fun relationships(archive: ArchiveContainer, part: String, policy: SafetyPolicy): Map { + val folder = part.substringBeforeLast('/', "") + val path = (if (folder.isEmpty()) "" else "$folder/") + "_rels/" + part.substringAfterLast('/') + ".rels" + if (!archive.contains(path)) return emptyMap() + return archive.xml(path, policy).descendants("Relationship").mapNotNull { + val id = it.attr("Id") ?: return@mapNotNull null + val target = it.attr("Target") ?: return@mapNotNull null + if (it.attr("TargetMode")?.equals("External", ignoreCase = true) == true) null + else id to resolvePart(part, target) + }.toMap() +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/TextCodec.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/TextCodec.kt new file mode 100644 index 0000000..21b0876 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/TextCodec.kt @@ -0,0 +1,37 @@ +package dev.fileworkbench.core + +object TextCodec { + /** UTF-8 and BOM-tagged UTF-16 only. Never silently interpret an arbitrary binary file as text. */ + fun decode(bytes: ByteArray): String { + val a = bytes.getOrNull(0)?.toInt()?.and(255) + val b = bytes.getOrNull(1)?.toInt()?.and(255) + if ((a == 255 && b == 254) || (a == 254 && b == 255)) { + if ((bytes.size - 2) % 2 != 0) throw PreviewException("Truncated UTF-16 text.") + val little = a == 255 + return buildString { + var i = 2 + while (i < bytes.size) { + val lo = bytes[i].toInt() and 255 + val hi = bytes[i + 1].toInt() and 255 + append((if (little) lo or (hi shl 8) else (lo shl 8) or hi).toChar()) + i += 2 + } + }.also { validateSurrogates(it) } + } + val text = try { bytes.decodeToString(throwOnInvalidSequence = true).removePrefix("\uFEFF") } + catch (_: Exception) { throw PreviewException("Unsupported text encoding. Save as UTF-8 or BOM-tagged UTF-16.") } + if ('\u0000' in text) throw PreviewException("Binary data is not supported by the text viewer.") + return text + } + private fun validateSurrogates(text: String) { + var i = 0 + while (i < text.length) { + val c = text[i] + if (c.isHighSurrogate()) { + if (i + 1 >= text.length || !text[i + 1].isLowSurrogate()) throw PreviewException("Invalid UTF-16 surrogate.") + i++ + } else if (c.isLowSurrogate()) throw PreviewException("Invalid UTF-16 surrogate.") + i++ + } + } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/TextPreview.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/TextPreview.kt new file mode 100644 index 0000000..e910680 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/TextPreview.kt @@ -0,0 +1,96 @@ +package dev.fileworkbench.core + +object TextPreview { + fun create(name: String, kind: FileKind, bytes: ByteArray, policy: SafetyPolicy = SafetyPolicy()): DocumentPreview { + if (bytes.size > policy.maxTextBytes) throw PreviewException("Text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB preview limit.") + val text = TextCodec.decode(bytes).replace("\r\n", "\n").replace('\r', '\n') + if (kind == FileKind.CSV) { + val delimiter = if (name.endsWith(".tsv", ignoreCase = true)) '\t' else CsvPreview.detectDelimiter(text) + return DocumentPreview(kind, Fidelity.TEXT, listOf(PreviewSection(name, table = CsvPreview.parse(text, delimiter, policy))), + listOf("Literal cell values; formulas are never executed. Delimiter: ${if (delimiter == '\t') "tab" else delimiter}.")) + } + val lines = text.split('\n') + if (lines.size > policy.maxBlocks) throw PreviewException("Too many lines for this preview.") + var code = false + val blocks = lines.map { line -> + if (kind == FileKind.MARKDOWN) { + when { + line.startsWith("```") -> { code = !code; TextBlock(line, BlockKind.CODE) } + code -> TextBlock(line, BlockKind.CODE) + line.startsWith('#') && line.takeWhile { it == '#' }.length in 1..6 && line.getOrNull(line.takeWhile { it == '#' }.length) == ' ' -> { + val level = line.takeWhile { it == '#' }.length + TextBlock(line.drop(level + 1), BlockKind.HEADING, level) + } + line.startsWith("- ") || line.startsWith("* ") -> TextBlock(line.drop(2), BlockKind.LIST_ITEM) + else -> TextBlock(line) + } + } else TextBlock(line, BlockKind.CODE) + } + return DocumentPreview(kind, Fidelity.TEXT, listOf(PreviewSection(name, blocks)), + if (kind == FileKind.MARKDOWN) listOf("Basic Markdown reading view. Inline markup is preserved; HTML and external resources are never rendered.") else emptyList()) + } +} + +object CsvPreview { + /** Count delimiters only outside quotes in the first logical record. */ + fun detectDelimiter(text: String): Char { + val counts = linkedMapOf(',' to 0, ';' to 0, '\t' to 0) + var quoted = false + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '"') { + if (quoted && text.getOrNull(i + 1) == '"') i++ else quoted = !quoted + } else if (!quoted) { + if (c == '\n' || c == '\r') break + if (c in counts) counts[c] = counts.getValue(c) + 1 + } + i++ + } + return counts.maxByOrNull { it.value }!!.key + } + + fun parse(text: String, delimiter: Char, policy: SafetyPolicy): TableData { + val rows = mutableListOf() + val cells = mutableListOf() + val value = StringBuilder() + var quoted = false + var closedQuote = false + var atFieldStart = true + var totalCells = 0 + var maxCols = 0 + fun addCell() { + if (cells.size >= policy.maxTableColumns || ++totalCells > policy.maxTableCells) throw PreviewException("CSV exceeds the cell preview limit.") + cells += TableCell(cells.size, value.toString()) + value.clear(); atFieldStart = true; closedQuote = false + } + fun addRow() { + addCell() + if (rows.size >= policy.maxTableRows) throw PreviewException("CSV exceeds the row preview limit.") + rows += TableRow(rows.size + 1, cells.toList()) + maxCols = maxOf(maxCols, cells.size) + cells.clear() + } + var i = 0 + while (i < text.length) { + val c = text[i] + if (quoted) { + if (c == '"') { + if (text.getOrNull(i + 1) == '"') { value.append('"'); i++ } + else { quoted = false; closedQuote = true } + } else value.append(c) + } else when { + c == delimiter -> addCell() + c == '\n' || c == '\r' -> { addRow(); if (c == '\r' && text.getOrNull(i + 1) == '\n') i++ } + closedQuote -> throw PreviewException("Unexpected characters after a quoted CSV field.") + c == '"' && atFieldStart -> { quoted = true; atFieldStart = false } + c == '"' -> throw PreviewException("Unexpected quote in CSV field.") + else -> { value.append(c); atFieldStart = false } + } + i++ + } + if (quoted) throw PreviewException("Unterminated quoted CSV field.") + if (value.isNotEmpty() || cells.isNotEmpty() || closedQuote || (text.isNotEmpty() && text.last() == delimiter)) addRow() + return TableData(rows, maxCols) + } +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/BrowserModel.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/BrowserModel.kt new file mode 100644 index 0000000..81609c5 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/BrowserModel.kt @@ -0,0 +1,93 @@ +package dev.fileworkbench.core.browser + +/** Opaque IDs belong to the platform adapter; never interpret them as filesystem paths. */ +data class BrowserEntry( + val id: String, + val name: String, + val mimeType: String, + val isDirectory: Boolean = false, + val size: Long? = null, + val modifiedMillis: Long? = null, + val virtual: Boolean = false, + val thumbnailSupported: Boolean = false, +) { + val label: String get() = safeFileLabel(name) +} + +data class BrowserLocation(val id: String, val label: String, val persistent: Boolean) +data class BrowserListing( + val entries: List, + val truncated: Boolean = false, + val providerLoading: Boolean = false, + val information: String? = null, +) + +enum class BrowserSort { NAME, MODIFIED, SIZE, TYPE } +data class BrowserOptions( + val query: String = "", + val sort: BrowserSort = BrowserSort.NAME, + val descending: Boolean = false, + val showHidden: Boolean = false, +) + +object BrowserLimits { + const val MAX_ENTRIES = 5_000 + const val MAX_LOCATIONS = 24 + const val MAX_DEPTH = 64 + const val MAX_SELECTED = 16 + const val MAX_MIME_TYPES = 32 + const val MAX_QUERY_LENGTH = 256 + const val MAX_LABEL_LENGTH = 200 + const val MAX_FILE_BYTES = 64L * 1024 * 1024 + const val MAX_PICK_BYTES = 128L * 1024 * 1024 +} + +/** Prevent control/bidi characters in provider names from spoofing rows; do not alter source IDs. */ +fun safeFileLabel(value: String): String = value.take(BrowserLimits.MAX_LABEL_LENGTH).map { c -> + if (c.code < 32 || c.code == 127 || c == '/' || c == '\\' || + c in '\u202A'..'\u202E' || c in '\u2066'..'\u2069') '_' else c +}.joinToString("").trim().takeUnless { it.isEmpty() || it == "." || it == ".." } ?: "Unnamed" + +/** This processes a bounded, single-directory listing; it never recursively indexes storage. */ +object BrowserEntries { + fun visible(entries: List, options: BrowserOptions, request: PickerRequest? = null): List { + val query = options.query.trim().take(BrowserLimits.MAX_QUERY_LENGTH) + return entries.asSequence().take(BrowserLimits.MAX_ENTRIES) + .distinctBy { it.id } + .filter { options.showHidden || !it.name.startsWith('.') } + .filter { query.isEmpty() || it.name.contains(query, ignoreCase = true) } + .filter { it.isDirectory || request == null || request.accepts(it) } + .sortedWith(comparator(options)).toList() + } + + fun comparator(options: BrowserOptions): Comparator = Comparator { a, b -> + when { + a.isDirectory != b.isDirectory -> if (a.isDirectory) -1 else 1 + else -> { + // Unknown metadata remains last in either direction. Folders sort by name. + val primary = when { + a.isDirectory -> compareNames(a, b) * direction(options) + options.sort == BrowserSort.MODIFIED -> compareNullable(a.modifiedMillis, b.modifiedMillis, options) + options.sort == BrowserSort.SIZE -> compareNullable(a.size, b.size, options) + options.sort == BrowserSort.TYPE -> a.mimeType.lowercase().compareTo(b.mimeType.lowercase()) * direction(options) + else -> compareNames(a, b) * direction(options) + } + if (primary != 0) primary else compareNames(a, b).takeIf { it != 0 } ?: a.id.compareTo(b.id) + } + } + } + + private fun compareNames(a: BrowserEntry, b: BrowserEntry) = a.name.lowercase().compareTo(b.name.lowercase()) + private fun direction(options: BrowserOptions) = if (options.descending) -1 else 1 + private fun compareNullable(a: Long?, b: Long?, options: BrowserOptions): Int = when { + a == null && b == null -> 0 + a == null -> 1 + b == null -> -1 + else -> a.compareTo(b) * direction(options) + } +} + +/** Platform adapters enforce access and perform IO; the iOS adapter can use security-scoped URLs. */ +interface DirectoryReader { + suspend fun list(location: BrowserLocation, directoryId: String): BrowserListing +} diff --git a/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/PickerRequest.kt b/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/PickerRequest.kt new file mode 100644 index 0000000..ac8596e --- /dev/null +++ b/core/src/commonMain/kotlin/dev/fileworkbench/core/browser/PickerRequest.kt @@ -0,0 +1,79 @@ +package dev.fileworkbench.core.browser + +/** Invalid MIME requests fail closed. EXTRA_MIME_TYPES narrows, rather than broadens, the base type. */ +data class PickerRequest( + val mimeType: String = "*/*", + val extraMimeTypes: List = emptyList(), + val allowMultiple: Boolean = false, + val localOnly: Boolean = false, +) { + val isValid: Boolean get() = MimeTypes.validPattern(mimeType) && + extraMimeTypes.size <= BrowserLimits.MAX_MIME_TYPES && extraMimeTypes.all(MimeTypes::validPattern) + + fun accepts(entry: BrowserEntry): Boolean = isValid && !entry.isDirectory && !entry.virtual && + MimeTypes.matches(mimeType, entry.mimeType) && + (extraMimeTypes.isEmpty() || extraMimeTypes.any { MimeTypes.matches(it, entry.mimeType) }) + + fun toggle(current: List, entry: BrowserEntry): List { + require(accepts(entry)) { "This file does not match the requesting app's file types." } + if (current.any { it.id == entry.id }) return current.filterNot { it.id == entry.id } + if (!allowMultiple) return listOf(entry) + require(current.size < BrowserLimits.MAX_SELECTED) { "Select at most ${BrowserLimits.MAX_SELECTED} files." } + return current + entry + } + + fun validateSelection(entries: List) { + require(entries.isNotEmpty()) { "Select a file first." } + require(entries.size <= if (allowMultiple) BrowserLimits.MAX_SELECTED else 1) { "Too many files selected." } + require(entries.distinctBy { it.id }.size == entries.size) { "Duplicate file selection." } + require(entries.all(::accepts)) { "Some selected files no longer match the requested types." } + // Declared sizes are hints. The adapter must enforce these limits on actual streamed bytes too. + var total = 0L + entries.forEach { entry -> + entry.size?.let { + require(it >= 0 && it <= BrowserLimits.MAX_FILE_BYTES) { "A selected file exceeds the 64 MiB limit." } + total += it + require(total <= BrowserLimits.MAX_PICK_BYTES) { "Selection exceeds the 128 MiB total limit." } + } + } + } +} + +object MimeTypes { + fun normalize(value: String): String = value.substringBefore(';').trim().lowercase() + private fun token(value: String): Boolean = value.isNotEmpty() && value.all { + it in 'a'..'z' || it in '0'..'9' || it in "!#\$&^_.+-" + } + fun validPattern(value: String): Boolean { + if (value.length > 160) return false + val parts = normalize(value).split('/') + if (parts.size != 2) return false + return (parts[0] == "*" && parts[1] == "*") || + (token(parts[0]) && (parts[1] == "*" || token(parts[1]))) + } + fun matches(pattern: String, actual: String): Boolean { + if (!validPattern(pattern) || !validPattern(actual) || '*' in actual) return false + val p = normalize(pattern).split('/') + val a = normalize(actual).split('/') + return (p[0] == "*" || p[0] == a[0]) && (p[1] == "*" || p[1] == a[1]) + } +} + +/** Reused by platform copy loops. Metadata hints cannot bypass byte limits; additions cannot overflow. */ +class SelectionCopyBudget( + private val perFileLimit: Long = BrowserLimits.MAX_FILE_BYTES, + private val totalLimit: Long = BrowserLimits.MAX_PICK_BYTES, +) { + init { require(perFileLimit > 0 && totalLimit > 0) } + var fileBytes: Long = 0; private set + var totalBytes: Long = 0; private set + fun beginFile() { fileBytes = 0 } + fun consume(bytes: Int) { + require(bytes >= 0) { "Invalid byte count." } + require(bytes.toLong() <= perFileLimit - fileBytes && bytes.toLong() <= totalLimit - totalBytes) { + "Selection exceeds the per-file or total byte limit." + } + fileBytes += bytes + totalBytes += bytes + } +} diff --git a/core/src/commonTest/kotlin/dev/fileworkbench/core/CoreTests.kt b/core/src/commonTest/kotlin/dev/fileworkbench/core/CoreTests.kt new file mode 100644 index 0000000..a0b3095 --- /dev/null +++ b/core/src/commonTest/kotlin/dev/fileworkbench/core/CoreTests.kt @@ -0,0 +1,178 @@ +package dev.fileworkbench.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +private class MemoryArchive(parts: Map) : ArchiveContainer { + private val data = parts.mapValues { it.value.encodeToByteArray() } + override val entries = data.map { ArchiveEntry(it.key, it.value.size.toLong(), it.value.size.toLong(), false) } + override fun read(path: String): ByteArray = data[path] ?: throw PreviewException("Missing part") +} + +class CoreTests { + private val policy = SafetyPolicy() + + @Test fun pdfMagicWinsOverName() { assertEquals(FileKind.PDF, FileClassifier.classify("fake.txt", "text/plain", "%PDF-1.7".encodeToByteArray())) } + @Test fun imageMagicWinsOverName() { assertEquals(FileKind.IMAGE, FileClassifier.classify("x.txt", null, byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47))) } + @Test fun caseInsensitiveExtensions() { assertEquals(FileKind.DOCX, FileClassifier.classify("HELLO.DOCX", null)) } + @Test fun mimeFallbackHandlesMissingExtension() { assertEquals(FileKind.XLSX, FileClassifier.classify("document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) } + @Test fun htmlAndSvgAreOnlyText() { assertEquals(FileKind.TEXT, FileClassifier.classify("x.svg", "image/svg+xml")); assertEquals(FileKind.TEXT, FileClassifier.classify("x.html", null)) } + @Test fun unknownBinaryIsNotAssumedText() { assertEquals(FileKind.UNKNOWN, FileClassifier.classify("random.bin", "application/octet-stream")) } + @Test fun legacyBinaryDetected() { assertEquals(FileKind.LEGACY_OFFICE, FileClassifier.classify("x.docx", null, byteArrayOf(0xd0.toByte(), 0xcf.toByte(), 0x11, 0xe0.toByte()))) } + @Test fun zipMagicDisallowsTextPretence() { assertEquals(FileKind.ZIP, FileClassifier.classify("attack.txt", "text/plain", byteArrayOf(0x50, 0x4b, 3, 4))) } + @Test fun extensionlessOfficeInspected() { + val archive = MemoryArchive(mapOf("word/document.xml" to "

hello

")) + assertEquals(FileKind.DOCX, PreviewEngine().fromArchive(FileKind.ZIP, archive).kind) + } + @Test fun utf8BomAndUnicode() { assertEquals("Grüße", TextCodec.decode(byteArrayOf(0xef.toByte(), 0xbb.toByte(), 0xbf.toByte()) + "Grüße".encodeToByteArray())) } + @Test fun utf16LittleEndian() { assertEquals("AΩ", TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0x41, 0, 0xa9.toByte(), 3))) } + @Test fun utf16BigEndian() { assertEquals("AΩ", TextCodec.decode(byteArrayOf(0xfe.toByte(), 0xff.toByte(), 0, 0x41, 3, 0xa9.toByte()))) } + @Test fun utf16RejectsOddBytes() { assertFailsWith { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 1)) } } + @Test fun utf16RejectsLoneSurrogate() { assertFailsWith { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0, 0xd8.toByte())) } } + @Test fun invalidUtf8Rejected() { assertFailsWith { TextCodec.decode(byteArrayOf(0xc3.toByte(), 0x28)) } } + @Test fun nulBinaryRejected() { assertFailsWith { TextCodec.decode(byteArrayOf(0, 1, 2)) } } + + @Test fun xmlPreservesMixedContent() { assertEquals("Hello world!", SafeXml.parse("

Hello world!

", policy).text()) } + @Test fun xmlDecodesSafeEntities() { assertEquals("<&\"'😀", SafeXml.parse("

<&"'😀

", policy).text()) } + @Test fun xmlCdataIsLiteral() { assertEquals(" + diff --git a/examples/table.csv b/examples/table.csv new file mode 100644 index 0000000..f1c9d93 --- /dev/null +++ b/examples/table.csv @@ -0,0 +1,5 @@ +Department;Budget;Note +Research;12500;"Local, read-only preview" +Administration;8400;"A quoted +multiline field" +IT;9500;"Quotes: ""example""" diff --git a/examples/welcome.md b/examples/welcome.md new file mode 100644 index 0000000..29a292d --- /dev/null +++ b/examples/welcome.md @@ -0,0 +1,18 @@ +# Files, not services + +This is a small reading-view example for Fileworkbench. + +## Try the basics + +- Select this file using Open file. +- Search for the word local. +- Export a text preview, or share the original. + +Local processing does not require a document account. + +```text +A code block remains selectable source text. +``` + +Inline **Markdown** is intentionally preserved in this basic preview. +Grüße, café, Καλημέρα and 日本語 test UTF-8 text handling. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..affa6a0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +org.gradle.parallel=true + +# Use an installed JDK 17; the daemon criteria and module toolchains select its version. +# Optional JDK17_HOME helps discovery without committing an absolute path. +org.gradle.java.installations.fromEnv=JAVA_HOME,JDK17_HOME +org.gradle.java.installations.auto-download=false +org.gradle.configuration-cache=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..9742e82 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,4 @@ +# Fileworkbench build runtime: installed JDK 17, any vendor. +# Checked-in Daemon JVM criteria. No machine-specific path or download URL. +# See docs/BUILD.md; select an installed JDK 17 for the IDE launcher as well. +toolchainVersion=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..af17a87 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +agp = "8.13.2" +kotlin = "2.2.21" +composeBom = "2025.08.00" +activity = "1.10.1" +lifecycle = "2.9.3" +core = "1.16.0" +coroutines = "1.10.2" +junit = "4.13.2" + +[libraries] +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +compose-ui = { module = "androidx.compose.ui:ui" } +compose-foundation = { module = "androidx.compose.foundation:foundation" } +compose-material3 = { module = "androidx.compose.material3:material3" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-test-junit = { module = "androidx.test.ext:junit", version = "1.2.1" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e40b3f5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +distributionSha256Sum=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854 +networkTimeout=30000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..84a217d --- /dev/null +++ b/gradlew @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# Thin source-only launcher. The official checksum-verified wrapper JAR is bootstrapped on first run. +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ ! -f "$ROOT/gradle/wrapper/gradle-wrapper.jar" ]; then + sh "$ROOT/scripts/bootstrap-wrapper.sh" +fi +if [ -n "${JAVA_HOME:-}" ]; then JAVA="$JAVA_HOME/bin/java"; else JAVA=java; fi +exec "$JAVA" -Dorg.gradle.appname=gradlew -classpath "$ROOT/gradle/wrapper/gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..06c734e --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,10 @@ +@echo off +setlocal +set ROOT=%~dp0 +if not exist "%ROOT%gradle\wrapper\gradle-wrapper.jar" ( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%scripts\bootstrap-wrapper.ps1" + if errorlevel 1 exit /b 1 +) +if defined JAVA_HOME (set "JAVA_EXE=%JAVA_HOME%\bin\java.exe") else (set "JAVA_EXE=java.exe") +"%JAVA_EXE%" -Dorg.gradle.appname=gradlew -classpath "%ROOT%gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %* +exit /b %ERRORLEVEL% diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..9603ebe --- /dev/null +++ b/ios/README.md @@ -0,0 +1,59 @@ +# iOS integration path — reusable core, not a shipped iOS application + +This release contains real common Kotlin code and opt-in Apple framework targets. It does **not** +contain an Xcode application, a ZIPFoundation integration, or a tested iOS build. + +## Build the shared framework on a Mac + +Use JDK 17, the project-pinned Kotlin toolchain and an Xcode version compatible with it. +Kotlin 2.2.21's compatibility table lists Xcode 26.0; see `docs/SOURCES.md` before upgrading tooling. + +```sh +./gradlew -PenableIos=true :core:assembleFileworkbenchCoreReleaseXCFramework +``` + +Expected Gradle output location after a successful build: +`core/build/XCFrameworks/release/FileworkbenchCore.xcframework`. +Targets: `iosArm64` and `iosSimulatorArm64` (Apple Silicon simulator). Add an Intel simulator +target only if required by a particular development machine. + +## What is already shareable + +`CoreApi`, classification, policy values, bounded XML parsing, DOCX/XLSX/PPTX/ODF text and table +extraction, CSV/Markdown handling, document models, literal search, and preview text export. +There are no Android or Java imports in `core/commonMain`. + +The 0.2.0 browser also shares `BrowserEntry`, `BrowserLocation`, `BrowserListing`, `DirectoryReader`, +`BrowserOptions`, `BrowserEntries`, MIME matching, `PickerRequest`, sanitized display labels, and +`SelectionCopyBudget`. Implement DirectoryReader with security-scoped bookmarks/URLs; keep those +platform handles opaque to common code. Reuse selection and byte limits without copying Android +Intent/Uri/FileProvider types into the core. The SwiftUI browser and Apple adapter are not implemented. + +## What the Apple shell must implement + +- SwiftUI document importer / document browser; security-scoped URL access and bookmarks. +- Copy chosen bytes to an application-private, bounded read-only snapshot; retain no cloud links as + if they were file bytes. Scope access using `startAccessingSecurityScopedResource` / matching stop. +- An `ArchiveContainer` adapter around a maintained Apple-capable ZIP library, with the same + entry/path/ratio/decompression limits and tests as `JvmZipArchive`. ZIPFoundation is a candidate, + **not** a bundled dependency or a completed integration. Enforce limits during inflation, not after. +- SwiftUI views of `DocumentPreview`, `PreviewSection`, and `TableData`. +- Native PDF/image viewing, likely PDFKit and platform image APIs, with independent security and + memory review. Android's isolated Service/Messenger transport is not portable to iOS and should + not be described as an iOS capability. +- Native recent-file persistence, explicit sharing/export, and cache/grant cleanup. + +`CoreApi.readText` can be the first integration target. Convert `Data` to `KotlinByteArray` in the +host, call the exported API, and display the returned blocks. Public entry points that can fail +are annotated with `@Throws(PreviewException::class)` for the native boundary. ZIP adapter errors +must be translated appropriately; do not allow unrelated Kotlin exceptions to terminate the host. + +Do not rewrite the Office readers in Swift. Do not put File Provider or Android URI types into +`DocumentPreview`. A future native Office engine belongs behind its own renderer adapter; it does +not replace the common file/document models. + +## Acceptance gate + +A Mac CI/device run must demonstrate framework compilation, a real document-picker open, text and +ZIP previews, PDF rendering, expired bookmark recovery and release of security-scoped access. +None of those Apple-runtime checks were executed for this source release. diff --git a/platform-jvm/build.gradle.kts b/platform-jvm/build.gradle.kts new file mode 100644 index 0000000..dbe2aa1 --- /dev/null +++ b/platform-jvm/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { alias(libs.plugins.kotlin.jvm) } +kotlin { jvmToolchain(17) } +dependencies { + implementation(project(":core")) + testImplementation(libs.junit) + testImplementation(kotlin("test")) +} diff --git a/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/JvmZipArchive.kt b/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/JvmZipArchive.kt new file mode 100644 index 0000000..62d0cef --- /dev/null +++ b/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/JvmZipArchive.kt @@ -0,0 +1,75 @@ +package dev.fileworkbench.platform + +import dev.fileworkbench.core.ArchiveContainer +import dev.fileworkbench.core.ArchiveEntry +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.SafetyPolicy +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.CRC32 +import kotlin.math.max + +/** JVM/Android adapter. Metadata listing never inflates entries, and no entry is extracted to disk. */ +class JvmZipArchive(file: File, private val policy: SafetyPolicy = SafetyPolicy()) : ArchiveContainer, Closeable { + private val zip = ZipFile(file) + private val indexed = linkedMapOf() + private var expanded = 0L + override val entries: List + + init { + try { + val items = zip.entries() + while (items.hasMoreElements()) { + if (indexed.size >= policy.maxArchiveEntries) throw PreviewException("Archive contains too many entries.") + val entry = items.nextElement() + validatePath(entry.name) + if (indexed.put(entry.name, entry) != null) throw PreviewException("Archive contains duplicate paths.") + } + entries = indexed.values.map { ArchiveEntry(it.name, it.size, it.compressedSize, it.isDirectory) } + } catch (e: Exception) { + zip.close() + throw e + } + } + + @Synchronized + override fun read(path: String): ByteArray { + val entry = indexed[path] ?: throw PreviewException("Missing document part: $path") + if (entry.isDirectory) throw PreviewException("A directory cannot be read as a document part.") + if (entry.size < 0 || entry.size > policy.maxEntryBytes) throw PreviewException("Document part exceeds the decompression limit.") + if (entry.compressedSize < 0 || entry.size > max(1L, entry.compressedSize) * policy.maxCompressionRatio) + throw PreviewException("Document part exceeds the compression-ratio limit.") + if (expanded + entry.size > policy.maxExpandedBytes) throw PreviewException("Document exceeds the total decompression limit.") + val output = ByteArrayOutputStream(minOf(entry.size, 65536L).toInt()) + zip.getInputStream(entry).use { stream -> + val buffer = ByteArray(16 * 1024) + var count = 0 + val checksum = CRC32() + while (true) { + if (Thread.currentThread().isInterrupted) throw PreviewException("Preview cancelled.") + val n = stream.read(buffer) + if (n == -1) break + count += n; expanded += n + if (count > policy.maxEntryBytes || expanded > policy.maxExpandedBytes || count.toLong() > max(1L, entry.compressedSize) * policy.maxCompressionRatio) + throw PreviewException("Expanded document exceeds the preview limit.") + checksum.update(buffer, 0, n) + output.write(buffer, 0, n) + } + if (count.toLong() != entry.size) throw PreviewException("Document part has an inconsistent size.") + if (entry.crc >= 0 && checksum.value != entry.crc) throw PreviewException("Document part failed its checksum.") + } + return output.toByteArray() + } + + override fun close() = zip.close() + + private fun validatePath(path: String) { + val pieces = path.removeSuffix("/").split('/') + if (path.isEmpty() || path.length > 1024 || path.startsWith('/') || '\\' in path || ':' in path || '\u0000' in path || + pieces.any { it.isEmpty() || it == "." || it == ".." }) + throw PreviewException("Archive contains an unsafe or ambiguous path.") + } +} diff --git a/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/LocalPreviewLoader.kt b/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/LocalPreviewLoader.kt new file mode 100644 index 0000000..80a2383 --- /dev/null +++ b/platform-jvm/src/main/kotlin/dev/fileworkbench/platform/LocalPreviewLoader.kt @@ -0,0 +1,32 @@ +package dev.fileworkbench.platform + +import dev.fileworkbench.core.* +import java.io.File + +/** Keeps file I/O outside the common core; no Android dependencies, so this adapter can be JVM-tested. */ +class LocalPreviewLoader(private val policy: SafetyPolicy = SafetyPolicy()) { + fun classify(file: File, displayName: String, mime: String?): FileKind { + val header = file.inputStream().use { stream -> + val data = ByteArray(16) + val n = stream.read(data) + if (n > 0) data.copyOf(n) else byteArrayOf() + } + return FileClassifier.classify(displayName, mime, header) + } + + fun load(file: File, displayName: String, mime: String?): DocumentPreview { + if (file.length() > policy.maxInputBytes) throw PreviewException("File exceeds the input limit.") + val kind = classify(file, displayName, mime) + val result = when { + kind in FileClassifier.archiveKinds -> JvmZipArchive(file, policy).use { PreviewEngine(policy).fromArchive(kind, it) } + kind in setOf(FileKind.TEXT, FileKind.MARKDOWN, FileKind.CSV) -> { + if (file.length() > policy.maxTextBytes) throw PreviewException("Text exceeds the ${policy.maxTextBytes / 1024 / 1024} MiB preview limit.") + TextPreview.create(displayName, kind, file.readBytes(), policy) + } + kind == FileKind.LEGACY_OFFICE -> throw PreviewException("Legacy, macro-enabled and encrypted Office files are not supported in this MVP. Use a trusted editor to export PDF, DOCX, XLSX, PPTX or ODF.") + else -> throw PreviewException("No reading preview is available for this file type.") + } + PreviewSafety.validate(result, policy) + return result + } +} diff --git a/platform-jvm/src/test/kotlin/dev/fileworkbench/platform/ZipTests.kt b/platform-jvm/src/test/kotlin/dev/fileworkbench/platform/ZipTests.kt new file mode 100644 index 0000000..08a236b --- /dev/null +++ b/platform-jvm/src/test/kotlin/dev/fileworkbench/platform/ZipTests.kt @@ -0,0 +1,76 @@ +package dev.fileworkbench.platform + +import dev.fileworkbench.core.* +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.fail +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ZipTests { + private fun zip(parts: Map): File { + val file = Files.createTempFile("fileworkbench-test-", ".zip").toFile() + ZipOutputStream(file.outputStream()).use { output -> + parts.forEach { (path, bytes) -> output.putNextEntry(ZipEntry(path)); output.write(bytes); output.closeEntry() } + } + return file + } + private fun expectPreviewFailure(block: () -> Unit) { + try { block(); fail("Expected PreviewException") } catch (_: PreviewException) { } + } + + @Test fun archiveRoundtrip() { + val file = zip(mapOf("folder/file.txt" to "hello".encodeToByteArray())) + try { JvmZipArchive(file).use { assertEquals("hello", it.read("folder/file.txt").decodeToString()); assertEquals(1, it.entries.size) } } + finally { file.delete() } + } + @Test fun traversalRejected() { + val file = zip(mapOf("../evil" to byteArrayOf(1))) + try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() } + } + @Test fun absolutePathsRejected() { + val file = zip(mapOf("/etc/x" to byteArrayOf(1))) + try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() } + } + @Test fun backslashPathsRejected() { + val file = zip(mapOf("..\\x" to byteArrayOf(1))) + try { expectPreviewFailure { JvmZipArchive(file).use { } } } finally { file.delete() } + } + @Test fun tooManyEntriesRejected() { + val file = zip(mapOf("a" to byteArrayOf(), "b" to byteArrayOf())) + try { expectPreviewFailure { JvmZipArchive(file, SafetyPolicy(maxArchiveEntries = 1)).use { } } } finally { file.delete() } + } + @Test fun oversizedEntryRejectedOnlyWhenRead() { + val file = zip(mapOf("a" to ByteArray(100) { it.toByte() })) + try { JvmZipArchive(file, SafetyPolicy(maxEntryBytes = 50)).use { assertEquals(1, it.entries.size); expectPreviewFailure { it.read("a") } } } finally { file.delete() } + } + @Test fun compressionBombRejected() { + val file = zip(mapOf("bomb" to ByteArray(500_000))) + try { JvmZipArchive(file).use { expectPreviewFailure { it.read("bomb") } } } finally { file.delete() } + } + @Test fun cumulativeExpansionBudgetEnforced() { + val file = zip(mapOf("a" to ByteArray(60) { it.toByte() }, "b" to ByteArray(60) { it.toByte() })) + try { JvmZipArchive(file, SafetyPolicy(maxExpandedBytes = 100)).use { it.read("a"); expectPreviewFailure { it.read("b") } } } finally { file.delete() } + } + @Test fun repeatedReadsConsumeBudget() { + val file = zip(mapOf("a" to ByteArray(60) { it.toByte() })) + try { JvmZipArchive(file, SafetyPolicy(maxExpandedBytes = 100)).use { it.read("a"); expectPreviewFailure { it.read("a") } } } finally { file.delete() } + } + @Test fun extensionlessOfficeLoadedThroughFullAdapter() { + val file = zip(mapOf("word/document.xml" to "

Real ZIP pipeline

".encodeToByteArray())) + try { + val result = LocalPreviewLoader().load(file, "download", "application/octet-stream") + assertEquals(FileKind.DOCX, result.kind); assertEquals("Real ZIP pipeline", result.sections[0].blocks[0].text) + } finally { file.delete() } + } + @Test fun textByteBudgetEnforcedBeforeRead() { + val file = Files.createTempFile("fileworkbench-text-", ".txt").toFile().apply { writeText("123456") } + try { expectPreviewFailure { LocalPreviewLoader(SafetyPolicy(maxTextBytes = 5)).load(file, "x.txt", "text/plain") } } finally { file.delete() } + } + @Test fun htmlRemainsInertSource() { + val file = Files.createTempFile("fileworkbench-html-", ".html").toFile().apply { writeText("") } + try { assertEquals("", LocalPreviewLoader().load(file, "x.html", "text/html").sections[0].blocks[0].text) } finally { file.delete() } + } +} diff --git a/scripts/KotlinSyntaxCheck.kt b/scripts/KotlinSyntaxCheck.kt new file mode 100644 index 0000000..37add48 --- /dev/null +++ b/scripts/KotlinSyntaxCheck.kt @@ -0,0 +1,35 @@ +package dev.fileworkbench.testing + +import com.intellij.openapi.util.Disposer +import com.intellij.psi.PsiErrorElement +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.psi.KtPsiFactory +import java.io.File + +/** Syntax only. This deliberately does not claim Android/Compose symbol or Gradle verification. */ +fun main(args: Array) { + val root = File(args.firstOrNull() ?: ".").canonicalFile + val disposable = Disposer.newDisposable() + try { + val environment = KotlinCoreEnvironment.createForProduction(disposable, CompilerConfiguration(), EnvironmentConfigFiles.JVM_CONFIG_FILES) + val factory = KtPsiFactory(environment.project, false) + var files = 0 + var errors = 0 + root.walkTopDown().onEnter { it.name !in setOf("build", "out", ".gradle", ".git") } + .filter { it.isFile && (it.extension == "kt" || it.extension == "kts") }.forEach { source -> + files++ + val text = source.readText() + val parsed = factory.createFile(source.name, text) + PsiTreeUtil.findChildrenOfType(parsed, PsiErrorElement::class.java).forEach { + errors++ + val line = text.take(it.textOffset).count { c -> c == '\n' } + 1 + println("FAIL ${source.relativeTo(root)}:$line ${it.errorDescription}") + } + } + println("Kotlin PSI syntax: $files files, $errors parse errors. Android/Compose type resolution is NOT tested.") + check(errors == 0) + } finally { Disposer.dispose(disposable) } +} diff --git a/scripts/bootstrap-wrapper.ps1 b/scripts/bootstrap-wrapper.ps1 new file mode 100644 index 0000000..2728088 --- /dev/null +++ b/scripts/bootstrap-wrapper.ps1 @@ -0,0 +1,40 @@ +$ErrorActionPreference = 'Stop' +$Root = Split-Path $PSScriptRoot -Parent +$Jar = Join-Path $Root 'gradle/wrapper/gradle-wrapper.jar' +$Work = Join-Path (Split-Path $Jar) "bootstrap.$([guid]::NewGuid().ToString('N'))" +$Temp = Join-Path $Work 'wrapper.jar' +$Expected = '7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172' +$DistExpected = '6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854' +New-Item -ItemType Directory -Force -Path $Work | Out-Null +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -UseBasicParsing -Uri 'https://raw.githubusercontent.com/gradle/gradle/v8.14.5/gradle/wrapper/gradle-wrapper.jar' -OutFile $Temp + $Actual = (Get-FileHash -Algorithm SHA256 $Temp).Hash.ToLowerInvariant() + if ($Actual -ne $Expected) { + Write-Host 'Tag wrapper differs from release; retrieving the verified release distribution.' + $Dist = Join-Path $Work 'gradle.zip' + Invoke-WebRequest -UseBasicParsing -Uri 'https://services.gradle.org/distributions/gradle-8.14.5-bin.zip' -OutFile $Dist + if ((Get-FileHash -Algorithm SHA256 $Dist).Hash.ToLowerInvariant() -ne $DistExpected) { + throw 'Distribution checksum mismatch; refusing to execute.' + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $Outer = [IO.Compression.ZipFile]::OpenRead($Dist) + try { + $Entries = @($Outer.Entries | Where-Object { $_.FullName -match '/gradle-wrapper-main-8\.14\.5\.jar$' }) + if ($Entries.Count -ne 1) { throw 'Expected exactly one wrapper-main JAR.' } + $Main = Join-Path $Work 'wrapper-main.jar' + [IO.Compression.ZipFileExtensions]::ExtractToFile($Entries[0], $Main, $true) + } finally { $Outer.Dispose() } + $Inner = [IO.Compression.ZipFile]::OpenRead($Main) + try { + $Entry = $Inner.GetEntry('gradle-wrapper.jar') + if ($null -eq $Entry) { throw 'Embedded wrapper missing.' } + [IO.Compression.ZipFileExtensions]::ExtractToFile($Entry, $Temp, $true) + } finally { $Inner.Dispose() } + if ((Get-FileHash -Algorithm SHA256 $Temp).Hash.ToLowerInvariant() -ne $Expected) { + throw 'Embedded wrapper checksum mismatch; refusing to execute.' + } + } + Move-Item -Force $Temp $Jar + Write-Host 'Official Gradle 8.14.5 wrapper verified.' +} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } } diff --git a/scripts/bootstrap-wrapper.sh b/scripts/bootstrap-wrapper.sh new file mode 100644 index 0000000..a35c9dc --- /dev/null +++ b/scripts/bootstrap-wrapper.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env sh +# Build tooling only. All downloaded executable bytes must match official release hashes. +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +JAR="$ROOT/gradle/wrapper/gradle-wrapper.jar" +EXPECTED=7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172 +DIST_EXPECTED=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854 +mkdir -p "$(dirname "$JAR")" +WORK=$(mktemp -d "$ROOT/gradle/wrapper/bootstrap.XXXXXX") +trap 'rm -rf "$WORK"' EXIT HUP INT TERM +fetch() { + if command -v curl >/dev/null 2>&1; then + curl --fail --location --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget --https-only --timeout=30 "$1" -O "$2" + else + echo 'Install curl/wget, or generate the wrapper with an installed Gradle 8.14.5.' >&2 + return 1 + fi +} +checksum() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 + else echo 'No SHA-256 utility found; refusing unverified tooling.' >&2; return 1; fi +} +printf '%s\n' 'Fetching the official Gradle 8.14.5 wrapper; a pinned SHA-256 is required.' +fetch https://raw.githubusercontent.com/gradle/gradle/v8.14.5/gradle/wrapper/gradle-wrapper.jar "$WORK/wrapper.jar" +ACTUAL=$(checksum "$WORK/wrapper.jar") +if [ "$ACTUAL" != "$EXPECTED" ]; then + # A source tag can contain an older build's wrapper. Never execute that mismatch. + # The released distribution contains the exact wrapper as an embedded resource. + printf '%s\n' 'Tag wrapper does not match the release checksum; retrieving the verified release distribution.' + command -v unzip >/dev/null 2>&1 || { + echo 'Install unzip for this fallback, or generate the wrapper with installed Gradle 8.14.5.' >&2 + exit 1 + } + fetch https://services.gradle.org/distributions/gradle-8.14.5-bin.zip "$WORK/gradle.zip" + [ "$(checksum "$WORK/gradle.zip")" = "$DIST_EXPECTED" ] || { + echo 'Gradle distribution checksum mismatch; refusing to execute.' >&2; exit 1 + } + ENTRY=$(unzip -Z1 "$WORK/gradle.zip" | grep '/gradle-wrapper-main-8\.14\.5\.jar$') + [ "$(printf '%s\n' "$ENTRY" | wc -l | tr -d ' ')" = 1 ] || { + echo 'Expected exactly one wrapper-main JAR in the verified release.' >&2; exit 1 + } + unzip -p "$WORK/gradle.zip" "$ENTRY" > "$WORK/wrapper-main.jar" + unzip -p "$WORK/wrapper-main.jar" gradle-wrapper.jar > "$WORK/wrapper.jar" + [ "$(checksum "$WORK/wrapper.jar")" = "$EXPECTED" ] || { + echo 'Embedded wrapper checksum mismatch; refusing to execute.' >&2; exit 1 + } +fi +mv "$WORK/wrapper.jar" "$JAR" +printf '%s\n' 'Official wrapper verified. The Gradle distribution is separately checksum-pinned.' diff --git a/scripts/check-build-config.py b/scripts/check-build-config.py new file mode 100644 index 0000000..6e56075 --- /dev/null +++ b/scripts/check-build-config.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Check this release's build pins and plugin IDs; NOT Gradle/Kotlin type checking.""" +from pathlib import Path +import hashlib +import re + +ROOT = Path(__file__).resolve().parent.parent +checks = 0 + +def verify(condition, message): + global checks + if not condition: + raise SystemExit("FAIL build configuration: " + message) + checks += 1 + print("PASS " + message) + +def text(name): + return (ROOT / name).read_text(encoding="utf-8") + +def properties(name): + return dict(line.split("=", 1) for line in text(name).splitlines() + if "=" in line and not line.lstrip().startswith("#")) + +DIST = "6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854" +JAR = "7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172" +wrapper = properties("gradle/wrapper/gradle-wrapper.properties") +verify(wrapper["distributionUrl"].replace(chr(92), "") == + "https://services.gradle.org/distributions/gradle-8.14.5-bin.zip", "Gradle distribution is exactly 8.14.5") +verify(wrapper["distributionSha256Sum"] == DIST, "official 8.14.5 distribution SHA-256 is pinned") +for script in ["scripts/bootstrap-wrapper.sh", "scripts/bootstrap-wrapper.ps1"]: + content = text(script) + verify(JAR in content and DIST in content and "v8.14.5/" in content and + "gradle-8.14.5-bin.zip" in content and "8.13" not in content, + script + " uses matching 8.14.5 downloads and hashes") +verify(properties("gradle/gradle-daemon-jvm.properties").get("toolchainVersion") == "17", "Gradle daemon requires JDK 17") +verify(text(".java-version").strip() == "17", "Java-version hint is 17") +verify("JavaVersion.VERSION_17" in text("settings.gradle.kts") and + 'gradle.gradleVersion == "8.14.5"' in text("settings.gradle.kts"), "settings reject conflicting Gradle/JVM overrides") +for module in ["androidApp", "core", "platform-jvm"]: + verify("jvmToolchain(17)" in text(module + "/build.gradle.kts"), module + " uses toolchain 17") +android = text("androidApp/build.gradle.kts") +verify("sourceCompatibility = JavaVersion.VERSION_17" in android and + "targetCompatibility = JavaVersion.VERSION_17" in android, "Android Java bytecode targets 17") +verify('versionName = "0.2.3"' in android and "versionCode = 5" in android, "application version is 0.2.3/code 5") + +# Deliberately limited to the project's simple version-catalog/alias structure. +root_build = text("build.gradle.kts") +catalog = text("gradle/libs.versions.toml") +plugin_section = catalog.split("[plugins]", 1)[1] +entries = re.findall(r'^([\w-]+)\s*=\s*\{\s*id\s*=\s*"([^"]+)"', plugin_section, re.M) +ids = dict(entries) +aliases = [a.replace(".", "-") for a in re.findall(r'alias\(libs\.plugins\.([\w.]+)\)', root_build)] +expected = {"com.android.application", "org.jetbrains.kotlin.android", "org.jetbrains.kotlin.multiplatform", + "org.jetbrains.kotlin.jvm", "org.jetbrains.kotlin.plugin.compose"} +resolved = [ids.get(a) for a in aliases] +verify(len(entries) == len(ids) == 5 and len(resolved) == 5 and set(resolved) == expected, + "five root plugin aliases resolve to five distinct expected IDs") +verify(not re.search(r'\b(?:id|kotlin)\s*\(', root_build), "root has no extra direct plugin requests") +verify('kotlin = "2.2.21"' in catalog and 'agp = "8.11.1"' in catalog, "Kotlin/AGP versions are unchanged") +verify("java-version: '17'" in text(".github/workflows/verify.yml"), "existing CI uses Java 17") +verify("[SAF-D1/NULL_CURSOR]" in text("androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt") and + "Fileworkbench 0.2.3 · Browser diagnostics SAF-D1" in text("androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt"), + "source contains the diagnostic error and visible release marker") +verify("org.gradle.java.home" not in properties("gradle.properties"), "tracked Gradle properties contain no machine-specific JDK home") +jar = ROOT / "gradle/wrapper/gradle-wrapper.jar" +if jar.exists(): + verify(hashlib.sha256(jar.read_bytes()).hexdigest() == JAR, "bundled official wrapper JAR matches its checksum") +else: + print("INFO wrapper JAR is not bundled; the first wrapper launch bootstraps the verified binary.") +print(f"Build-configuration checks: {checks} passed. Source checks only, not an Android build.") diff --git a/scripts/check-kotlin-syntax.sh b/scripts/check-kotlin-syntax.sh new file mode 100644 index 0000000..ba0252f --- /dev/null +++ b/scripts/check-kotlin-syntax.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +KOTLIN_DIR="$(cd "$(dirname "$(command -v kotlinc)")/.." && pwd)" +mkdir -p out/syntax +kotlinc scripts/KotlinSyntaxCheck.kt -jvm-target 17 -cp "$KOTLIN_DIR/lib/kotlin-compiler.jar" -d out/syntax/check.jar +java -cp "out/syntax/check.jar:$KOTLIN_DIR/lib/*" dev.fileworkbench.testing.KotlinSyntaxCheckKt . diff --git a/scripts/check-project.py b/scripts/check-project.py new file mode 100644 index 0000000..e981f91 --- /dev/null +++ b/scripts/check-project.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Source-level invariants, not a substitute for Android lint, Gradle or device testing.""" +from pathlib import Path +import xml.etree.ElementTree as ET +root = Path(__file__).resolve().parent.parent +android = '{http://schemas.android.com/apk/res/android}' +for xml in (root / 'androidApp/src').rglob('*.xml'): + ET.parse(xml) +manifest = ET.parse(root / 'androidApp/src/main/AndroidManifest.xml').getroot() +assert not manifest.findall('uses-permission'), 'Unexpected permission in app manifest' +application = manifest.find('application') +assert application.get(android+'allowBackup') == 'false' +service = application.find('service') +assert service.get(android+'isolatedProcess') == 'true' +assert service.get(android+'exported') == 'false' +provider = application.find('provider') +assert provider.get(android+'exported') == 'false' +for source in (root / 'core/src/commonMain').rglob('*.kt'): + content = source.read_text() + assert 'import android.' not in content and 'import java.' not in content, source +all_android = '\n'.join(p.read_text() for p in (root / 'androidApp/src/main/kotlin').rglob('*.kt')) +assert 'WebView(' not in all_android +assert 'Log.' not in all_android +print('PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls.') + +# Browser/picker entry points must not impersonate the system document chooser. +activities = {item.get(android+'name'): item for item in application.findall('activity')} +main = activities['.MainActivity'] +picker = activities['.FilePickerActivity'] +assert picker.get(android+'exported') == 'true' +assert picker.get(android+'launchMode', 'standard') == 'standard' +all_actions = {item.get(android+'name') for item in application.findall('.//action')} +assert 'android.intent.action.GET_CONTENT' in all_actions +assert 'android.intent.action.OPEN_DOCUMENT' not in all_actions +assert 'android.intent.action.OPEN_DOCUMENT_TREE' not in all_actions +assert any(item.get(android+'name') == 'android.intent.category.APP_FILES' for item in main.findall('.//category')) +assert any(item.get(android+'name') == 'android.intent.category.OPENABLE' for item in picker.findall('.//category')) +paths = ET.parse(root / 'androidApp/src/main/res/xml/file_paths.xml').getroot() +assert {(item.tag, item.get('path')) for item in paths} == {('cache-path', 'shared/'), ('cache-path', 'picked/')} +assert all('FixtureDocumentsProvider' not in item.get(android+'name', '') for item in application.findall('provider')) +for file in ['MainActivity.kt', 'FilePickerActivity.kt']: + content = (root / 'androidApp/src/main/kotlin/dev/fileworkbench/android' / file).read_text() + assert 'sanitized' in content + assert content.index('sanitized') < content.index('super.onCreate(savedInstanceState)') +copy_code = (root / 'androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt').read_text() +assert 'SelectionCopyBudget()' in copy_code +assert 'flags = Intent.FLAG_GRANT_READ_URI_PERMISSION' in copy_code +assert 'FLAG_GRANT_PREFIX_URI_PERMISSION' not in copy_code +assert 'FLAG_GRANT_WRITE_URI_PERMISSION' not in copy_code +assert 'FLAG_GRANT_PERSISTABLE_URI_PERMISSION' not in copy_code +print('PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest.') + +# Regression guard for the reported instrumentation compile failure. This deliberately +# checks only the known constructor/use pattern; it is NOT Kotlin type checking. +import re +for source in (root / 'androidApp/src').rglob('*.kt'): + assert not re.search(r"\bPdfDocument\s*\(\s*\)\s*\.\s*use\b", source.read_text()), ( + f'{source}: PdfDocument does not implement Closeable/AutoCloseable; use try/finally' + ) +print('PASS PDF source regression guard: no PdfDocument().use call (pattern check only).') + +# The release is intentionally pinned; prevent mixed old/new wrapper or plugin files. +import subprocess +import sys +subprocess.run([sys.executable, str(root / 'scripts/check-build-config.py')], check=True) diff --git a/scripts/prepare-offline-tests.py b/scripts/prepare-offline-tests.py new file mode 100644 index 0000000..2315745 --- /dev/null +++ b/scripts/prepare-offline-tests.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Run the same @Test method bodies offline, omitting only JUnit/Kotlin runner annotations.""" +from pathlib import Path +import re +import shutil +root = Path(__file__).resolve().parent.parent +out = root / "out/offline-tests/generated" +shutil.rmtree(out, ignore_errors=True) +out.mkdir(parents=True) +cases = [] +for source in sorted(list((root / "core/src/commonTest").rglob("*Tests.kt")) + list((root / "platform-jvm/src/test").rglob("*Tests.kt"))): + text = source.read_text() + package = re.search(r"^package ([\w.]+)", text, re.M).group(1) + cls = re.search(r"^class (\w+Tests)", text, re.M).group(1) + for method in re.findall(r"@Test\s+fun (\w+)\(", text): + cases.append((f"{cls}.{method}", f"{package}.{cls}().{method}()")) + text = re.sub(r"^import (?:kotlin\.test|org\.junit)\.Test\s*$", "", text, flags=re.M).replace("@Test ", "") + (out / source.name).write_text(text) +runner = """package dev.fileworkbench.testing +fun main() { + val cases: List Unit>> = listOf( +""" +runner += ",\n".join(f' "{name}" to {{ {call} }}' for name, call in cases) +runner += """ + ) + var failed = 0 + for ((name, run) in cases) { + try { run(); println("PASS $name") } + catch (e: Throwable) { failed++; println("FAIL $name: ${e.message}"); e.printStackTrace() } + } + println("RESULT: ${cases.size - failed}/${cases.size} checks passed") + check(failed == 0) { "$failed checks failed" } +} +""" +(out / "OfflineRunner.kt").write_text(runner) +print(f"Prepared {len(cases)} production-core/JVM checks.") diff --git a/scripts/regressions/FolderListingHarness.kt.template b/scripts/regressions/FolderListingHarness.kt.template new file mode 100644 index 0000000..544f69e --- /dev/null +++ b/scripts/regressions/FolderListingHarness.kt.template @@ -0,0 +1,172 @@ +package dev.fileworkbench.testing + +import dev.fileworkbench.core.PreviewException +import dev.fileworkbench.core.browser.* +import java.io.Closeable +import java.io.IOException + +// Deliberately small doubles, not Android stubs for compiling the application. +// The production listing block below is inserted verbatim by test-folder-listing.py. +private object Document { const val COLUMN_DOCUMENT_ID = "document_id" } +private object DocumentsContract { + const val EXTRA_LOADING = "loading" + const val EXTRA_INFO = "info" + const val EXTRA_ERROR = "error" + fun buildDocumentUriUsingTree(tree: String, id: String) = "$tree/document/$id" +} +private class Extras(private val values: Map = emptyMap()) { + fun getBoolean(key: String, fallback: Boolean) = values[key] as? Boolean ?: fallback + fun getString(key: String) = values[key] as? String +} +// Test-only representations for the diagnostic expression. They do not simulate +// Android URI grants, provider availability or OS behaviour. +private data class TestUri(val value: String, val authority: String? = "fixture.provider") +private object Build { object VERSION { const val SDK_INT = 36 } } +private object UriReadGrants { + var retainedRead = false + var failLookup = false + @Suppress("UNUSED_PARAMETER") + fun hasPersistentRead(resolver: TestResolver, tree: String): Boolean { + if (failLookup) throw SecurityException("fixture grant lookup failure") + return retainedRead + } +} +private data class Row(val id: String?, val name: String = "sample.txt") +private class TestCursor( + val rows: List, + val extras: Extras = Extras(), + private val failOnMove: Boolean = false, +) : Closeable { + var index = -1 + var closeCount = 0 + fun moveToNext(): Boolean { + if (failOnMove) throw IOException("fixture read failure") + return ++index < rows.size + } + fun string(column: String): String? { + check(column == Document.COLUMN_DOCUMENT_ID) + return rows[index].id + } + override fun close() { closeCount++ } +} +private class TestSignal(private val cancelled: Boolean = false) { + fun throwIfCanceled() { if (cancelled) throw java.util.concurrent.CancellationException("fixture cancellation") } +} +private class TestResolver(private val cursor: TestCursor?) { + var queries = 0 + @Suppress("UNUSED_PARAMETER") + fun query(uri: TestUri, projection: Array, selection: String?, arguments: Array?, order: String?, signal: TestSignal): TestCursor? { + queries++ + return cursor + } +} +private fun entry(cursor: TestCursor, uri: String) = BrowserEntry(uri, cursor.rows[cursor.index].name, "text/plain") +private fun readListing(resolver: TestResolver, signal: TestSignal = TestSignal()): BrowserListing = run { + val tree = "fixture:root" + val children = TestUri("fixture:root/children") + val PROJECTION = arrayOf(Document.COLUMN_DOCUMENT_ID) +/* PRODUCTION_LISTING_BLOCK */ +} +private inline fun expectThrows(block: () -> Unit): T { + try { block() } catch (e: Throwable) { + check(e is T) { "Expected ${T::class.java.simpleName}, got $e" } + return e + } + error("Expected ${T::class.java.simpleName}") +} +private fun read(cursor: TestCursor): BrowserListing { + val resolver = TestResolver(cursor) + try { return readListing(resolver) } finally { + check(cursor.closeCount == 1) { "Cursor must close exactly once" } + check(resolver.queries == 1) { "Listing must issue exactly one query" } + } +} +fun main() { + var passed = 0 + var failed = 0 + fun test(name: String, block: () -> Unit) { + try { block(); passed++; println("PASS $name") } + catch (e: Throwable) { failed++; println("FAIL $name: ${e.javaClass.simpleName}: ${e.message}") } + } + test("normal rows without extras are not rejected") { + val listing = read(TestCursor(listOf(Row("file")))) + check(listing.entries.map { it.name } == listOf("sample.txt")) + check(!listing.truncated && !listing.providerLoading && listing.information == null) + } + test("empty cursor without extras is a valid empty directory") { + val listing = read(TestCursor(emptyList())) + check(listing.entries.isEmpty() && !listing.truncated && !listing.providerLoading && listing.information == null) + } + test("information-only cursor keeps rows and message") { + val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files")))) + check(listing.entries.size == 1 && listing.information == "Cached files") + } + test("loading cursor without error keeps partial rows") { + val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("loading" to true)))) + check(listing.entries.size == 1 && listing.providerLoading && listing.information == null) + } + test("loading cursor may initially contain no rows") { + val listing = read(TestCursor(emptyList(), Extras(mapOf("loading" to true)))) + check(listing.entries.isEmpty() && listing.providerLoading && listing.information == null) + } + test("provider error takes precedence over info without discarding available rows") { + val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files", "error" to "Some files are unavailable")))) + check(listing.entries.size == 1 && listing.information == "Some files are unavailable") + } + test("actual null query result still raises the no-listing error") { + val resolver = TestResolver(null) + val error = expectThrows { readListing(resolver) } + check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true) + check(error.message?.contains("Provider: fixture.provider") == true) + check(error.message?.contains("Android API: 36; retained read grant: false") == true) + check(error.message?.contains("fixture:root/children") == false) + check(resolver.queries == 1) + } + test("cursor closes when row iteration throws") { + val cursor = TestCursor(listOf(Row("file")), failOnMove = true) + expectThrows { read(cursor) } + check(cursor.closeCount == 1) + } + test("cursor closes on cancellation during iteration") { + val cursor = TestCursor(listOf(Row("file"))) + expectThrows { readListing(TestResolver(cursor), TestSignal(cancelled = true)) } + check(cursor.closeCount == 1) + } + test("entry limit still marks an oversized directory truncated") { + val listing = read(TestCursor((0..BrowserLimits.MAX_ENTRIES).map { Row("file-$it") })) + check(listing.entries.size == BrowserLimits.MAX_ENTRIES && listing.truncated) + } + test("exactly the entry limit is not marked truncated") { + val listing = read(TestCursor((0 until BrowserLimits.MAX_ENTRIES).map { Row("file-$it") })) + check(listing.entries.size == BrowserLimits.MAX_ENTRIES && !listing.truncated) + } + test("duplicate provider IDs remain deduplicated") { + val listing = read(TestCursor(listOf(Row("file"), Row("file")))) + check(listing.entries.size == 1) + } + test("missing and oversized document IDs are skipped") { + val listing = read(TestCursor(listOf(Row(null), Row("x".repeat(8193)), Row("file")))) + check(listing.entries.size == 1 && listing.entries.single().id.endsWith("/file")) + } + test("provider messages still use the shared label sanitizer") { + val listing = read(TestCursor(emptyList(), Extras(mapOf("error" to "Warning\nunsafe\u202E")))) + check(listing.information == "Warning_unsafe_") + } + test("diagnostic reflects a retained read grant without treating it as successful access") { + UriReadGrants.retainedRead = true + try { + val error = expectThrows { readListing(TestResolver(null)) } + check(error.message?.contains("retained read grant: true") == true) + } finally { UriReadGrants.retainedRead = false } + } + test("unavailable permission diagnostic cannot replace the original null-cursor error") { + UriReadGrants.failLookup = true + try { + val error = expectThrows { readListing(TestResolver(null)) } + check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true) + check(error.message?.contains("retained read grant: unknown") == true) + } finally { UriReadGrants.failLookup = false } + } + println("Folder-listing host regression: $passed passed, $failed failed. NOT an Android/device test.") + check(failed == 0) { "$failed regression checks failed" } +} diff --git a/scripts/test-build-config.py b/scripts/test-build-config.py new file mode 100644 index 0000000..32519b3 --- /dev/null +++ b/scripts/test-build-config.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Regression checks for the release-pin guard; no Gradle execution or downloads.""" +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +passed = 0 +cases = [ + ("baseline", None, None, None, True), + ("stale Gradle distribution", "gradle/wrapper/gradle-wrapper.properties", "gradle-8.14.5-bin", "gradle-9.7.1-bin", False), + ("wrong daemon JVM", "gradle/gradle-daemon-jvm.properties", "toolchainVersion=17", "toolchainVersion=25", False), + ("duplicate JVM alias", "build.gradle.kts", "alias(libs.plugins.kotlin.jvm) apply false", + "alias(libs.plugins.kotlin.jvm) apply false\n alias(libs.plugins.kotlin.jvm) apply false", False), + ("catalog maps two aliases to JVM", "gradle/libs.versions.toml", 'id = "org.jetbrains.kotlin.android"', + 'id = "org.jetbrains.kotlin.jvm"', False), + ("extra direct plugin request", "build.gradle.kts", "plugins {", + 'plugins {\n id("org.jetbrains.kotlin.jvm") apply false', False), + ("stale shell bootstrap", "scripts/bootstrap-wrapper.sh", "v8.14.5/", "v8.13.0/", False), + ("stale Windows bootstrap", "scripts/bootstrap-wrapper.ps1", "v8.14.5/", "v8.13.0/", False), +] +for name, relative, old, new, expect_success in cases: + with tempfile.TemporaryDirectory(prefix="fileworkbench-build-config-") as work: + target = Path(work) / "project" + shutil.copytree(ROOT, target, ignore=shutil.ignore_patterns("out", "build", ".gradle", ".git", ".idea", "local.properties", "__pycache__")) + if relative: + file = target / relative + source = file.read_text(encoding="utf-8") + assert old in source, name + ": expected fixture content missing" + file.write_text(source.replace(old, new, 1), encoding="utf-8") + else: + # Local IDE/SDK state is legitimate on the developer's machine. + # The source-package check must not accidentally prohibit ordinary builds. + (target / "local.properties").write_text("sdk.dir=/example/local/sdk\n", encoding="utf-8") + (target / ".idea").mkdir() + result = subprocess.run([sys.executable, str(target / "scripts/check-build-config.py")], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if (result.returncode == 0) != expect_success: + print(result.stdout) + raise SystemExit("FAIL " + name) + passed += 1 + print("PASS " + name + (" accepted" if expect_success else " rejected")) +print(f"Build-guard regression: {passed}/{len(cases)} cases passed. Not a Gradle build.") diff --git a/scripts/test-core.sh b/scripts/test-core.sh new file mode 100644 index 0000000..accb202 --- /dev/null +++ b/scripts/test-core.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Offline verification of the production common core + JVM ZIP adapter. +# Uses an installed Kotlin/JVM compiler and kotlin-test.jar; does not claim an Android Gradle build. +set -euo pipefail +cd "$(dirname "$0")/.." +command -v kotlinc >/dev/null || { echo 'Install Kotlin (kotlinc) and a JDK first.' >&2; exit 1; } +KOTLIN_BIN="$(command -v kotlinc)" +KOTLIN_DIR="$(cd "$(dirname "$KOTLIN_BIN")/.." && pwd)" +TEST_JAR="$KOTLIN_DIR/lib/kotlin-test.jar" +[ -f "$TEST_JAR" ] || { echo "kotlin-test.jar not found: $TEST_JAR" >&2; exit 1; } +mkdir -p out/offline-tests +python3 scripts/prepare-offline-tests.py +SOURCES=() +while IFS= read -r source; do SOURCES+=("$source"); done < <(find core/src/commonMain platform-jvm/src/main out/offline-tests/generated -name '*.kt' | sort) +kotlinc "${SOURCES[@]}" -jvm-target 17 -cp "$TEST_JAR" -include-runtime -d out/offline-tests/tests.jar +java -cp "out/offline-tests/tests.jar:$TEST_JAR" dev.fileworkbench.testing.OfflineRunnerKt | tee out/offline-tests/results.txt diff --git a/scripts/test-folder-listing.py b/scripts/test-folder-listing.py new file mode 100644 index 0000000..7df91ae --- /dev/null +++ b/scripts/test-folder-listing.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Run the actual SafBrowserStore cursor-reading block on JVM test doubles. + +Requires a local Kotlin compiler and JDK; no SDK, Gradle, or downloads. This checks +Kotlin control flow and resource cleanup, NOT Android API compatibility, Binder, +URI grants, async IO, provider implementation, or device/UI behaviour. +""" +import argparse +from pathlib import Path +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--source', type=Path, default=ROOT / 'androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt', + help='Optional historical source for reproducing the regression') +args = parser.parse_args() +compiler = shutil.which('kotlinc') +java = shutil.which('java') +if compiler is None or java is None: + parser.error('Install kotlinc and a JDK, then rerun this offline check.') +source = args.source.read_text(encoding='utf-8') +# Copy verbatim, rather than maintaining a second corrected implementation. +start_marker = ' val rows = ArrayList()' +end_marker = ' BrowserListing(rows.distinctBy { it.id }, truncated, loading, information)' +if source.count(start_marker) != 1 or source.count(end_marker) != 1: + parser.error('Listing method changed shape; review and update the harness extraction boundaries.') +start = source.index(start_marker) +end = source.index(end_marker, start) + len(end_marker) +block = source[start:end] +template = (ROOT / 'scripts/regressions/FolderListingHarness.kt.template').read_text(encoding='utf-8') +assert template.count('/* PRODUCTION_LISTING_BLOCK */') == 1 +out = ROOT / 'out/folder-listing' +out.mkdir(parents=True, exist_ok=True) +generated = out / 'FolderListingHarness.kt' +generated.write_text(template.replace('/* PRODUCTION_LISTING_BLOCK */', block), encoding='utf-8') +core = sorted(str(p) for p in (ROOT / 'core/src/commonMain').rglob('*.kt')) +jar = out / 'regression.jar' +print(f'Source under test: {args.source}', flush=True) +print('Extracted production cursor-reading block + real common models; Android APIs are test doubles.', flush=True) +compiled = subprocess.run([compiler, *core, str(generated), '-jvm-target', '17', '-include-runtime', '-d', str(jar)], cwd=ROOT) +if compiled.returncode: + raise SystemExit(compiled.returncode) +raise SystemExit(subprocess.run([java, '-cp', str(jar), 'dev.fileworkbench.testing.FolderListingHarnessKt'], cwd=ROOT).returncode) diff --git a/scripts/verify-android.sh b/scripts/verify-android.sh new file mode 100644 index 0000000..02b3f15 --- /dev/null +++ b/scripts/verify-android.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +python3 scripts/check-project.py +./gradlew --no-daemon :core:jvmTest :platform-jvm:test :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug +printf '%s\n' 'APK: androidApp/build/outputs/apk/debug/androidApp-debug.apk' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d449467 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,14 @@ +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +// Fail early on an accidental IDE/system-Gradle override of the project baseline. +check(gradle.gradleVersion == "8.14.5") { + "Fileworkbench requires Gradle 8.14.5. Use this checkout's Gradle wrapper." +} +check(JavaVersion.current() == JavaVersion.VERSION_17) { + "Fileworkbench requires JDK 17 to run Gradle. Set the IDE Gradle JVM and JAVA_HOME to JDK 17." +} +rootProject.name = "Fileworkbench" +include(":core", ":platform-jvm", ":androidApp") diff --git a/validation/0.2.1/ANDROID_BUILD_ATTEMPT.txt b/validation/0.2.1/ANDROID_BUILD_ATTEMPT.txt new file mode 100644 index 0000000..11701b4 --- /dev/null +++ b/validation/0.2.1/ANDROID_BUILD_ATTEMPT.txt @@ -0,0 +1,6 @@ +Fetching the official Gradle 8.13 wrapper; a pinned SHA-256 is required. + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0curl: (6) Could not resolve host: raw.githubusercontent.com + +Exit status: 6 diff --git a/validation/0.2.1/CORE_TESTS.txt b/validation/0.2.1/CORE_TESTS.txt new file mode 100644 index 0000000..2f8eb8d --- /dev/null +++ b/validation/0.2.1/CORE_TESTS.txt @@ -0,0 +1,114 @@ +Prepared 112 production-core/JVM checks. +PASS CoreTests.pdfMagicWinsOverName +PASS CoreTests.imageMagicWinsOverName +PASS CoreTests.caseInsensitiveExtensions +PASS CoreTests.mimeFallbackHandlesMissingExtension +PASS CoreTests.htmlAndSvgAreOnlyText +PASS CoreTests.unknownBinaryIsNotAssumedText +PASS CoreTests.legacyBinaryDetected +PASS CoreTests.zipMagicDisallowsTextPretence +PASS CoreTests.extensionlessOfficeInspected +PASS CoreTests.utf8BomAndUnicode +PASS CoreTests.utf16LittleEndian +PASS CoreTests.utf16BigEndian +PASS CoreTests.utf16RejectsOddBytes +PASS CoreTests.utf16RejectsLoneSurrogate +PASS CoreTests.invalidUtf8Rejected +PASS CoreTests.nulBinaryRejected +PASS CoreTests.xmlPreservesMixedContent +PASS CoreTests.xmlDecodesSafeEntities +PASS CoreTests.xmlCdataIsLiteral +PASS CoreTests.xmlRejectsDtd +PASS CoreTests.xmlRejectsCustomEntity +PASS CoreTests.xmlRejectsMismatchedTags +PASS CoreTests.xmlRejectsMultipleRoots +PASS CoreTests.xmlRejectsDeepNesting +PASS CoreTests.xmlRejectsTooManyNodes +PASS CoreTests.xmlRejectsTooManyAttributes +PASS CoreTests.xmlRejectsDuplicateAttribute +PASS CoreTests.xmlRejectsUnquotedAttribute +PASS CoreTests.xmlRejectsInvalidCharacterReference +PASS CoreTests.packagePathsStayInsideRoot +PASS CoreTests.packageRootEscapeRejected +PASS CoreTests.externalRelationshipsRejected +PASS CoreTests.csvQuotedDelimitersAndNewlines +PASS CoreTests.csvEscapedQuotes +PASS CoreTests.csvEmptyTrailingCells +PASS CoreTests.csvWindowsNewlines +PASS CoreTests.csvSemicolonDetection +PASS CoreTests.csvFormulaRemainsLiteral +PASS CoreTests.csvBadQuotesFailClearly +PASS CoreTests.csvRowLimit +PASS CoreTests.csvColumnLimit +PASS CoreTests.basicMarkdownHasHeadingsAndCode +PASS CoreTests.textByteLimit +PASS CoreTests.columnLabelsBeyondZ +PASS CoreTests.docxReadingTextAndTables +PASS CoreTests.xlsxSparseCellsAndSharedStrings +PASS CoreTests.xlsxTooWideRejected +PASS CoreTests.pptxFollowsRelationshipOrderNotFilenames +PASS CoreTests.odtMixedTextSpacesAndHeadings +PASS CoreTests.odsRepeatedPaddingDoesNotExplode +PASS CoreTests.odsRepeatedNonemptyRowsRejected +PASS CoreTests.odpSlideNamesPreserved +PASS CoreTests.malformedOfficePartRejected +PASS CoreTests.zipIndexDoesNotReadEntries +PASS CoreTests.literalSearchAndHitCap +PASS CoreTests.tableSearchUsesActualRows +PASS CoreTests.textExportContainsSectionNamesAndValues +PASS CoreTests.repeatedContentAmplificationBounded +PASS CoreTests.veryLongSingleBlockRejected +PASS CoreTests.macroExtensionsNotReclassifiedAsZip +PASS CoreTests.renamedMacroPackageRejected +PASS CoreTests.publicCoreFacadeReadsText +PASS CoreTests.publicCoreFacadeRejectsBinary +PASS BrowserTests.directoriesRemainFirstWhenDescending +PASS BrowserTests.filtersNamesCaseInsensitively +PASS BrowserTests.searchIsLiteralNotRegex +PASS BrowserTests.hidesDotFilesUnlessEnabled +PASS BrowserTests.unknownSizesRemainLastInBothDirections +PASS BrowserTests.modifiedSortingUsesTimestamps +PASS BrowserTests.typeSortingIsDeterministic +PASS BrowserTests.duplicateProviderRowsDoNotDuplicateComposeKeys +PASS BrowserTests.listingProcessingHasAnUpperBound +PASS BrowserTests.labelsStripSpoofingButLeaveIdsUntouched +PASS BrowserTests.labelsAreBoundedAndNonempty +PASS BrowserTests.mimeMatchingHandlesWildcardsAndParameters +PASS BrowserTests.malformedMimeRequestsFailClosed +PASS BrowserTests.wildcardIsNotAnActualFileMimeType +PASS BrowserTests.extraMimeTypesNarrowTheBaseType +PASS BrowserTests.excessiveMimeTypesAreRejected +PASS BrowserTests.foldersStayNavigableInFilteredPicker +PASS BrowserTests.directoriesAndVirtualDocumentsCannotBeReturned +PASS BrowserTests.singleSelectionReplacesPreviousSelection +PASS BrowserTests.togglingSelectedFileRemovesIt +PASS BrowserTests.multipleSelectionIsBounded +PASS BrowserTests.unsupportedSelectionIsRejected +PASS BrowserTests.emptySelectionIsRejected +PASS BrowserTests.duplicateSelectionIsRejected +PASS BrowserTests.singleRequestCannotReturnTwoFiles +PASS BrowserTests.individualSizeBudgetIsChecked +PASS BrowserTests.aggregateSizeBudgetIsChecked +PASS BrowserTests.negativeSizeIsNotTrusted +PASS BrowserTests.unknownSizesAreAllowedPendingStreamingLimit +PASS BrowserTests.comparatorRemainsAntisymmetric +PASS BrowserTests.actualStreamBytesAreLimitedEvenWithUnknownMetadata +PASS BrowserTests.totalByteBudgetSpansFiles +PASS BrowserTests.newFileResetsOnlyTheFileCounter +PASS BrowserTests.zeroByteFilesAreAllowed +PASS BrowserTests.negativeCopyCountsAreRejected +PASS BrowserTests.byteCountersRejectHugeChunksWithoutOverflow +PASS BrowserTests.invalidCopyBudgetsAreRejected +PASS ZipTests.archiveRoundtrip +PASS ZipTests.traversalRejected +PASS ZipTests.absolutePathsRejected +PASS ZipTests.backslashPathsRejected +PASS ZipTests.tooManyEntriesRejected +PASS ZipTests.oversizedEntryRejectedOnlyWhenRead +PASS ZipTests.compressionBombRejected +PASS ZipTests.cumulativeExpansionBudgetEnforced +PASS ZipTests.repeatedReadsConsumeBudget +PASS ZipTests.extensionlessOfficeLoadedThroughFullAdapter +PASS ZipTests.textByteBudgetEnforcedBeforeRead +PASS ZipTests.htmlRemainsInertSource +RESULT: 112/112 checks passed diff --git a/validation/0.2.1/KOTLIN_SYNTAX.txt b/validation/0.2.1/KOTLIN_SYNTAX.txt new file mode 100644 index 0000000..44e7395 --- /dev/null +++ b/validation/0.2.1/KOTLIN_SYNTAX.txt @@ -0,0 +1 @@ +Kotlin PSI syntax: 44 files, 0 parse errors. Android/Compose type resolution is NOT tested. diff --git a/validation/0.2.1/PATCH_CHECK.txt b/validation/0.2.1/PATCH_CHECK.txt new file mode 100644 index 0000000..5495633 --- /dev/null +++ b/validation/0.2.1/PATCH_CHECK.txt @@ -0,0 +1 @@ +PASS: patch applies to original 0.2.0 test, produces the supplied fixed file, and reverses exactly. diff --git a/validation/0.2.1/REGRESSION_GUARD.txt b/validation/0.2.1/REGRESSION_GUARD.txt new file mode 100644 index 0000000..5fdc521 --- /dev/null +++ b/validation/0.2.1/REGRESSION_GUARD.txt @@ -0,0 +1,6 @@ +PASS: source guard rejects the original constructor/use expression and accepts the corrected source. +This is a pattern assertion, not Android API type checking. + +PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls. +PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest. +PASS PDF source regression guard: no PdfDocument().use call (pattern check only). diff --git a/validation/0.2.1/SOURCE_CHECKS.txt b/validation/0.2.1/SOURCE_CHECKS.txt new file mode 100644 index 0000000..e0aba00 --- /dev/null +++ b/validation/0.2.1/SOURCE_CHECKS.txt @@ -0,0 +1,3 @@ +PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls. +PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest. +PASS PDF source regression guard: no PdfDocument().use call (pattern check only). diff --git a/validation/0.2.2/android-build-attempt.txt b/validation/0.2.2/android-build-attempt.txt new file mode 100644 index 0000000..99d90f3 --- /dev/null +++ b/validation/0.2.2/android-build-attempt.txt @@ -0,0 +1,7 @@ + +Build command: ./gradlew --no-daemon --console=plain :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest +Fetching the official Gradle 8.13 wrapper; a pinned SHA-256 is required. + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0curl: (6) Could not resolve host: raw.githubusercontent.com +Exit status: 6 diff --git a/validation/0.2.2/core-tests.txt b/validation/0.2.2/core-tests.txt new file mode 100644 index 0000000..2f8eb8d --- /dev/null +++ b/validation/0.2.2/core-tests.txt @@ -0,0 +1,114 @@ +Prepared 112 production-core/JVM checks. +PASS CoreTests.pdfMagicWinsOverName +PASS CoreTests.imageMagicWinsOverName +PASS CoreTests.caseInsensitiveExtensions +PASS CoreTests.mimeFallbackHandlesMissingExtension +PASS CoreTests.htmlAndSvgAreOnlyText +PASS CoreTests.unknownBinaryIsNotAssumedText +PASS CoreTests.legacyBinaryDetected +PASS CoreTests.zipMagicDisallowsTextPretence +PASS CoreTests.extensionlessOfficeInspected +PASS CoreTests.utf8BomAndUnicode +PASS CoreTests.utf16LittleEndian +PASS CoreTests.utf16BigEndian +PASS CoreTests.utf16RejectsOddBytes +PASS CoreTests.utf16RejectsLoneSurrogate +PASS CoreTests.invalidUtf8Rejected +PASS CoreTests.nulBinaryRejected +PASS CoreTests.xmlPreservesMixedContent +PASS CoreTests.xmlDecodesSafeEntities +PASS CoreTests.xmlCdataIsLiteral +PASS CoreTests.xmlRejectsDtd +PASS CoreTests.xmlRejectsCustomEntity +PASS CoreTests.xmlRejectsMismatchedTags +PASS CoreTests.xmlRejectsMultipleRoots +PASS CoreTests.xmlRejectsDeepNesting +PASS CoreTests.xmlRejectsTooManyNodes +PASS CoreTests.xmlRejectsTooManyAttributes +PASS CoreTests.xmlRejectsDuplicateAttribute +PASS CoreTests.xmlRejectsUnquotedAttribute +PASS CoreTests.xmlRejectsInvalidCharacterReference +PASS CoreTests.packagePathsStayInsideRoot +PASS CoreTests.packageRootEscapeRejected +PASS CoreTests.externalRelationshipsRejected +PASS CoreTests.csvQuotedDelimitersAndNewlines +PASS CoreTests.csvEscapedQuotes +PASS CoreTests.csvEmptyTrailingCells +PASS CoreTests.csvWindowsNewlines +PASS CoreTests.csvSemicolonDetection +PASS CoreTests.csvFormulaRemainsLiteral +PASS CoreTests.csvBadQuotesFailClearly +PASS CoreTests.csvRowLimit +PASS CoreTests.csvColumnLimit +PASS CoreTests.basicMarkdownHasHeadingsAndCode +PASS CoreTests.textByteLimit +PASS CoreTests.columnLabelsBeyondZ +PASS CoreTests.docxReadingTextAndTables +PASS CoreTests.xlsxSparseCellsAndSharedStrings +PASS CoreTests.xlsxTooWideRejected +PASS CoreTests.pptxFollowsRelationshipOrderNotFilenames +PASS CoreTests.odtMixedTextSpacesAndHeadings +PASS CoreTests.odsRepeatedPaddingDoesNotExplode +PASS CoreTests.odsRepeatedNonemptyRowsRejected +PASS CoreTests.odpSlideNamesPreserved +PASS CoreTests.malformedOfficePartRejected +PASS CoreTests.zipIndexDoesNotReadEntries +PASS CoreTests.literalSearchAndHitCap +PASS CoreTests.tableSearchUsesActualRows +PASS CoreTests.textExportContainsSectionNamesAndValues +PASS CoreTests.repeatedContentAmplificationBounded +PASS CoreTests.veryLongSingleBlockRejected +PASS CoreTests.macroExtensionsNotReclassifiedAsZip +PASS CoreTests.renamedMacroPackageRejected +PASS CoreTests.publicCoreFacadeReadsText +PASS CoreTests.publicCoreFacadeRejectsBinary +PASS BrowserTests.directoriesRemainFirstWhenDescending +PASS BrowserTests.filtersNamesCaseInsensitively +PASS BrowserTests.searchIsLiteralNotRegex +PASS BrowserTests.hidesDotFilesUnlessEnabled +PASS BrowserTests.unknownSizesRemainLastInBothDirections +PASS BrowserTests.modifiedSortingUsesTimestamps +PASS BrowserTests.typeSortingIsDeterministic +PASS BrowserTests.duplicateProviderRowsDoNotDuplicateComposeKeys +PASS BrowserTests.listingProcessingHasAnUpperBound +PASS BrowserTests.labelsStripSpoofingButLeaveIdsUntouched +PASS BrowserTests.labelsAreBoundedAndNonempty +PASS BrowserTests.mimeMatchingHandlesWildcardsAndParameters +PASS BrowserTests.malformedMimeRequestsFailClosed +PASS BrowserTests.wildcardIsNotAnActualFileMimeType +PASS BrowserTests.extraMimeTypesNarrowTheBaseType +PASS BrowserTests.excessiveMimeTypesAreRejected +PASS BrowserTests.foldersStayNavigableInFilteredPicker +PASS BrowserTests.directoriesAndVirtualDocumentsCannotBeReturned +PASS BrowserTests.singleSelectionReplacesPreviousSelection +PASS BrowserTests.togglingSelectedFileRemovesIt +PASS BrowserTests.multipleSelectionIsBounded +PASS BrowserTests.unsupportedSelectionIsRejected +PASS BrowserTests.emptySelectionIsRejected +PASS BrowserTests.duplicateSelectionIsRejected +PASS BrowserTests.singleRequestCannotReturnTwoFiles +PASS BrowserTests.individualSizeBudgetIsChecked +PASS BrowserTests.aggregateSizeBudgetIsChecked +PASS BrowserTests.negativeSizeIsNotTrusted +PASS BrowserTests.unknownSizesAreAllowedPendingStreamingLimit +PASS BrowserTests.comparatorRemainsAntisymmetric +PASS BrowserTests.actualStreamBytesAreLimitedEvenWithUnknownMetadata +PASS BrowserTests.totalByteBudgetSpansFiles +PASS BrowserTests.newFileResetsOnlyTheFileCounter +PASS BrowserTests.zeroByteFilesAreAllowed +PASS BrowserTests.negativeCopyCountsAreRejected +PASS BrowserTests.byteCountersRejectHugeChunksWithoutOverflow +PASS BrowserTests.invalidCopyBudgetsAreRejected +PASS ZipTests.archiveRoundtrip +PASS ZipTests.traversalRejected +PASS ZipTests.absolutePathsRejected +PASS ZipTests.backslashPathsRejected +PASS ZipTests.tooManyEntriesRejected +PASS ZipTests.oversizedEntryRejectedOnlyWhenRead +PASS ZipTests.compressionBombRejected +PASS ZipTests.cumulativeExpansionBudgetEnforced +PASS ZipTests.repeatedReadsConsumeBudget +PASS ZipTests.extensionlessOfficeLoadedThroughFullAdapter +PASS ZipTests.textByteBudgetEnforcedBeforeRead +PASS ZipTests.htmlRemainsInertSource +RESULT: 112/112 checks passed diff --git a/validation/0.2.2/folder-listing-fixed.txt b/validation/0.2.2/folder-listing-fixed.txt new file mode 100644 index 0000000..e839b62 --- /dev/null +++ b/validation/0.2.2/folder-listing-fixed.txt @@ -0,0 +1,23 @@ +Source under test: /mnt/data/fw-0.2.2/fileworkbench/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt +Extracted production cursor-reading block + real common models; Android APIs are test doubles. +out/folder-listing/FolderListingHarness.kt:58:23: warning: variable 'loading' initializer is redundant + var loading = false + ^ +out/folder-listing/FolderListingHarness.kt:59:36: warning: variable 'information' initializer is redundant + var information: String? = null + ^ +PASS normal rows without extras are not rejected +PASS empty cursor without extras is a valid empty directory +PASS information-only cursor keeps rows and message +PASS loading cursor without error keeps partial rows +PASS loading cursor may initially contain no rows +PASS provider error takes precedence over info without discarding available rows +PASS actual null query result still raises the no-listing error +PASS cursor closes when row iteration throws +PASS cursor closes on cancellation during iteration +PASS entry limit still marks an oversized directory truncated +PASS exactly the entry limit is not marked truncated +PASS duplicate provider IDs remain deduplicated +PASS missing and oversized document IDs are skipped +PASS provider messages still use the shared label sanitizer +Folder-listing host regression: 14 passed, 0 failed. NOT an Android/device test. diff --git a/validation/0.2.2/folder-listing-original.txt b/validation/0.2.2/folder-listing-original.txt new file mode 100644 index 0000000..1f4a965 --- /dev/null +++ b/validation/0.2.2/folder-listing-original.txt @@ -0,0 +1,21 @@ +Source under test: /mnt/data/SafBrowserStore-before-0.2.2.kt +Extracted production cursor-reading block + real common models; Android APIs are test doubles. +FAIL normal rows without extras are not rejected: PreviewException: The storage provider did not return a folder listing. +FAIL empty cursor without extras is a valid empty directory: PreviewException: The storage provider did not return a folder listing. +FAIL information-only cursor keeps rows and message: PreviewException: The storage provider did not return a folder listing. +FAIL loading cursor without error keeps partial rows: PreviewException: The storage provider did not return a folder listing. +FAIL loading cursor may initially contain no rows: PreviewException: The storage provider did not return a folder listing. +PASS provider error takes precedence over info without discarding available rows +PASS actual null query result still raises the no-listing error +PASS cursor closes when row iteration throws +PASS cursor closes on cancellation during iteration +FAIL entry limit still marks an oversized directory truncated: PreviewException: The storage provider did not return a folder listing. +FAIL exactly the entry limit is not marked truncated: PreviewException: The storage provider did not return a folder listing. +FAIL duplicate provider IDs remain deduplicated: PreviewException: The storage provider did not return a folder listing. +FAIL missing and oversized document IDs are skipped: PreviewException: The storage provider did not return a folder listing. +PASS provider messages still use the shared label sanitizer +Folder-listing host regression: 5 passed, 9 failed. NOT an Android/device test. +Exception in thread "main" java.lang.IllegalStateException: 9 regression checks failed + at dev.fileworkbench.testing.FolderListingHarnessKt.main(FolderListingHarness.kt:158) + at dev.fileworkbench.testing.FolderListingHarnessKt.main(FolderListingHarness.kt) +Original-source regression exit status: 1 (failure expected) diff --git a/validation/0.2.2/kotlin-syntax.txt b/validation/0.2.2/kotlin-syntax.txt new file mode 100644 index 0000000..44e7395 --- /dev/null +++ b/validation/0.2.2/kotlin-syntax.txt @@ -0,0 +1 @@ +Kotlin PSI syntax: 44 files, 0 parse errors. Android/Compose type resolution is NOT tested. diff --git a/validation/0.2.2/patch-checks.txt b/validation/0.2.2/patch-checks.txt new file mode 100644 index 0000000..ee53816 --- /dev/null +++ b/validation/0.2.2/patch-checks.txt @@ -0,0 +1,13 @@ +PASS 0.2.0: git apply --check, apply (exact fixed bytes), reverse --check and reverse (exact original bytes) +PASS 0.2.1: git apply --check, apply (exact fixed bytes), reverse --check and reverse (exact original bytes) +PASS downloadable SafBrowserStore.kt is identical to the complete project file +Modified files versus shipped 0.2.1: + CHANGELOG.md + README.md + androidApp/build.gradle.kts + androidApp/src/androidTest/kotlin/dev/fileworkbench/android/BrowserIntegrationTest.kt + androidApp/src/debug/kotlin/dev/fileworkbench/android/testing/FixtureDocumentsProvider.kt + androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt + docs/BROWSER.md + docs/BROWSER_ACCEPTANCE.md + docs/VALIDATION.md diff --git a/validation/0.2.2/source-checks.txt b/validation/0.2.2/source-checks.txt new file mode 100644 index 0000000..e0aba00 --- /dev/null +++ b/validation/0.2.2/source-checks.txt @@ -0,0 +1,3 @@ +PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls. +PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest. +PASS PDF source regression guard: no PdfDocument().use call (pattern check only). diff --git a/validation/0.2.3/android-build-attempt.txt b/validation/0.2.3/android-build-attempt.txt new file mode 100644 index 0000000..142a8d5 --- /dev/null +++ b/validation/0.2.3/android-build-attempt.txt @@ -0,0 +1,8 @@ +Attempt: ./gradlew --no-daemon :core:jvmTest :platform-jvm:test :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug +Fetching the official Gradle 8.14.5 wrapper; a pinned SHA-256 is required. + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0curl: (6) Could not resolve host: raw.githubusercontent.com + +Exit status: 6 +TERM environment variable not set. diff --git a/validation/0.2.3/archive-checks.txt b/validation/0.2.3/archive-checks.txt new file mode 100644 index 0000000..d4f76d2 --- /dev/null +++ b/validation/0.2.3/archive-checks.txt @@ -0,0 +1,8 @@ +Full-project archive checks (2026-09-24) +PASS ZIP CRC integrity and unique, safe paths under fileworkbench/. +PASS SHA-256 manifest for every packaged file other than the manifest itself. +PASS all files from the 0.2.2 source archive are retained. +PASS no build/cache/IDE/SDK-local state, generated test JAR, signing key or APK. +PASS project/source/pin checks run successfully from a fresh archive extraction. +INFO external Gradle wrapper JAR remains a verified first-run bootstrap download. +These package/source checks do not constitute an Android build or device test. diff --git a/validation/0.2.3/build-guard-tests.txt b/validation/0.2.3/build-guard-tests.txt new file mode 100644 index 0000000..c4a117d --- /dev/null +++ b/validation/0.2.3/build-guard-tests.txt @@ -0,0 +1,9 @@ +PASS baseline accepted +PASS stale Gradle distribution rejected +PASS wrong daemon JVM rejected +PASS duplicate JVM alias rejected +PASS catalog maps two aliases to JVM rejected +PASS extra direct plugin request rejected +PASS stale shell bootstrap rejected +PASS stale Windows bootstrap rejected +Build-guard regression: 8/8 cases passed. Not a Gradle build. diff --git a/validation/0.2.3/core-tests.txt b/validation/0.2.3/core-tests.txt new file mode 100644 index 0000000..2f8eb8d --- /dev/null +++ b/validation/0.2.3/core-tests.txt @@ -0,0 +1,114 @@ +Prepared 112 production-core/JVM checks. +PASS CoreTests.pdfMagicWinsOverName +PASS CoreTests.imageMagicWinsOverName +PASS CoreTests.caseInsensitiveExtensions +PASS CoreTests.mimeFallbackHandlesMissingExtension +PASS CoreTests.htmlAndSvgAreOnlyText +PASS CoreTests.unknownBinaryIsNotAssumedText +PASS CoreTests.legacyBinaryDetected +PASS CoreTests.zipMagicDisallowsTextPretence +PASS CoreTests.extensionlessOfficeInspected +PASS CoreTests.utf8BomAndUnicode +PASS CoreTests.utf16LittleEndian +PASS CoreTests.utf16BigEndian +PASS CoreTests.utf16RejectsOddBytes +PASS CoreTests.utf16RejectsLoneSurrogate +PASS CoreTests.invalidUtf8Rejected +PASS CoreTests.nulBinaryRejected +PASS CoreTests.xmlPreservesMixedContent +PASS CoreTests.xmlDecodesSafeEntities +PASS CoreTests.xmlCdataIsLiteral +PASS CoreTests.xmlRejectsDtd +PASS CoreTests.xmlRejectsCustomEntity +PASS CoreTests.xmlRejectsMismatchedTags +PASS CoreTests.xmlRejectsMultipleRoots +PASS CoreTests.xmlRejectsDeepNesting +PASS CoreTests.xmlRejectsTooManyNodes +PASS CoreTests.xmlRejectsTooManyAttributes +PASS CoreTests.xmlRejectsDuplicateAttribute +PASS CoreTests.xmlRejectsUnquotedAttribute +PASS CoreTests.xmlRejectsInvalidCharacterReference +PASS CoreTests.packagePathsStayInsideRoot +PASS CoreTests.packageRootEscapeRejected +PASS CoreTests.externalRelationshipsRejected +PASS CoreTests.csvQuotedDelimitersAndNewlines +PASS CoreTests.csvEscapedQuotes +PASS CoreTests.csvEmptyTrailingCells +PASS CoreTests.csvWindowsNewlines +PASS CoreTests.csvSemicolonDetection +PASS CoreTests.csvFormulaRemainsLiteral +PASS CoreTests.csvBadQuotesFailClearly +PASS CoreTests.csvRowLimit +PASS CoreTests.csvColumnLimit +PASS CoreTests.basicMarkdownHasHeadingsAndCode +PASS CoreTests.textByteLimit +PASS CoreTests.columnLabelsBeyondZ +PASS CoreTests.docxReadingTextAndTables +PASS CoreTests.xlsxSparseCellsAndSharedStrings +PASS CoreTests.xlsxTooWideRejected +PASS CoreTests.pptxFollowsRelationshipOrderNotFilenames +PASS CoreTests.odtMixedTextSpacesAndHeadings +PASS CoreTests.odsRepeatedPaddingDoesNotExplode +PASS CoreTests.odsRepeatedNonemptyRowsRejected +PASS CoreTests.odpSlideNamesPreserved +PASS CoreTests.malformedOfficePartRejected +PASS CoreTests.zipIndexDoesNotReadEntries +PASS CoreTests.literalSearchAndHitCap +PASS CoreTests.tableSearchUsesActualRows +PASS CoreTests.textExportContainsSectionNamesAndValues +PASS CoreTests.repeatedContentAmplificationBounded +PASS CoreTests.veryLongSingleBlockRejected +PASS CoreTests.macroExtensionsNotReclassifiedAsZip +PASS CoreTests.renamedMacroPackageRejected +PASS CoreTests.publicCoreFacadeReadsText +PASS CoreTests.publicCoreFacadeRejectsBinary +PASS BrowserTests.directoriesRemainFirstWhenDescending +PASS BrowserTests.filtersNamesCaseInsensitively +PASS BrowserTests.searchIsLiteralNotRegex +PASS BrowserTests.hidesDotFilesUnlessEnabled +PASS BrowserTests.unknownSizesRemainLastInBothDirections +PASS BrowserTests.modifiedSortingUsesTimestamps +PASS BrowserTests.typeSortingIsDeterministic +PASS BrowserTests.duplicateProviderRowsDoNotDuplicateComposeKeys +PASS BrowserTests.listingProcessingHasAnUpperBound +PASS BrowserTests.labelsStripSpoofingButLeaveIdsUntouched +PASS BrowserTests.labelsAreBoundedAndNonempty +PASS BrowserTests.mimeMatchingHandlesWildcardsAndParameters +PASS BrowserTests.malformedMimeRequestsFailClosed +PASS BrowserTests.wildcardIsNotAnActualFileMimeType +PASS BrowserTests.extraMimeTypesNarrowTheBaseType +PASS BrowserTests.excessiveMimeTypesAreRejected +PASS BrowserTests.foldersStayNavigableInFilteredPicker +PASS BrowserTests.directoriesAndVirtualDocumentsCannotBeReturned +PASS BrowserTests.singleSelectionReplacesPreviousSelection +PASS BrowserTests.togglingSelectedFileRemovesIt +PASS BrowserTests.multipleSelectionIsBounded +PASS BrowserTests.unsupportedSelectionIsRejected +PASS BrowserTests.emptySelectionIsRejected +PASS BrowserTests.duplicateSelectionIsRejected +PASS BrowserTests.singleRequestCannotReturnTwoFiles +PASS BrowserTests.individualSizeBudgetIsChecked +PASS BrowserTests.aggregateSizeBudgetIsChecked +PASS BrowserTests.negativeSizeIsNotTrusted +PASS BrowserTests.unknownSizesAreAllowedPendingStreamingLimit +PASS BrowserTests.comparatorRemainsAntisymmetric +PASS BrowserTests.actualStreamBytesAreLimitedEvenWithUnknownMetadata +PASS BrowserTests.totalByteBudgetSpansFiles +PASS BrowserTests.newFileResetsOnlyTheFileCounter +PASS BrowserTests.zeroByteFilesAreAllowed +PASS BrowserTests.negativeCopyCountsAreRejected +PASS BrowserTests.byteCountersRejectHugeChunksWithoutOverflow +PASS BrowserTests.invalidCopyBudgetsAreRejected +PASS ZipTests.archiveRoundtrip +PASS ZipTests.traversalRejected +PASS ZipTests.absolutePathsRejected +PASS ZipTests.backslashPathsRejected +PASS ZipTests.tooManyEntriesRejected +PASS ZipTests.oversizedEntryRejectedOnlyWhenRead +PASS ZipTests.compressionBombRejected +PASS ZipTests.cumulativeExpansionBudgetEnforced +PASS ZipTests.repeatedReadsConsumeBudget +PASS ZipTests.extensionlessOfficeLoadedThroughFullAdapter +PASS ZipTests.textByteBudgetEnforcedBeforeRead +PASS ZipTests.htmlRemainsInertSource +RESULT: 112/112 checks passed diff --git a/validation/0.2.3/environment.txt b/validation/0.2.3/environment.txt new file mode 100644 index 0000000..6cc0185 --- /dev/null +++ b/validation/0.2.3/environment.txt @@ -0,0 +1,15 @@ +2026-09-24T09:31:01Z +openjdk version "21.0.11" 2026-04-21 +OpenJDK Runtime Environment (build 21.0.11+10-1-deb13u2-Debian) +OpenJDK 64-Bit Server VM (build 21.0.11+10-1-deb13u2-Debian, mixed mode, sharing) +info: kotlinc-jvm 1.9.0 (JRE 21.0.11+10-1-deb13u2-Debian) + +Android SDK variables +ANDROID_HOME=not set +ANDROID_SDK_ROOT=not set + +Installed JVMs +default-java +java-1.21.0-openjdk-amd64 +java-21-openjdk-amd64 +openjdk-21 diff --git a/validation/0.2.3/folder-listing.txt b/validation/0.2.3/folder-listing.txt new file mode 100644 index 0000000..1583747 --- /dev/null +++ b/validation/0.2.3/folder-listing.txt @@ -0,0 +1,25 @@ +Source under test: /mnt/data/fw-0.2.3/fileworkbench/androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt +Extracted production cursor-reading block + real common models; Android APIs are test doubles. +out/folder-listing/FolderListingHarness.kt:71:23: warning: variable 'loading' initializer is redundant + var loading = false + ^ +out/folder-listing/FolderListingHarness.kt:72:36: warning: variable 'information' initializer is redundant + var information: String? = null + ^ +PASS normal rows without extras are not rejected +PASS empty cursor without extras is a valid empty directory +PASS information-only cursor keeps rows and message +PASS loading cursor without error keeps partial rows +PASS loading cursor may initially contain no rows +PASS provider error takes precedence over info without discarding available rows +PASS actual null query result still raises the no-listing error +PASS cursor closes when row iteration throws +PASS cursor closes on cancellation during iteration +PASS entry limit still marks an oversized directory truncated +PASS exactly the entry limit is not marked truncated +PASS duplicate provider IDs remain deduplicated +PASS missing and oversized document IDs are skipped +PASS provider messages still use the shared label sanitizer +PASS diagnostic reflects a retained read grant without treating it as successful access +PASS unavailable permission diagnostic cannot replace the original null-cursor error +Folder-listing host regression: 16 passed, 0 failed. NOT an Android/device test. diff --git a/validation/0.2.3/kotlin-syntax.txt b/validation/0.2.3/kotlin-syntax.txt new file mode 100644 index 0000000..44e7395 --- /dev/null +++ b/validation/0.2.3/kotlin-syntax.txt @@ -0,0 +1 @@ +Kotlin PSI syntax: 44 files, 0 parse errors. Android/Compose type resolution is NOT tested. diff --git a/validation/0.2.3/shell-checks.txt b/validation/0.2.3/shell-checks.txt new file mode 100644 index 0000000..096e73f --- /dev/null +++ b/validation/0.2.3/shell-checks.txt @@ -0,0 +1 @@ +Shell syntax checks passed. PowerShell execution not available here. diff --git a/validation/0.2.3/source-checks.txt b/validation/0.2.3/source-checks.txt new file mode 100644 index 0000000..b628e7c --- /dev/null +++ b/validation/0.2.3/source-checks.txt @@ -0,0 +1,23 @@ +PASS Gradle distribution is exactly 8.14.5 +PASS official 8.14.5 distribution SHA-256 is pinned +PASS scripts/bootstrap-wrapper.sh uses matching 8.14.5 downloads and hashes +PASS scripts/bootstrap-wrapper.ps1 uses matching 8.14.5 downloads and hashes +PASS Gradle daemon requires JDK 17 +PASS Java-version hint is 17 +PASS settings reject conflicting Gradle/JVM overrides +PASS androidApp uses toolchain 17 +PASS core uses toolchain 17 +PASS platform-jvm uses toolchain 17 +PASS Android Java bytecode targets 17 +PASS application version is 0.2.3/code 5 +PASS five root plugin aliases resolve to five distinct expected IDs +PASS root has no extra direct plugin requests +PASS Kotlin/AGP versions are unchanged +PASS existing CI uses Java 17 +PASS source contains the diagnostic error and visible release marker +PASS tracked Gradle properties contain no machine-specific JDK home +INFO wrapper JAR is not bundled; the first wrapper launch bootstraps the verified binary. +Build-configuration checks: 18 passed. Source checks only, not an Android build. +PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls. +PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest. +PASS PDF source regression guard: no PdfDocument().use call (pattern check only).