From bf909b3485aba04dec322d15a52d09b71f5a8198 Mon Sep 17 00:00:00 2001 From: Hadrian Burkhardt Date: Wed, 14 Oct 2026 17:12:02 +0200 Subject: [PATCH] M8: localisation groundwork - resource the file-kind labels, pseudo-locale check --- CHANGELOG.md | 14 +++++ README.md | 62 +++++++++++++++++-- app/build.gradle.kts | 4 ++ .../filemanager/text/TextRes.kt | 20 ++++++ .../filemanager/ui/browser/DetailsSheet.kt | 3 +- app/src/main/res/values/strings.xml | 10 +++ docs/ROADMAP.md | 2 +- 7 files changed, 109 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac59d6..2bbe16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,22 @@ upload needs a **higher `versionCode`** than every previous upload. device where the user had never declined anything. Reworded so it is true whether access was declined or simply never granted. +- **The details sheet put untranslatable English on screen.** It rendered `FileKind.name` + directly, so "Type" read `directory`, `archive`, `apk` — enum constants rather than + labels, and impossible to translate. Every category now has a string resource and a + `FileKind.labelRes()` mapping alongside the existing `OperationKind.titleRes()`. + ### Added +- **M8 — localisation groundwork.** Every user-visible string lives in + `res/values/strings.xml` with real ``, and an audit of the Compose sources found + exactly one genuine miss (above). The debug build type enables + `isPseudoLocalesEnabled`, so running it in English (XA) renders resource-backed text + accented and bracketed while anything hardcoded looks normal — a mechanical way to catch + the next one. Confirmed the pseudo-locale reaches the debug APK and not the release. + Byte units and the timestamp pattern stay unlocalised on purpose, documented in the + README along with the pseudo-locale check. + - **M7 — on-device validation.** The app was run on a real Xiaomi M2101K7AG (Android 12, MIUI V140) rather than only emulator images. Verified on hardware: the first-run permission prompt, "All files access" granted while the app is alive (picked up on diff --git a/README.md b/README.md index 17615cc..aae7801 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ no advertising or tracking SDKs. ## Status -**M7 — on-device validation: done.** Run on a real Xiaomi M2101K7AG (Android 12/MIUI). The -permission flows, real-storage browsing, the text editor's read/write path and the -storage-usage screen were all exercised on hardware, and two real defects were found and -fixed. See [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md). +**M8 — localisation groundwork: done.** Every user-visible string is in resources with real +plurals, the audit's one genuine miss (the details sheet showing an enum name) is fixed, and +the debug build's pseudo-locale makes the check mechanical. See the README's *Localisation* +section. ## Tech stack @@ -99,6 +99,60 @@ access"** through Play's Permissions Declaration Form, and have the core purpose 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 + +- **"Open with" and Share only work 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`. +- **RAR and 7z are refused**, not supported — they need native code, which this project + deliberately does not ship. +- **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). + ## Licence Proprietary — all rights reserved. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f32a683..099c1f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,10 @@ android { debug { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" + // Pseudo-locale (en-XA): every string from resources is rendered accented and + // wrapped in brackets, so any text that comes out unchanged is text that never + // went through `strings.xml`. Set the device language to English (XA) to see it. + isPseudoLocalesEnabled = true } release { if (hasReleaseSigning) { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt index dab22ed..88dd987 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt @@ -2,6 +2,7 @@ package de.softwareapp_hb.filemanager.text import androidx.annotation.StringRes import de.softwareapp_hb.filemanager.R +import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.domain.OperationKind /** @@ -19,3 +20,22 @@ fun OperationKind.titleRes(): Int = when (this) { OperationKind.COMPRESS -> R.string.operation_compress_title OperationKind.EXTRACT -> R.string.operation_extract_title } + +/** + * The human name of a file category, for the "Type" row of the details sheet. + * + * `FileKind.name` is an enum constant, not a label: showing it directly would put + * untranslatable English in front of the user. + */ +@StringRes +fun FileKind.labelRes(): Int = when (this) { + FileKind.DIRECTORY -> R.string.browser_details_folder + FileKind.IMAGE -> R.string.browser_kind_image + FileKind.VIDEO -> R.string.browser_kind_video + FileKind.AUDIO -> R.string.browser_kind_audio + FileKind.DOCUMENT -> R.string.browser_kind_document + FileKind.ARCHIVE -> R.string.browser_kind_archive + FileKind.APK -> R.string.browser_kind_apk + FileKind.TEXT -> R.string.browser_kind_text + FileKind.OTHER -> R.string.browser_kind_other +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt index d43f3a7..633d3d3 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt @@ -17,6 +17,7 @@ import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.FileSize import de.softwareapp_hb.filemanager.core.Timestamps +import de.softwareapp_hb.filemanager.text.labelRes /** Name, location, size, type and modified date for one entry. */ @OptIn(ExperimentalMaterial3Api::class) @@ -39,7 +40,7 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) { FileSize.format(entry.sizeBytes) }, ) - DetailLine(stringResource(R.string.browser_details_kind), entry.kind.name.lowercase()) + DetailLine(stringResource(R.string.browser_details_kind), stringResource(entry.kind.labelRes())) Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified -> DetailLine(stringResource(R.string.browser_details_modified), modified) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a392bd..0b3fcc6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,6 +93,16 @@ Modified Folder + + Image + Video + Audio + Document + Archive + App package + Text + File + Delete permanently? diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f44d0fc..71b3f2f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,7 +17,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M5 | Analysis, archives, viewers | ✅ | `domain/UsageAnalysis` measures recursive folder sizes and finds the largest files, shown by a bar-per-row storage-usage screen; `domain/ArchiveEngine` creates ZIP and extracts ZIP/TAR/TAR.GZ/GZ/BZ2/XZ using `java.util.zip` + Commons Compress (no native code), with **every entry name filtered through `core/ArchivePath` against zip-slip**; a Coil-backed image viewer, thumbnails in the list and grid, a text editor, and compress/extract in the selection bar. **141 JVM tests** total (26 added here) | | M6 | Release config & Play readiness | ✅ | Upload keystore generated (4096-bit RSA, `CN=Hadrian Burkhardt`) and wired through the git-ignored `keystore.properties`; `bundleRelease` + `assembleRelease` verified — **4 682 814-byte `.aab`** (`sha256 9014627a…`), 2 081 955-byte APK signed by the upload key, R8 mapping kept. [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) (incl. the **Permissions Declaration Form** for "All files access", the Data safety answers and the shipped-permission audit) and [PRIVACY.md](../PRIVACY.md) written for a restricted-permission app. Shipped permission set audited: **no `INTERNET`** | | M7 | On-device validation & hardening | ✅ | Run on a real **Xiaomi M2101K7AG** (Android 12 / MIUI V140) — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). Verified on hardware: the first-run permission prompt, granting "All files access" while the app is alive (picked up on resume), browsing real storage, the text editor's read **and** write path (independently checked with `adb shell cat`), the storage-usage screen, landscape layout, and **graceful degradation when access is revoked mid-use**. Found and fixed two real defects: misleading first-run copy ("Full access was declined" on a device where nothing had been declined), and a usage screen that stayed blank for **~45–60 s** on a 55 GB volume — now one listing per directory instead of a `stat` per node, with rows streamed out as they are measured. **142 JVM tests** | -| M8 | Localisation groundwork | ⏳ | every user-visible string in `res/values/strings.xml` with real ``; `text/` mapping | +| M8 | Localisation groundwork | ✅ | Every user-visible string is in `res/values/strings.xml` with real ``, and `text/TextRes.kt` maps **both** `OperationKind` and `FileKind` onto resources so the screen, the notification and the details sheet cannot drift. The audit found a genuine miss: the details sheet rendered `FileKind.name`, i.e. untranslatable English — now a resource. `isPseudoLocalesEnabled = true` on the debug build makes "did every string come from resources?" mechanically checkable; verified `en-rXA` is present in the debug APK and the flag is debug-only. Byte units and the timestamp pattern are deliberately unlocalised, and the README says why. **142 JVM tests** | | M9 | Monetisation | ⏳ | **one-time paid** — price set only in the Play Console before the first upload; no Billing code and no `BILLING` permission | | M10 | Store assets | ⏳ | icon/feature-graphic generators, per-form-factor screenshots, [STORE_LISTING](STORE_LISTING.md); display name confirmed |