This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
**/build/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.DS_Store
|
||||||
|
out/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
17
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# No app-specific shrinking rules in the read-only MVP.
|
||||||
+155
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+93
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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><&"'😀</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>�</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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"application": "Fileworkbench",
|
||||||
|
"readOnly": true,
|
||||||
|
"networkPermission": false,
|
||||||
|
"note": "Displayed as inert source, not executed"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Department;Budget;Note
|
||||||
|
Research;12500;"Local, read-only preview"
|
||||||
|
Administration;8400;"A quoted
|
||||||
|
multiline field"
|
||||||
|
IT;9500;"Quotes: ""example"""
|
||||||
|
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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" }
|
||||||
Vendored
BIN
Binary file not shown.
+8
@@ -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
|
||||||
@@ -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
@@ -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%
|
||||||
@@ -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.
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
@@ -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.'
|
||||||
@@ -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.")
|
||||||
@@ -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 .
|
||||||
@@ -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
Reference in New Issue
Block a user