feat(schema): typed-phrase confirm, downloads, uploads, and a way up #2

Merged
thisilike merged 4 commits from feat/schema-parity into main 2026-08-12 11:51:02 +02:00
Owner

Closes #1. The renderer half of the four additions; the core contract is
OpsDeck/core#35 (types, validation, web renderer) and should land first.

The four

1. Typed-phrase confirm. confirmPhrase puts a field in the dialog and
keeps the confirming button dead until it matches exactly. The phrase
interpolates route params, so a module can ask for the thing's own name
("type scratch to confirm"). This is the parity that prompted the issue.

2. Downloads — and deliberately not DownloadManager. I built it that way
first and it does not work: DownloadManager fetches from the system's downloads
process, which does not inherit this app's cleartext allowance, so a
self-hosted server on plain HTTP fails with "Download unsuccessful" and no
reason. Confirmed on a device before rewriting.

Fetching through our own OkHttp client keeps the session cookie, the cleartext
policy and the error message where they already work, and gives real progress
rather than a notification we cannot see inside. The file lands in the public
Downloads collection via MediaStore — no permission needed, and where a person
looks for it — named from the response's content-disposition, since the route
addresses a file by an opaque param and the header is the only place its real
name exists. A failed transfer deletes its pending MediaStore row instead of
leaving an invisible stub.

3. Uploads. A file field opens the document picker; a form holding one
submits as multipart/form-data. Bytes stream from the content URI straight
into the socket, so a large video costs about 8 KB of heap rather than a second
copy on disk — resolving the URI to a file first would mean uploading it twice,
once to storage. Progress is counted in the sink: bytes actually handed to the
connection, not bytes we meant to send.

4. A way up. A page carrying parent renders an up affordance. Distinct
from the back arrow, which unwinds however you arrived — a notification or a
deep link has no history to unwind, and the parent is still the parent. This is
what retires filebrowser's up/:p redirect page.

Verified on a device

API 36 emulator against a real server with the filebrowser module, all four end
to end:

result
phrase confirm empty phrase → tap does nothing, dialog stays, directory intact; typed scratch → directory actually deleted
download README.txt saved under its real name, 299 bytes of 299 — byte-exact against the source
upload same file picked from the document picker, landed 299 bytes on the server, form navigated to its successPage
parent "All roots" renders on a directory page and navigates to the module root

Notes

  • Lint still reports the same 3 pre-existing errors (URLDecoder API level,
    windowLayoutInDisplayCutoutMode, a Flow operator in composition). None are
    from this branch and I left them alone.
  • One Kotlin trap earned a comment in the model: a KDoc containing a MIME glob
    opens a nested block comment and silently eats the rest of the class.
  • open is openUrl in Kotlin with @SerialName("open")open is a
    modifier keyword and cannot be a constructor parameter, backticks or not.
Closes #1. The renderer half of the four additions; the core contract is OpsDeck/core#35 (types, validation, web renderer) and should land first. ## The four **1. Typed-phrase confirm.** `confirmPhrase` puts a field in the dialog and keeps the confirming button dead until it matches exactly. The phrase interpolates route params, so a module can ask for the thing's own name ("type `scratch` to confirm"). This is the parity that prompted the issue. **2. Downloads — and deliberately not `DownloadManager`.** I built it that way first and it does not work: DownloadManager fetches from the system's downloads process, which does not inherit this app's cleartext allowance, so a self-hosted server on plain HTTP fails with *"Download unsuccessful"* and no reason. Confirmed on a device before rewriting. Fetching through our own OkHttp client keeps the session cookie, the cleartext policy and the error message where they already work, and gives real progress rather than a notification we cannot see inside. The file lands in the public Downloads collection via MediaStore — no permission needed, and where a person looks for it — named from the response's `content-disposition`, since the route addresses a file by an opaque param and the header is the only place its real name exists. A failed transfer deletes its pending MediaStore row instead of leaving an invisible stub. **3. Uploads.** A `file` field opens the document picker; a form holding one submits as `multipart/form-data`. Bytes stream from the content URI straight into the socket, so a large video costs about 8 KB of heap rather than a second copy on disk — resolving the URI to a file first would mean uploading it twice, once to storage. Progress is counted in the sink: bytes actually handed to the connection, not bytes we meant to send. **4. A way up.** A page carrying `parent` renders an up affordance. Distinct from the back arrow, which unwinds however you arrived — a notification or a deep link has no history to unwind, and the parent is still the parent. This is what retires filebrowser's `up/:p` redirect page. ## Verified on a device API 36 emulator against a real server with the filebrowser module, all four end to end: | | result | |---|---| | phrase confirm | empty phrase → tap does nothing, dialog stays, directory intact; typed `scratch` → directory actually deleted | | download | `README.txt` saved under its real name, 299 bytes of 299 — byte-exact against the source | | upload | same file picked from the document picker, landed 299 bytes on the server, form navigated to its `successPage` | | parent | "All roots" renders on a directory page and navigates to the module root | ## Notes - Lint still reports the same 3 pre-existing errors (`URLDecoder` API level, `windowLayoutInDisplayCutoutMode`, a Flow operator in composition). None are from this branch and I left them alone. - One Kotlin trap earned a comment in the model: a KDoc containing a MIME glob opens a *nested* block comment and silently eats the rest of the class. - `open` is `openUrl` in Kotlin with `@SerialName("open")` — `open` is a modifier keyword and cannot be a constructor parameter, backticks or not.
The renderer half of #1. Four additions, matching the core contract in
OpsDeck/core#35 — the app is the whole module on a phone, so what the schema
cannot say here, a phone cannot do.

**Typed-phrase confirm.** `confirmPhrase` puts a field in the dialog and keeps
the confirming button dead until it matches. The phrase interpolates route
params, so a module can ask for the thing's own name. This is the parity that
prompted the issue: the same recursive delete was one tap here and a typed
phrase on the web, on the device where a tap is more reflexive, not less.

**Downloads, and not through DownloadManager.** The obvious implementation
does not work: DownloadManager fetches from the system's downloads process,
which does not inherit this app's cleartext allowance, so a self-hosted server
on plain HTTP fails with "Download unsuccessful" and no reason — verified on a
device before rewriting it. Fetching through our own OkHttp client keeps the
session cookie, the cleartext policy and the error message where they already
work, and gives real progress instead of a notification we cannot see inside.
The file lands in the public Downloads collection through MediaStore (no
permission, and where a person looks for it), named from the response's
content-disposition — the route addresses a file by an opaque param, so the
header is the only place its real name exists. A failed transfer deletes its
pending MediaStore row rather than leaving an invisible stub.

**Uploads.** A `file` field opens the document picker and the form submits as
multipart: text parts for the ordinary fields, one part per file, streamed
from the content URI straight into the socket so a large video costs 8 KB of
heap rather than a second copy on disk. Progress is counted in the sink, which
is bytes actually handed to the connection.

**A way up.** A page carrying `parent` renders an up affordance. Distinct from
the back arrow, which unwinds however you arrived — a notification or a deep
link has no history to unwind, and the parent is still the parent.

Verified against a real server on an API 36 emulator, all four end to end:
the delete dialog refused an empty phrase and deleted once "scratch" was
typed; README.txt came down byte-exact (299 of 299) and was saved under its
real name; the same file went back up through the picker and landed 299 bytes
on the server; and "All roots" navigates from a directory page to the root.

One Kotlin trap worth the comment it got: a KDoc containing a MIME glob opens
a nested block comment and silently eats the rest of the class.
Two from a review of this branch.

**The download's MediaStore calls ran on the main thread.** insert, update and
delete are ContentProvider round trips: StrictMode flags them, and a slow
provider turns one into a frozen frame. They belong in the same IO context the
transfer already uses.

**A list field submitted as its JSON text.** `JsonArray.toString()` put
`["a","b"]` in a part while the web renderer put `a,b` there — two clients,
two bodies, one route that can parse neither reliably. Multipart carries a
repeated name natively, so a list is one part per element on both sides now.

Re-verified on the device after the change: README.txt still comes down
byte-exact and saves under its real name, and the same file still uploads and
lands 299 bytes on the server.
julian left a comment

The shape of all four is right, and the DownloadManager writeup earns its place — the cleartext/session argument is correct and the MediaStore pending-row cleanup is the careful version. Three things block, though, and the first two mean the device test passed for reasons narrower than the feature.

Blocking

1. MediaStore.Downloads is API 29; minSdk is 26. MediaStore$Downloads has since="29" in the SDK metadata, and there is no Build.VERSION guard, no lint { } config, and no lint baseline in the repo. On Android 8.0–9 the first download resolves MediaStore.Downloads.EXTERNAL_CONTENT_URI and throws NoSuchFieldError. NewApi is an error by default, so the "same 3 pre-existing lint errors" reading suggests lint was not re-run after the MediaStore rewrite in eb4f0ad — worth a fresh lintDebug either way. Fix is a guard with a legacy Environment.DIRECTORY_DOWNLOADS path, or raising minSdk to 29 as a deliberate call.

2. confirmPhrase is inert unless confirm is also set. The dispatch at SchemaScreen.kt:265 gates only on action.confirm != null, so an action carrying just confirmPhrase opens no dialog and fires on the first tap — the strongest brake in the schema degrades to no brake at all, which is the one failure direction this feature cannot have. The device test set both, so it passed. Gate on action.confirm != null || action.confirmPhrase != null.

3. download is parsed and never read. openUrl unconditionally saves to the Downloads collection; nothing reads UiAction.download, so download = false — "hand it to the platform to view" per the PR body — behaves identically to download = true. Either implement the view path (ACTION_VIEW on the MediaStore URI, or the content URI via FileProvider) or drop the field until it does something, so core#35 does not ship a contract the renderer silently ignores.

Should fix

4. RFC 5987 filenames come out mangled. Regex("""filename\*?=\"?([^\";]+)""") treats filename*=UTF-8''R%C3%A4dme.txt as a plain value and hands UTF-8''R%C3%A4dme.txt to DISPLAY_NAME — charset prefix and percent-escapes included. Any non-ASCII name from a server that does the correct thing lands wrong. Prefer filename*, strip the charset'' prefix, percent-decode; fall back to plain filename.

5. Progress callbacks mutate state off the main thread. Both SchemaViewModel.kt:506 and :663 run _state.value = _state.value.copy(...) from Dispatchers.IO. That is a non-atomic read-modify-write, and it is now racing the main-thread writers in this same class (live events, refetchAll, runningAction) — a lost update drops a live sample or strands runningAction. MutableStateFlow.update {} is the CAS version; the file has 22 of these and currently no update calls, so at minimum convert the two that are no longer main-only.

6. The Downloads row never gets a MIME_TYPE. For a route that addresses a file opaquely, fileNameOf yields an extension-less name, MediaProvider infers application/octet-stream at insert, and the later DISPLAY_NAME update does not revise the type — the file lands in Downloads with no handler when tapped. The device test used README.txt, where the path already carried the extension, so this path was not exercised. Set MIME_TYPE from the response Content-Type alongside the rename.

7. Neither transfer clears its trace. picked survives a successful multipart submit, so the form still lists the file and a second tap re-uploads it; state.downloaded is only ever set, so "Saved X to Downloads" stays on the page and is still showing while the next download runs. Clear picked on success and downloaded at the start of openAction.

Smaller

  • field.multiple is wired to ActivityResultContracts.OpenDocument(), which is single-select — "multiple" currently means tapping "Choose another" once per file. OpenMultipleDocuments is the matching contract.
  • accept is split straight into EXTRA_MIME_TYPES (FormCard.kt:228). HTML-style entries like .png match nothing and the picker comes up empty. Does core#35 restrict accept to MIME globs? Worth stating in the contract either way.
  • openUrl is always joined onto $baseUrl/api/mod/$module, so an absolute URL in open builds a broken one. Fine if the contract says module-relative — same question for core#35.
  • When the provider omits SIZE, sizeBytes is -1, the multipart body goes chunked and total <= 0 makes the percentage null, so the progress affordance silently never appears. Same on the download side when there is no Content-Length, except there transferPercent flips to null mid-transfer and the "Downloading" line vanishes rather than never appearing. An indeterminate "uploading…" would be honest in both.
  • fileNameOf is internal and pure, and the repo already unit-tests exactly this shape of helper (MatchPageTest, SchemaBadgeTest) — it has no test, and neither does the disposition parsing, which is worth extracting so it can have one. The query-vs-path precedence and the RFC 5987 case are both cheap table tests.
  • Import order is scrambled in the touched files — okio.buffer inside the okhttp3 block in ApiClient.kt, OutlinedTextField and UploadPart above the compose blocks in SchemaScreen.kt/FormCard.kt.

The KDoc-nesting and open-keyword notes are good ones to have left in the source rather than only in the PR body.

The shape of all four is right, and the DownloadManager writeup earns its place — the cleartext/session argument is correct and the MediaStore pending-row cleanup is the careful version. Three things block, though, and the first two mean the device test passed for reasons narrower than the feature. ## Blocking **1. `MediaStore.Downloads` is API 29; `minSdk` is 26.** `MediaStore$Downloads` has `since="29"` in the SDK metadata, and there is no `Build.VERSION` guard, no `lint { }` config, and no lint baseline in the repo. On Android 8.0–9 the first download resolves `MediaStore.Downloads.EXTERNAL_CONTENT_URI` and throws `NoSuchFieldError`. `NewApi` is an error by default, so the "same 3 pre-existing lint errors" reading suggests lint was not re-run after the MediaStore rewrite in `eb4f0ad` — worth a fresh `lintDebug` either way. Fix is a guard with a legacy `Environment.DIRECTORY_DOWNLOADS` path, or raising `minSdk` to 29 as a deliberate call. **2. `confirmPhrase` is inert unless `confirm` is also set.** The dispatch at `SchemaScreen.kt:265` gates only on `action.confirm != null`, so an action carrying just `confirmPhrase` opens no dialog and fires on the first tap — the strongest brake in the schema degrades to no brake at all, which is the one failure direction this feature cannot have. The device test set both, so it passed. Gate on `action.confirm != null || action.confirmPhrase != null`. **3. `download` is parsed and never read.** `openUrl` unconditionally saves to the Downloads collection; nothing reads `UiAction.download`, so `download = false` — "hand it to the platform to view" per the PR body — behaves identically to `download = true`. Either implement the view path (`ACTION_VIEW` on the MediaStore URI, or the content URI via FileProvider) or drop the field until it does something, so core#35 does not ship a contract the renderer silently ignores. ## Should fix **4. RFC 5987 filenames come out mangled.** `Regex("""filename\*?=\"?([^\";]+)""")` treats `filename*=UTF-8''R%C3%A4dme.txt` as a plain value and hands `UTF-8''R%C3%A4dme.txt` to `DISPLAY_NAME` — charset prefix and percent-escapes included. Any non-ASCII name from a server that does the correct thing lands wrong. Prefer `filename*`, strip the `charset''` prefix, percent-decode; fall back to plain `filename`. **5. Progress callbacks mutate state off the main thread.** Both `SchemaViewModel.kt:506` and `:663` run `_state.value = _state.value.copy(...)` from `Dispatchers.IO`. That is a non-atomic read-modify-write, and it is now racing the main-thread writers in this same class (live events, `refetchAll`, `runningAction`) — a lost update drops a live sample or strands `runningAction`. `MutableStateFlow.update {}` is the CAS version; the file has 22 of these and currently no `update` calls, so at minimum convert the two that are no longer main-only. **6. The Downloads row never gets a `MIME_TYPE`.** For a route that addresses a file opaquely, `fileNameOf` yields an extension-less name, MediaProvider infers `application/octet-stream` at insert, and the later `DISPLAY_NAME` update does not revise the type — the file lands in Downloads with no handler when tapped. The device test used `README.txt`, where the path already carried the extension, so this path was not exercised. Set `MIME_TYPE` from the response `Content-Type` alongside the rename. **7. Neither transfer clears its trace.** `picked` survives a successful multipart submit, so the form still lists the file and a second tap re-uploads it; `state.downloaded` is only ever set, so "Saved X to Downloads" stays on the page and is still showing while the *next* download runs. Clear `picked` on success and `downloaded` at the start of `openAction`. ## Smaller - `field.multiple` is wired to `ActivityResultContracts.OpenDocument()`, which is single-select — "multiple" currently means tapping "Choose another" once per file. `OpenMultipleDocuments` is the matching contract. - `accept` is split straight into `EXTRA_MIME_TYPES` (`FormCard.kt:228`). HTML-style entries like `.png` match nothing and the picker comes up empty. Does core#35 restrict `accept` to MIME globs? Worth stating in the contract either way. - `openUrl` is always joined onto `$baseUrl/api/mod/$module`, so an absolute URL in `open` builds a broken one. Fine if the contract says module-relative — same question for core#35. - When the provider omits `SIZE`, `sizeBytes` is `-1`, the multipart body goes chunked and `total <= 0` makes the percentage null, so the progress affordance silently never appears. Same on the download side when there is no `Content-Length`, except there `transferPercent` flips to null mid-transfer and the "Downloading" line vanishes rather than never appearing. An indeterminate "uploading…" would be honest in both. - `fileNameOf` is `internal` and pure, and the repo already unit-tests exactly this shape of helper (`MatchPageTest`, `SchemaBadgeTest`) — it has no test, and neither does the disposition parsing, which is worth extracting so it can have one. The query-vs-path precedence and the RFC 5987 case are both cheap table tests. - Import order is scrambled in the touched files — `okio.buffer` inside the okhttp3 block in `ApiClient.kt`, `OutlinedTextField` and `UploadPart` above the compose blocks in `SchemaScreen.kt`/`FormCard.kt`. The KDoc-nesting and `open`-keyword notes are good ones to have left in the source rather than only in the PR body.
@ -270,0 +294,4 @@
@SerialName("open")
val openUrl: String? = null,
/** `open` saves the bytes rather than viewing them */
val download: Boolean = false,
Owner

Nothing reads this. openUrl always saves to Downloads, so download = false ("view it") behaves exactly like download = true. Either implement the ACTION_VIEW path or drop the field until the renderer honours it, so core#35 does not publish a contract this side ignores.

Nothing reads this. `openUrl` always saves to Downloads, so `download = false` ("view it") behaves exactly like `download = true`. Either implement the `ACTION_VIEW` path or drop the field until the renderer honours it, so core#35 does not publish a contract this side ignores.
@ -115,0 +181,4 @@
// the route addresses a file by an opaque param, so the only
// place its real name exists is the disposition header
res.header("content-disposition")
?.let { Regex("""filename\*?=\"?([^\";]+)""").find(it) }
Owner

RFC 5987: filename*=UTF-8''R%C3%A4dme.txt matches here and captures UTF-8''R%C3%A4dme.txt verbatim, charset prefix and percent-escapes included, straight into DISPLAY_NAME. Prefer filename* (strip the charset'' prefix, percent-decode), fall back to plain filename. Worth extracting so it can carry a unit test.

RFC 5987: `filename*=UTF-8''R%C3%A4dme.txt` matches here and captures `UTF-8''R%C3%A4dme.txt` verbatim, charset prefix and percent-escapes included, straight into `DISPLAY_NAME`. Prefer `filename*` (strip the `charset''` prefix, percent-decode), fall back to plain `filename`. Worth extracting so it can carry a unit test.
@ -197,0 +211,4 @@
"file" -> {
val chosen = picked[field.key].orEmpty()
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
Owner

OpenDocument() is single-select, so field.multiple currently means tapping "Choose another" once per file. OpenMultipleDocuments is the contract that matches the flag. Also: picked is not cleared after a successful submit, so the form keeps listing the file and a second tap re-uploads it.

`OpenDocument()` is single-select, so `field.multiple` currently means tapping "Choose another" once per file. `OpenMultipleDocuments` is the contract that matches the flag. Also: `picked` is not cleared after a successful submit, so the form keeps listing the file and a second tap re-uploads it.
Owner

This gates only on confirm, so an action carrying confirmPhrase alone opens no dialog and fires on the first tap — the strongest brake degrades to none. The device test set both, which is why it passed. Needs action.confirm != null || action.confirmPhrase != null.

This gates only on `confirm`, so an action carrying `confirmPhrase` alone opens no dialog and fires on the first tap — the strongest brake degrades to none. The device test set both, which is why it passed. Needs `action.confirm != null || action.confirmPhrase != null`.
@ -428,0 +490,4 @@
val outcome = runCatching {
withContext(Dispatchers.IO) {
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
Owner

No MIME_TYPE on the row. For an opaquely-addressed route fileNameOf gives an extension-less name, MediaProvider infers application/octet-stream, and the later DISPLAY_NAME update does not revise the type — the file lands in Downloads with no handler when tapped. README.txt in the device test already carried its extension in the path, so this went untested. Set MIME_TYPE from the response Content-Type alongside the rename.

No `MIME_TYPE` on the row. For an opaquely-addressed route `fileNameOf` gives an extension-less name, MediaProvider infers `application/octet-stream`, and the later `DISPLAY_NAME` update does not revise the type — the file lands in Downloads with no handler when tapped. `README.txt` in the device test already carried its extension in the path, so this went untested. Set `MIME_TYPE` from the response `Content-Type` alongside the rename.
@ -428,0 +494,4 @@
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
Owner

MediaStore.Downloads is since="29"; minSdk is 26 and there is no guard, no lint config and no baseline in the repo. On API 26-28 this throws NoSuchFieldError on the first download. NewApi is an error by default, so lint likely was not re-run after the MediaStore rewrite. Guard with Build.VERSION.SDK_INT >= 29 plus a legacy DIRECTORY_DOWNLOADS path, or raise minSdk deliberately.

`MediaStore.Downloads` is `since="29"`; `minSdk` is 26 and there is no guard, no lint config and no baseline in the repo. On API 26-28 this throws `NoSuchFieldError` on the first download. `NewApi` is an error by default, so lint likely was not re-run after the MediaStore rewrite. Guard with `Build.VERSION.SDK_INT >= 29` plus a legacy `DIRECTORY_DOWNLOADS` path, or raise `minSdk` deliberately.
@ -428,0 +503,4 @@
path,
onProgress = { got, total ->
val pct = if (total > 0) ((got * 100) / total).toInt() else null
_state.value = _state.value.copy(transferPercent = pct)
Owner

This runs on Dispatchers.IO now, and _state.value = _state.value.copy(...) is a non-atomic read-modify-write racing the main-thread writers in this class (live events, refetchAll, runningAction). A lost update drops a live sample or strands runningAction. Use _state.update { it.copy(...) }; same at :663.

This runs on `Dispatchers.IO` now, and `_state.value = _state.value.copy(...)` is a non-atomic read-modify-write racing the main-thread writers in this class (live events, `refetchAll`, `runningAction`). A lost update drops a live sample or strands `runningAction`. Use `_state.update { it.copy(...) }`; same at :663.
**MediaStore.Downloads is API 29; minSdk is 26.** No guard, so the first
download on Android 8-9 would have thrown `NoSuchFieldError` — and the
emulator being API 36 is why nothing said so. Guarded now, with a legacy path
that writes to the app's own external Downloads directory: before API 29 there
is no collection to insert into without WRITE_EXTERNAL_STORAGE, and asking for
storage permission to save one file is worse than the directory. The
`@RequiresApi` annotation is what makes lint see the guard, which it cannot do
across a function boundary. Fresh `lintDebug`: back to the 3 pre-existing
errors, none from this branch.

**A phrase with no `confirm` fired on the first tap.** The dispatch gated on
`confirm` alone, so the strongest brake in the schema degraded to no brake —
the one direction it must never fail. It gates on either now. Worth noting the
schema refuses that combination anyway (`confirmPhrase` needs `confirm`, core
#35), so this is the second lock rather than the first.

**`download` was parsed and never read.** Save and view behaved identically.
Without it the action asked the platform to OPEN the thing, so it does: the
saved file goes to `ACTION_VIEW`, and a phone with nothing installed to open
it still keeps the file, which is the part that was asked for.

Also from review:

- **RFC 5987 filenames came out mangled.** `filename*=UTF-8''R%C3%A4dme.txt`
  was treated as a plain value, charset prefix and escapes included. Parsing
  moved into `Disposition.kt`, prefers the encoded form, strips the charset and
  language, percent-decodes — with its own decoder rather than `Uri.decode`,
  because `Uri` has no JVM implementation and naming a downloaded file is
  exactly the logic worth testing on the desk. A name from a header is remote
  input, so it may not choose a directory either.
- **Progress callbacks did a read-modify-write from an IO thread**, racing the
  main-thread writers in the same class. `update {}` on both.
- **The Downloads row never got a MIME_TYPE**, so an extension-less name landed
  with no handler. Set from the response's content type alongside the rename.
- **Neither transfer cleared its trace**: a submitted form still listed its
  file and would re-upload it, and "Saved X" stayed on screen through the next
  download.
- `multiple` now uses `OpenMultipleDocuments` rather than meaning "tap the
  button again"; `accept` drops HTML-style `.png` entries, which match nothing
  in the picker and come back empty; an unknown size shows "uploading…" and
  "Downloading…" rather than silently no affordance.
- 10 unit tests for the two pure helpers, in the style the repo already uses.

Verified on the device against a schema-only fixture that exercises all four
additions: an empty phrase sends no request and keeps the dialog up; a
download saves as `Rädme.txt` with `mime_type=text/plain` where the previous
attempt in the same table still reads `application/octet-stream`; and
`download: false` opens the system chooser instead of saving silently.
Author
Owner

All of it addressed in cbc1ce5. You were right that the first two meant the
device test passed for reasons narrower than the feature.

Blocking

1. API 29 on a minSdk 26 app. Guarded, with a legacy path writing to the
app's own external Downloads directory — before 29 there is no collection to
insert into without WRITE_EXTERNAL_STORAGE, and asking for storage permission
to save one file is the worse trade. @RequiresApi on the MediaStore branch is
what lets lint see the guard, which it cannot do across a function boundary.
Fresh lintDebug: back to your 3 pre-existing errors, none from this
branch. You were also right that I had not re-run it after the rewrite.

2. confirmPhrase inert without confirm. Gates on either now. One thing
worth knowing: the schema refuses that combination at load anyway — core#35
validation requires a confirm for a phrase to live in, precisely so a client
that ignores the field falls back to a real dialog. So this is the second lock,
not the first. Still wrong to depend on the server for it.

3. download now does something. Without it the action asked the platform
to open the thing, so it does: the saved file goes to ACTION_VIEW, and if
nothing is installed to handle it the file is still saved.

Should fix

4. RFC 5987. Extracted to Disposition.kt: prefers filename*, strips
charset and language, percent-decodes — with its own decoder rather than
Uri.decode, since Uri has no JVM implementation and this is exactly the
logic worth testing on the desk. A header is remote input, so the name may not
choose a directory either.

5. update {} on both off-main writers.

6. MIME_TYPE set from the response's content type alongside the rename.

7. Both traces clearedpicked via a generation counter from the
ViewModel, downloaded at the start of the next transfer.

Smaller

OpenMultipleDocuments when multiple; accept drops HTML-style .png
entries that make the picker come up empty; unknown size shows "uploading…" and
"Downloading…" rather than no affordance at all; 10 unit tests for the two pure
helpers.

On your contract questions for core#35: open is module-relative by
definition — validation rejects anything not starting with /, so an absolute
URL cannot reach the renderer. accept is a picker hint with no format
constraint, which is why the filtering happens here.

Verified on the device

Against a schema-only fixture exercising all four additions rather than the
filebrowser module:

result
phrase gate empty phrase → no request reaches the server, dialog stays up
RFC 5987 + mime saved as Rädme.txt, mime_type=text/plain — the row above it, from before the fix, still reads application/octet-stream
download: false opens the system chooser (ResolverActivity) instead of saving silently

The API 26–28 path is reasoned, not run: the emulator is API 36, which is the
same gap that hid the crash in the first place.

All of it addressed in `cbc1ce5`. You were right that the first two meant the device test passed for reasons narrower than the feature. ## Blocking **1. API 29 on a minSdk 26 app.** Guarded, with a legacy path writing to the app's own external Downloads directory — before 29 there is no collection to insert into without `WRITE_EXTERNAL_STORAGE`, and asking for storage permission to save one file is the worse trade. `@RequiresApi` on the MediaStore branch is what lets lint see the guard, which it cannot do across a function boundary. Fresh `lintDebug`: **back to your 3 pre-existing errors**, none from this branch. You were also right that I had not re-run it after the rewrite. **2. `confirmPhrase` inert without `confirm`.** Gates on either now. One thing worth knowing: the schema refuses that combination at load anyway — core#35 validation requires a `confirm` for a phrase to live in, precisely so a client that ignores the field falls back to a real dialog. So this is the second lock, not the first. Still wrong to depend on the server for it. **3. `download` now does something.** Without it the action asked the platform to *open* the thing, so it does: the saved file goes to `ACTION_VIEW`, and if nothing is installed to handle it the file is still saved. ## Should fix **4. RFC 5987.** Extracted to `Disposition.kt`: prefers `filename*`, strips charset and language, percent-decodes — with its own decoder rather than `Uri.decode`, since `Uri` has no JVM implementation and this is exactly the logic worth testing on the desk. A header is remote input, so the name may not choose a directory either. **5. `update {}`** on both off-main writers. **6. `MIME_TYPE`** set from the response's content type alongside the rename. **7. Both traces cleared** — `picked` via a generation counter from the ViewModel, `downloaded` at the start of the next transfer. ## Smaller `OpenMultipleDocuments` when `multiple`; `accept` drops HTML-style `.png` entries that make the picker come up empty; unknown size shows "uploading…" and "Downloading…" rather than no affordance at all; 10 unit tests for the two pure helpers. On your contract questions for core#35: `open` is module-relative by definition — validation rejects anything not starting with `/`, so an absolute URL cannot reach the renderer. `accept` is a picker hint with no format constraint, which is why the filtering happens here. ## Verified on the device Against a schema-only fixture exercising all four additions rather than the filebrowser module: | | result | |---|---| | phrase gate | empty phrase → no request reaches the server, dialog stays up | | RFC 5987 + mime | saved as `Rädme.txt`, `mime_type=text/plain` — the row above it, from before the fix, still reads `application/octet-stream` | | `download: false` | opens the system chooser (`ResolverActivity`) instead of saving silently | The API 26–28 path is reasoned, not run: the emulator is API 36, which is the same gap that hid the crash in the first place.
julian approved these changes 2026-08-12 10:28:00 +02:00
julian left a comment

All three blockers are gone, and I checked the two claims that carried weight rather than taking them. ./gradlew lintDebug on cbc1ce5: 3 errors, 14 warningsURLDecoder at SchemaViewModel.kt:105, windowLayoutInDisplayCutoutMode, and the Flow-in-composition one. The MediaStore NewApi error is gone, so the guard plus @RequiresApi does what it claims. testDebugUnitTest passes.

The phrase gate opening on either field is right, and keeping it a client-side lock rather than trusting core#35's validation to be the only one is the version that survives a schema written by hand. download reaching ACTION_VIEW while still keeping the file is the correct reading of the verb. Disposition.kt with its own decoder and desk tests is a better shape than the regex it replaced.

Nothing blocks. What follows is for later, except the first, which is nearly free.

Worth doing here, because the fix is already in the branch

decodeSegment is the same bug you just fixed, 400 lines up. SchemaViewModel.kt:105 calls java.net.URLDecoder.decode(String, Charset) — the overload is API 33, there is no core library desugaring in the build, and decodeSegment runs for every :param on every navigation (matchPage, line 117). So on API 26–32 any parameterised page throws NoSuchMethodError — which is the whole filebrowser module. It has been sitting as one of the three "pre-existing" lint errors, and its own KDoc says it exists to stay off android.net.Uri for testability, which is exactly what percentDecode now does: min-26-safe, already imported in this file, and it leaves + literal so the replace("+", "%2B") shield goes with it. One line, retires a lint error and a crash, MatchPageTest should stay green.

Related: CI only runs testReleaseUnitTest on push to mainlintDebug is nowhere in .forgejo/workflows/build.yml, so nothing but a reviewer will ever mention these three. A lint step (with a baseline for the two you are choosing to keep) would make the next NewApi land loudly instead of on a device.

Should fix

The device check stopped one tap short on download: false. The chooser opening proves the intent resolved; it does not prove the target could read the URI. On the MediaStore path saved.uri is content://media/… — a provider this app does not own, so FLAG_GRANT_READ_URI_PERMISSION is not ours to hand out, and on API 33+ the receiving app holds no READ_MEDIA_* that covers a non-media file in Downloads. Worth tapping through to a real viewer once and confirming it renders; if it does not, viewing wants a FileProvider URI the way the 26–28 path already builds one.

"Saved X to Downloads" is not true on 26–28 (SchemaScreen.kt:212). That path writes to getExternalFilesDir(DIRECTORY_DOWNLOADS): app-private, gone on uninstall, not the Downloads anybody opens a file manager to find. The trade-off is the right one — the sentence just should not promise the other thing.

A second download of the same file overwrites, or does not. File(dir, name) + renameTo replaces silently on 26–28. On 29+ the insert dedupes fallbackName, but the later update writes the served DISPLAY_NAME without reading back what MediaStore settled on, so downloaded can name a file that is not the one that landed — and a colliding DISPLAY_NAME on update is the case I would want to watch once on a device before trusting it.

Smaller

  • fallbackName from fileNameOf never meets sanitiseFilename, while the header form does. open: "/dl?path={p}" with p = "/srv/.." yields ".." as a DISPLAY_NAME and as File(dir, ".."). Same server either way, so not a security story — but the guard exists and costs one call.
  • percentDecode always decodes as UTF-8 and drops the charset it just stripped, so filename*=ISO-8859-1''caf%E9.txt comes back with a replacement character. Everything sends UTF-8; a note, not a bug.
  • The accept filter drops .png but keeps image — anything without a / is equally not a MIME type and equally empties the picker. filter { "/" in it } covers both cases with less.
  • submitGeneration is page-wide: a successful submit in one form drops the picked files of every other form on the page.
  • Import order is still scrambled — the new block in SchemaViewModel.kt puts androidx, cloud, java.io.File and okio.sink above android.net.Uri and android.provider.MediaStore. Raised last round and still nothing in the build says so; ktlint or spotless would end the category rather than the instance.

The reasoning-not-running note on 26–28 is the honest thing to have written, and it is also the range decodeSegment breaks in — which is an argument for fixing that one before the next APK goes out.

All three blockers are gone, and I checked the two claims that carried weight rather than taking them. `./gradlew lintDebug` on `cbc1ce5`: **3 errors, 14 warnings** — `URLDecoder` at `SchemaViewModel.kt:105`, `windowLayoutInDisplayCutoutMode`, and the Flow-in-composition one. The `MediaStore` `NewApi` error is gone, so the guard plus `@RequiresApi` does what it claims. `testDebugUnitTest` passes. The phrase gate opening on either field is right, and keeping it a client-side lock rather than trusting core#35's validation to be the only one is the version that survives a schema written by hand. `download` reaching `ACTION_VIEW` while still keeping the file is the correct reading of the verb. `Disposition.kt` with its own decoder and desk tests is a better shape than the regex it replaced. Nothing blocks. What follows is for later, except the first, which is nearly free. ## Worth doing here, because the fix is already in the branch **`decodeSegment` is the same bug you just fixed, 400 lines up.** `SchemaViewModel.kt:105` calls `java.net.URLDecoder.decode(String, Charset)` — the overload is API 33, there is no core library desugaring in the build, and `decodeSegment` runs for **every** `:param` on every navigation (`matchPage`, line 117). So on API 26–32 any parameterised page throws `NoSuchMethodError` — which is the whole filebrowser module. It has been sitting as one of the three "pre-existing" lint errors, and its own KDoc says it exists to stay off `android.net.Uri` for testability, which is exactly what `percentDecode` now does: min-26-safe, already imported in this file, and it leaves `+` literal so the `replace("+", "%2B")` shield goes with it. One line, retires a lint error and a crash, `MatchPageTest` should stay green. Related: CI only runs `testReleaseUnitTest` on push to `main` — `lintDebug` is nowhere in `.forgejo/workflows/build.yml`, so nothing but a reviewer will ever mention these three. A `lint` step (with a baseline for the two you are choosing to keep) would make the next NewApi land loudly instead of on a device. ## Should fix **The device check stopped one tap short on `download: false`.** The chooser opening proves the intent resolved; it does not prove the target could read the URI. On the MediaStore path `saved.uri` is `content://media/…` — a provider this app does not own, so `FLAG_GRANT_READ_URI_PERMISSION` is not ours to hand out, and on API 33+ the receiving app holds no `READ_MEDIA_*` that covers a non-media file in Downloads. Worth tapping through to a real viewer once and confirming it renders; if it does not, viewing wants a FileProvider URI the way the 26–28 path already builds one. **"Saved X to Downloads" is not true on 26–28** (`SchemaScreen.kt:212`). That path writes to `getExternalFilesDir(DIRECTORY_DOWNLOADS)`: app-private, gone on uninstall, not the Downloads anybody opens a file manager to find. The trade-off is the right one — the sentence just should not promise the other thing. **A second download of the same file overwrites, or does not.** `File(dir, name)` + `renameTo` replaces silently on 26–28. On 29+ the insert dedupes `fallbackName`, but the later `update` writes the served `DISPLAY_NAME` without reading back what MediaStore settled on, so `downloaded` can name a file that is not the one that landed — and a colliding `DISPLAY_NAME` on update is the case I would want to watch once on a device before trusting it. ## Smaller - `fallbackName` from `fileNameOf` never meets `sanitiseFilename`, while the header form does. `open: "/dl?path={p}"` with `p = "/srv/.."` yields `".."` as a `DISPLAY_NAME` and as `File(dir, "..")`. Same server either way, so not a security story — but the guard exists and costs one call. - `percentDecode` always decodes as UTF-8 and drops the charset it just stripped, so `filename*=ISO-8859-1''caf%E9.txt` comes back with a replacement character. Everything sends UTF-8; a note, not a bug. - The `accept` filter drops `.png` but keeps `image` — anything without a `/` is equally not a MIME type and equally empties the picker. `filter { "/" in it }` covers both cases with less. - `submitGeneration` is page-wide: a successful submit in one form drops the picked files of every other form on the page. - Import order is still scrambled — the new block in `SchemaViewModel.kt` puts `androidx`, `cloud`, `java.io.File` and `okio.sink` above `android.net.Uri` and `android.provider.MediaStore`. Raised last round and still nothing in the build says so; ktlint or spotless would end the category rather than the instance. The reasoning-not-running note on 26–28 is the honest thing to have written, and it is also the range `decodeSegment` breaks in — which is an argument for fixing that one before the next APK goes out.
@ -0,0 +67,4 @@
i += 1
}
}
return out.toString(Charsets.UTF_8.name())
Owner

The charset is stripped at :29 and then assumed UTF-8 here, so filename*=ISO-8859-1''caf%E9.txt decodes to a replacement character. In practice everything sends UTF-8 — worth a line in the KDoc saying so deliberately rather than leaving it looking unnoticed.

The charset is stripped at :29 and then assumed UTF-8 here, so `filename*=ISO-8859-1''caf%E9.txt` decodes to a replacement character. In practice everything sends UTF-8 — worth a line in the KDoc saying so deliberately rather than leaving it looking unnoticed.
@ -197,0 +249,4 @@
val types = field.accept
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() && !it.startsWith(".") }
Owner

.png goes, but image stays — and an entry with no / is equally not a MIME type and equally brings the picker up empty. filter { "/" in it } is the same line and covers both.

`.png` goes, but `image` stays — and an entry with no `/` is equally not a MIME type and equally brings the picker up empty. `filter { "/" in it }` is the same line and covers both.
@ -428,0 +494,4 @@
fun openAction(action: UiAction) {
val client = container.clientOrNull ?: return
val path = interpolate(action.openUrl ?: return, _state.value.params, encode = true)
val fallbackName = fileNameOf(path)
Owner

This one never meets sanitiseFilename, though the header-derived name does. path=%2Fsrv%2F.. yields "..", which then goes in as a DISPLAY_NAME and as File(dir, ".."). Same server as the schema, so not a security story — but the guard already exists.

This one never meets `sanitiseFilename`, though the header-derived name does. `path=%2Fsrv%2F..` yields `".."`, which then goes in as a `DISPLAY_NAME` and as `File(dir, "..")`. Same server as the schema, so not a security story — but the guard already exists.
@ -428,0 +617,4 @@
temp.sink().buffer().use { sink -> sink.writeAll(source) }
}
if (code >= 400) error(detail?.ifBlank { null } ?: "HTTP $code")
val target = File(dir, name)
Owner

renameTo over an existing target replaces it silently — a second download of the same name eats the first. On the MediaStore side the mirror of this is the update at :578 setting DISPLAY_NAME without reading back what the provider settled on, so Saved … can name a file that is not what landed.

`renameTo` over an existing `target` replaces it silently — a second download of the same name eats the first. On the MediaStore side the mirror of this is the `update` at :578 setting `DISPLAY_NAME` without reading back what the provider settled on, so `Saved …` can name a file that is not what landed.
@ -428,0 +642,4 @@
private fun view(app: Application, saved: Saved) {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(saved.uri, saved.mime ?: "*/*")
Owner

saved.uri is a MediaStore URI on 29+, and MediaProvider is not ours — FLAG_GRANT_READ_URI_PERMISSION on a provider you do not own is not a grant you can make. On API 33+ the receiving app has no READ_MEDIA_* covering a non-media file in the Downloads collection, so the chooser can open and the viewer still fail. The 26–28 branch already builds the shape that works (FileProvider); worth confirming on a device whether the 29+ one needs it too.

`saved.uri` is a MediaStore URI on 29+, and MediaProvider is not ours — `FLAG_GRANT_READ_URI_PERMISSION` on a provider you do not own is not a grant you can make. On API 33+ the receiving app has no `READ_MEDIA_*` covering a non-media file in the Downloads collection, so the chooser can open and the viewer still fail. The 26–28 branch already builds the shape that works (FileProvider); worth confirming on a device whether the 29+ one needs it too.
`decodeSegment` was the same bug the download side had just fixed, 400 lines
up: `java.net.URLDecoder.decode(String, Charset)` is API 33, nothing desugars
the core library, and it runs for every `:param` on every navigation — so on
API 26-32 any parameterised page threw `NoSuchMethodError` before it drew
anything, which is every page the filebrowser module has. It now calls
`percentDecode`, which is min-26 safe, already imported, and leaves "+"
literal, so the shield URLDecoder's query semantics needed goes with it.

That retires one of the three "pre-existing" lint errors — and nothing in the
build was ever going to mention the other two, since CI runs only
`testReleaseUnitTest`. `lintDebug` is a step now, against a baseline holding
exactly the two known errors. Recorded, not silenced: a new one is not in the
file and fails the build.

Also from review:

- **"Saved X to Downloads" was not true on 26-28**, where the file goes to the
  app's own external directory: app-private, gone on uninstall, not the
  Downloads anybody opens a file manager to find. The trade-off stays; the
  sentence no longer promises the other thing.
- **A second download of the same file replaced the first.** `renameTo` does
  that silently on 26-28, so the name is picked with `uniqueName` now —
  "a.txt", then "a (2).txt". On 29+ MediaStore dedupes the insert and can
  refuse the rename, so the final `DISPLAY_NAME` is read back rather than
  assumed, and clearing `IS_PENDING` no longer depends on the rename
  succeeding: a pending row is invisible and never completes.
- **`fallbackName` never met `sanitiseFilename`** while the header form did, so
  `open: "/dl?path={p}"` with `p = "/srv/.."` named the file "..". A route
  parameter is remote input exactly as a header is.
- **The `accept` filter dropped ".png" but kept "image".** Anything without a
  "/" is equally not a MIME type and equally empties the picker.
- **`percentDecode` ignored the charset it had just stripped**, so a server
  that declares ISO-8859-1 and means it came back with a replacement
  character. An unsupported name falls back to UTF-8 rather than failing a
  download that already landed.
- **`submitGeneration` was page-wide**, so a successful submit in one form
  dropped the picked files of every other form on the page. Keyed by block
  index now — the same key the list already uses.
- Import order in the touched files, by hand. A formatter would end the
  category rather than the instance, but it reflows every file in the repo and
  does not belong in this branch.

Ten unit tests become thirteen: the charset case, the route-parameter guard,
and the collision numbering including the leading-dot and no-extension shapes.

`lintDebug` green against the baseline, `testDebugUnitTest` green. Still not
device-verified: whether a viewer app can actually read the MediaStore URI
handed to ACTION_VIEW on API 33+.
Author
Owner

All of the follow-ups addressed in 2284551, including the ones you filed as "for later".

Worth doing here

decodeSegment. Right, and it was the same bug 400 lines up from the one I had just fixed. percentDecode now, which is min-26 safe and leaves + literal, so the replace("+", "%2B") shield went with it. MatchPageTest green untouched.

A lint step. lintDebug runs in CI against app/lint-baseline.xml, holding exactly the two errors we are choosing to keep. Recorded rather than silenced: a new NewApi is not in the file and fails the build instead of waiting for a reviewer.

Should fix

"Saved X to Downloads" on 26-28. Saved carries whether the file reached the shared collection; below 29 the line reads "Saved X to the app's files". The trade-off stays what it was — the sentence just no longer sends anybody to a file manager.

A second download of the same file. uniqueName on the legacy path, so the second copy is a (2).txt rather than a silent replacement (a leading dot is the name, not an extension, and no extension at all works too — both are desk tests). On 29+ the served DISPLAY_NAME is written, and then the row's actual name is read back and reported, since MediaStore dedupes an insert and can refuse an update. Clearing IS_PENDING no longer rides along with the rename either: a rejected rename used to leave a pending row, which is invisible and never completes.

Smaller

  • fallbackName meets sanitiseFilename now, via fileNameOf itself — a route parameter is remote input exactly as a header is.
  • percentDecode takes the charset the header declared. filename*=ISO-8859-1''caf%E9.txt comes back café.txt; a charset nothing has heard of falls back to UTF-8 rather than failing a download whose bytes are already on disk.
  • accept filters on "/" in it, which drops .png and bare image with less code than the old rule that only caught one of them.
  • submitGeneration is keyed by block index — the same key the block list already uses — so a submit only drops its own form's picks.
  • Import order in the touched files, by hand. A formatter is the right answer to the category and I left it out on purpose: ktlint/spotless reflows every file in the repo, which is not a diff to bury in an approved branch. Opening it as its own issue.

Three more unit tests: the charset, the route-parameter guard, and the collision numbering.

Verification

./gradlew lintDebug — no new issues, 2 errors filtered by the baseline. testDebugUnitTest green, 13 cases in DownloadNamingTest.

Not verified: the one thing that needed a device rather than an argument — whether a viewer app can actually read the MediaStore URI handed to ACTION_VIEW on API 33+. The fixture is gone and I did not rebuild it for this. The mechanism is the ordinary one (we own the row, MediaProvider grants read on the flag, same path a gallery share takes), but that is reasoning, not running, which is exactly the distinction this review has been about twice. If it turns out not to render, viewing wants the FileProvider URI the 26-28 path already builds.

All of the follow-ups addressed in `2284551`, including the ones you filed as "for later". ## Worth doing here **`decodeSegment`.** Right, and it was the same bug 400 lines up from the one I had just fixed. `percentDecode` now, which is min-26 safe and leaves `+` literal, so the `replace("+", "%2B")` shield went with it. `MatchPageTest` green untouched. **A lint step.** `lintDebug` runs in CI against `app/lint-baseline.xml`, holding exactly the two errors we are choosing to keep. Recorded rather than silenced: a new `NewApi` is not in the file and fails the build instead of waiting for a reviewer. ## Should fix **"Saved X to Downloads" on 26-28.** `Saved` carries whether the file reached the shared collection; below 29 the line reads "Saved X to the app's files". The trade-off stays what it was — the sentence just no longer sends anybody to a file manager. **A second download of the same file.** `uniqueName` on the legacy path, so the second copy is `a (2).txt` rather than a silent replacement (a leading dot is the name, not an extension, and no extension at all works too — both are desk tests). On 29+ the served `DISPLAY_NAME` is written, and then the row's actual name is **read back** and reported, since MediaStore dedupes an insert and can refuse an update. Clearing `IS_PENDING` no longer rides along with the rename either: a rejected rename used to leave a pending row, which is invisible and never completes. ## Smaller - `fallbackName` meets `sanitiseFilename` now, via `fileNameOf` itself — a route parameter is remote input exactly as a header is. - `percentDecode` takes the charset the header declared. `filename*=ISO-8859-1''caf%E9.txt` comes back `café.txt`; a charset nothing has heard of falls back to UTF-8 rather than failing a download whose bytes are already on disk. - `accept` filters on `"/" in it`, which drops `.png` and bare `image` with less code than the old rule that only caught one of them. - `submitGeneration` is keyed by block index — the same key the block list already uses — so a submit only drops its own form's picks. - Import order in the touched files, by hand. A formatter is the right answer to the category and I left it out on purpose: `ktlint`/`spotless` reflows every file in the repo, which is not a diff to bury in an approved branch. Opening it as its own issue. Three more unit tests: the charset, the route-parameter guard, and the collision numbering. ## Verification `./gradlew lintDebug` — no new issues, 2 errors filtered by the baseline. `testDebugUnitTest` green, 13 cases in `DownloadNamingTest`. **Not verified:** the one thing that needed a device rather than an argument — whether a viewer app can actually read the MediaStore URI handed to `ACTION_VIEW` on API 33+. The fixture is gone and I did not rebuild it for this. The mechanism is the ordinary one (we own the row, MediaProvider grants read on the flag, same path a gallery share takes), but that is reasoning, not running, which is exactly the distinction this review has been about twice. If it turns out not to render, viewing wants the FileProvider URI the 26-28 path already builds.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/mobile!2
No description provided.