feat(audio-rooms): URL-based font loading

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/<sha256-of-url>`, 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.
This commit is contained in:
Claude
2026-04-27 00:53:09 +00:00
parent 04ac8d3157
commit ee09fc4014
3 changed files with 121 additions and 8 deletions
@@ -88,7 +88,7 @@ internal fun AudioRoomFullScreen(
onLeave: () -> Unit, onLeave: () -> Unit,
) { ) {
val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) } val roomTheme = androidx.compose.runtime.remember(event) { RoomTheme.from(event) }
AudioRoomThemedScope(theme = roomTheme) { AudioRoomThemedScope(theme = roomTheme, accountViewModel = accountViewModel) {
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -25,13 +25,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme 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 * Apply a [RoomTheme] (NIP-53 `c` / `bg` tags) to the room body
@@ -47,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
@Composable @Composable
internal fun AudioRoomThemedScope( internal fun AudioRoomThemedScope(
theme: RoomTheme, theme: RoomTheme,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val original = MaterialTheme.colorScheme val original = MaterialTheme.colorScheme
@@ -65,9 +70,28 @@ internal fun AudioRoomThemedScope(
} }
val originalTypography = MaterialTheme.typography 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<FontFamily?>(null, theme.fontUrl) {
val url = theme.fontUrl
value =
if (url.isNullOrBlank()) {
null
} else {
RoomFontLoader.load(url, context, accountViewModel.httpClientBuilder::okHttpClientForImage)
}
}
val themedTypography = val themedTypography =
remember(theme.fontFamily, originalTypography) { remember(theme.fontFamily, urlFont, originalTypography) {
val family = systemFontFamilyFor(theme.fontFamily) // 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) { if (family == null) {
originalTypography originalTypography
} else { } else {
@@ -101,12 +125,10 @@ internal fun AudioRoomThemedScope(
/** /**
* Map a kind-30312 `["f", family]` value to a Compose [FontFamily]. * 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 * arbitrary names (e.g. "Inter") fall back to platform default
* unless a downloader is wired up. URL-based loading * unless the room ships a [RoomTheme.fontUrl] (handled by
* (RoomTheme.fontUrl) is the natural follow-up: fetch + cache * [RoomFontLoader] above).
* via OkHttp, then build a FontFamily from a local file. Until
* then, an unknown family is silently a no-op.
*/ */
private fun systemFontFamilyFor(name: String?): FontFamily? = private fun systemFontFamilyFor(name: String?): FontFamily? =
when (name?.trim()?.lowercase()) { when (name?.trim()?.lowercase()) {
@@ -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))
}
}
}