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 device where the user had never declined anything. Reworded so it is true whether access
was declined or simply never granted. 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 ### 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, - **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 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 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 ## Status
**M7 — on-device validation: done.** Run on a real Xiaomi M2101K7AG (Android 12/MIUI). The **M8 — localisation groundwork: done.** Every user-visible string is in resources with real
permission flows, real-storage browsing, the text editor's read/write path and the plurals, the audit's one genuine miss (the details sheet showing an enum name) is fixed, and
storage-usage screen were all exercised on hardware, and two real defects were found and the debug build's pseudo-locale makes the check mechanical. See the README's *Localisation*
fixed. See [docs/EDGE_CASE_TESTING.md](docs/EDGE_CASE_TESTING.md). section.
## Tech stack ## 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 in its store description. [PRIVACY.md](PRIVACY.md) is written to match the shipped
permissions. 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 ## Licence
Proprietary — all rights reserved. Proprietary — all rights reserved.
+4
View File
@@ -72,6 +72,10 @@ android {
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
versionNameSuffix = "-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 { release {
if (hasReleaseSigning) { if (hasReleaseSigning) {
@@ -2,6 +2,7 @@ package de.softwareapp_hb.filemanager.text
import androidx.annotation.StringRes import androidx.annotation.StringRes
import de.softwareapp_hb.filemanager.R import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.domain.OperationKind 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.COMPRESS -> R.string.operation_compress_title
OperationKind.EXTRACT -> R.string.operation_extract_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.FileEntry
import de.softwareapp_hb.filemanager.core.FileSize import de.softwareapp_hb.filemanager.core.FileSize
import de.softwareapp_hb.filemanager.core.Timestamps import de.softwareapp_hb.filemanager.core.Timestamps
import de.softwareapp_hb.filemanager.text.labelRes
/** Name, location, size, type and modified date for one entry. */ /** Name, location, size, type and modified date for one entry. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,7 +40,7 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
FileSize.format(entry.sizeBytes) 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 -> Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified ->
DetailLine(stringResource(R.string.browser_details_modified), 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_modified">Modified</string>
<string name="browser_details_folder">Folder</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 ===== --> <!-- ===== Browser: delete + conflict ===== -->
<string name="browser_delete_title">Delete permanently?</string> <string name="browser_delete_title">Delete permanently?</string>
<plurals name="browser_delete_body"> <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) | | 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`** | | 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** | | 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 | | 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 | | M10 | Store assets | ⏳ | icon/feature-graphic generators, per-form-factor screenshots, [STORE_LISTING](STORE_LISTING.md); display name confirmed |