initial commit v 0.2.0
Verify source and build Android / android (push) Canceled after 0s

This commit is contained in:
2026-09-24 22:49:27 +02:00
commit db873063f8
129 changed files with 7512 additions and 0 deletions
+9
View File
@@ -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
+33
View File
@@ -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/
+10
View File
@@ -0,0 +1,10 @@
.gradle/
.kotlin/
**/build/
local.properties
.idea/
*.iml
*.jks
*.keystore
.DS_Store
out/
+1
View File
@@ -0,0 +1 @@
17
+60
View File
@@ -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.
+21
View File
@@ -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.
+158
View File
@@ -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.
+127
View File
@@ -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
+13
View File
@@ -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.
+51
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
# No app-specific shrinking rules in the read-only MVP.
@@ -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))
}
}
@@ -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<MainActivity>()
@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()
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug-only synthetic files for instrumentation. Never included in the release variant. -->
<provider
android:name=".testing.FixtureDocumentsProvider"
android:authorities="${applicationId}.browserfixture"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
@@ -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<out String>?): 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<String, Any>(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<out String>?): Cursor = documents(listOf(documentId), projection)
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, 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<String>, projection: Array<out String>?): 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<String, Any?>(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) }
}
+101
View File
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Deliberately no INTERNET, storage, contacts, account or media permissions. -->
<application
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Fileworkbench"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Files-app launch category (Android 10+). This is not the web-browser role. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.APP_FILES" />
</intent-filter>
<!-- Accessible content-tree folder links; no claim to raw filesystem access. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="vnd.android.document/directory" />
<data android:mimeType="inode/directory" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="application/pdf" />
<data android:mimeType="image/jpeg" />
<data android:mimeType="image/png" />
<data android:mimeType="image/webp" />
<data android:mimeType="image/gif" />
<data android:mimeType="image/bmp" />
<data android:mimeType="image/svg+xml" />
<data android:mimeType="text/*" />
<data android:mimeType="application/json" />
<data android:mimeType="application/xml" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
<data android:mimeType="application/vnd.oasis.opendocument.text" />
<data android:mimeType="application/vnd.oasis.opendocument.spreadsheet" />
<data android:mimeType="application/vnd.oasis.opendocument.presentation" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="image/*" />
<data android:mimeType="text/*" />
<data android:mimeType="application/json" />
<data android:mimeType="application/xml" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
<data android:mimeType="application/vnd.oasis.opendocument.text" />
<data android:mimeType="application/vnd.oasis.opendocument.spreadsheet" />
<data android:mimeType="application/vnd.oasis.opendocument.presentation" />
</intent-filter>
</activity>
<!-- GET_CONTENT is an import picker. OPEN_DOCUMENT / OPEN_DOCUMENT_TREE remain system-owned. -->
<activity
android:name=".FilePickerActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="Fileworkbench files">
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<service
android:name=".render.PdfRenderService"
android:exported="false"
android:isolatedProcess="true"
android:process=":pdf_renderer" />
<provider
android:name=".browser.WorkbenchFileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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() })
}
}
}
@@ -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)
}
}
@@ -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<RecentDocument> = 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<String>("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<String>("sourceUri")
savedState.remove<Int>("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()
}
}
@@ -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<String, Bitmap>(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() }
}
@@ -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<BrowserLocation> = emptyList(),
val location: BrowserLocation? = null,
val trail: List<BrowserEntry> = 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<BrowserEntry> = 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<String>("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<String>("browser.root") }
val ids = savedState.get<ArrayList<String>>("browser.path.ids").orEmpty()
val names = savedState.get<ArrayList<String>>("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<Application>(), 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<Uri>) {
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<Application>(), 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<BrowserEntry> = runCatching {
val array = JSONArray(savedState.get<String>("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() }
}
@@ -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<BrowserEntry>): 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() }
}
@@ -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<Uri> {
if (intent == null) return emptyList()
val uris = mutableListOf<Uri>()
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()
}
}
@@ -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 <T> 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) }
}
}
}
@@ -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<BrowserLocation> = 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<BrowserEntry>()
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<BrowserLocation>) {
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"
}
}
}
@@ -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)
}
@@ -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() }
}
@@ -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<RecentDocument> = 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<RecentDocument>) {
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<String>())
}
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"
}
@@ -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.")
}
}
@@ -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<Messenger>()
private var bound = false
private val requests = ConcurrentHashMap<Int, CompletableDeferred<Bundle>>()
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<Bundle>()
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
}
}
@@ -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<ParcelFileDescriptor>("input")
val output = message.data.getParcelable<ParcelFileDescriptor>("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
}
}
@@ -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<RecentDocument>,
onOpenFile: () -> Unit,
onAddFolder: () -> Unit,
onPreview: (BrowserEntry) -> Unit,
onRecent: (RecentDocument) -> Unit,
onForgetRecent: (String) -> Unit,
) {
var removeLocation by remember { mutableStateOf<BrowserLocation?>(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<Bitmap?>(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<RecentDocument>, 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"
@@ -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<SearchHit>()) }
val listState = rememberLazyListState()
val horizontal = rememberScrollState()
val scope = rememberCoroutineScope()
var highlight by remember(documentId) { mutableStateOf(-1) }
var jump by remember(documentId) { mutableStateOf<Int?>(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++
}
}
}
@@ -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<String?>(null) }
var pendingTextId by rememberSaveable { mutableStateOf<String?>(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"
}
@@ -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") } })
}
@@ -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)
}
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#174E4C" android:pathData="M0,0h48v48h-48z" />
<path android:fillColor="#FFFFFF" android:pathData="M13,8h15l8,8v24h-23z" />
<path android:fillColor="#C3E9E1" android:pathData="M28,8v9h8z" />
<path android:strokeColor="#246A67" android:strokeWidth="2" android:pathData="M18,24h13M18,29h13M18,34h8" />
</vector>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Fileworkbench</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.Fileworkbench" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">#246A67</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared" path="shared/" />
<cache-path name="picked" path="picked/" />
</paths>
+7
View File
@@ -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
}
+21
View File
@@ -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")) }
}
}
@@ -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<SearchHit> = PreviewText.search(preview, query)
fun export(preview: DocumentPreview): String = PreviewText.render(preview)
}
@@ -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] }
}
@@ -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<TableCell>)
data class TableData(val rows: List<TableRow>, val columnCount: Int)
data class PreviewSection(val title: String, val blocks: List<TextBlock> = 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<PreviewSection>,
val warnings: List<String> = emptyList(),
val entries: List<ArchiveEntry> = 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<ArchiveEntry>
fun read(path: String): ByteArray
fun contains(path: String): Boolean = entries.any { it.path == path && !it.directory }
}
interface FormatHandler {
val kinds: Set<FileKind>
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<SearchHit> {
val needle = query.trim().take(256)
if (needle.isEmpty() || maxHits <= 0) return emptyList()
val hits = mutableListOf<SearchHit>()
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
}
@@ -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<TextBlock>()
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<TextBlock> {
val blocks = mutableListOf<TextBlock>()
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<PreviewSection> = 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<TableRow>()
var rowNumber = 1
var columns = 0
// Do not pull rows from nested tables inside a cell.
val rowNodes = mutableListOf<XmlNode>()
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<TableCell>()
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<FormatHandler> = 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
}
}
@@ -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) }
} }
}
}
}
}
@@ -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<String, String>, val parts: MutableList<XmlPart> = 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<XmlNode> = parts.filterIsInstance<XmlNode>().filter { local == null || it.localName == local }
fun descendants(local: String): List<XmlNode> = 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<XmlNode>()
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) -> {
val end = source.indexOf("-->", pos + 4)
if (end < 0) throw PreviewException("Unterminated XML comment.")
pos = end + 3
}
source.startsWith("<![CDATA[", pos) -> {
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("<!", pos) -> throw PreviewException("DTD and XML declarations other than comments/CDATA are disabled.")
source.startsWith("<?", pos) -> {
val end = source.indexOf("?>", pos + 2)
if (end < 0) throw PreviewException("Unterminated XML instruction.")
pos = end + 2 // Never executed.
}
source.startsWith("</", pos) -> {
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<String, String>()
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<String>()
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<String, String> {
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()
}
@@ -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++
}
}
}
@@ -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<TableRow>()
val cells = mutableListOf<TableCell>()
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)
}
}
@@ -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<BrowserEntry>,
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<BrowserEntry>, options: BrowserOptions, request: PickerRequest? = null): List<BrowserEntry> {
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<BrowserEntry> = 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
}
@@ -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<String> = 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<BrowserEntry>, entry: BrowserEntry): List<BrowserEntry> {
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<BrowserEntry>) {
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
}
}
@@ -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<String, String>) : 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 "<document><body><p><r><t>hello</t></r></p></body></document>"))
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<PreviewException> { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 1)) } }
@Test fun utf16RejectsLoneSurrogate() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0, 0xd8.toByte())) } }
@Test fun invalidUtf8Rejected() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0xc3.toByte(), 0x28)) } }
@Test fun nulBinaryRejected() { assertFailsWith<PreviewException> { TextCodec.decode(byteArrayOf(0, 1, 2)) } }
@Test fun xmlPreservesMixedContent() { assertEquals("Hello world!", SafeXml.parse("<p>Hello <span>world</span>!</p>", policy).text()) }
@Test fun xmlDecodesSafeEntities() { assertEquals("<&\"'😀", SafeXml.parse("<p>&lt;&amp;&quot;&apos;&#x1F600;</p>", policy).text()) }
@Test fun xmlCdataIsLiteral() { assertEquals("<script>&raw;", SafeXml.parse("<p><![CDATA[<script>&raw;]]></p>", policy).text()) }
@Test fun xmlRejectsDtd() { assertFailsWith<PreviewException> { SafeXml.parse("<!DOCTYPE p [<!ENTITY a SYSTEM 'file:///secret'>]><p>&a;</p>", policy) } }
@Test fun xmlRejectsCustomEntity() { assertFailsWith<PreviewException> { SafeXml.parse("<p>&xxe;</p>", policy) } }
@Test fun xmlRejectsMismatchedTags() { assertFailsWith<PreviewException> { SafeXml.parse("<a><b></a>", policy) } }
@Test fun xmlRejectsMultipleRoots() { assertFailsWith<PreviewException> { SafeXml.parse("<a/><b/>", policy) } }
@Test fun xmlRejectsDeepNesting() { assertFailsWith<PreviewException> { SafeXml.parse("<a>".repeat(100) + "</a>".repeat(100), policy) } }
@Test fun xmlRejectsTooManyNodes() { assertFailsWith<PreviewException> { SafeXml.parse("<a><b/><c/></a>", policy.copy(maxXmlNodes = 2)) } }
@Test fun xmlRejectsTooManyAttributes() { assertFailsWith<PreviewException> { SafeXml.parse("<a x='1' y='2'/>", policy.copy(maxXmlAttributes = 1)) } }
@Test fun xmlRejectsDuplicateAttribute() { assertFailsWith<PreviewException> { SafeXml.parse("<a x='1' x='2'/>", policy) } }
@Test fun xmlRejectsUnquotedAttribute() { assertFailsWith<PreviewException> { SafeXml.parse("<a x=1/>", policy) } }
@Test fun xmlRejectsInvalidCharacterReference() { assertFailsWith<PreviewException> { SafeXml.parse("<a>&#0;</a>", policy) } }
@Test fun packagePathsStayInsideRoot() { assertEquals("ppt/slides/slide1.xml", resolvePart("ppt/presentation.xml", "slides/slide1.xml")); assertEquals("xl/styles.xml", resolvePart("xl/worksheets/sheet1.xml", "../styles.xml")) }
@Test fun packageRootEscapeRejected() { assertFailsWith<PreviewException> { resolvePart("word/document.xml", "../../secret") } }
@Test fun externalRelationshipsRejected() { assertFailsWith<PreviewException> { resolvePart("word/document.xml", "https://host/file") } }
@Test fun csvQuotedDelimitersAndNewlines() {
val table = CsvPreview.parse("name,note\nAlice,\"hello,\nworld\"\n", ',', policy)
assertEquals(2, table.rows.size); assertEquals("hello,\nworld", table.rows[1].cells[1].value)
}
@Test fun csvEscapedQuotes() { assertEquals("say \"hello\"", CsvPreview.parse("\"say \"\"hello\"\"\"", ',', policy).rows[0].cells[0].value) }
@Test fun csvEmptyTrailingCells() { assertEquals(listOf("a", "", ""), CsvPreview.parse("a,,", ',', policy).rows.single().cells.map { it.value }) }
@Test fun csvWindowsNewlines() { assertEquals(2, CsvPreview.parse("a,b\r\nc,d\r\n", ',', policy).rows.size) }
@Test fun csvSemicolonDetection() { assertEquals(';', CsvPreview.detectDelimiter("name;note\nAnna;\"x,y\"")) }
@Test fun csvFormulaRemainsLiteral() { assertEquals("=SUM(A1:A3)", CsvPreview.parse("=SUM(A1:A3)", ',', policy).rows[0].cells[0].value) }
@Test fun csvBadQuotesFailClearly() { assertFailsWith<PreviewException> { CsvPreview.parse("\"hello", ',', policy) }; assertFailsWith<PreviewException> { CsvPreview.parse("\"hi\"oops", ',', policy) } }
@Test fun csvRowLimit() { assertFailsWith<PreviewException> { CsvPreview.parse("a\nb\nc", ',', policy.copy(maxTableRows = 2)) } }
@Test fun csvColumnLimit() { assertFailsWith<PreviewException> { CsvPreview.parse("a,b,c", ',', policy.copy(maxTableColumns = 2)) } }
@Test fun basicMarkdownHasHeadingsAndCode() {
val blocks = TextPreview.create("x.md", FileKind.MARKDOWN, "# Heading\n- item\n```\n# not heading\n```".encodeToByteArray()).sections[0].blocks
assertEquals(BlockKind.HEADING, blocks[0].kind); assertEquals(BlockKind.LIST_ITEM, blocks[1].kind); assertEquals(BlockKind.CODE, blocks[3].kind)
}
@Test fun textByteLimit() { assertFailsWith<PreviewException> { TextPreview.create("x.txt", FileKind.TEXT, "abcd".encodeToByteArray(), policy.copy(maxTextBytes = 3)) } }
@Test fun columnLabelsBeyondZ() { assertEquals("A", columnLabel(0)); assertEquals("Z", columnLabel(25)); assertEquals("AA", columnLabel(26)); assertEquals("DX", columnLabel(127)) }
@Test fun docxReadingTextAndTables() {
val xml = """<w:document xmlns:w="w"><w:body><w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Heading</w:t></w:r></w:p><w:p><w:r><w:t>Hello</w:t><w:tab/><w:t>world</w:t></w:r><w:del><w:r><w:t>deleted</w:t></w:r></w:del></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"""
val preview = PreviewEngine().fromArchive(FileKind.DOCX, MemoryArchive(mapOf("word/document.xml" to xml)))
assertEquals(Fidelity.READING_PREVIEW, preview.fidelity)
val blocks = preview.sections[0].blocks
assertEquals(2, blocks[0].level); assertEquals("Hello\tworld", blocks[1].text); assertEquals("A | B", blocks[2].text)
assertTrue(preview.warnings.isNotEmpty())
}
@Test fun xlsxSparseCellsAndSharedStrings() {
val preview = PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive())
val sheet = preview.sections.single()
assertEquals("Budget (hidden)", sheet.title)
val cells = sheet.table!!.rows.single().cells
assertEquals(listOf(0, 2, 3), cells.map { it.column })
assertEquals("Research", cells[0].value); assertEquals("42", cells[1].value); assertEquals("A1+C1", cells[1].formula)
assertEquals("[no cached value]", cells[2].value)
}
@Test fun xlsxTooWideRejected() {
val archive = MemoryArchive(mapOf("xl/workbook.xml" to "<workbook xmlns:r='r'><sheets><sheet name='x' r:id='a'/></sheets></workbook>",
"xl/_rels/workbook.xml.rels" to "<Relationships><Relationship Id='a' Target='worksheets/x.xml'/></Relationships>",
"xl/worksheets/x.xml" to "<worksheet><sheetData><row r='1'><c r='ZZZ1'><v>1</v></c></row></sheetData></worksheet>"))
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.XLSX, archive) }
}
@Test fun pptxFollowsRelationshipOrderNotFilenames() {
val parts = mapOf(
"ppt/presentation.xml" to "<p:presentation xmlns:p='p' xmlns:r='r'><p:sldIdLst><p:sldId id='256' r:id='second'/><p:sldId id='257' r:id='first'/></p:sldIdLst></p:presentation>",
"ppt/_rels/presentation.xml.rels" to "<Relationships><Relationship Id='first' Target='slides/slide1.xml'/><Relationship Id='second' Target='slides/slide2.xml'/></Relationships>",
"ppt/slides/slide1.xml" to "<sld><p><r><t>First file</t></r></p></sld>",
"ppt/slides/slide2.xml" to "<sld><p><r><t>Second file</t></r></p></sld>")
val sections = PreviewEngine().fromArchive(FileKind.PPTX, MemoryArchive(parts)).sections
assertEquals("Second file", sections[0].blocks[0].text); assertEquals("First file", sections[1].blocks[0].text)
}
@Test fun odtMixedTextSpacesAndHeadings() {
val archive = odf("text", "<office:text><text:h text:outline-level='2'>Header</text:h><text:p>Hello<text:s text:c='2'/><text:span>world</text:span>!</text:p></office:text>")
val blocks = PreviewEngine().fromArchive(FileKind.ODT, archive).sections[0].blocks
assertEquals("Hello world!", blocks[1].text); assertEquals(2, blocks[0].level)
}
@Test fun odsRepeatedPaddingDoesNotExplode() {
val archive = odf("spreadsheet", "<office:spreadsheet><table:table table:name='Data'><table:table-row><table:table-cell><text:p>v</text:p></table:table-cell><table:table-cell table:number-columns-repeated='16383'/></table:table-row><table:table-row table:number-rows-repeated='1048575'><table:table-cell table:number-columns-repeated='16384'/></table:table-row></table:table></office:spreadsheet>")
val table = PreviewEngine().fromArchive(FileKind.ODS, archive).sections[0].table!!
assertEquals(1, table.rows.size); assertEquals(1, table.columnCount)
}
@Test fun odsRepeatedNonemptyRowsRejected() {
val archive = odf("spreadsheet", "<office:spreadsheet><table:table><table:table-row table:number-rows-repeated='500000'><table:table-cell><text:p>value</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet>")
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.ODS, archive) }
}
@Test fun odpSlideNamesPreserved() {
val archive = odf("presentation", "<office:presentation><draw:page draw:name='Intro'><text:p>Hello</text:p></draw:page><draw:page draw:name='Next'><text:p>Next text</text:p></draw:page></office:presentation>")
val result = PreviewEngine().fromArchive(FileKind.ODP, archive)
assertEquals(listOf("Intro", "Next"), result.sections.map { it.title })
}
@Test fun malformedOfficePartRejected() { assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.DOCX, MemoryArchive(mapOf("word/document.xml" to "<document>"))) } }
@Test fun zipIndexDoesNotReadEntries() {
val archive = object : ArchiveContainer {
override val entries = listOf(ArchiveEntry("large.txt", 1_000_000_000L, 10, false))
override fun read(path: String): ByteArray = error("Must not inflate for index")
}
assertEquals(Fidelity.ARCHIVE_INDEX, PreviewEngine().fromArchive(FileKind.ZIP, archive).fidelity)
}
@Test fun literalSearchAndHitCap() {
val preview = TextPreview.create("x.txt", FileKind.TEXT, "Hello [world]\nhello second\nother".encodeToByteArray())
assertEquals(1, PreviewText.search(preview, "[world]").size)
assertEquals(1, PreviewText.search(preview, "HELLO", maxHits = 1).size)
assertEquals(0, PreviewText.search(preview, " ").size)
assertEquals(0, PreviewText.search(preview, "Hello", maxHits = 0).size)
}
@Test fun tableSearchUsesActualRows() { assertEquals(1, PreviewText.search(PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive()), "Research").size) }
@Test fun textExportContainsSectionNamesAndValues() {
val text = PreviewText.render(PreviewEngine().fromArchive(FileKind.XLSX, xlsxArchive()))
assertTrue("Budget (hidden)" in text); assertTrue("A: Research" in text)
}
@Test fun repeatedContentAmplificationBounded() {
val preview = DocumentPreview(FileKind.XLSX, Fidelity.READING_PREVIEW, listOf(PreviewSection("x", table = TableData(
listOf(TableRow(1, List(10) { TableCell(it, "a".repeat(200)) })), 10))))
assertFailsWith<PreviewException> { PreviewSafety.validate(preview, policy.copy(maxTextBytes = 1000)) }
}
@Test fun veryLongSingleBlockRejected() {
val preview = DocumentPreview(FileKind.TEXT, Fidelity.TEXT, listOf(PreviewSection("x", listOf(TextBlock("a".repeat(40_000))))))
assertFailsWith<PreviewException> { PreviewSafety.validate(preview) }
}
@Test fun macroExtensionsNotReclassifiedAsZip() {
assertEquals(FileKind.LEGACY_OFFICE, FileClassifier.classify("x.xlsm", null, byteArrayOf(0x50, 0x4b, 3, 4)))
}
@Test fun renamedMacroPackageRejected() {
val archive = MemoryArchive(mapOf("xl/workbook.xml" to "<workbook/>", "xl/vbaProject.bin" to "binary"))
assertFailsWith<PreviewException> { PreviewEngine().fromArchive(FileKind.ZIP, archive) }
}
@Test fun publicCoreFacadeReadsText() {
val preview = CoreApi().readText("sample.md", null, "# Portable".encodeToByteArray())
assertEquals("Portable", preview.sections[0].blocks[0].text)
}
@Test fun publicCoreFacadeRejectsBinary() {
assertFailsWith<PreviewException> { CoreApi().readText("file.pdf", "application/pdf", "%PDF-1.4".encodeToByteArray()) }
}
private fun xlsxArchive(): ArchiveContainer = MemoryArchive(mapOf(
"xl/workbook.xml" to "<workbook xmlns:r='r'><sheets><sheet name='Budget' state='hidden' sheetId='1' r:id='rId1'/></sheets></workbook>",
"xl/_rels/workbook.xml.rels" to "<Relationships><Relationship Id='rId1' Target='worksheets/custom.xml'/><Relationship Id='external' Target='https://example.test/x' TargetMode='External'/></Relationships>",
"xl/sharedStrings.xml" to "<sst><si><r><t>Research</t></r></si></sst>",
"xl/worksheets/custom.xml" to "<worksheet><sheetData><row r='3'><c r='A3' t='s'><v>0</v></c><c r='C3'><f>A1+C1</f><v>42</v></c><c r='D3'><f>SUM(A1:A3)</f></c></row></sheetData></worksheet>"))
private fun odf(type: String, body: String): ArchiveContainer = MemoryArchive(mapOf(
"mimetype" to "application/vnd.oasis.opendocument.$type",
"content.xml" to "<office:document-content xmlns:office='office' xmlns:text='text' xmlns:table='table' xmlns:draw='draw'><office:body>$body</office:body></office:document-content>"))
}
@@ -0,0 +1,173 @@
package dev.fileworkbench.core.browser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse
import kotlin.test.assertFailsWith
class BrowserTests {
private fun file(name: String, size: Long? = null, mime: String = "text/plain", id: String = name) =
BrowserEntry(id, name, mime, size = size)
private fun folder(name: String) = BrowserEntry(name, name, "vnd.android.document/directory", isDirectory = true)
@Test fun directoriesRemainFirstWhenDescending() {
val entries = listOf(file("z.txt"), folder("A"), file("a.txt"), folder("Z"))
assertEquals(listOf("Z", "A", "z.txt", "a.txt"), BrowserEntries.visible(entries, BrowserOptions(descending = true)).map { it.name })
}
@Test fun filtersNamesCaseInsensitively() {
assertEquals(listOf("Report.PDF"), BrowserEntries.visible(listOf(file("Report.PDF"), file("other")), BrowserOptions(query = " REPORT ")).map { it.name })
}
@Test fun searchIsLiteralNotRegex() {
assertEquals(listOf("a[1].txt"), BrowserEntries.visible(listOf(file("a[1].txt"), file("a1.txt")), BrowserOptions(query = "[1]")).map { it.name })
}
@Test fun hidesDotFilesUnlessEnabled() {
val entries = listOf(file(".env"), file("normal"))
assertEquals(1, BrowserEntries.visible(entries, BrowserOptions()).size)
assertEquals(2, BrowserEntries.visible(entries, BrowserOptions(showHidden = true)).size)
}
@Test fun unknownSizesRemainLastInBothDirections() {
val entries = listOf(file("unknown"), file("big", 99), file("small", 2))
assertEquals(listOf("small", "big", "unknown"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.SIZE)).map { it.name })
assertEquals(listOf("big", "small", "unknown"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.SIZE, descending = true)).map { it.name })
}
@Test fun modifiedSortingUsesTimestamps() {
val entries = listOf(file("a").copy(modifiedMillis = 20), file("b").copy(modifiedMillis = 30), file("c"))
assertEquals(listOf("b", "a", "c"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.MODIFIED, descending = true)).map { it.name })
}
@Test fun typeSortingIsDeterministic() {
val entries = listOf(file("b", mime = "text/plain"), file("a", mime = "application/pdf"))
assertEquals(listOf("a", "b"), BrowserEntries.visible(entries, BrowserOptions(sort = BrowserSort.TYPE)).map { it.name })
}
@Test fun duplicateProviderRowsDoNotDuplicateComposeKeys() {
assertEquals(1, BrowserEntries.visible(listOf(file("a"), file("other", id = "a")), BrowserOptions()).size)
}
@Test fun listingProcessingHasAnUpperBound() {
val entries = (0..BrowserLimits.MAX_ENTRIES).map { file("$it") }
assertEquals(BrowserLimits.MAX_ENTRIES, BrowserEntries.visible(entries, BrowserOptions()).size)
}
@Test fun labelsStripSpoofingButLeaveIdsUntouched() {
val entry = file("x\u202Etxt\n/file", id = "opaque:/id")
assertEquals("x_txt__file", entry.label)
assertEquals("opaque:/id", entry.id)
}
@Test fun labelsAreBoundedAndNonempty() {
assertEquals(BrowserLimits.MAX_LABEL_LENGTH, safeFileLabel("x".repeat(300)).length)
assertEquals("Unnamed", safeFileLabel(".."))
assertEquals("Unnamed", safeFileLabel(" "))
}
@Test fun mimeMatchingHandlesWildcardsAndParameters() {
assertTrue(MimeTypes.matches("image/*", "IMAGE/PNG"))
assertTrue(MimeTypes.matches("text/plain", "text/plain; charset=utf-8"))
assertTrue(MimeTypes.matches("*/*", "application/octet-stream"))
assertFalse(MimeTypes.matches("image/*", "application/pdf"))
}
@Test fun malformedMimeRequestsFailClosed() {
listOf("", "plain", "*/pdf", "image/p*", "x/y/z", "image /png", "*", "x/" + "a".repeat(200)).forEach {
assertFalse(PickerRequest(it).isValid, it)
assertFalse(PickerRequest(it).accepts(file("a")), it)
}
}
@Test fun wildcardIsNotAnActualFileMimeType() {
assertFalse(MimeTypes.matches("*/*", "image/*"))
}
@Test fun extraMimeTypesNarrowTheBaseType() {
val request = PickerRequest("image/*", listOf("image/png", "application/pdf"))
assertTrue(request.accepts(file("a", mime = "image/png")))
assertFalse(request.accepts(file("a", mime = "image/jpeg")))
assertFalse(request.accepts(file("a", mime = "application/pdf")))
}
@Test fun excessiveMimeTypesAreRejected() {
assertFalse(PickerRequest(extraMimeTypes = List(33) { "image/png" }).isValid)
}
@Test fun foldersStayNavigableInFilteredPicker() {
val entries = listOf(folder("A"), file("b", mime = "image/png"), file("c"))
assertEquals(listOf("A", "b"), BrowserEntries.visible(entries, BrowserOptions(), PickerRequest("image/*")).map { it.name })
}
@Test fun directoriesAndVirtualDocumentsCannotBeReturned() {
assertFalse(PickerRequest().accepts(folder("A")))
assertFalse(PickerRequest().accepts(file("cloud").copy(virtual = true)))
}
@Test fun singleSelectionReplacesPreviousSelection() {
val a = file("a"); val b = file("b")
assertEquals(listOf(b), PickerRequest().toggle(listOf(a), b))
}
@Test fun togglingSelectedFileRemovesIt() {
val a = file("a")
assertTrue(PickerRequest(allowMultiple = true).toggle(listOf(a), a).isEmpty())
}
@Test fun multipleSelectionIsBounded() {
val selected = (1..BrowserLimits.MAX_SELECTED).map { file("$it") }
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).toggle(selected, file("extra")) }
}
@Test fun unsupportedSelectionIsRejected() {
assertFailsWith<IllegalArgumentException> { PickerRequest("image/*").toggle(emptyList(), file("a")) }
}
@Test fun emptySelectionIsRejected() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(emptyList()) }
}
@Test fun duplicateSelectionIsRejected() {
val a = file("a")
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).validateSelection(listOf(a, a)) }
}
@Test fun singleRequestCannotReturnTwoFiles() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("a"), file("b"))) }
}
@Test fun individualSizeBudgetIsChecked() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("huge", BrowserLimits.MAX_FILE_BYTES + 1))) }
}
@Test fun aggregateSizeBudgetIsChecked() {
val entries = (1..3).map { file("$it", BrowserLimits.MAX_FILE_BYTES) }
assertFailsWith<IllegalArgumentException> { PickerRequest(allowMultiple = true).validateSelection(entries) }
}
@Test fun negativeSizeIsNotTrusted() {
assertFailsWith<IllegalArgumentException> { PickerRequest().validateSelection(listOf(file("x", -1))) }
}
@Test fun unknownSizesAreAllowedPendingStreamingLimit() {
PickerRequest().validateSelection(listOf(file("unknown")))
}
@Test fun comparatorRemainsAntisymmetric() {
val entries = listOf(folder("A"), folder("B"), file("a", 1), file("A", 4), file("a", id = "other"))
BrowserSort.values().forEach { sort ->
val comparator = BrowserEntries.comparator(BrowserOptions(sort = sort, descending = true))
for (a in entries) for (b in entries) {
assertEquals(-comparator.compare(b, a).compareTo(0), comparator.compare(a, b).compareTo(0))
}
}
}
@Test fun actualStreamBytesAreLimitedEvenWithUnknownMetadata() {
val budget = SelectionCopyBudget(perFileLimit = 10, totalLimit = 20)
budget.beginFile(); budget.consume(8)
assertFailsWith<IllegalArgumentException> { budget.consume(3) }
assertEquals(8L, budget.totalBytes)
}
@Test fun totalByteBudgetSpansFiles() {
val budget = SelectionCopyBudget(perFileLimit = 10, totalLimit = 15)
budget.beginFile(); budget.consume(10)
budget.beginFile(); budget.consume(5)
assertFailsWith<IllegalArgumentException> { budget.consume(1) }
}
@Test fun newFileResetsOnlyTheFileCounter() {
val budget = SelectionCopyBudget(10, 30)
budget.consume(9); budget.beginFile()
assertEquals(0L, budget.fileBytes)
assertEquals(9L, budget.totalBytes)
}
@Test fun zeroByteFilesAreAllowed() {
val budget = SelectionCopyBudget(10, 30)
budget.beginFile(); budget.consume(0)
assertEquals(0L, budget.totalBytes)
}
@Test fun negativeCopyCountsAreRejected() {
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget().consume(-1) }
}
@Test fun byteCountersRejectHugeChunksWithoutOverflow() {
val budget = SelectionCopyBudget(8, 10)
assertFailsWith<IllegalArgumentException> { budget.consume(Int.MAX_VALUE) }
assertEquals(0L, budget.totalBytes)
}
@Test fun invalidCopyBudgetsAreRejected() {
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget(0, 10) }
assertFailsWith<IllegalArgumentException> { SelectionCopyBudget(10, -1) }
}
}
+101
View File
@@ -0,0 +1,101 @@
# Architecture
## Boundaries
```text
Android picker / content URI / single-file open-with
|
DocumentStore (Android)
metadata -> size-limited private snapshot -> detector
|
+----------------+------------------+
| | |
PDF images text / packages
| | |
read-only PFD ImageLoader LocalPreviewLoader
| (Android bitmap) | |
isolated Service bytes JvmZipArchive
| | |
bounded raw ARGB +------+-----+
| |
PdfRenderClient common Kotlin core
| XML / Office / CSV / Markdown
| models / policy / search / export
+------------------- Compose UI -------------+
```
Native PDF data travels through granted file descriptors, not provider URIs or filesystem paths.
The isolated worker cannot browse the app's private files. The host validates dimensions, byte
length and page metadata before copying raw pixels to a bitmap. There is no PNG decoder in that
return path. General image decoding still runs in the host process, and common parsers run on an
IO dispatcher in the host; do not describe all parsing as process-isolated.
## Why Kotlin Multiplatform first, not a new C++ format stack
The current reusable work is format identification, reading-preview extraction, policy, models,
search and export. It needs neither Android APIs nor a handwritten native Office rendering engine.
Those pieces live in `core/src/commonMain`, which also enables direct JVM tests. Kotlin common code
can be compiled for Apple targets without passing Android URI or Bitmap types through the API.
A later LibreOfficeKit or other native renderer can be a separate native adapter. This does not
require the pure common core or native user interfaces to be rewritten in C++. Conversely, the JVM
ZIP implementation is explicitly **not** portable to iOS; an equivalent Apple adapter is required.
## Common model
- `FileClassifier` combines bounded header evidence, name/MIME hints and package entry inspection.
- `SafetyPolicy` centralizes ceilings and is shared by readers and archive adapters.
- `ArchiveContainer` exposes metadata and bounded part reads; it must never resolve external URLs.
- `FormatHandler` is the internal extension seam for package-reading preview handlers.
- `DocumentPreview` declares its fidelity, section/table/block data, warnings and archive metadata.
- `PreviewSafety` limits aggregate output amplification, including repeated shared strings.
- `PreviewText` performs literal searches and a text export; no formula, script or macro execution.
- `CoreApi` provides a small public boundary useful to a native host.
`SafeXml` is a bounded preview-specific XML subset, not a validating standards-complete XML library.
It rejects DTD/custom entities and verifies basic syntax/nesting. It does not resolve namespace
URIs, validate schemas, establish signature trust or guarantee complete OOXML/ODF semantics.
Fuzzing and comparative testing against well-maintained parsers remain release gates.
## Android lifecycle
`ViewerViewModel` owns the current document, page bitmap, reading preview, recents and loading/error
state. Rotation retains the ViewModel; saved provider URI/page state allows process-restoration
attempts. External attachment grants may expire, so reselection is an explicit recovery path.
A processing mutex serializes active document operations. New opens cancel earlier requests; input
copy loops observe coroutine cancellation, and stale staged files are cleaned. Common parsing is
bounded but not a hard-preemptible sandbox. PDF rendering requests have a timeout; cancellation and
worker death need further real-device stress testing.
Snapshots use random internal names. Display names are sanitized separately. The original provider
file is never opened for writing. Share exports go into a separate narrow FileProvider directory;
that provider is not exported. Save/preview-export callbacks check the expected document identity
so a new incoming document does not silently replace the intended export.
## Storage contract
The native browser lists user-chosen document trees through `SafBrowserStore`. The system picker
still supplies user-mediated directory grants and standalone files. There is no whole-device crawl,
proprietary cloud or direct WebDAV/SFTP client. The app has no network permission; a provider may.
`BrowserViewModel` owns locations, visited breadcrumbs, options, selection and provider jobs, separate
from `ViewerViewModel`. Shared `BrowserEntry` / `BrowserLocation` / `DirectoryReader` types, sorting,
MIME/selection rules and `SelectionCopyBudget` live in commonMain. Android URI values are opaque IDs,
not parsed filesystem paths in the core. A saveable Compose state holder retains browser scroll
when the viewer replaces that screen. Provider metadata work has bounded workers and cancellation.
`FilePickerActivity` is a separate standard-launch activity. It accepts GET_CONTENT and returns only
user-confirmed, bounded snapshots via `WorkbenchFileProvider`, a narrow FileProvider subclass that
preserves MIME metadata. A single read grant applies to each selected copy, never to a source tree.
Only the requested standard extras survive activity-intent sanitization before ViewModel creation.
System OPEN_DOCUMENT is used for standalone selection, preventing GET_CONTENT picker recursion.
Folder bookmarks own tree grants. Document-history eviction only releases separate document grants.
The per-process active-snapshot set protects a live MainActivity preview when a picker viewer opens;
explicit clear-cache remains an intentional global invalidation of exported copies. Registry/cache
cleanup and blocked-stream cancellation still require device stress tests.
Persistent recents store URI, display name, persistence flag and timestamp, up to 20 entries. No
content indexing is performed across unopened files. Cache files are plaintext inside the Android
sandbox; cache deletion is logical deletion, not guaranteed secure erasure.
+147
View File
@@ -0,0 +1,147 @@
# Lightweight file browser — 0.2.0
This is implemented source, not a device-verified APK. See `VALIDATION.md` for the executed checks.
## Everyday workflow
Open **Browse → Add folder**, select an allowed folder in Android's system UI, and grant read access.
The folder becomes a saved location when the provider offers a persistable grant. Tap it to browse
its children. Folder rows navigate; file rows open the existing read-only preview. **Files** / Back
returns to the browser with its location, filter, and saved Compose scroll state. **Up** and the
breadcrumb buttons navigate visited folders. Breadcrumbs represent the visited route, not a guessed
filesystem path. An incoming nested directory link can have a direct root-to-target breadcrumb.
Use **Open file** for a single file anywhere the system picker can access, including individual
Downloads items. That system UI does not require opening Google Docs or Microsoft Office. A provider
may need its own network/account; Fileworkbench itself has neither an account nor network permission.
The **Recent** tab contains opened previews. Removing a recent item or clearing history does not
revoke saved folder grants. Removing a saved folder releases its tree read grant and does not delete
files. Removing a folder can make recent documents relying on that grant inaccessible.
## Browser controls
- Saved locations, nested folder navigation, breadcrumbs, Up, refresh, and explicit access errors.
- Literal, case-insensitive filename search **inside the loaded current folder only**.
- Sort by name, modified date, size, or MIME type. Folders stay first; unknown sizes/dates stay last.
- Dotfile visibility toggle. No recursive content indexing or background storage crawl.
- File type badges, provider-supplied size/date, and file-details dialog.
- **More → Preview** uses the existing PDF/image/text/Office/CSV/ZIP preview path.
- **More → Open in another app** hands off a read-only copy using a chooser that excludes this viewer.
This also works for unsupported preview formats when their bytes fit the copy limit.
- Opt-in provider thumbnails. Off by default; enable them in a folder's **Options** menu. Only visible
rows whose provider advertises thumbnail support are requested. Other files retain type badges.
No automatic full-file PDF/Office rendering is performed while listing a folder.
Office support remains **text/table reading previews**, not full document-layout rendering. Formats
unsupported by the existing viewer have not gained rendering engines merely by appearing in a list.
## Registration as a files application
The main manifest declares:
| Entry point | Behaviour |
|---|---|
| `MAIN` + `LAUNCHER` | Normal application entry. |
| `MAIN` + `android.intent.category.APP_FILES` | Android's files-app launch category, introduced in API 29. Opens browser locations. |
| `VIEW` + content-directory MIME types | Opens a usable granted document tree; otherwise explains how to select a folder. |
| Existing file `VIEW` / single-file `SEND` | Existing file-preview entry points remain present. |
| `GET_CONTENT` + `OPENABLE` + `*/*` | Separate `FilePickerActivity`, used by apps asking a third-party picker for file contents. |
`APP_FILES` is **not** the web-browser role. Registration makes the app an eligible handler; it does
not guarantee that an OEM or caller exposes a default-files-app switch or chooser.
`ACTION_OPEN_DOCUMENT` and `ACTION_OPEN_DOCUMENT_TREE` remain system-owned. No activity claims those
actions. Fileworkbench is not a general `DocumentsProvider` re-exporting the user's saved folders.
The debug variant alone has a protected synthetic-fixture DocumentsProvider for instrumentation;
it contains no user's files and is excluded from release builds.
## Acting as another app's file picker
The incoming request's base MIME filter and optional `EXTRA_MIME_TYPES` are checked; extra filters
narrow the base filter. Invalid requests fail closed. Folders remain navigable but cannot be selected.
Virtual documents cannot be returned as ordinary byte streams; export them in their provider first.
Single selection replaces the previous file; multi-selection is allowed only when requested.
Selections can span folders and be reviewed/removed. **Nothing is returned automatically:** the user
must press **Use file(s)**. Previewing is available through a row's More menu; the preview footer can
select/deselect that file. Cancelling returns `RESULT_CANCELED` and no file grant.
For `EXTRA_LOCAL_ONLY`, saved provider folders are deliberately not browsed: this app cannot prove
that an arbitrary provider's documents are already offline. **Open file** delegates to the system
picker with LOCAL_ONLY and the caller's MIME/multiple constraints. Provider/platform conformance to
that hint still matters; this is not a hard network sandbox around other installed providers.
At confirmation the app re-queries the selected documents and validates their types/capabilities.
Actual bytes are copied with per-file and aggregate limits. Results contain fresh narrow
FileProvider URIs, with only `FLAG_GRANT_READ_URI_PERMISSION`. Multi-results use ClipData, and a
single result also has a data URI/type. No original tree URI, prefix, write, or persistable grant is
returned. MIME metadata is preserved for copied extensionless files.
These are **temporary imported copies**, not in-place edit handles or permanent cloud links. Copies
may expire after a day, be evicted by Android, or be removed by explicit cache clearing. Receivers
should import/read them promptly. Browser external-open copies have the same limits and semantics.
Exported activities sanitize intent extras before ViewModel construction. External callers cannot
seed internal SavedStateHandle keys such as `sourceUri` or `browser.selection` to silently preselect
files from previously granted storage. Only the expected standard request parameters survive.
## Bounds and trade-offs
| Resource | Current bound |
|---|---|
| Saved folders | 24 |
| Navigation depth | 64 visited levels; repeated-folder loops rejected |
| Listing | 5,000 visited provider rows; truncation is explicitly shown |
| Search | Current loaded directory; query capped at 256 characters |
| Selection | 1 file, or up to 16 when multiple selection is requested |
| Preview / picker / external-open copy | 64 MiB per file |
| Picker request aggregate | 128 MiB |
| Picker-copy disk cache | 256 MiB and 32 sessions; new copies refused when full |
| Thumbnails | 96-pixel requested size, 4 MiB memory cache, two concurrent requests |
| Provider metadata/thumbnail queries | Bounded worker pool and 20-second awaiting timeout with CancellationSignal |
A truncated directory is not a complete device search: sort/filter only affect the loaded subset.
Use the system picker to reach other entries. Refresh is manual, including when a cloud provider
reports that more entries are still loading. There is no claim of live watching or recursive search.
Blocking provider streams and thumbnail/native decoders still need device-level cancellation,
resource-pressure, and hostile-provider testing. A timeout can stop the UI waiting; it cannot force
an arbitrary external provider to stop executing. See the remaining work in `SECURITY.md`.
## Storage that is deliberately unavailable
There is no all-files-access permission. Android 11+ restricts granting the internal-storage root,
reliable SD-card roots, and the Downloads root via a directory grant; Android/data and Android/obb
also have restrictions. Other apps' private storage is not accessible. A permitted Downloads
subfolder can be bookmarked where offered; individual Downloads files use **Open file**.
There are no rename/delete/move/create-folder controls, broad MediaStore gallery index, raw-path
browser, SMB/WebDAV/SFTP client, or archive extraction operations in this release.
## Reuse on iOS
`core/commonMain/browser` contains real shared entry/location/listing models, `DirectoryReader`,
filename filters/sorting, MIME validation, selection policy, safe labels, and streamed-copy budgets.
IDs remain opaque strings. Android ContentResolver, Uri grants, thumbnails, FileProvider, and Compose
remain in `androidApp`. The future iOS host supplies security-scoped URL/bookmark access and a native
browser/preview UI; Android picker registration is not an iOS feature. No Apple binary was built here.
## Folder-listing correction in 0.2.2
Versions 0.2.0 and 0.2.1 can reject a valid folder with "The storage provider did
not return a folder listing." A nullable `use` result, caused by the absence of an
optional provider error message, was incorrectly treated as a missing cursor.
0.2.2 moves the null check to the query itself. This is not a request for wider
permissions. Rebuild/install the fix and reopen or refresh the saved folder.
See `HOTFIX-0.2.2.md` and `VALIDATION.md` for the change and verification boundary.
## SAF-D1 diagnostics integrated in 0.2.3
The continuing user-reported listing failure is unresolved. This release keeps
the 0.2.2 null-check correction and adds a distinct `[SAF-D1/NULL_CURSOR]` error
only when the query itself returns no cursor. It reports the provider authority,
Android API and retained-read-grant status, not file paths or contents. The
Locations footer includes the app version and `Browser diagnostics SAF-D1` so
the installed source can be identified. Query behaviour and permissions remain
unchanged. See `RELEASE-0.2.3.md` and `BUILD.md` before reproducing the failure.
+85
View File
@@ -0,0 +1,85 @@
# Browser acceptance plan — device execution pending
Run the normal build before claiming any row below passes:
```sh
./scripts/verify-android.sh
./gradlew :androidApp:connectedDebugAndroidTest
```
The added `BrowserIntegrationTest` sources cover synthetic-provider listings, a 5,000-row cap,
nested IDs, MIME-preserving read-only copies, multi-result ClipData, manifest resolution, local-only
request forwarding, virtual-file rejection, and malicious restore-key stripping. They have **not**
been compiled or run in the authoring environment. `ViewerSmokeTest` includes Browse/Recent/Add
folder checks and retains the isolated PDF-service test.
The debug variant exposes **Fileworkbench test files** through a protected fixture DocumentsProvider.
This is intentional testing support and absent from the release variant. It does not expose the
app's real documents or browser grants. Same-UID instrumentation checks result shape/bytes, not
cross-UID enforcement; perform the external-caller checks below on a real phone.
| Case | Expected result | Status |
|---|---|---|
| First launch | Browse/Recent, Open file/Add folder, no account or broad permission prompt | Not run |
| Add Documents/test folder | One system consent, saved location, files shown within app | Not run |
| Restart/reboot | Granted folder reopens; otherwise explicit renewal path | Not run |
| Download root on Android 11+ | Directory restriction visible; individual file open works | Not run |
| SD/USB removed or provider logged out | Recoverable error, no false empty-folder claim | Not run |
| Large/nested folder | Responsive bounded listing, folders first, explicit truncation | Not run |
| Search/sort/hidden files | Correct current-folder filtering, no claim of global results | Not run |
| Preview and Back | Same folder/filter/scroll; supported PDF/image/text/Office views | Not run |
| Provider thumbnails on/off | Only opted-in visible-row requests; type badge fallback | Not run |
| Clear recents/cache | Files remain, saved folder grants remain, temporary copies removed | Not run |
| Remove folder | Bookmark/tree grant removed, source files remain | Not run |
| Files-app category | Correct manifest handler where platform/caller supports it | Not run |
| Directory VIEW with tree/read grant | Target folder or explicit reselection recovery | Not run |
| Directory VIEW without usable grant | No raw path crawling; asks user to add folder | Not run |
| Separate caller GET_CONTENT single MIME | Only matching files selectable; cancel grants nothing | Not run |
| GET_CONTENT multiple MIME / multiple files | Reviewed files only; ClipData readable from caller | Not run |
| Caller attempts write / sibling / tree access | Denied; only each selected copy is read-granted | Not run |
| Extensionless copied content | Resolver MIME matches preserved metadata | Not run |
| Caller injects sourceUri/browser.selection extras | No unsolicited preview or preselection | Not run |
| Caller LOCAL_ONLY | System delegation preserves hint; saved roots not queried | Not run |
| Caller rotates / process dies / changes MIME | Request constraints survive; stale grants revalidated | Not run |
| Provider changes file MIME/size/virtual status | Revalidated; mismatches refused before return | Not run |
| Provider dishonest size / overlong stream | Actual 64/128 MiB budgets stop copying | Not run |
| Cancel while copying / provider hang | No result/grant; eventual partial-copy cleanup | Not run |
| Main viewer and picker coexist | New picker does not delete live viewer snapshot | Not run |
| Open unsupported file in another app | Explicit read-only copy; viewer excluded from chooser | Not run |
| TalkBack / large fonts / portrait/landscape | Usable labels, checks, focus and scrolling | Not run |
For a launch-only smoke check after installation:
```sh
adb shell am start -a android.intent.action.MAIN \
-c android.intent.category.APP_FILES -p dev.fileworkbench.android
adb shell am start -a android.intent.action.GET_CONTENT \
-c android.intent.category.OPENABLE -t 'image/*' -p dev.fileworkbench.android
```
`am start` is not a real result-receiving application. It cannot replace verification that a distinct
UID receives exactly the granted result URIs and cannot read siblings/write originals. Record actual
SDK, device, command, build commit, and outcome for every completed test. Do not mark the whole plan
passed merely because an APK builds or the offline core tests succeed.
## 0.2.2 listing regression
After installing this build over the previous app, reopen a saved folder and
refresh. Confirm normal and empty folders do not show the no-listing exception,
then navigate into a subfolder and preview a file. Reselect through Add folder
only when the provider's original permission was temporary or has been revoked.
The debug provider includes additional synthetic folders for empty, info-only,
loading (with and without cached rows), and provider-error listings. The updated
`BrowserIntegrationTest` covers these cases; run `:androidApp:connectedDebugAndroidTest`
with a device/emulator. Host cursor tests in `scripts/test-folder-listing.py` do not
replace those Android tests or verify real storage-provider permissions.
## 0.2.3 diagnostic reproduction
Install the newly built APK over the existing app using the same signing identity.
Do not uninstall/clear app data merely to reproduce the failure. Confirm the
Locations footer reads `Fileworkbench 0.2.3 · Browser diagnostics SAF-D1` before
reopening the previously failing folder. Record the whole new on-screen error,
including marker/provider/API/grant status. A missing retained grant is not proof
that temporary access is absent. This is a diagnostic test, not a claimed fix.
+204
View File
@@ -0,0 +1,204 @@
# Building and running — Fileworkbench 0.2.3
## Pinned baseline
| Component | Selected version |
|---|---|
| Gradle build JVM / daemon | Installed JDK **17**, any vendor |
| Java/Kotlin compilation toolchains and JVM bytecode target | **17** |
| Gradle wrapper distribution | **8.14.5** |
| Android Gradle Plugin | 8.11.1 (unchanged) |
| Kotlin / Compose compiler plugin | 2.2.21 (unchanged) |
| Compose BOM | 2025.08.00 (unchanged) |
| Android compile / target SDK | 36 / 36 |
| Android minimum SDK | 26 |
| Android Build Tools | 35.0.0 (AGP default) |
These are deliberate project pins, not a recommendation to use the newest releases.
This source package is **not a verified Android APK**. The current execution results
and limitations are recorded in `VALIDATION.md`.
## Start with a fresh extraction
This is the whole project, not a patch. Extract it to a new directory and open the
contained `fileworkbench` directory (the one containing `settings.gradle.kts`).
Keep your existing checkout and any local changes separately. Do not copy the old
`build.gradle.kts`, `settings.gradle.kts`, version catalog, wrapper files,
`gradle/gradle-daemon-jvm.properties`, `.idea`, `.gradle` or `.kotlin` over this release.
Otherwise old Gradle 9.x / Java 25 / duplicate-plugin settings can be reintroduced.
There are no machine-specific IDE files or absolute SDK/JDK paths in this archive.
For your Gitea repository, review the new files against your checkout after the
fresh project imports successfully; do not overwrite uncommitted work blindly.
## Install/select JDK 17 and the SDK
Install a **JDK 17**, not only a JRE. In Android Studio/IntelliJ, open
**Settings → Build, Execution, Deployment → Build Tools → Gradle**, use the wrapper
from this project, and select that JDK 17 as the **Gradle JDK/JVM**. On IDE versions
that expose daemon JVM criteria, leave this project's version-17 criteria intact.
You do not need to change the IDE's own boot runtime or uninstall other JDKs.
In SDK Manager install Android SDK Platform 36, Build Tools 35.0.0 and Platform
Tools. Accept any SDK licences yourself. Let the IDE generate a new
`local.properties` with `sdk.dir`, or use your existing `ANDROID_HOME` environment
setting. Do not commit local.properties, signing keys or private documents.
Set terminal `JAVA_HOME` to your installed JDK 17 directory (not its `bin` folder).
The project also recognises an optional `JDK17_HOME` for toolchain discovery.
The checked-in `gradle/gradle-daemon-jvm.properties` contains `toolchainVersion=17`;
module toolchains also select 17. No vendor or absolute installation path is pinned.
Automatic JDK downloads are disabled: install a JDK 17 before building.
### Linux/macOS terminal
```bash
cd /path/to/new/fileworkbench
# Replace this example with the path of your actual JDK 17 installation.
export JAVA_HOME="/path/to/jdk-17"
export PATH="$JAVA_HOME/bin:$PATH"
chmod +x gradlew scripts/*.sh
java -version
./gradlew --version
./gradlew help
./gradlew :core:jvmTest :platform-jvm:test \
:androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug
```
### Windows PowerShell
```powershell
cd C:\path\to\new\fileworkbench
# Replace this example with the path of your actual JDK 17 installation.
$env:JAVA_HOME = "C:\path\to\jdk-17"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
java -version
.\gradlew.bat --version
.\gradlew.bat help
.\gradlew.bat :core:jvmTest :platform-jvm:test :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug
```
The environment changes above apply only to the current terminal. Verify that
`--version` reports **Gradle 8.14.5**, with the intended Java 17 launcher and daemon
criteria. A version command alone does not prove that the application compiles.
## First-run wrapper bootstrap
The external `gradle-wrapper.jar` could not be downloaded in the authoring runtime,
so it is **not bundled**. All project source, Gradle configuration, launchers and
bootstrap scripts are present. The first `./gradlew --version` invocation (Windows:
`.\gradlew.bat --version`) downloads the wrapper binary and verifies its SHA-256.
Do this once **before importing into the IDE**, so its Gradle integration can see
the wrapper JAR. An internet connection is required for this and dependency setup.
Both shell and PowerShell bootstrap scripts now request the 8.14.5 source tag and
release distribution. A downloaded JAR with the wrong checksum is not executed;
the script falls back to the separately verified distribution and checks its
embedded wrapper. The fallback needs `unzip` on Unix-like systems or .NET ZIP
support in PowerShell. A failed initial network request still aborts; resolve that
network problem or bootstrap from an official local Gradle installation.
Official **8.14.5 wrapper JAR SHA-256**:
```text
7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172
```
Official **8.14.5 binary distribution SHA-256**:
```text
6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854
```
Hashes were verified against Gradle's release-checksum reference on 2026-09-24;
this is not a claim that the binary downloads succeeded here.
Once the official wrapper runs, the standard upstream launchers can be generated:
```sh
./gradlew wrapper --gradle-version 8.14.5 --distribution-type bin \
--gradle-distribution-sha256-sum 6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854
./gradlew wrapper
```
Commit the verified wrapper JAR and generated scripts in your own repository.
A network-free build is possible only after the required tooling and dependencies
are locally available. The installed application's main manifest has no network
permission; an external storage provider may use its own network connection.
## Install and identify this diagnostic build
After successful APK assembly, with a connected and authorised device:
```sh
adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk
```
Use your existing debug signing identity when installing over the current app.
Changing source directories on the same machine normally keeps the same user
keystore; this archive does not contain a signing key. Do not uninstall or clear
app data just to test the folder issue. A signing mismatch needs to be resolved
with the existing signing key rather than silently discarding application data.
The package remains `dev.fileworkbench.android`, version **0.2.3**, code **5**.
In **Browse → Locations**, verify the footer:
```text
Fileworkbench 0.2.3 · Browser diagnostics SAF-D1 · read-only
```
Then open the previously failing folder. A genuinely null cursor now produces
`[SAF-D1/NULL_CURSOR]`, the storage-provider authority, Android API level, and
retained-grant status. Send the full new message for diagnosis. A false retained
grant does not establish a permission failure: temporary access may still exist.
The query, successful listing behaviour and storage permissions are unchanged.
## Verification and troubleshooting
With a device/emulator, execute the Android instrumentation sources:
```sh
./gradlew :androidApp:connectedDebugAndroidTest
```
`./scripts/verify-android.sh` runs source checks plus Gradle tests, APK assembly and
lint. The existing optional GitHub workflow uses Java 17 and these same commands;
it has not been run here. These commands can also run on your local machine or a
suitably configured Gitea runner; no Gitea workflow is introduced in this release.
For offline **host-only** checks with Python 3, Kotlin compiler and an installed JVM:
```sh
python3 scripts/check-project.py
python3 scripts/test-build-config.py
bash scripts/test-core.sh
python3 scripts/test-folder-listing.py
bash scripts/check-kotlin-syntax.sh
```
Host test bytecode targets 17, but the scripts run on the local JVM; see the
validation report for the actual host version. They do not compile Android APIs.
**A duplicate `org.jetbrains.kotlin.jvm` plugin request:** this archive's root
script contains five distinct plugin aliases, with no extra `id("...")` or
`kotlin("jvm")` declarations. Do not append a second request for the JVM plugin.
Its use in `platform-jvm/build.gradle.kts` is intentional. The offline
`scripts/check-build-config.py` verifies the root aliases and catalog IDs.
**Gradle still selects 9.x or Java 25:** confirm that you opened this new directory,
are using its wrapper and selected JDK 17 in the IDE. Inspect user-level Gradle
properties/init scripts and environment variables for local overrides. The new
settings guards reject a different Gradle version or daemon JVM rather than
silently building with an unintended toolchain.
**Could not find a matching JDK / no download URL:** install JDK 17 and make it
discoverable using JAVA_HOME/JDK17_HOME. The package deliberately has no per-machine
path or automatically provisioned JDK download.
## Primary references
- Gradle release hashes: https://gradle.org/release-checksums/
- Gradle 8.14.5 release notes: https://docs.gradle.org/8.14.5/release-notes.html
- Daemon JVM criteria: https://docs.gradle.org/current/userguide/gradle_daemon.html
- Android build JVM selection: https://developer.android.com/build/jdks
- Android command-line builds: https://developer.android.com/build/building-cmdline
+19
View File
@@ -0,0 +1,19 @@
Fileworkbench 0.2.0 actual Android build attempt — 2026-09-23
Command: ./scripts/verify-android.sh
Environment:
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_HOME: not configured
Android SDK/emulator: not installed in this authoring environment
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.
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
Build command exit status: 6
No Android compiler, D8, lint or device execution was reached when bootstrap failed.
+68
View File
@@ -0,0 +1,68 @@
# Continuation brief
## User objective
Replace the routine act of opening mobile file attachments in Google/Microsoft apps with an
account-free local viewer. Android first; preserve an iOS path through reusable core logic.
Do not turn this into a storage cloud or a new full Office suite as the first deliverable.
## Current source
Read `README.md`, `FEATURES.md`, `ARCHITECTURE.md`, `SECURITY.md`, `BUILD.md` and `VALIDATION.md`.
Android Kotlin/Compose implementation, isolated PDF service, JVM ZIP adapter and genuine common
Kotlin reading-preview parsers are present. 112 offline core/JVM checks passed. Android and Apple
builds have not completed; Android bootstrap attempts fail before compilation. The archive contains no APK and no LibreOffice binary.
## Browser addition in 0.2.0
Read `BROWSER.md` and `BROWSER_ACCEPTANCE.md`. Native SAF folder browsing and a separate GET_CONTENT
picker are implemented, with APP_FILES/directory VIEW registration. Shared browser models, sort/filter,
MIME/selection policy and streamed-copy limits are in commonMain. Android adapters own grants, metadata,
opt-in thumbnails and copied FileProvider results. Exported activity extras are sanitized before
SavedStateHandle defaults are created. Do not remove that protection or forward directory grants.
No broad storage access, all-files permission, raw-path enumeration, file mutation, persistent picker
result or replacement system DocumentsUI is implemented. Android 11+ root/Downloads restrictions are
intentional and explained in the UI. The debug-only fixture DocumentsProvider is for tests only.
## First actions
Bootstrap the official checksum-verified Gradle wrapper on a connected build machine; install the
pinned Android SDK/JDK tooling; resolve/build/test/lint. Fix all actual compile/runtime issues before
extending formats. Run `ViewerSmokeTest`, `BrowserIntegrationTest`, and the manual distinct-UID real-provider flows. Preserve known limitations
in the UI and documentation. Update the validation report only with commands actually run.
## Non-negotiable boundaries
Keep originals read-only; explicit copy/export/share only. Do not add INTERNET, all-files access,
telemetry or account requirements to solve a document-opening problem. A selected provider may
have independent networking. Do not execute scripts/macros/formulas or load external resources.
Keep input/output/ZIP/XML/repetition limits enforced during parsing. Never extract archive paths to
the filesystem. Preserve capability/fidelity labels and explicit error recovery.
Keep Android types out of commonMain. `platform-jvm` is not the iOS implementation. Use the configured
Apple framework and build a native ZIP/file/PDF adapter later. Do not call an untested interface or
placeholder native bridge an implemented engine.
## Known areas for review
Custom preview XML parser is bounded but not schema/namespace validating. Office previews omit
substantial content. PDF IPC, worker cancellation/lifecycle and actual device memory need testing.
Image EXIF/animation handling is absent. Export/share jobs and cache cleanup need race tests. UI
strings are currently English and mostly inline; move them to a localization layer before adding
languages. Dependency locking/verification/SBOM requires an actual resolved build.
Follow the implementation slices in `ROADMAP.md`, with measurable acceptance results. Keep source
and documentation complete in each release ZIP. Do not commit user documents, local SDK paths,
private signing keys or generated build caches.
## Continuation from 0.2.3
Current requested baseline: Gradle 8.14.5, installed JDK 17, Kotlin 2.2.21, AGP 8.11.1.
All sources and SAF-D1 diagnostics are integrated; no patch step remains. Check
`docs/VALIDATION.md` before claiming build/device success. The persistent folder
failure is unresolved; obtain the exact new message after confirming the 0.2.3 /
SAF-D1 footer. Do not infer permission failure from retained-grant=false alone.
Do not upgrade the build toolchain or add a duplicate root Kotlin JVM plugin to
investigate the storage-provider issue. Current pins are checked offline by
`scripts/check-build-config.py` as well as by Gradle settings at configuration time.
+35
View File
@@ -0,0 +1,35 @@
# Office engine decision record
## Decision in 0.1.0
Ship an Android source implementation with local reading previews and a reusable Kotlin core.
Do not claim the LibreOfficeKit engine is bundled. Do not claim a reading preview preserves document
layout, all text, formula results or embedded objects.
The earlier high-level discussion made embedding LibreOfficeKit sound closer to adding a small
library dependency than it really is. LibreOffice's own Android notes describe bundling a native
LibreOffice core, bootstrapping it, a dedicated LOK thread, native callbacks and tiled rendering.
This is a substantive integration/build/security/licensing track; `SOURCES.md` points to the primary
upstream documentation. Its existing work remains valuable and is the preferred direction to
investigate rather than writing a new compatible Office layout engine.
## Required prototype deliverables
1. A pinned upstream revision, licence inventory and documented build recipe with real Android
binaries for the required ABIs. Do not assume a maintained public AAR exists.
2. A local open/page/tile/render prototype on actual hardware, including startup resources/fonts.
Do not redistribute fonts copied from the authoring environment.
3. Single-thread/queue ownership rules for engine calls and callbacks, a crash-isolated service,
and a bounded transport for page metadata, pixels, text and search results.
4. Explicit macro/external-link/font/network policies enforced inside the chosen engine and verified
against hostile documents, not just disabled in the host UI.
5. Fidelity tests across DOCX/XLSX/PPTX/ODF and only then legacy formats. Account for missing fonts,
unsupported layout behaviours, cached vs calculated spreadsheet values, and revision semantics.
6. Independent assessment of Apple build/distribution integration. The common Kotlin preview model
remains reusable; the native engine may need different platform bootstrapping/rendering adapters.
## Not done in this repository
There is no `liblo-native-code.so`, LOK JNI bridge, Collabora build, converted PDF cache, native engine
licence bundle or Office layout validation. The `FormatHandler` interface is an extension seam for
reading previews; it is not evidence of a full engine implementation.
+62
View File
@@ -0,0 +1,62 @@
# Feature and format matrix — 0.2.0
“Implemented” below means implementation source is present. Android runtime features remain
unverified until a real SDK build and device run. Core/JVM behaviour has the executed tests recorded
in `VALIDATION.md`.
| Area / format | Implemented source behaviour | Not implemented / important limits |
|---|---|---|
| Android file opening | System picker, VIEW intent, single-file SEND, read grants, bounded snapshots | No multi-file SEND, raw file paths, virtual-file conversion or cloud API |
| Native file browser | Saved folders, nested navigation, breadcrumbs/Up, metadata, search/sort/dotfiles, opt-in provider thumbnails | Scoped folders only; no root/all-files access, global index, live watch, file mutation or grid mode |
| Files-app registration | APP_FILES, content-directory VIEW, separate GET_CONTENT picker | Does not replace system OPEN_DOCUMENT / OPEN_DOCUMENT_TREE or guarantee a device default-files setting |
| File picker | MIME constraints, single/multiple selection, review/confirmation, read-only copied URI results, LOCAL_ONLY system delegation | No directory results, in-place editing, permanent URI promise, cloud-native conversion or original tree grants |
| External opening | Read-only copy and chooser excluding this viewer | 64 MiB limit; edits by recipients cannot update the original |
| Recent files | Up to 20 URI/name entries; permission retention when granted; remove/clear | Not copies retained forever; temporary attachment access can expire |
| PDF | Isolated Android renderer, one page at a time, previous/next/jump, pinch zoom | No text search/selection, continuous page scrolling, OCR, forms, annotations, passwords or printing |
| JPEG/PNG/WebP/GIF/BMP | Platform bitmap decoding, bounds checks/downsampling, zoom | First frame only; no EXIF orientation, animated playback, image editing or HEIC guarantee |
| Plain text and source | UTF-8 / BOM-tagged UTF-16, selectable text, literal search | No syntax highlighting, arbitrary legacy encodings, hex editor or giant-file streaming |
| Markdown | Heading/list/code-block emphasis; original inline markup retained | Not CommonMark-complete; no rich inline rendering, HTML execution or remote resources |
| CSV/TSV | Quoted fields, escaped quotes, multiline fields, comma/semicolon/tab detection, table view | Literal values only; no formulas, editing or schema inference |
| DOCX | Body paragraphs, basic heading/list recognition, flattened table rows | Not page layout; images, fields/revisions, comments, drawings, headers/footers and advanced structures incomplete |
| XLSX | Named sheets, relationships, sparse cell positions, shared/inline strings, stored values, formula strings | No calculation; cached values may be stale; date serials may show raw; no number styles/charts/merged cells/filter fidelity |
| PPTX | Presentation relationship order, slide sections, text paragraphs | Shape/XML text order not visual reading order; no graphical slides, charts, animations, speaker notes |
| ODT | Basic paragraphs/headings and mixed text | No layout fidelity, embedded graphics, revision semantics or full list/table formatting |
| ODS | Named sheets, stored values, bounded row/cell repeats, sparse blank-padding skip | No calculations/styles/charts; hidden content semantics incomplete |
| ODP | Named slide sections, text paragraphs | No full slide rendering or object-placement fidelity |
| ZIP | Entry/path/size listing and filename search; no extraction | No TAR/7z/RAR, encrypted ZIP, nested opening, selective extraction or compression |
| HTML/SVG/XML/JSON/YAML/etc. | Inert source text when recognized | No WebView, embedded scripting, SVG rasterization or structural tree editor |
| ICS/VCF | Raw text by extension | No calendar/contact import or structured card UI |
| Legacy DOC/XLS/PPT/RTF | Explicit unsupported message | No legacy rendering; no LibreOffice native engine |
| Macro-enabled Office | Known macro extensions and VBA packages rejected | Never execute macros; no automatic conversion |
| Encrypted Office | Unsupported package/binary error | No password flow or decryption |
| Share/save | Share an original snapshot, save a copy, export labelled preview text | Never edit original; no general conversion or Office-to-PDF export |
| Search | Supported extracted blocks/rows, literal case-insensitive matching, bounded hits | No PDF/OCR search; counts refer to matching blocks/rows, not all word occurrences |
| iOS | Common Kotlin core and opt-in framework targets | No app, ZIP adapter, bookmark implementation, native renderer or tested Apple build |
Browser limits: 24 saved folders, 64 visited levels, 5,000 listing rows, 16 selected files when allowed,
64 MiB per copied file, 128 MiB per selection, 256 MiB picker-copy cache, and 4 MiB thumbnail cache.
These are additional to the preview/parser limits below. See `BROWSER.md`.
## Initial policy ceilings
64 MiB imported file; 4 MiB plain-text bytes / expanded preview characters; 8 MiB per inflated ZIP
part; 32 MiB cumulative inflation; compression ratio up to 300:1; 4,096 archive entries; XML depth
96; 100,000 XML nodes; 64 attributes per element; 20,000 blocks; 32,768 characters per block/cell;
2,000 rows per displayed table; 128 columns; 50,000 total table cells; 200 sections; 1,000 search
result blocks/rows. Native PDF output is limited to 3 million pixels, width 2,048 and height 4,096.
Images over 100 million declared input pixels are rejected and decoded previews are downsampled.
These are conservative ceilings, not performance guarantees. A file below a byte ceiling can still
be rejected for structure, content, missing parts, encoding or a secondary limit. ZIP indexes do
not inflate file entries; expansion ceilings apply when a part is actually read. Very compressible
legitimate XML can hit the same ratio limit as malicious content and be refused.
## Fidelity contract
The app does not silently present a reading preview as the complete original. A visible banner
identifies Office previews, and a tap shows detailed caveats. Text exports retain notices.
Original-file sharing sends the untouched snapshot, not converted/extracted text.
Documents requiring exact placement, complete revision history, financial recalculation, or legal/
scientific completeness must be checked in an appropriate full-fidelity application. This release
is intended for opening and inspecting routine files, not certifying their complete contents.
+53
View File
@@ -0,0 +1,53 @@
# 0.2.1 — PDF instrumentation-test compilation fix
The reported failure was in `:androidApp:compileDebugAndroidTestKotlin`, at the
`PdfDocument().use` expression in `ViewerSmokeTest.kt`.
## Cause
Android's `android.graphics.pdf.PdfDocument` has a `close()` method, but does not
implement `java.io.Closeable` or `java.lang.AutoCloseable`. The Kotlin standard
library's `use` extensions require one of these interfaces. A method named `close`
is not sufficient. The type-parameter R diagnostic is a consequence of the invalid
receiver; explicit type arguments or dependency upgrades are not the fix.
## Correction
The PDF fixture now uses explicit `try/finally`: finish the started page, write the
PDF through an output stream (which can still use `use`), and close the document.
A unique temporary fixture is deleted even when setup or rendering throws; the
render client also retains explicit cleanup. Existing test assertions are retained.
No runtime feature or dependency changes were made. The complete source release
increments versionCode to 3 / versionName to 0.2.1 and updates validation documents.
The one-file patch changes only ViewerSmokeTest.kt, so it can be applied without
updating version metadata or replacing other locally modified files.
## Apply to a local 0.2.0 checkout
Replace `androidApp/src/androidTest/kotlin/dev/fileworkbench/android/ViewerSmokeTest.kt`
with the supplied fixed file, or run `git apply` with the supplied one-file patch.
The patch was checked against the exact test in the supplied 0.2.0 source archive.
Then run from PowerShell at the repository root:
```powershell
.\gradlew.bat :androidApp:compileDebugAndroidTestKotlin
.\gradlew.bat :core:jvmTest :platform-jvm:test :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug
```
The first command retries the failed compilation task. The second continues full
build validation. These do not run the instrumentation tests on a device; that
requires `:androidApp:connectedDebugAndroidTest` with a connected device/emulator.
## Verification boundary
See `VALIDATION.md`. The portable checks and syntax checks passed here. The Android
compilation retry stopped at Gradle-wrapper download with a DNS error before the
compiler ran; no APK or device-test success is claimed.
## Primary API references
- Android PdfDocument API: https://developer.android.com/reference/android/graphics/pdf/PdfDocument
- Kotlin Closeable.use: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/use.html
- Kotlin AutoCloseable.use: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/use.html
+61
View File
@@ -0,0 +1,61 @@
# 0.2.2 — Valid folder listings incorrectly treated as missing
## Runtime correction
File: `androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt`.
Before, the no-listing check followed the complete resource-handling expression:
```kotlin
resolver.query(children, PROJECTION, null, null, null, signal)?.use { cursor ->
// Read rows and optional status messages.
cursor.extras.getString(DocumentsContract.EXTRA_ERROR)?.let { information = safeFileLabel(it) }
} ?: throw PreviewException("The storage provider did not return a folder listing.")
```
The last expression returns null when no error is reported, and `use` returns the
result of its block. Thus success triggered the no-listing error. This was a
Fileworkbench control-flow bug, not a requirement for broader storage access.
After, check only the cursor returned by the provider:
```kotlin
val queryResult = resolver.query(children, PROJECTION, null, null, null, signal)
?: throw PreviewException("The storage provider did not return a folder listing.")
queryResult.use { cursor ->
// The existing row/extras handling stays unchanged.
}
```
A genuinely null query result still fails; an empty cursor is a valid empty listing.
The change does not swallow exceptions or treat missing access as an empty folder.
## Apply without replacing local build configuration
Use either the supplied replacement `SafBrowserStore.kt` at the path above or
`git apply` with the supplied one-file patch. Do not apply both. The patch was
verified against the shipped 0.2.0 and 0.2.1 source, whose browser file is identical.
It changes no Gradle/JDK settings, dependencies, manifest permissions, stored data,
or document contents. Applying only the patch does not change the displayed
application version. The complete source release sets version 0.2.2 / code 4.
Rebuild and install over the old debug app, then open/refresh the saved folder.
There is no migration and no reason to clear app data for this fix. Temporary grants
may require selecting a folder again; no new broad permission is requested.
## Regression coverage
`scripts/test-folder-listing.py` extracts the actual cursor-reading block and runs
14 JVM checks on test doubles. The old source fails 9 of these with the reported
message; the corrected source passes all 14. This is not an Android API/device test.
The debug fixture and `BrowserIntegrationTest` add five real Android test cases;
those still need a connected device/emulator to execute. The existing normal,
nested and large-folder tests also cover the corrected path.
See `VALIDATION.md` for the executed checks and their exact limitations.
## Primary references
- Kotlin use returns the block result: https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/use.html
- Android EXTRA_ERROR is optional: https://developer.android.com/reference/android/provider/DocumentsContract#EXTRA_ERROR
- ContentResolver query result: https://developer.android.com/reference/android/content/ContentResolver
+21
View File
@@ -0,0 +1,21 @@
# Fileworkbench 0.2.0
Complete source update of the 0.1.0 project, not an APK or a patch-only archive.
Added a native read-only folder browser and Android file-picker registration. The app can browse
chosen document trees, preview supported files with the existing viewer, return to the browser,
and hand read-only copies to other apps. See BROWSER.md for the UI and exact integration contract.
Android integration: APP_FILES category, granted content-directory VIEW, and GET_CONTENT with
single/multiple MIME-filtered user-confirmed selections. System OPEN_DOCUMENT/OPEN_DOCUMENT_TREE
are not replaced. No unrestricted storage access, editing or file deletion was added.
Shared core: browser data models, directory-reader boundary, filename filtering/sorting, MIME
matching, selection validation and streaming copy budgets. Android UI/provider code stays separate.
Validation: 112 offline core/JVM checks passed; 44 Kotlin/Gradle-Kotlin sources parsed without syntax
errors; source/XML invariants passed. Real Android build failed before compilation at wrapper DNS
resolution. Twelve Android instrumentation tests are included but unexecuted. No APK is included.
Build on the same network-capable JDK 17 / SDK 36 machine or runner described in BUILD.md, then execute
BROWSER_ACCEPTANCE.md. No GitHub or Gitea connection, repository upload or remote CI run was performed.
+41
View File
@@ -0,0 +1,41 @@
# Fileworkbench 0.2.3 — complete diagnostic source release
This is a full source project with the previously separate SAF-D1 changes already
integrated. It is not a patch and does not require an existing checkout.
## Included changes
- Preserve all 0.2.2 modules, browser/viewer features, native iOS-facing core,
fixtures, earlier fixes and regression tests.
- Integrate the SAF-D1 null-cursor diagnostic and its updated 16-case host harness.
- Display `Fileworkbench 0.2.3 · Browser diagnostics SAF-D1` in the Locations footer.
- Set Android versionName to 0.2.3 and versionCode to 5, retaining the package ID.
- Pin Gradle 8.14.5 in the distribution URL, shell/PowerShell bootstraps and hashes.
- Add checked-in JDK-17 daemon criteria. All module toolchains and bytecode targets
use 17; the settings script rejects conflicting Gradle/JVM versions.
- Keep the original five distinct root plugin aliases and Kotlin/AGP versions;
add an offline source check to catch alias duplication and mismatched pins.
- Update setup instructions for a fresh extraction and preserve historical evidence.
No extra permissions, cloud services, Office rendering engines, JDK, SDK, signing
keys or compiled APK are added. The external wrapper JAR still needs first-run
bootstrap on a network-capable machine.
## Folder issue status
The earlier nullable-`use` result bug was corrected, but the user still reports a
listing failure. There is no device trace establishing why it persists. SAF-D1
identifies an actual null query result separately, with provider authority, API
level and retained-read-grant status. It does not change how files are queried or
claim to repair provider behaviour. No paths, document IDs or contents are added
to the diagnostic. Temporary grants remain distinct from persistent grants.
After building and installing, verify the 0.2.3/SAF-D1 footer, then reproduce the
folder problem and capture the full new error. Do not clear app data for this test.
## Start here
Extract into a fresh directory, install/select JDK 17, and run the supplied wrapper
once before IDE import. Use `docs/BUILD.md` for Windows and Linux/macOS commands.
Do not copy old IDE/Gradle state or build configuration over the new source.
The updated `docs/VALIDATION.md` distinguishes host tests from Android verification.
+72
View File
@@ -0,0 +1,72 @@
# Next implementation slices
The source release is an MVP implementation baseline, not a declaration that every runtime gate
has passed. Complete A before treating it as an installable/public release. Version 0.2.0 adds the requested
lightweight browser and picker in source; this does not waive the uncompleted Android runtime gate.
## A — Build and Android runtime gate
Resolve the pinned dependencies, run unit tests/lint, build debug and instrumented APKs, and execute
`ViewerSmokeTest` and `BrowserIntegrationTest` on an emulator/device. Test real picker and incoming VIEW/SEND flows from at least
a file manager, a browser and a mail app. Cover current Android and API 26. Fix compilation/API
mismatches before adding features. Record exact SDK/device/build results in `VALIDATION.md`.
Also execute `BROWSER_ACCEPTANCE.md`: folder grants/revocation, picker MIME/multi-selection, distinct-UID
copy/read-only grants, local-only requests, injected restore keys, and return-to-browser lifecycle.
Acceptance: a real APK opens PDF, image, text, CSV and each supported Office/ODF preview; search,
page changes, rotation, two successive incoming documents, back, save/share and cache clearing work.
## B — Lifecycle, storage and security hardening
Extend the new debug provider fixtures with doubles for non-seekable streams, unknown or dishonest sizes, revoked grants,
slow/blocked reads and inaccessible virtual documents. Test process death, cancelled requests,
renderer crash/hang, rapid opens, exports interrupted by a new open, memory pressure and cleanup.
Use dedicated jobs/budgets for export/share operations and robust cancellation/worker recycling.
Fuzz the bounded XML/ZIP/Office core and compare accepted structures with mature implementations.
Acceptance: no original is modified, no wrong document is exported, leaked grant/cache growth is
bounded, malformed files fail clearly, and no failed render leaves the UI permanently busy.
## C — Better everyday reading
Apply EXIF orientation, add richer Markdown parsing without web execution, improve CSV dialect
selection and syntax/source presentation, table navigation, reader font/spacing controls, accessibility,
localization, and preserved reader positions. Consider safe ZIP-entry preview without extraction.
Add PDF text extraction/search with version-aware capability detection and careful engine evaluation.
Acceptance: capabilities are visible per format; unsupported actions are not presented as available;
real users can read/search without confusing raw source, cached values and graphical layout.
## D — Full-fidelity Office engine integration
Do not enlarge the hand-written preview parser into a full Office renderer. Evaluate/build a real
LibreOfficeKit-based Android engine (or a suitable maintained alternative) against the current
Android SDK/NDK, ABI, packaging, security and licence requirements. Pin source revisions, establish
reproducible native builds and collect a representative Office/ODF fidelity corpus. Use native engine
thread ownership and bounded tile/page delivery; preserve the phone UI and the shared core models.
Acceptance: genuine native rendering from real DOCX/XLSX/PPTX/ODF files on arm64 hardware; measured
startup, memory, installation size and known fidelity differences; no host networking/macros/external
resource access. Legacy DOC/XLS/PPT are enabled only when actually rendered and tested. Maintain the
reading-preview mode as a separate labelled choice. Never ship a placeholder JNI/empty engine as support.
See `ENGINE_INTEGRATION.md` for the decision record.
## E — iOS shell
Build the actual common framework on compatible Mac/Xcode tooling. Add SwiftUI file UI, scoped URL
access/bookmarks, an equivalent bounded ZIP adapter, native PDF/image handling, search, shares and
cache cleanup. Reuse common readers instead of rewriting them in Swift.
Acceptance: equivalent first-file-open and expired-grant recovery; common fixture parity; framework
and native host tests. An Android isolated service is not an available iOS feature and needs a separate
platform security design.
## Later, optional
Annotations with a clear original/sidecar/export distinction; PDF forms; EPUB; EML; structured ICS/VCF;
certificates/fonts; media and more archive formats; printing and conversion; simple text editing;
then focused Office editing only when a tested engine and deliberate mobile UX exist. Storage
connectors or broader file management must remain separate from document rendering. Collaboration/accounts
are not necessary to achieve the original goal and are not MVP prerequisites.
+91
View File
@@ -0,0 +1,91 @@
# Security and privacy model
This is an unaudited MVP source release. “No network permission” does not mean “no possible
vulnerability,” and passing the included tests does not prove safety for arbitrary hostile files.
## Trust boundaries
An external app/provider controls document bytes, MIME type, metadata and timing. Name/MIME hints
are not treated as proof of format. The app accepts external `content://` URIs, not arbitrary
filesystem paths or HTTP URLs. Its own share-provider URIs are rejected as incoming documents to
reduce confused-deputy access to private cached data.
Providers and the Android platform are still dependencies. A chosen cloud provider can fetch bytes
using its own network permissions. Provider reads/queries may block; a coroutine cancellation is
not a universal hard timeout for a malfunctioning provider.
## Implemented controls
- Main manifest requests no INTERNET, broad storage, contacts or account permissions.
- Originals are read-only; bounded private copies provide stable/seekable inputs.
- Known macro-enabled files and VBA packages are rejected; no formula/macro/script engine exists.
- HTML, XML and SVG can be shown as source, never executed in a WebView.
- The document core does not fetch embedded external resources, relationships, fonts, images or URLs.
User-chosen provider files and opt-in provider thumbnails can involve the provider's own networking.
- ZIP entries are never extracted to filesystem paths. Traversal/absolute/ambiguous paths and
duplicate names are rejected; inflated bytes, entry count, ratio, cumulative expansion and CRC
are checked when applicable. Listing does not inflate large entries.
- XML DTD, custom/general entities and other declarations are disabled. No network/entity resolver
exists. Nesting/node/attribute/output limits reduce denial-of-service opportunities.
- Repeated ODF rows/cells and OOXML shared strings have output budgets.
- PDF input/output are confined to an isolated Android service using file descriptors. The host
accepts only dimension-checked raw ARGB output, not attacker-provided executable objects/HTML.
- The nonexported share FileProvider exposes only `cache/shared/` and `cache/picked/`, not the app root,
sessions, recents or saved folder grants. Outgoing intents grant individual copies read-only access.
- Picker MIME filters are checked before selection and rechecked against fresh metadata at confirmation.
Actual streamed byte counts enforce per-file and aggregate budgets regardless of declared sizes.
- LOCAL_ONLY picker requests delegate to Android with the same constraints rather than query arbitrary
saved provider folders. No URI supplied in incoming picker extras is automatically selected/read.
- Exported activity intents are sanitized before ViewModel creation to prevent caller-supplied saved-state
keys such as sourceUri/browser.selection from seeding previews or selections using existing grants.
- Document-history grant cleanup is separate from bookmark tree-grant cleanup. Originals are never deleted.
- Provider query jobs have a bounded worker pool, CancellationSignal, and an awaiting timeout. Listings,
labels, thumbnails, selections and copied bytes have explicit bounds. No recursive scan occurs.
- Active preview snapshots are tracked across concurrent viewer/picker activities; one viewer's startup
no longer unconditionally deletes another viewer's live input file.
- Backup and device-transfer exclusions are configured. No content/URI logging or analytics calls
are present in application source.
## Explicit remaining work
Native image decoding is not separately sandboxed. The common XML/Office code is a limited custom
parser, not independently audited. Complete namespace/schema semantics are not checked. Unicode
and malformed-package fuzzing needs broader coverage. Rich Office rendering will substantially
increase the native attack surface and requires a fresh review.
Android compilation, IPC, lifecycle, provider behaviour, device-memory pressure, process death,
export races, accessibility and actual permission/backup behaviour have not been runtime-tested
here. The PDF timeout handles the awaiting client; it is not a proof of immediate worker termination
on every device or cancellation path. Run the acceptance checks before ordinary distribution.
There is no application-level encryption, master password or secure-window mode. File names/URIs
in recents can be sensitive. Displayed content can appear in screenshots/app-switcher previews.
Copying selected text and explicit shares pass content to platform/recipient surfaces. Cache cleanup
is not cryptographic erasure. Uninstall behaviour, device backups and rooted-device access are not
security guarantees provided by this project.
Dependencies are directly version-pinned but no resolved dependency verification manifest, SBOM or
transitive lockfile is included. Generate and review these after a real build. Release signing keys
must be created and controlled by the project owner, not bundled into this repository.
## Before sensitive production use
Require successful Android tests on at least the oldest supported API and a current device, a
reviewed hostile-document corpus, memory/timeout tests, IPC failure tests, dependency scanning,
licence review and an external security review appropriate to the intended users.
## Browser-specific residual risks
FileProvider/intent grants, actual Android SavedStateHandle behaviour, external caller access, and
provider query cancellation have not been device-verified. Same-UID fixture tests are not proof of
cross-UID grant isolation. Copied content can change between metadata retrieval and streaming; the
copy is bounded, not a transactional snapshot of an arbitrary external provider. MIME is metadata,
not proof of safe bytes. Native thumbnail/image decoding needs independent hostile-input review.
Temporary GET_CONTENT copies are for prompt import, not permanent links. Explicit cache clearing,
Android cache eviction or expiry can invalidate them. A receiver cannot write the source document.
No immutable/versioned cloud snapshot semantics are claimed. Shared MIME metadata is private,
bounded and cleared alongside snapshots. Persistent browser bookmarks contain sensitive names/URIs.
A debug-only DocumentsProvider supplies synthetic fixture files for tests. It is protected by the
system document-provider permission and is absent from release builds; it never serves user storage.
+60
View File
@@ -0,0 +1,60 @@
# Primary technical references
Consulted for design/tooling verification on 2026-09-23. These references explain platform APIs and
upstream architecture; they do not verify this project's Android/iOS runtime behaviour.
- Android Storage Access Framework: user-selected provider files, grants, metadata, byte streams,
and special handling required for virtual documents.
https://developer.android.com/training/data-storage/shared/documents-files
- Android PdfRenderer: seekable descriptors, lifecycle and recommendation to use an isolated process
for untrusted input.
https://developer.android.com/reference/android/graphics/pdf/PdfRenderer
- Kotlin Multiplatform overview and platform-independent source sets.
https://kotlinlang.org/docs/multiplatform/get-started.html
https://kotlinlang.org/docs/multiplatform/multiplatform-discover-project.html
- Kotlin Multiplatform compatibility table: Kotlin 2.2.21, Gradle, AGP and Xcode ranges.
https://kotlinlang.org/docs/multiplatform/multiplatform-compatibility-guide.html
- Android Gradle Plugin 8.11 release notes: Gradle 8.13, JDK 17, API 36 and Build Tools 35 baseline.
https://developer.android.com/build/releases/agp-8-11-0-release-notes
- Compose BOM / compiler configuration.
https://developer.android.com/develop/ui/compose/bom
https://developer.android.com/jetpack/androidx/releases/compose
https://developer.android.com/jetpack/androidx/releases/compose-kotlin
- Gradle official distribution and wrapper checksums (8.13 entries).
https://gradle.org/release-checksums/
- LibreOffice Android architecture and bootstrapping notes: real native engine, LOK thread and tiles.
https://raw.githubusercontent.com/LibreOffice/core/master/android/README.md
Versions in this project are pinned development choices, not “latest” recommendations. Check current
compatibility/security information before upgrading or distributing. The new preview parsers were
written for this project; they are neither upstream LibreOffice components nor standards-certified
implementations.
## Browser and picker implementation references (reviewed 2026-09-23)
- Android SAF folder access and persistable grants: https://developer.android.com/training/data-storage/shared/documents-files
- Android 11 directory restrictions: https://developer.android.com/about/versions/11/privacy/storage
- Intent categories / GET_CONTENT / LOCAL_ONLY / multi-result contracts: https://developer.android.com/reference/android/content/Intent
- System-owned OPEN_DOCUMENT: https://developer.android.com/guide/components/intents-common#Storage
- FileProvider sharing and temporary URI grants: https://developer.android.com/training/secure-file-sharing/share-file
- Provider metadata / thumbnails: https://developer.android.com/reference/android/provider/DocumentsContract
- Root capabilities / LOCAL_ONLY: https://developer.android.com/reference/android/provider/DocumentsContract.Root
- AndroidX ComponentActivity lifecycle / ViewModel default arguments (inspect the pinned dependency version): https://developer.android.com/reference/androidx/activity/ComponentActivity
These references establish API contracts, not proof that this source has passed a real Android build.
## 0.2.3 tooling update (verified 2026-09-24)
The current project changes the previous Gradle 8.13 baseline to the user's requested
8.14.5 while retaining JDK 17, Kotlin 2.2.21 and AGP 8.11.1.
- Official 8.14.5 binary distribution and wrapper JAR SHA-256 hashes:
https://gradle.org/release-checksums/
- Gradle 8.14.5 release:
https://docs.gradle.org/8.14.5/release-notes.html
- Daemon JVM criteria (`toolchainVersion=17`), local detection and precedence:
https://docs.gradle.org/current/userguide/gradle_daemon.html
Official documentation and published hashes were accessible through web retrieval;
the local build runtime could not resolve the download host. These references do
not establish that the project has compiled or that the folder issue is fixed.
+3
View File
@@ -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.
Kotlin PSI syntax: 44 files, 0 parse errors. Android/Compose type resolution is NOT tested.
+114
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
# Validation report — Fileworkbench source release 0.2.0
Date: 2026-09-23. This is an implemented-source release, **not a verified Android APK**.
## Executed
| Check | Result | Scope |
|---|---|---|
| Offline production core/JVM compilation and checks | **112 / 112 passed** | Kotlin/JVM 1.9.0, OpenJDK 21.0.11; not the pinned Android toolchain |
| Android resource/manifest XML and source invariants | Passed | Source-level checks only |
| Kotlin PSI parse | **44 files; 0 parse errors** | Syntax only; does not resolve Android/Compose/Gradle symbols |
| Android build entry point `./scripts/verify-android.sh` | **Failed at wrapper download, exit 6** | DNS lookup for raw.githubusercontent.com failed before Android compilation |
| APK generation | **No APK produced** | No Android SDK/Gradle dependencies/emulator available |
The 112 checks comprise the retained 75 document-core/JVM checks plus 37 new browser/MIME/selection/
byte-budget checks. The offline runner invokes the same unit-test method bodies used by the Gradle
suite after removing only test-runner annotations/imports. It does not substitute for a resolved
Gradle build, Android instrumentation, or Apple framework compilation.
Executed commands:
```sh
./scripts/test-core.sh
python3 scripts/check-project.py
./scripts/check-kotlin-syntax.sh
./scripts/verify-android.sh
```
Raw evidence is in `TEST_RESULTS.txt`, `SOURCE_CHECKS.txt`, and `BUILD_ATTEMPT.txt`. The actual build
attempt reached project source checks and the Gradle wrapper bootstrap, then failed with:
```text
curl: (6) Could not resolve host: raw.githubusercontent.com
```
No Android compiler, D8, lint, packaging, installation, or emulator ran after that failure.
## What the structural checks establish
The main manifest is parseable and declares no uses-permission entries; the PDF service is marked
isolated/nonexported; the production FileProvider is nonexported and its allowlist contains only
shared/ and picked/ caches. APP_FILES and a separate standard-launch GET_CONTENT activity are
registered, and no activity declares OPEN_DOCUMENT/OPEN_DOCUMENT_TREE. The debug fixture provider
is absent from the main manifest. Source assertions check read-only copied-result flags, use of
streaming byte budgets, and intent sanitization before activity ViewModel creation.
Common Kotlin files have no Android/Java imports. There is no WebView instantiation or Log call in
main Android source. These are useful invariants, **not** runtime grant-isolation or security proofs.
## Written but not executed
There are **12 Android instrumentation test methods**: the two retained/extended viewer smoke tests
and ten browser/picker integration tests. They cover UI entry points, PDF IPC, synthetic SAF metadata,
large/nested folders, copied MIME metadata, single/multiple result structure, intent registration,
LOCAL_ONLY/MIME forwarding, virtual documents, and rejection of caller-supplied restore-state keys.
These Android tests have not been compiled, installed or run. The debug-only synthetic document
provider is test support, not a user-storage provider. Same-UID result checks cannot establish
cross-UID grants; a separate caller app is explicitly required by `BROWSER_ACCEPTANCE.md`.
## Remaining verification gates
- Resolve pinned Gradle/Kotlin/Android dependencies under JDK 17; compile, lint, D8 and assemble both
debug APKs. Fix actual compiler/API/packaging errors before claiming an installable release.
- Run instrumented tests and `BROWSER_ACCEPTANCE.md` on real devices/emulators, including API 26,
Android 11+ storage restrictions, a current phone, and an independent result-receiving app.
- Verify preview-to-browser navigation/scroll, rotation/process restoration, separate picker/viewer
lifetimes, folder grant renewal/removal, cache/URI invalidation, and existing preview regressions.
- Verify cancellation with slow/unresponsive providers, truthful and dishonest sizes, truncated
listings, malformed documents, thumbnail decoding, memory pressure and copied-file limits.
- Verify accessibility, large fonts, portrait/landscape layouts, and actual visual rendering.
- Build the Apple framework and implement/test its native browser/file-access adapters separately.
No device screenshots, performance measurements, whole-phone storage access, perfect Office
rendering, security audit, iOS app, or successfully executed CI build are claimed by this release.
+47
View File
@@ -0,0 +1,47 @@
# Validation report — Fileworkbench source release 0.2.1
Date: 2026-09-24. **Source hotfix, not a verified Android APK.**
## Change scope
Corrected the invalid `PdfDocument().use` call in the Android instrumentation smoke
test. Added explicit page/document/client cleanup and unique temporary-file cleanup.
No production feature or dependency changes. Updated version metadata and documentation,
and added a narrow source-pattern regression guard. Details: `HOTFIX-0.2.1.md`.
## Executed in this session
| Check | Result | Boundary |
|---|---|---|
| Portable core/JVM checks | **112/112 passed** | Kotlin/JVM 1.9.0, JDK 21.0.11; not pinned Android/Gradle toolchain |
| Kotlin PSI syntax | **44 files, zero parse errors** | Does not resolve Android or Compose types |
| XML and source invariants | Passed | Includes guard against the exact invalid constructor/use expression |
| Regression guard positive/negative check | Passed | Rejects original source; accepts fixed source; not type checking |
| One-file patch application and reversal | Passed | Exact original 0.2.0 ViewerSmokeTest; output matches supplied fixed file |
| `:androidApp:compileDebugAndroidTestKotlin` retry | **Blocked before compilation** | Gradle-wrapper download failed at DNS resolution; exit 6 |
| APK / installation / device tests | **Not produced / not run** | No Android SDK or emulator installed here |
The new source guard catches the reported expression only; it does not establish
that arbitrary `use` calls have valid receivers. No Android build is claimed.
Raw evidence is under `validation/0.2.1/`. Historical 0.2.0 evidence remains in its
original files; the previous report is preserved as `VALIDATION-0.2.0.md`.
The attempted Android command was:
```sh
./gradlew --no-daemon --console=plain :androidApp:compileDebugAndroidTestKotlin
```
The failure was:
```text
curl: (6) Could not resolve host: raw.githubusercontent.com
```
## Still required on a configured build machine
Retry instrumentation compilation, then assemble the debug app and test APKs, run
unit tests and lint, and run instrumented tests on a phone or emulator. Follow the
existing `BROWSER_ACCEPTANCE.md` checklist. iOS framework/application verification
is unchanged and remains outstanding.
+75
View File
@@ -0,0 +1,75 @@
# Validation report — Fileworkbench source release 0.2.2
Date: 2026-09-24. **Folder-listing source hotfix; not a verified new Android APK.**
The user reported a successful local build of the preceding source, then a runtime
failure when adding a folder: "The storage provider did not return a folder listing."
The local device, provider and Android version have not been inspected here.
## Reproduced cause
`SafBrowserStore.list` used `query(...)?.use { ... } ?: throw ...`. The final
expression inside `use` was a nullable read of `DocumentsContract.EXTRA_ERROR`.
When the provider returned a valid cursor without that optional message, the block
returned null and the trailing Elvis operator raised the no-listing exception,
even after successfully reading all rows. Empty directories had the same issue.
The fix checks the query result for null **before** entering `use`. A genuinely
missing cursor still raises the original exception. Cursor closure, row limits,
loading/info/error messages, URI checks and permission handling are unchanged.
Only `SafBrowserStore.kt` needs to change to apply the runtime correction.
## Executed checks
| Check | Result | Boundary |
|---|---|---|
| Extracted original production listing block | **9 failures / 14 checks** | Reproduces the exact reported exception on valid cursors without EXTRA_ERROR |
| Extracted fixed production listing block | **14/14 passed** | JVM control-flow/resource checks using real common models and Android test doubles |
| Shared-core/JVM adapter suite | **112/112 passed** | Local Kotlin/JVM compiler, not the Android/Gradle toolchain |
| Kotlin PSI syntax | **44 files, zero parse errors** | Syntax only; no Android/Compose type resolution |
| XML and existing source invariants | Passed | No new storage permissions or dependencies |
| One-file patch | Applies to both shipped 0.2.0 and 0.2.1 source | Exact checked archive baselines, not an unseen locally modified checkout |
| New Android instrumentation tests | **5 added; not run here** | Empty, info-only, loading with/without cached rows, and provider-error listings |
| Android APK/test-APK build attempt | **Blocked before compilation** | Gradle-wrapper bootstrap cannot resolve raw.githubusercontent.com; exit 6 |
| Phone/emulator/iOS checks | Not performed here | No APK or device-test result supplied |
The host harness copies the cursor-reading block directly out of the selected
production source and compiles it with the real common models. The resolver,
cursor, extras, cancellation signal and URI builder are lightweight test doubles.
It does **not** test Android API compatibility, Binder, real permissions, provider
behaviour, asynchronous scheduling, or the screen. It is not a substitute for the
instrumentation tests or the local Android build.
Raw logs are in `validation/0.2.2/`. The preceding report is preserved as
`VALIDATION-0.2.1.md`; older logs retain their original provenance.
## Reproduce the host checks
With a local Kotlin compiler, Java and Python 3:
```sh
python3 scripts/test-folder-listing.py
bash scripts/test-core.sh
bash scripts/check-kotlin-syntax.sh
python3 scripts/check-project.py
```
To demonstrate the regression against the preceding file, pass its actual local
path to `python3 scripts/test-folder-listing.py --source /path/to/old/SafBrowserStore.kt`.
That command should fail on the old implementation; the failure is expected.
## Local Android acceptance
Keep the local Gradle/JDK configuration that already builds. Apply the one-file fix,
then rebuild and install over the existing debug app using the same signing key:
```powershell
.\gradlew.bat :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest
adb install -r .\androidApp\build\outputs\apk\debug\androidApp-debug.apk
```
Open the saved folder and refresh, or select it again if its grant was temporary.
Check a normal folder, an empty folder, a nested folder and a document preview.
With the complete updated source and a device/emulator, run
`:androidApp:connectedDebugAndroidTest` to exercise the Android regression cases.
The separate broader browser checklist remains in `BROWSER_ACCEPTANCE.md`.
+75
View File
@@ -0,0 +1,75 @@
# Validation — Fileworkbench 0.2.3
Date: 2026-09-24. **Complete diagnostic source release; no verified APK supplied.**
## Scope and provenance
The shipped 0.2.2 source ZIP was extracted into a fresh working directory. The
previously supplied SAF-D1 diagnostic changes were applied there and incorporated
into this complete project. The current source retains the earlier PDF lifecycle
and nullable-cursor-result corrections. Gradle is pinned to 8.14.5; the daemon,
module toolchains and bytecode targets select 17. Root plugin aliases are distinct.
The user still reports the folder-listing failure. No subsequent device trace has
established its cause. **This release instruments that failure; it does not claim
to fix it.** A versioned footer identifies the diagnostic source, and a genuinely
null query cursor has the distinct `[SAF-D1/NULL_CURSOR]` message.
## Executed checks for this source
| Check | Result | Boundary |
|---|---|---|
| Common core + JVM adapter suite | **112/112 passed** | Local Kotlin compiler; not Gradle/Android compilation |
| Actual production listing block + diagnostic harness | **16/16 passed** | Real shared models and Android test doubles; no Binder/real provider/permission validation |
| Build-configuration invariants | **18 passed** | Static pins/aliases/hashes/markers, not Gradle plugin resolution |
| Build-guard regression cases | **8/8 passed** | Accepts the baseline with local SDK/IDE files; rejects wrong Gradle/JVM, duplicate plugin aliases/IDs and stale shell/Windows bootstrap tags |
| Kotlin PSI syntax | **44 files, zero parse errors** | Includes Kotlin source and Gradle Kotlin scripts; no Android/Compose/Gradle type resolution |
| Existing project/XML/security invariants | Passed | Permission-free main manifest, isolated PDF service, read-only picker boundaries, portable core, prior PDF regression guard |
| Shell syntax | Passed | Bash/sh syntax only; PowerShell not executed |
| Android build attempt | **Blocked before Gradle/Android compilation** | Wrapper fetch: `curl: (6) Could not resolve host: raw.githubusercontent.com` |
| Device/emulator tests | Not executed here | The test sources are retained; no device is attached |
| iOS build/tests | Not executed here | Core architecture retained; no iOS shell added |
The local host has **OpenJDK 21.0.11 and kotlinc 1.9.0**, not JDK 17 or the
project's Kotlin 2.2.21 toolchain. The host harnesses now emit JVM-17 bytecode but
run on that local JVM 21. **Do not interpret their results as a JDK-17 Android
build.** The actual Gradle configuration requests an installed JDK 17; no JDK-21
fallback has been added to the project. No Android SDK or downloaded wrapper is
available in this authoring runtime.
The full Android command was attempted using the newly pinned launcher. Its
bootstrap failed before the settings guards or compiler could run. No APK or
successful Gradle sync is claimed. The network-dependent bootstrap fallback and
Windows PowerShell bootstrap remain unexecuted here.
Raw evidence for this release is under `validation/0.2.3/`. Previous results retain
their versioned paths; the old report is preserved as `VALIDATION-0.2.2.md`. Old
`BUILD_ATTEMPT.txt`/test outputs elsewhere in docs are historical, not new results.
## Packaging checks
The full archive is assembled from this source tree, excluding `out`, build caches,
local IDE/SDK state, keystores and generated test JARs. Prior source modules,
resources, fixtures, licence, documentation and examples remain present. It ships
no APK, wrapper JAR, JDK or Android SDK. The first wrapper invocation bootstraps the
verified external JAR. See `SOURCE-MANIFEST.sha256` for per-file integrity hashes
and `validation/0.2.3/archive-checks.txt` for extraction/integrity checks.
## Reproduce host checks
```sh
python3 scripts/check-project.py
python3 scripts/test-build-config.py
bash scripts/test-core.sh
python3 scripts/test-folder-listing.py
bash scripts/check-kotlin-syntax.sh
```
## Next local acceptance step
Follow `BUILD.md`: fresh extraction, installed JDK 17, wrapper Gradle 8.14.5,
SDK 36, then tests/assembly/lint. Install the resulting APK with the existing
signing identity, confirm **Fileworkbench 0.2.3 · Browser diagnostics SAF-D1** in
Browse → Locations, and reproduce the folder problem. Preserve the existing app
data. Capture the full new error instead of inferring permission or provider
failure from the earlier ambiguous wording.
+8
View File
@@ -0,0 +1,8 @@
# Example files
Copy these small ordinary files to a device to test picker, text, CSV and source views. They contain
no personal data. `archive.zip` contains simple text/source files for ZIP-index testing.
Office/ODF format and malicious-structure test data is generated in-memory by the test sources;
no personal Office documents or copyrighted documents are included. Use a representative set of
your own non-sensitive Office/ODF/PDF/image documents for the Android acceptance checks.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
{
"application": "Fileworkbench",
"readOnly": true,
"networkPermission": false,
"note": "Displayed as inert source, not executed"
}
+6
View File
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html lang="en"><body>
<h1>Source view only</h1>
<p>This HTML is displayed as source; it is not placed into a WebView.</p>
<script>document.body.textContent = "This script must not execute in Fileworkbench.";</script>
</body></html>
+5
View File
@@ -0,0 +1,5 @@
Department;Budget;Note
Research;12500;"Local, read-only preview"
Administration;8400;"A quoted
multiline field"
IT;9500;"Quotes: ""example"""
1 Department Budget Note
2 Research 12500 Local, read-only preview
3 Administration 8400 A quoted multiline field
4 IT 9500 Quotes: "example"
+18
View File
@@ -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.
+10
View File
@@ -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
+4
View File
@@ -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
+34
View File
@@ -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" }
Binary file not shown.
+8
View File
@@ -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
Vendored
+9
View File
@@ -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 "$@"
Vendored
+10
View File
@@ -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%
+59
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
plugins { alias(libs.plugins.kotlin.jvm) }
kotlin { jvmToolchain(17) }
dependencies {
implementation(project(":core"))
testImplementation(libs.junit)
testImplementation(kotlin("test"))
}
@@ -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<String, ZipEntry>()
private var expanded = 0L
override val entries: List<ArchiveEntry>
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.")
}
}
@@ -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
}
}
@@ -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<String, ByteArray>): 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 "<document><body><p><r><t>Real ZIP pipeline</t></r></p></body></document>".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("<script>alert(1)</script>") }
try { assertEquals("<script>alert(1)</script>", LocalPreviewLoader().load(file, "x.html", "text/html").sections[0].blocks[0].text) } finally { file.delete() }
}
}
+35
View File
@@ -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<String>) {
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) }
}
+40
View File
@@ -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 } }
+52
View File
@@ -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.'
+70
View File
@@ -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.")
+7
View File
@@ -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 .
+65
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More