# Roadmap File Manager is built against a fixed milestone plan. **M0–M20 are built**; **M21** (Android TV) is specified and deliberately waits for a device. This file is the single source of truth for what is done and what remains, and the queue records what is left. Legend: βœ… done Β· 🚧 in progress Β· ⏳ not started Β· πŸ’‘ candidate (proposed, not scheduled) ## Milestones | # | Milestone | Status | Evidence / notes | |---|---|---|---| | M0 | Toolchain & scaffold | βœ… | Gradle 9.3.1 + AGP 9.1.0 wrapper, version catalog, app module, Compose theme, debug APK builds, installs and runs on the `phone` AVD: package `de.softwareapp_hb.filemanager.debug`, `versionCode 1`, `minSdk 26`/`targetSdk 36`, **no `INTERNET` permission**, `lintDebug` clean (one expected `UnusedResources` for the not-yet-used notification icon) | | M1 | Pure-Kotlin core + JVM tests | βœ… | `core/` is **Android-free**: `StoragePath` (normalised absolute paths that cannot escape the root), `FileEntry` + `FileKind` classification, `MimeTypes`, `NaturalOrder` + `SortSpec` (directories first; `file2` before `file10`), `FileSize`, `NameMatcher` (substring + `*`/`?` glob), `NameAllocator` ("name (1)") and `TransferPlanner` (conflict policies, move-into-self). **44 JVM tests** across 10 classes, all green | | M2 | Storage layer | βœ… | `StorageSource` with two backends β€” `AllFilesSource` (`java.io.File` under all-files access) and `SafSource` (`DocumentsContract` over a persisted tree URI), the latter split so the tree↔path mapping (`SafDocumentIds`) is exhaustively testable; `SafGrant` for the picker and its persistence; `VolumeRepository` (`StorageManager`/`StorageVolume` + `StatFs`); `StorageAccess` + `StorageAccessPolicy` for the all-files/SAF decision. **76 JVM tests** total (32 added here) | | M3 | Operations engine + service | βœ… | `domain/FileOperations` composes the backend primitives into copy/move/delete/rename with recursive walking, an honest progress total, cooperative cancellation between entries and per-item failure reporting; conflicts are decided through the M1 `TransferPlanner` (`ASK` is surfaced for the UI to resolve). `service/FileOpService` is a `dataSync` foreground service mirroring progress into a notification (`FileOpNotifications` + `text/TextRes`). **99 JVM tests** total (23 added here) | | M4 | Compose UI | βœ… | `BrowserViewModel` + `BrowserUiState` over `StorageLocation`; browser screen with list/grid, breadcrumbs, in-folder search, a sort/hidden-files/view sheet, multi-select action bar, details sheet, and new-folder/rename/delete/conflict dialogs; a roots view and the "All files access" + SAF-folder prompts; `data/MediaStoreDeleter` routes a delete through the platform's own dialog where it can and falls back to a direct delete; a `FileProvider` backs "open with" and share. **115 JVM tests** total (16 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/7z using `java.util.zip` + Commons Compress + XZ for Java (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 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 | βœ… | **Decided: one-time paid β€” €4.49 base with a 14-day launch price of €2.29**, both set in the Play Console (*Products β€Ί App pricing*), so there is **no Billing client, no `com.android.vending.BILLING` permission and no purchase code**. Rationale, market anchors and Console steps: [MONETISATION](MONETISATION.md). Verified against the release APK: zero billing and zero `INTERNET` references. **The price must be set before the first upload** β€” Play allows paid β†’ free but never free β†’ paid | | M10 | Store assets | βœ… | Name decided: **Plain Files** (one string; `applicationId` already locked). `tools/make-store-assets.sh` renders the 512Γ—512 **RGBA** icon and the 1024Γ—500 **RGB** feature graphic from the app's own vectors, so the store icon *is* the launcher icon; `tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). [STORE_LISTING](STORE_LISTING.md) carries length-checked copy (name 11/30, short 67/80, full 2730/4000), the permissions paragraph the `MANAGE_EXTERNAL_STORAGE` declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. **Screenshots captured:** 5 phone (1080Γ—2160), 4 on a 7-inch tablet (1200Γ—1920) and 4 on a 10-inch tablet (2560Γ—1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing | | M11 | Library views | βœ… | A **Libraries** section on the root screen β€” Images, Videos, Audio, Documents, Archives, Recent β€” each row carrying how many files the index knows of that kind, each opening an ordinary listing (thumbnails, sort, multi-select, details, the existing viewers) answered from the index instead of from a directory. `core/FileCategory` groups `FileKind`s and is asserted to leave `DIRECTORY`, `APK` and `OTHER` out deliberately; `SearchIndex` gained `byKind`/`recent`/`kindCounts`, and SQLite schema **v3** adds the derived `kind` column β€” the migration rebuilds the table *and* drops the volume stamps, because a stamp whose rows have gone would make the next search trust an empty index. A library obeys the indexed search's own two rules: the answer comes first with the walk behind it, and every row is confirmed against the filesystem before it is shown. Not included, and recorded in the CHANGELOG as decisions rather than omissions: Downloads and New files, and restoring a library across process death. **308 JVM tests** (19 added here) | | M12 | Duplicate finder | βœ… | ***Find duplicates*** **in the overflow.** Three passes, cheapest first: **size** (the index's `duplicateCandidates` query β€” one query and no reading at all), **the first 64 KB** of every candidate (a filter that may only rule files *out*), and the **whole file** to confirm (`domain/DuplicateFinder`, SHA-256, cancellable between files and between read buffers). Groups are ordered by reclaimable space; each header states the copies and what they cost; every row carries its folder, because identical names with no paths are unactionable; and **nothing is preselected** β€” the delete is the app's ordinary one, through the trash and the undo snackbar. A file that cannot be read is counted and reported rather than skipped, and the empty state says how many files were compared β€” or that the index has never run and there was nothing to compare. **323 JVM tests** (15 added). Verified on the Xiaomi phone against four genuine groups in its own storage; the delete was deliberately not pressed on a device that is not ours β€” see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md) | | M13 | Video thumbnails | βœ… | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency β€” `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx β€” the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` β€” because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** | | M14 | Batch rename with sequence continuation | βœ… | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers β€” **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** β€” with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) | | M15 | Favourites | βœ… | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries β€” absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted β€” an unmounted card and a deleted folder are the same thing from here β€” with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) | | M16 | Copy to… / Move to… | βœ… | A destination picker behind the selection bar's overflow β€” volumes, then folders one level at a time, with Up and the trail above them β€” that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a move clears it. Conflicts ask what a paste asks, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below | | M17 | The mark of a change | βœ… | A dot on the rows an operation produced, spent by the next read of that folder. In memory, per folder: no timer, no watcher, no badge. `BrowserUiState.changed` + `markChanged` on every operation that creates something; the dot is drawn by `MarkOfChange` in both list and grid, with a content description TalkBack reads. **7 tests** (4 view model, 3 Compose) | | M18 | Store-only archives | βœ… | Compressing asks two things: the name, and whether to compress. Off stores the files as they are β€” `Deflater.NO_COMPRESSION`, one pass β€” which is what a bulk copy off the phone actually wants. A `STORED` entry would need each file's size and CRC before its header, i.e. reading everything twice. **4 tests** | | M19 | The trash says what it does | βœ… | The trash states its window ("Deleted files are kept for 24 hours") and offers **Empty trash** behind one confirmation; opening the trash sweeps expired batches, so the 24-hour retention no longer waits for the next delete to run. **4 tests** | | M20 | Launcher shortcuts | βœ… | Images, Recent and Trash as static shortcuts β€” three intents with a route extra, `core/ShortcutRoute` answering `null` for anything it does not know. Favourites was dropped from the plan: it is a section of the home screen, not a view. **3 tests** | | M21 | Android TV | ⏳ | Specified below and deliberately not started: its acceptance needs a TV device pass this machine cannot provide. | ## Key decisions | Decision | Choice | Rationale | |---|---|---| | Language / UI | Kotlin + Jetpack Compose | Mirrors the sibling app's proven, installed toolchain; no NDK | | Storage access | `MANAGE_EXTERNAL_STORAGE` first, SAF fallback | Google Play explicitly permits "All files access" for file managers; SAF keeps the app usable if the user declines | | Connectivity | Local-first (no `INTERNET` in v1) | Preserves the "nothing leaves the device" claim and keeps the Data Safety form trivial | | File sharing | Receives `SEND`/`SEND_MULTIPLE`, serves `GET_CONTENT`/`OPEN_DOCUMENT`, and deliberately does **not** register `ACTION_VIEW` | A file manager should be able to receive a file and to supply one, and neither costs a permission. Registering as a viewer as well would put Plain Files in the "open with" chooser the app itself uses, offering it back to itself | | Performance | Measured, not assumed: [PERFORMANCE](PERFORMANCE.md) holds the numbers and `tools/measure-startup.sh` takes one of them | The survey's top complaint is latency, so a speed claim has to be a number β€” and a number nobody can reproduce is worse than none | | Quality gate | `tools/verify.sh` β€” map check, detekt, tests, lint; `--release` adds R8 β€” run by CI, by the pre-push hook and by hand | One command that means the same thing in all three places. The detekt baseline is what makes adopting static analysis possible without a backlog-clearing week, while still failing on anything new | | Monetisation | One-time paid (€4.49; €2.29 launch price), set in the Console | No Billing client, no `BILLING` permission, no purchase code β€” which is what keeps "nothing leaves the device" true in the shipped APK. Free β†’ paid is impossible after the first upload, so the price must be set beforehand. See [MONETISATION](MONETISATION.md) | ## Deliberately deferred (post-1.0) - Cloud storage (Drive/OneDrive/Dropbox/…); network clients (SMB/FTP/SFTP/WebDAV); FTP/Web server. All of these need `INTERNET`, which is the one permission the app deliberately does not declare β€” see the connectivity decision above. - App manager (`QUERY_ALL_PACKAGES`), APK installation (`REQUEST_INSTALL_PACKAGES`). - RAR archives (the format needs native code) and 7z *creation*; root explorer; encryption vault; themes/icon packs. ## Candidates from the competitive analysis A survey of the market leader (its features, and what its users praise and complain about) is in [COMPETITIVE_ANALYSIS](COMPETITIVE_ANALYSIS.md). Its gaps that cost **no** new permission are listed there as πŸ’‘ candidates. **Four of them have since been shipped: category libraries (M11), the duplicate finder (M12), batch rename with sequence continuation (M14) and favourites (M15)**. The last one standing there β€” the post-paste **"copy again"** and **"return to source folder"** β€” is **closed as superseded**, not deferred: both exist only because that app's flow makes you leave the source folder to paste. `Copy to…` / `Move to…` (in the queue below) removes that constraint, and with it both features: you never leave the source, so the selection is still there for the next destination, and there is no "back to source" to offer. Keeping them would be building a workaround for a limitation we would no longer have. ## The queue after 1.0 Ordered by what it protects, not by what is fun. **Four of the five items are done** β€” thumbnails, the scaling numbers (which turned into progressive listing), Copy to… / Move to…, and the CI benchmark. What is left is the baseline profile's *measurement*, which needs a physical API 33+ device and cannot be finished on this machine; everything else in the list says what it produced. **M17–M20** come from the same survey's remaining gaps and are implemented (the change mark, store-only archives, a trash that says what it does, launcher shortcuts). **M21** (Android TV) is specified and deliberately not started: its acceptance needs a TV device pass this machine cannot provide. 1. **Baseline profile** β€” **generated and committed**: `app/src/main/baseline-prof.txt` is bundled in the release APK, written by the `:baselineprofile` module. What remains is its *measurement*: collection needs a device with API 33+, and the phone is Android 12, while the API 35 emulator here is too noisy to resolve the delta. One run on a physical API 33+ device β€” `compile --reset` versus `compile -m speed-profile` β€” turns it from "bundled" into a number in [PERFORMANCE](PERFORMANCE.md). 2. **Thumbnail hardening** β€” **done**: thumbnails are keyed by path *and* size *and* modification time (`core/ThumbnailKey`, four tests), the icon is drawn under the thumbnail instead of an empty square while decoding, and the image loader's caches are decided explicitly β€” memory only at 15 % of the heap, no disk cache. The device pass is in [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md): 6 200 files with 300 images and a folder of 300 images, correct and stable, no crash or OOM. A 20 000-*image* folder is still unmeasured; jank on the screen is measurable now that both the phone run and the CI emulator report `frameDurationCpuMs` (see [PERFORMANCE](PERFORMANCE.md)), but no test drives the photo grid yet. 3. **The scaling numbers** β€” **done**: 1 000 β†’ 12–14 frames to open, 5 000 β†’ 41, 20 000 β†’ 184 with a 598 outlier; the outlier was investigated and the real fix was progressive listing (200-entry batches, O(n+k) merge), after which 20 000 files opens in **10–16 frames** with a 32–48 max across two runs. The whole suite at that size, and what search costs there, is in [PERFORMANCE](PERFORMANCE.md). 4. **Copy to… / Move to… (M16)** β€” **done**: the selection bar's overflow opens a folder picker (volumes, then folders one level at a time, Up and the trail above them) and confirming copies or moves into the picked folder without leaving the current one. The selection survives a copy so a second destination is two taps; a move clears it. Conflicts ask the same question as a paste, naming the picked folder. Seven tests cover the verbs, the picker's walk, folders-only listing, the conflict target, cancellation, and the selection rule. Driven on the phone for both verbs (see `EDGE_CASE_TESTING.md`), which is what caught the picker naming internal storage by its raw name `0`. A copy into a picked folder says **"Copied to "** and offers **Show**, which opens that folder β€” the destination's label resolved the way breadcrumbs are, so a volume root reads *Internal storage* rather than `0`. A move keeps the undoable message instead, because its sources are gone. 5. **A CI run of the benchmarks on an emulator** β€” **done**: `.github/workflows/benchmark.yml` runs the four benchmarks on API 35 (`reactivecircus/android-emulator-runner`, `google_apis`, x86_64, SwiftShader) on demand and every Monday, installs the app and resets its compilation before instrumenting it, pulls the JSON, and publishes `tools/benchmark-report.py`'s table on the run page β€” failing the job if `frameDurationCpuMs` is missing, since that metric is why the job exists. While wiring it up, the phone's own JSON from the progressive-listing run turned out to contain `frameDurationCpuMs` and `frameOverrunMs` after all (P50 13.9 ms / 19.0 ms for the 1 000-file open), so `PERFORMANCE.md`'s "counts only" finding was a property of the earlier runs, not of the device; corrected there. ### M16 β€” Copy to… / Move to… **What**: from a selection, choose a destination folder and start the copy (or move) immediately, without leaving the folder you are in. **Why this shape**: `BrowserViewModel.paste()` already calls `planTransfer(paths, target, policy)` with the current folder as `target`; the engine is target-agnostic, so the operation half needs no new machinery β€” `FileOpService` and the conflict prompt work regardless of what is on screen. What is missing is a destination picker. **Acceptance criteria** - Selecting files and choosing *Copy to…* opens a folder picker: folders only, one level at a time, with Up and breadcrumbs, every root the app can see (internal and SD card), and the granted tree in SAF mode. - Confirming copies into the chosen folder and **leaves the user where they were**, with the selection still intact β€” so a second destination is two taps. `Move to…` is the same picker with `cut = true`. - A name conflict asks the same question as a paste does, with the picked folder named in it, and resolve applies to that folder. - Progress belongs to the existing operation notification, not to the screen the user is on. - After a copy, a message names the destination and offers to show it. After a move the selection is cleared and the listing refreshed, because the rows are gone. - Tests: plan/enqueue with an explicit target; the conflict prompt carrying the picked folder; selection survives a copy and is cleared by a move; the picker lists folders only. **Not in scope**: drag and drop, a two-pane phone layout, remembering recent destinations. ### M17 β€” The mark of a change **What**: the rows an operation created carry a dot until the folder is read again, in both the list and the grid. **Why this shape**: the survey's 3.6.5 ("temporarily highlight new files with a dot after file operations") answers an obvious question β€” *what did that 4 GB copy just do?* β€” and it got sharper with M16, because a copy can now land in a folder the user is not looking at. The dot is a **set of paths the view model marks when an operation lands**, not a timestamp on every row: no timer, no background job, and "temporarily" means "until you see the folder again". The marks live in memory, so a restart forgets them β€” the same lifetime as the clipboard, and for the same reason. **Acceptance criteria** - Copying, moving, compressing, extracting, renaming and creating a folder or file mark what they produced. - The dot is drawn in the list and in the grid and carries a content description, so TalkBack says it and a Compose test can find it. - Opening the folder again clears its marks; opening a *different* folder does not disturb them. - Tests: a copy into the current folder marks the copy and no untouched row; a copy into another folder marks it there; re-opening clears. **Not in scope**: catching files that appear while the folder is open (that needs a watcher), a notification or a badge on the home screen. ### M18 β€” Store-only archives **What**: compressing asks two things β€” the name, and whether to compress β€” because the survey's most enthusiastic review is a 150 GB push to a PC, and that push is faster as one file that was cheap to build. **Why this shape**: we refuse `INTERNET`, so their FTP-server answer is out; what is left is to make the file that leaves as cheap as possible. `Deflater.NO_COMPRESSION` does that in one pass. A `STORED` zip entry would be more literal and needs each file's size and CRC *before* the entry header is written, i.e. reading every file twice β€” slower than the deflate it avoids. **Acceptance criteria** - `ArchiveEngine.createZip` takes the choice; the archive is a valid zip either way and extracts to identical bytes. - The dialog opens on "compress contents" and does not remember the last answer. - Tests: both variants round-trip; the uncompressed one is measurably larger; the view model passes the flag. **Not in scope**: a remembered preference, an estimated time, choosing a compression *level*. ### M19 β€” The trash says what it does **What**: the trash screen states the window (24 hours) and offers **Empty trash**; the expiry sweep runs when the trash is opened, not only after the next delete. **Why this shape**: the retention already exists (`Trash.RETENTION_HOURS`) and is invisible, and `purgeExpired` runs *after staging* β€” so a user who deletes once and never again keeps a day-old batch forever. Those are the two halves of "why is my storage full" that the app itself can answer. Making the window configurable would need a settings surface this app does not have; saying what it is costs nothing. **Acceptance criteria** - Opening the trash sweeps expired batches first, and the screen says how long items are kept. - **Empty trash** asks once, then deletes every batch for good β€” it is the one destructive action in the app with no undo, because that is what emptying means. - Tests: an expired batch is gone after opening, a fresh one survives, emptying removes all and leaves the screen saying it is empty. **Not in scope**: a per-item permanent delete, a configurable window, a size cap. ### M20 β€” Launcher shortcuts **What**: long-press the app icon β†’ **Images**, **Recent**, **Trash**. **Why this shape**: static shortcuts need no permission and no database β€” three intents with an extra, mapped to views that are otherwise two or three taps deep. It is the cheapest route to the screens the survey says people install the app for. **Recent, not Favourites** β€” the one change from the proposal above. Favourites is a *section of the home screen* rather than a view of its own, so a shortcut to it would open the home screen and promise a section that is absent when nothing is starred. Recent is a library with its own name and its own state, so the shortcut opens exactly what it says. **Acceptance criteria** - Three shortcuts in `res/xml/shortcuts.xml`; each opens the app directly at that view, and back from there behaves like any other entry. - An unknown or absent extra is ignored, so an old shortcut cannot crash a new build. - Tests: the route parser maps the three names and returns `null` for anything else; each route opens the right view. **Not in scope**: dynamic shortcuts built from favourites, deep links to a path. ### M21 β€” Android TV (specified, not started) **What**: a leanback-capable build β€” banner, launcher entry, and the whole browser and every dialog reachable with a d-pad. **Why this shape, and why it is not done**: Android TV is the only genuinely *new market* the survey names that costs no permission, and it is also the one milestone whose acceptance cannot be met on this machine β€” there is no TV AVD here and no KVM to run one at a usable speed. A half-tested TV build reviews worse than no TV build, which is the survey's own lesson about the competitor's timeouts, so the plan is written down and the work waits for a device. **Acceptance criteria (when it starts)**: `LEANBACK_LAUNCHER` intent filter and a banner; focus traversal through the browser, the selection bar, the transfer picker and every dialog; no touch-only affordance (long press and double tap need a d-pad equivalent); a TV emulator pass recorded in [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). **Not in scope**: TV-specific layouts, gamepad support, a screensaver.