# File Manager An **ad-free**, **one-time-paid** Android file manager. It browses and manages the files on your device — locally, with no ads, no account and no analytics. Nothing ever leaves the device: the app declares no network permission and contains no advertising or tracking SDKs. > **Working title.** The display name and the store listing are decided before the > first Play upload (see [docs/ROADMAP.md](docs/ROADMAP.md), M10). The `applicationId` > is already fixed and can never change after the first release. ## Status **M10 — store assets: done.** The app is named **Plain Files**, the icon and feature graphic are generated from the app's own vectors, and [docs/STORE_LISTING.md](docs/STORE_LISTING.md) holds length-checked listing copy. The screenshots are captured: 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600). ## Tech stack | Area | Choice | |---|---| | Language | Kotlin 2.4.10 (using AGP 9 *built-in Kotlin*) | | UI | Jetpack Compose, BOM 2026.06.01, Material 3 | | Build | Gradle 9.3.1, Android Gradle Plugin 9.1.0 | | Storage access | `MANAGE_EXTERNAL_STORAGE` ("All files access") with a Storage Access Framework fallback | | Archives | Apache Commons Compress + XZ for Java (both pure Java — no NDK) | | SDK levels | `compileSdk`/`targetSdk` 36, `minSdk` 26 | `targetSdk` is 36 because Google Play requires new apps and updates to target Android 16 (API 36) from 31 August 2026. ## Project layout ``` app/src/main/java/de/softwareapp_hb/filemanager/ ├── core/ Pure-Kotlin engine (no Android imports!) — paths, sorting, formatting, │ search matching, copy/move planning, conflict policy ├── data/ Storage backends (all-files access + SAF), volumes, shares coming in │ from other apps, file operations, archives, deletion ├── domain/ Browser + operation controllers, models, permission state ├── service/ Foreground service for long-running file operations ├── ui/ Compose theme, browser screen, components, ViewModels └── text/ Domain enum → string-resource mapping app/src/test/java/… JVM unit tests for the core engine ``` `core/` deliberately has **no Android dependencies**, so the trickiest logic is covered by fast JVM tests. ## Building Prerequisites: - JDK 17+ (JDK 25 verified) - Android SDK with `platforms;android-36` and `build-tools;36.0.0` ```bash # Tell Gradle where the SDK lives (this file is git-ignored) echo "sdk.dir=$HOME/Android/Sdk" > local.properties ./gradlew :app:assembleDebug # debug APK ./gradlew :app:testDebugUnitTest # JVM unit tests for the engine ./gradlew :app:lintDebug # Android Lint ./gradlew :app:bundleRelease # Play upload bundle (.aab) ``` Release signing is optional and driven by a git-ignored `keystore.properties` (see `keystore.properties.example`); without it the release build still runs R8/minification but produces an unsigned artefact. ## Tracked in git - Source, resources and Gradle build files. - `docs/` — roadmap and release documentation. - `PRIVACY.md`, `CHANGELOG.md`, and (from M10) `store-assets/`. Not tracked: `local.properties`, `keystore.properties`, `*.jks`, build outputs. ## Releasing Release signing is driven by the git-ignored `keystore.properties` (see `keystore.properties.example`). Without that file the release build still runs R8 and resource shrinking, but produces an unsigned artefact that cannot be uploaded. ```bash ./gradlew :app:bundleRelease # Play upload bundle (.aab) ./gradlew :app:assembleRelease # APK, for local checks # Audit what is actually shipped — in particular that there is no INTERNET permission: $ANDROID_HOME/build-tools/36.0.0/aapt2 dump permissions \ app/build/outputs/apk/release/app-release.apk ``` Read [docs/PLAY_RELEASE_CHECKLIST.md](docs/PLAY_RELEASE_CHECKLIST.md) before uploading. Step 4 is the one that matters most for this app: a file manager must declare **"All files access"** through Play's Permissions Declaration Form, and have the core purpose documented in its store description. [PRIVACY.md](PRIVACY.md) is written to match the shipped permissions. ## Localisation All user-visible text lives in `app/src/main/res/values/strings.xml`, with real `` wherever a count can be one, and the screen and the progress notification resolve their labels through `text/TextRes.kt` so the two cannot drift apart. **English is currently the only locale.** Adding a language is then a file, not a refactor: ``` app/src/main/res/values-de/strings.xml # same keys, translated values ``` Deliberately **not** localised, and why: - **Byte units** ("KB", "MB") — symbols, not words, and every file manager and the system Settings app show them the same way. - **The timestamp pattern** (`yyyy-MM-dd HH:mm`) — a fixed, sortable format; the zone is passed in explicitly rather than assumed. ### Checking that nothing was missed Two things are easy to get wrong: a literal typed into a composable, and showing an enum constant directly (`FileKind.name` puts untranslatable English on screen — the details sheet used to do exactly that). The mechanical check is the pseudo-locale: ```bash ./gradlew :app:assembleDebug # Settings > System > Languages > English (XA), then launch the debug build. ``` The debug build type sets `isPseudoLocalesEnabled = true`, so every string that came from `strings.xml` renders accented and bracketed (`[Îmàgé]`). **Anything that looks normal is a string that never went through resources.** The release build is unaffected. One caveat: **Google Play's automatic translation translates your store listing, not the app UI.** Translated UI text has to be supplied in `values-/`. ## Known limitations - **Sharing a file *out* only works in the all-files mode.** A SAF path is relative to the granted tree rather than a filesystem path, so it cannot be handed to `FileProvider`. Receiving a share and being picked from another app's chooser work either way: the first needs no filesystem path, and the second falls back to `RESULT_CANCELED` rather than returning a URI the caller could not read. - **RAR, CAB, ISO and WIM are refused**, not supported — they need native code, which this project deliberately does not ship. (The Commons Compress jar contains no RAR classes at all.) **7z is different**: Commons Compress reads it in pure Java, so it *is* supported — but the format needs a seekable channel rather than a stream, because its header lives at the end of the file. All-files access can supply one; the SAF fallback cannot, so 7z extraction reports that plainly in folder mode instead of half-working. - **7z files can be opened but not created** — writing stays ZIP-only. - **Archive extraction does not restore original timestamps or permissions**, and there is no archive encryption or password support. - **Cancellation happens between entries**, so a single very large file finishes before a stop is honoured. - **View state is not persisted across process death** (`SavedStateHandle` is not wired up). - **Long-press multi-select could not be driven from adb** on the MIUI test device, so the compress/extract buttons want a manual pass (see [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md)). - The display name is still the working title — decided before the first upload (M10). ## Monetisation **One-time paid: €4.49 base, with a €2.29 launch price for the first 14 days.** No ads, no subscription, no in-app purchases, no free tier. The price lives on the Play listing, not in the build, which is why there is no Billing client in the dependencies and no `com.android.vending.BILLING` permission in the APK — that is what keeps "no network permission, nothing leaves your device" true of the *shipped artefact* rather than just the source. Rationale, market anchors, the rejected alternatives and the Console steps: [docs/MONETISATION.md](docs/MONETISATION.md). ⚠️ **The price must be set before the first upload** — Play allows paid → free but never free → paid. ## Licence Proprietary — all rights reserved.