M8: localisation groundwork - resource the file-kind labels, pseudo-locale check

This commit is contained in:
2026-10-14 17:12:02 +02:00
parent a79b7eb58e
commit bf909b3485
7 changed files with 109 additions and 6 deletions
+14
View File
@@ -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 `<plurals>`, 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
+58 -4
View File
@@ -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 `<plurals>`
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-<locale>/`.
## 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.
+4
View File
@@ -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) {
@@ -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
}
@@ -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)
}
+10
View File
@@ -93,6 +93,16 @@
<string name="browser_details_modified">Modified</string>
<string name="browser_details_folder">Folder</string>
<!-- ===== File categories (the "Type" row of the details sheet) ===== -->
<string name="browser_kind_image">Image</string>
<string name="browser_kind_video">Video</string>
<string name="browser_kind_audio">Audio</string>
<string name="browser_kind_document">Document</string>
<string name="browser_kind_archive">Archive</string>
<string name="browser_kind_apk">App package</string>
<string name="browser_kind_text">Text</string>
<string name="browser_kind_other">File</string>
<!-- ===== Browser: delete + conflict ===== -->
<string name="browser_delete_title">Delete permanently?</string>
<plurals name="browser_delete_body">
+1 -1
View File
@@ -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 `<plurals>`; `text/` mapping |
| M8 | Localisation groundwork | ✅ | Every user-visible string is in `res/values/strings.xml` with real `<plurals>`, 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 |