feat: gateway files client — fetch any gateway-hosted file over HTTP (#726)

* feat: gateway files client — fetch any gateway-hosted file over HTTP

Add GatewayFileClient: a typed client for the gateway's managed-files
download endpoint (GET /api/files/download?path=<enc>&token=<enc>), which
streams the raw bytes of any file living on the gateway host. This is the
mobile equivalent of desktop's mediaExternalUrl() and works on a remote
phone (real HTTP, not a host-local file read).

- buildDownloadUrl(): pure authed-URL builder (null on blank creds /
  non-absolute path), mirrors desktop's token-in-query auth.
- fetch(): OkHttp GET via the shared client, maps 401/403/404/413 to typed
  GatewayFileResult variants; returns GatewayFile(name, mime, bytes).
- MediaTypes: IMAGE/AUDIO/VIDEO/FILE classification by extension (mirrors
  desktop MEDIA_BY_EXT) so future consumers can present images, audio,
  video, or generic files — beyond just images.

Pure builders/classifiers are unit-tested. Mobile-only, backend untouched.
This is the foundation PR; issue #724's MEDIA: rendering will consume it.

* fix: decode filename + handle download-URL in mediaNameFromPath

- GatewayFileClient.parseFilename now URL-decodes the captured value so
  filename*=UTF-8''a%20b.png yields 'a b.png'.
- MediaTypes.mediaNameFromPath extracts the filename from the percent-encoded
  path= query param of a full /api/files/download?path=<enc> URL (the real
  case), not just a bare path.

* fix(remote): fix unit tests for gateway file client and media parsing

Replace plus-encoded spaces with %20 in download URL builder, URL-decode filenames in parseFilename, and extract target filename from URL path parameters.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-26 21:02:26 +03:00
1 parent 5e409e8dc6
commit 439ae9fcba
4 files changed
+385

No files matched your search

@@ -0,0 +1,78 @@
package com.m57.hermescontrol.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayFileClientTest {
private val base = "https://gw.example.com:9119"
private val tok = "s e/cret"
@Test
fun `buildDownloadUrl encodes path and token`() {
val url = GatewayFileClient.buildDownloadUrl(base, tok, "/tmp/foo.png")!!
assertEquals(
"$base/api/files/download?path=%2Ftmp%2Ffoo.png&token=${java.net.URLEncoder.encode(
tok,
"UTF-8",
).replace("+", "%20")}",
url,
)
assertTrue(url.contains("path=%2Ftmp%2Ffoo.png"))
assertTrue(url.contains("token="))
}
@Test
fun `buildDownloadUrl rejects blank base or token`() {
assertNull(GatewayFileClient.buildDownloadUrl("", tok, "/tmp/x.png"))
assertNull(GatewayFileClient.buildDownloadUrl(base, "", "/tmp/x.png"))
}
@Test
fun `buildDownloadUrl rejects relative paths`() {
assertNull(GatewayFileClient.buildDownloadUrl(base, tok, "relative/path.png"))
assertNull(GatewayFileClient.buildDownloadUrl(base, tok, "MEDIA:relative.png"))
}
@Test
fun `buildDownloadUrl handles quoted and spaced paths`() {
val url = GatewayFileClient.buildDownloadUrl(base, tok, "\"/tmp/a b.png\"")!!
assertTrue(url.contains("path=%2Ftmp%2Fa%20b.png"))
assertFalse(url.contains("MEDIA:"))
}
@Test
fun `normalizePath expands tilde`() {
val home = System.getenv("HOME") ?: "/home/test"
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
}
@Test
fun `normalizePath strips surrounding quotes`() {
assertEquals("/tmp/x.png", GatewayFileClient.normalizePath("'/tmp/x.png'"))
assertEquals("/tmp/x.png", GatewayFileClient.normalizePath("`/tmp/x.png`"))
}
@Test
fun `normalizePath requires absolute path`() {
assertNull(GatewayFileClient.normalizePath("relative.png"))
}
@Test
fun `classifyStatus maps known codes`() {
assertEquals(GatewayFileResult.NotFound, GatewayFileClient.classifyStatus(404))
assertEquals(GatewayFileResult.Forbidden, GatewayFileClient.classifyStatus(403))
assertEquals(GatewayFileResult.TooLarge, GatewayFileClient.classifyStatus(413))
assertEquals(GatewayFileResult.Unauthorized, GatewayFileClient.classifyStatus(401))
assertNull(GatewayFileClient.classifyStatus(200))
assertNull(GatewayFileClient.classifyStatus(500))
}
@Test
fun `parseFilename extracts from content-disposition`() {
assertEquals("report.pdf", GatewayFileClient.parseFilename("attachment; filename=\"report.pdf\""))
assertEquals("a b.png", GatewayFileClient.parseFilename("inline; filename*=UTF-8''a%20b.png"))
}
}
@@ -0,0 +1,38 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Test
class MediaTypesTest {
@Test
fun `classifies known image extensions`() {
assertEquals(MediaKind.IMAGE, mediaKindForPath("/tmp/foo.png"))
assertEquals(MediaKind.IMAGE, mediaKindForPath("a/b.jpg"))
assertEquals(MediaKind.IMAGE, mediaKindForPath("x.WEBP"))
}
@Test
fun `classifies audio and video`() {
assertEquals(MediaKind.AUDIO, mediaKindForPath("/tmp/clip.mp3"))
assertEquals(MediaKind.VIDEO, mediaKindForPath("/tmp/mov.mkv"))
}
@Test
fun `unknown extension falls back to file`() {
assertEquals(MediaKind.FILE, mediaKindForPath("/tmp/notes.txt"))
assertEquals(MediaKind.FILE, mediaKindForPath("/tmp/noext"))
}
@Test
fun `mime lookup matches kind`() {
assertEquals("image/png", mediaMimeForPath("/tmp/x.png"))
assertEquals("audio/mpeg", mediaMimeForPath("/tmp/x.mp3"))
assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird"))
}
@Test
fun `name extraction handles urls and paths`() {
assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf"))
assertEquals("a.png", mediaNameFromPath("/home/u/a.png"))
}
}