From ee09fc4014f167861acfc504298eddcb5ca80a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 00:53:09 +0000 Subject: [PATCH] feat(audio-rooms): URL-based font loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the deferred font-URL path of the kind-30312 `["f", family, url]` tag. New RoomFontLoader downloads the font asset via the account's image-channel OkHttpClient (so it picks up the same Tor / privacy posture as image fetches), caches under `cacheDir/audio-room-fonts/`, and wraps the local file as a Compose FontFamily via `Font(file = ..., ...)`. Same URL → same cache key → no repeat downloads across launches. Failures (404, malformed font, Tor circuit timeout) fall back to the system-font-name mapping or platform default — themes never fail loud. AudioRoomThemedScope subscribes via `produceState`; while the download is in flight the typography stays on the system-font fallback so the room renders immediately. Once the file lands, the URL-loaded family takes over. --- .../audiorooms/room/AudioRoomFullScreen.kt | 2 +- .../audiorooms/room/AudioRoomThemedScope.kt | 36 ++++++-- .../audiorooms/room/RoomFontLoader.kt | 91 +++++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/RoomFontLoader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 7aa52082b..174210b29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -88,7 +88,7 @@ internal fun AudioRoomFullScreen( onLeave: () -> Unit, ) { val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) } - AudioRoomThemedScope(theme = roomTheme) { + AudioRoomThemedScope(theme = roomTheme, accountViewModel = accountViewModel) { Column( modifier = Modifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt index cbfac2b10..c2960a8b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomThemedScope.kt @@ -25,13 +25,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** * Apply a [RoomTheme] (NIP-53 `c` / `bg` tags) to the room body @@ -47,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme @Composable internal fun AudioRoomThemedScope( theme: RoomTheme, + accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { val original = MaterialTheme.colorScheme @@ -65,9 +70,28 @@ internal fun AudioRoomThemedScope( } val originalTypography = MaterialTheme.typography + + // URL-based font loading. produceState fetches + caches on + // Dispatchers.IO; until the download completes (or if it + // fails), we fall back to the system-font-name mapping below. + // Same URL → same SHA-256 cache key → no repeat downloads + // across launches. + val context = LocalContext.current + val urlFont by produceState(null, theme.fontUrl) { + val url = theme.fontUrl + value = + if (url.isNullOrBlank()) { + null + } else { + RoomFontLoader.load(url, context, accountViewModel.httpClientBuilder::okHttpClientForImage) + } + } val themedTypography = - remember(theme.fontFamily, originalTypography) { - val family = systemFontFamilyFor(theme.fontFamily) + remember(theme.fontFamily, urlFont, originalTypography) { + // Prefer the URL-loaded font when available; fall back + // to the system-font-name mapping ("sans-serif" etc.) + // while the download is in flight or after a failure. + val family = urlFont ?: systemFontFamilyFor(theme.fontFamily) if (family == null) { originalTypography } else { @@ -101,12 +125,10 @@ internal fun AudioRoomThemedScope( /** * Map a kind-30312 `["f", family]` value to a Compose [FontFamily]. - * Only the four CSS-style generic-family names are honoured today — + * Only the four CSS-style generic-family names are honoured — * arbitrary names (e.g. "Inter") fall back to platform default - * unless a downloader is wired up. URL-based loading - * (RoomTheme.fontUrl) is the natural follow-up: fetch + cache - * via OkHttp, then build a FontFamily from a local file. Until - * then, an unknown family is silently a no-op. + * unless the room ships a [RoomTheme.fontUrl] (handled by + * [RoomFontLoader] above). */ private fun systemFontFamilyFor(name: String?): FontFamily? = when (name?.trim()?.lowercase()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/RoomFontLoader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/RoomFontLoader.kt new file mode 100644 index 000000000..a9ed37409 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/RoomFontLoader.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room + +import android.content.Context +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.security.MessageDigest + +/** + * Download a font referenced by a NIP-53 `["f", family, url]` tag, + * cache it on disk under the app's cache dir, and wrap the result + * as a Compose [FontFamily] for typography overrides. + * + * The cache key is the SHA-256 of the URL — same URL → same file + * across launches, so a returning user pays the network cost + * exactly once. Failures (404, malformed font, Tor circuit + * timeout) return null; the caller falls back to the family-name + * mapping or platform default. + * + * Runs the actual download on [Dispatchers.IO] so the @Composable + * call site can [androidx.compose.runtime.produceState] this + * without blocking recomposition. + */ +internal object RoomFontLoader { + suspend fun load( + url: String, + context: Context, + clientFor: (String) -> OkHttpClient, + ): FontFamily? = + withContext(Dispatchers.IO) { + runCatching { + val cached = ensureCached(url, context, clientFor) ?: return@runCatching null + // Compose's `Font(file)` builder reads the file lazily + // when the typography is rendered, so the IO-thread + // download here just needs to land the file on disk. + FontFamily(Font(file = cached, weight = FontWeight.Normal, style = FontStyle.Normal)) + }.getOrNull() + } + + private fun ensureCached( + url: String, + context: Context, + clientFor: (String) -> OkHttpClient, + ): File? { + val cacheDir = File(context.cacheDir, "audio-room-fonts").also { it.mkdirs() } + val file = File(cacheDir, hashName(url)) + if (file.exists() && file.length() > 0L) return file + + val client = clientFor(url) + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return null + file.outputStream().use { out -> resp.body.byteStream().copyTo(out) } + } + return file.takeIf { it.length() > 0L } + } + + private fun hashName(url: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(url.toByteArray(Charsets.UTF_8)) + // Hex SHA-256 — collision-resistant + filename-safe. + return buildString(bytes.size * 2) { + for (b in bytes) append(String.format("%02x", b)) + } + } +}