From 1a9a4624f896acdd1b93f76d8295a4cab7db26d0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 10 Aug 2026 01:10:08 +0200 Subject: [PATCH] feat(android): palette resolved before the first frame storedPalette() reads and resolves the theme file without building the engine, so onCreate can set the window background and system-bar icon polarity before anything draws. Previously the background was a hex literal in themes.xml and the bar icons were pinned dark, which meant every cold start flashed one flavour's colour and Latte drew white icons on a near-white page. Adds the Material scheme mapping, type ramp and shape tokens the palette feeds, plus unit tests for both. --- .../java/io/narl/protonstream/MainActivity.kt | 38 +++ .../narl/protonstream/native/NativeRuntime.kt | 21 +- .../io/narl/protonstream/ui/theme/Accent.kt | 223 ++++++++++++++++++ .../io/narl/protonstream/ui/theme/Shape.kt | 36 +++ .../io/narl/protonstream/ui/theme/Theme.kt | 154 ++++++++++-- .../io/narl/protonstream/ui/theme/Type.kt | 75 ++++++ android/app/src/main/res/values/colors.xml | 9 + android/app/src/main/res/values/themes.xml | 17 +- .../protonstream/ui/theme/SchemeRolesTest.kt | 129 ++++++++++ .../protonstream/ui/theme/TypeRampTest.kt | 109 +++++++++ crates/pstr-android/src/lib.rs | 138 ++++++++++- 11 files changed, 918 insertions(+), 31 deletions(-) create mode 100644 android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt create mode 100644 android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt create mode 100644 android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/test/java/io/narl/protonstream/ui/theme/SchemeRolesTest.kt create mode 100644 android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt diff --git a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt index ec52ebe..aceade7 100644 --- a/android/app/src/main/java/io/narl/protonstream/MainActivity.kt +++ b/android/app/src/main/java/io/narl/protonstream/MainActivity.kt @@ -25,19 +25,25 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope +import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.playback.MpvPlaybackState import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.PlaybackService import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.ProtonStreamApp +import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.ProtonStreamTheme import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import uniffi.pstr_android.PaletteRecord class MainActivity : ComponentActivity() { private val playerHost = mutableStateOf(null) @@ -85,7 +91,19 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Before anything draws. The palette decides the window's own + // background and which way round the system bars' icons go, and both + // are wrong for a frame if they are set from a composition instead. + // Infallible by construction — an unreadable theme file resolves to the + // shipped default rather than throwing. + runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed) enableEdgeToEdge() + // Re-applied on every change, not just at startup: picking Latte from + // the settings page has to turn the status bar's icons dark in the same + // frame the page behind them turns light. + lifecycleScope.launch { + AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow) + } bound = bindService( Intent(this, PlaybackService::class.java), playbackConnection, @@ -121,6 +139,26 @@ class MainActivity : ComponentActivity() { } } + /** + * The parts of the window Compose does not reach: the background behind the + * whole activity, and the polarity of the system bars' icons. + * + * The background matters for the frames before and between compositions — + * the launch frame, and a rotation. It used to be a hex literal in + * `themes.xml`, which meant every cold start showed a colour from one + * flavour regardless of which one was chosen. + * + * The icons matter because the app ships a light flavour. Pinned dark, as + * they were, Latte drew white status-bar icons onto a near-white page. + */ + private fun dressWindow(palette: PaletteRecord) { + window.setBackgroundDrawable(palette.background.toInt().toDrawable()) + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = palette.light + isAppearanceLightNavigationBars = palette.light + } + } + override fun onUserLeaveHint() { super.onUserLeaveHint() // The backstop for launchers that still send this instead of arming diff --git a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt index 463ef0b..c925074 100644 --- a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt +++ b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import uniffi.pstr_android.AndroidEngine import uniffi.pstr_android.AndroidPaths +import uniffi.pstr_android.PaletteRecord /** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */ object NativeRuntime { @@ -36,14 +37,24 @@ object NativeRuntime { } } - private fun createEngine(): AndroidEngine { + /** + * The stored palette, without building the engine. + * + * Callable from the main thread, and meant to be: it is the only palette + * read early enough to decide what the first frame is painted in, and + * [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a + * Keystore key. This reads one small JSON file and resolves it. + */ + fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths()) + + private fun paths(): AndroidPaths { check(::appContext.isInitialized) { "NativeRuntime is not initialized" } val config = File(appContext.noBackupFilesDir, "config") val data = File(appContext.filesDir, "data") val cache = File(appContext.cacheDir, "stream") - return AndroidEngine( - AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath), - KeystoreSecretStore(appContext), - ) + return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath) } + + private fun createEngine(): AndroidEngine = + AndroidEngine(paths(), KeystoreSecretStore(appContext)) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt new file mode 100644 index 0000000..1fb9bfb --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt @@ -0,0 +1,223 @@ +package io.narl.protonstream.ui.theme + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors +import androidx.compose.material3.SliderState +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp + +/** + * The accent, as a gradient when there is one to draw. + * + * The desktop paints the accent as a two-hue ramp on everything that is a + * control — the seek bar, the primary buttons, the tile progress bar — and the + * viewer's "paint the accent as a gradient" toggle is what turns it off. That + * toggle was already stored, already offered on this screen, and drawn nowhere + * on Android, so the same stored setting meant two different things depending + * on which client read it. + * + * Nothing here reads the toggle. It does not need to: when gradients are off, + * `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses + * to a flat fill on its own. `primary` and `secondary` are those two colours — + * see [schemeOf] — which is why this asks Material for them rather than + * reaching past it for the palette. + */ +@Composable +internal fun accentBrush(): Brush { + val scheme = MaterialTheme.colorScheme + return remember(scheme.primary, scheme.secondary) { + Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary)) + } +} + +/** + * The primary button: the accent ramp, at the shared corner radius. + * + * Material's filled button has its own shape token (`CornerFull`) that does not + * read `MaterialTheme.shapes`, so the radius has to be handed to it — which is + * the whole reason the plain buttons below exist as well. They add nothing but + * that. + */ +@Composable +internal fun AccentButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + val shape = MaterialTheme.shapes.small + Button( + onClick = onClick, + // Only while it is live: a disabled button keeps Material's flat + // disabled container, because a greyed control that still wears the + // one strong colour in the window reads as pressable. + modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier, + enabled = enabled, + shape = shape, + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + content = content, + ) +} + +@Composable +internal fun TonalButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) = FilledTonalButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = MaterialTheme.shapes.small, + content = content, +) + +@Composable +internal fun EdgedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) = OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = MaterialTheme.shapes.small, + content = content, +) + +@Composable +internal fun QuietButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) = TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = MaterialTheme.shapes.small, + content = content, +) + +/** + * How far along something is, drawn in the accent. + * + * Hand-drawn rather than `LinearProgressIndicator` because that one takes a + * `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop + * indicator either side of the bar — detail at a size where the desktop draws a + * plain bar. [fill] is a parameter for the one bar that is deliberately not the + * accent: a download in flight sits directly under the watch-progress bar of + * the same row, and two ramps in the same colour stacked two pixels apart say + * nothing. + */ +@Composable +internal fun AccentProgress( + progress: () -> Float, + modifier: Modifier = Modifier, + fill: Brush? = null, +) { + val brush = fill ?: accentBrush() + val track = MaterialTheme.colorScheme.surfaceContainerHighest + Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) { + val radius = CornerRadius(size.height / 2) + drawRoundRect(color = track, cornerRadius = radius) + val done = size.width * progress().coerceIn(0f, 1f) + // Below one bar's width there is nothing to round, and a rounded rect + // narrower than its own corners draws as a wrong-looking pill. + if (done >= size.height) { + drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius) + } + } +} + +/** + * The seek bar's track, in the accent ramp. + * + * Passed to `Slider`'s `track` slot, so the thumb, the gestures and the + * accessibility semantics stay Material's — this replaces the two rectangles it + * would have drawn and nothing else. + * + * The spent side is the accent and the rest is white at a third: the seek bar + * is the one accent surface that sits over arbitrary film footage, where a + * palette colour for the *unplayed* remainder would be a coloured line over a + * picture rather than the absence of one. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) { + val brush = accentBrush() + Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) { + val radius = CornerRadius(size.height / 2) + drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius) + val done = size.width * state.coercedValueAsFraction + if (done >= size.height) { + drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius) + } + } +} + +/** + * The navigation pill, in the accent. + * + * Solid rather than a ramp, and this is the one place the two clients cannot + * agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`, + * with no slot to draw into, so a gradient here would mean reimplementing the + * bar. The accent itself is still the right answer — the desktop's rule is that + * the accent marks the thing you are on — and it is nearer to that than + * `secondaryContainer`, which is the accent taken most of the way back to the + * page. + */ +@Composable +internal fun accentNavigationColors(): NavigationSuiteItemColors { + val scheme = MaterialTheme.colorScheme + return NavigationSuiteDefaults.itemColors( + navigationBarItemColors = NavigationBarItemDefaults.colors( + selectedIconColor = scheme.onPrimary, + selectedTextColor = scheme.onSurface, + indicatorColor = scheme.primary, + ), + navigationRailItemColors = NavigationRailItemDefaults.colors( + selectedIconColor = scheme.onPrimary, + selectedTextColor = scheme.onSurface, + indicatorColor = scheme.primary, + ), + navigationDrawerItemColors = NavigationDrawerItemDefaults.colors( + selectedContainerColor = scheme.primary, + selectedIconColor = scheme.onPrimary, + selectedTextColor = scheme.onPrimary, + ), + ) +} + +/** A solid fill, for the callers of [AccentProgress] that want one colour. */ +internal fun solid(color: Color): Brush = SolidColor(color) + +/** Thin enough to sit under a row without becoming part of it. */ +private const val PROGRESS_HEIGHT = 4f + +/** The seek bar is dragged with a finger, so it is the thicker of the two. */ +private const val TRACK_HEIGHT = 6f diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt new file mode 100644 index 0000000..d204391 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt @@ -0,0 +1,36 @@ +package io.narl.protonstream.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * One corner radius, which is the desktop client's corner radius. + * + * `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab + * pill, the progress bars — and only a badge over artwork is tighter, at 4. + * Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen + * puts a 28dp bottom sheet next to a 12dp card next to a fully-round button, + * and none of those is the shape the other client draws. + * + * Flat rather than a ramp, therefore, with the two ends kept for the two cases + * where a single radius is wrong: `extraSmall` for something small enough that + * 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of + * the screen and need to read as a lifted sheet rather than a seam. + */ +internal val ProtonShapes = Shapes( + extraSmall = RoundedCornerShape(CORNER_TIGHT.dp), + small = RoundedCornerShape(CORNER.dp), + medium = RoundedCornerShape(CORNER.dp), + large = RoundedCornerShape(CORNER.dp), + extraLarge = RoundedCornerShape(CORNER_SHEET.dp), +) + +/** The radius `pstr-app` draws everything at. */ +internal const val CORNER = 8f + +/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */ +internal const val CORNER_TIGHT = 4f + +/** A bottom sheet, which has no desktop counterpart. */ +internal const val CORNER_SHEET = 16f diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt index 8b03839..efec479 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt @@ -29,8 +29,29 @@ object AppearanceState { private val current = MutableStateFlow(null) val palette: StateFlow = current.asStateFlow() - /** Read the stored choice. Safe to call repeatedly; the last read wins. */ + /** + * The palette read before the first frame, from + * [io.narl.protonstream.native.NativeRuntime.storedPalette]. + * + * Separate from [load] because it runs on the main thread during + * `onCreate`: it is the only read that is early enough to decide what the + * first composition is painted in, and it is cheap enough to be allowed + * there. + */ + fun seed(palette: PaletteRecord) { + current.value = palette + } + + /** + * Read the stored choice through the engine. Safe to call repeatedly; the + * last read wins. + * + * A no-op once [seed] has run — it resolves the same file through the same + * Rust, so the only thing a second read can do is build the engine earlier + * than the first screen needed it. + */ suspend fun load() { + if (current.value != null) return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } } .onSuccess { current.value = it } } @@ -48,23 +69,48 @@ fun ProtonStreamTheme(content: @Composable () -> Unit) { // with — a theme file that cannot be read must never be a reason the // library does not open. LaunchedEffect(Unit) { AppearanceState.load() } - MaterialTheme(colorScheme = palette?.let(::schemeOf) ?: ProtonColors, content = content) + MaterialTheme( + colorScheme = schemeOf(palette ?: ProtonFallback), + // The other two thirds of what the clients share. Colour was already + // one resolved answer for both; type and shape were Material's defaults + // here and named constants there, which is most of why the two read as + // different apps even where the colours agreed. + typography = ProtonTypography, + shapes = ProtonShapes, + content = content, + ) } /** - * One resolved palette as Material's roles. + * One resolved palette as Material's roles — **all** of them. + * + * Every role Material names has to be answered here, because a role left out + * does not fall back to something neutral: it keeps the value + * `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own + * baseline purple-and-grey and belongs to no flavour this app ships. + * + * That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with + * (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in + * the library grid, every episode row, every share and every download was drawn + * in baseline neutral on top of a flavoured page. `inverseSurface` is the + * snackbar — the app's only error channel — and `scrim` is the dim behind every + * dialog. * * Material names more roles than a palette has colours, so several are answered - * by the same one — `surfaceVariant` and `card` above all, which is what makes - * a tile and a form field the same shade in both clients. + * by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the + * blend behind them is the shared one. `SchemeRolesTest` asserts that nothing + * here is left at its baseline. */ -private fun schemeOf(palette: PaletteRecord): ColorScheme { +internal fun schemeOf(palette: PaletteRecord): ColorScheme { val base = if (palette.light) lightColorScheme() else darkColorScheme() return base.copy( primary = color(palette.accent), onPrimary = color(palette.onAccent), primaryContainer = color(palette.accentDim), onPrimaryContainer = color(palette.text), + // Readable *on* `inverseSurface`, which is the ink colour: `accentDim` + // is the accent taken towards the page, so it inverts with the rest. + inversePrimary = color(palette.accentDim), secondary = color(palette.accentAlt), onSecondary = color(palette.onAccent), // The accent taken back towards the page, not the card's hover shade. @@ -74,35 +120,97 @@ private fun schemeOf(palette: PaletteRecord): ColorScheme { // a dark grey row, readable as text but not as controls. secondaryContainer = color(palette.accentDim), onSecondaryContainer = color(palette.text), + // The palette has two hues, not three. Tertiary takes the second one — + // the gradient partner — rather than inventing a third that no flavour + // chose. + tertiary = color(palette.accentAlt), + onTertiary = color(palette.onAccent), + tertiaryContainer = color(palette.accentDim), + onTertiaryContainer = color(palette.text), background = color(palette.background), onBackground = color(palette.text), surface = color(palette.surface), onSurface = color(palette.text), surfaceVariant = color(palette.card), onSurfaceVariant = color(palette.muted), - surfaceContainer = color(palette.card), - surfaceContainerHigh = color(palette.cardHover), - surfaceContainerLow = color(palette.sunken), - outline = color(palette.border), - outlineVariant = color(palette.muted), + // No elevation tint. Material tints a raised surface with `primary`, + // and the desktop draws no such tint at all — its rule is that the + // accent is the only strong colour in the window and everything wearing + // it is something to click. A card that is faintly accent-coloured + // because it happens to be raised breaks that. + surfaceTint = Color.Transparent, + // Genuinely inverted, both ways round: on a dark flavour this is a pale + // snackbar with dark ink, on Latte a dark one with pale ink. + inverseSurface = color(palette.text), + inverseOnSurface = color(palette.background), error = color(palette.danger), onError = color(palette.background), + errorContainer = color(palette.dangerDim), + onErrorContainer = color(palette.text), + // Swapped from what was here before, to match what Material draws with + // each: `outline` is a border meant to be seen (an outlined button, + // a swatch that is not selected), `outlineVariant` is a divider meant + // to be barely there. `border` is the fainter of the two. + outline = color(palette.muted), + outlineVariant = color(palette.border), + // Black in both of Material's own schemes, and for the same reason: a + // scrim is not a colour, it is an absence of light. + scrim = Color.Black, + surfaceBright = color(palette.elevated), + surfaceDim = color(palette.background), + // The containment ladder, in the order the palette already runs: the + // deepest colour in the flavour at the bottom, `elevated` — one step + // past the card's hover shade — at the top. + surfaceContainerLowest = color(palette.sunken), + surfaceContainerLow = color(palette.surface), + surfaceContainer = color(palette.card), + surfaceContainerHigh = color(palette.cardHover), + surfaceContainerHighest = color(palette.elevated), + // The fixed roles are Material's "the same in light and dark", which a + // per-flavour palette has no equivalent of. They take the accent pair + // rather than a baseline hue, so a component that reaches for one still + // lands inside the flavour. + primaryFixed = color(palette.accentDim), + primaryFixedDim = color(palette.accent), + onPrimaryFixed = color(palette.text), + onPrimaryFixedVariant = color(palette.muted), + secondaryFixed = color(palette.accentDim), + secondaryFixedDim = color(palette.accentAlt), + onSecondaryFixed = color(palette.text), + onSecondaryFixedVariant = color(palette.muted), + tertiaryFixed = color(palette.accentDim), + tertiaryFixedDim = color(palette.accentAlt), + onTertiaryFixed = color(palette.text), + onTertiaryFixedVariant = color(palette.muted), ) } /** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */ private fun color(argb: UInt) = Color(argb.toInt()) -/** The shipped default, spelled out for the frames before the store is read. */ -private val ProtonColors = darkColorScheme( - primary = Color(0xFF7D4DFF), - onPrimary = Color(0xFFEAEAF0), - secondary = Color(0xFF3F6FE0), - background = Color(0xFF0E0E12), - surface = Color(0xFF17171D), - surfaceVariant = Color(0xFF1E1E26), - onBackground = Color(0xFFEAEAF0), - onSurface = Color(0xFFEAEAF0), - outline = Color(0xFF2A2A35), - error = Color(0xFFE05561), +/** + * The shipped default, spelled out for the case where Rust cannot be asked. + * + * A `PaletteRecord` rather than a `ColorScheme`, so that it goes through + * [schemeOf] like any other palette and inherits every mapping above — a + * fallback that answered only ten roles would be the same bug in a smaller + * place. The values are `pstr_core::appearance::Palette::PROTON`. + */ +internal val ProtonFallback = PaletteRecord( + background = 0xFF0E0E12u, + surface = 0xFF17171Du, + sunken = 0xFF0A0A0Du, + card = 0xFF1E1E26u, + cardHover = 0xFF2A2A35u, + border = 0xFF262630u, + text = 0xFFEAEAF0u, + muted = 0xFF8E8E9Cu, + accent = 0xFF7D4DFFu, + accentAlt = 0xFF3F6FE0u, + accentDim = 0xFF5333ADu, + onAccent = 0xFFEAEAF0u, + danger = 0xFFE05561u, + elevated = 0xFF333340u, + dangerDim = 0xFF7F2F37u, + light = false, ) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt new file mode 100644 index 0000000..1ee6bd7 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt @@ -0,0 +1,75 @@ +package io.narl.protonstream.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * The type ramp, which is the desktop client's type ramp. + * + * `pstr_app::theme::Role` names nine sizes and the desktop asks for a role + * rather than a number at every call site. Material names fifteen styles, so + * several of them land on the same rung — that is the point. The two clients + * were built to share one palette; sharing one scale is the other half of why + * they should read as one app. + * + * The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. + * + * Where a role maps is decided by what actually draws with it here, not by + * Material's own sizes — Material's `displayLarge` is 57sp, which is a size for + * a phone's lock screen and not for anything in a library grid. `bodyLarge` is + * what an unstyled `Text` gets, so it takes the desktop's body rung and the two + * smaller body styles step down from there. + * + * [TypeRampTest] pins every one of these against the Rust ramp. + */ +internal val ProtonTypography = Typography( + displayLarge = role(DISPLAY, FontWeight.SemiBold), + displayMedium = role(DISPLAY, FontWeight.SemiBold), + displaySmall = role(TITLE, FontWeight.SemiBold), + // The name of the title you opened — the desktop's display line. + headlineLarge = role(DISPLAY, FontWeight.SemiBold), + headlineMedium = role(DISPLAY, FontWeight.SemiBold), + // A dialog's heading — `AlertDialog` titles draw with this one. + headlineSmall = role(HEADING, FontWeight.SemiBold), + // A page heading: "Offline downloads", "Settings". + titleLarge = role(TITLE, FontWeight.SemiBold), + // A run of content under a heading: "Continue watching", "Playback". + titleMedium = role(SECTION, FontWeight.Medium), + // A row that groups the rows under it — a season header. + titleSmall = role(SUBHEAD, FontWeight.Medium), + bodyLarge = role(BODY, FontWeight.Normal), + bodyMedium = role(LABEL, FontWeight.Normal), + // Context rather than content: counts, sizes, the note under a field. + bodySmall = role(CAPTION, FontWeight.Normal), + // The text on a button. + labelLarge = role(LABEL, FontWeight.Medium), + labelMedium = role(CAPTION, FontWeight.Medium), + // The smallest thing that stays legible. + labelSmall = role(MICRO, FontWeight.Medium), +) + +internal const val DISPLAY = 26f +internal const val TITLE = 20f +internal const val HEADING = 18f +internal const val SECTION = 17f +internal const val SUBHEAD = 15f +internal const val BODY = 14f +internal const val LABEL = 13f +internal const val CAPTION = 12f +internal const val MICRO = 11f + +/** + * One rung as a `TextStyle`. + * + * Line height is derived rather than named per style: a ramp whose leading was + * chosen nine separate times is the same problem the sizes had. 1.35 is loose + * enough for the two-line title names in the grid and tight enough that a + * label on a control does not float. + */ +private fun role(size: Float, weight: FontWeight) = TextStyle( + fontSize = size.sp, + lineHeight = (size * 1.35f).sp, + fontWeight = weight, +) diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3d17b7e --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #0E0E12 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index edd313c..9fbeea8 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,8 +1,23 @@ + diff --git a/android/app/src/test/java/io/narl/protonstream/ui/theme/SchemeRolesTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/theme/SchemeRolesTest.kt new file mode 100644 index 0000000..63d5fcf --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/theme/SchemeRolesTest.kt @@ -0,0 +1,129 @@ +package io.narl.protonstream.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import uniffi.pstr_android.PaletteRecord + +/** + * That every Material colour role is answered by the palette. + * + * Material names roughly fifty roles and a `ColorScheme` is built by copying a + * baseline, so a role nobody names keeps Material's own purple-and-grey — + * silently, and only where that particular role happens to be drawn. That is + * how `surfaceContainerHighest` went unnoticed: nothing referred to it by name, + * but it is what `Card` fills with, so every tile, episode row, share and + * download in the app was baseline neutral on a flavoured page. + * + * The check is reflective rather than a list, because a list is exactly as + * incomplete as the mapping it was written from — and because Material adds + * roles between versions, and a role added tomorrow should fail this the day + * the dependency moves. + */ +class SchemeRolesTest { + /** + * A palette whose every colour is distinct and none of which Material would + * ever produce, so that "this role came from the palette" is decidable by + * looking at the value. + */ + private fun sentinels(light: Boolean) = PaletteRecord( + background = 0xFF010203u, + surface = 0xFF040506u, + sunken = 0xFF070809u, + card = 0xFF0A0B0Cu, + cardHover = 0xFF0D0E0Fu, + border = 0xFF101112u, + text = 0xFF131415u, + muted = 0xFF161718u, + accent = 0xFF191A1Bu, + accentAlt = 0xFF1C1D1Eu, + accentDim = 0xFF1F2021u, + onAccent = 0xFF222324u, + danger = 0xFF252627u, + elevated = 0xFF28292Au, + dangerDim = 0xFF2B2C2Du, + light = light, + ) + + /** Every role on a `ColorScheme`, read by name, whatever the version names. */ + private fun roles(scheme: ColorScheme): Map = + ColorScheme::class.java.declaredMethods + .filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java } + .associate { method -> + method.isAccessible = true + // Kotlin mangles the getter of a value-class property: + // `getPrimary-0d7_KjU`. The property is what the name says up + // to the dash. + method.name.removePrefix("get").substringBefore('-') to + Color((method.invoke(scheme) as Long).toULong()) + } + + @Test + fun `every role is answered by the palette`() { + for (light in listOf(false, true)) { + val palette = sentinels(light) + val allowed = buildSet { + addAll( + listOf( + palette.background, palette.surface, palette.sunken, palette.card, + palette.cardHover, palette.border, palette.text, palette.muted, + palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent, + palette.danger, palette.elevated, palette.dangerDim, + ).map { Color(it.toInt()) }, + ) + // The two roles that are deliberately not a palette colour: no + // elevation tint at all, and a scrim that is an absence of + // light rather than a hue. + add(Color.Transparent) + add(Color.Black) + } + + val leaked = roles(schemeOf(palette)).filterValues { it !in allowed } + assertTrue( + "light=$light: ${leaked.keys.sorted()} still carry Material's baseline", + leaked.isEmpty(), + ) + } + } + + /** + * The count is asserted too. A reflective sweep that found no roles at all + * would pass the check above for the wrong reason. + */ + @Test + fun `the sweep sees the whole scheme`() { + val count = roles(darkColorScheme()).size + assertTrue("only $count roles found; the reflective sweep is not working", count >= 40) + assertEquals(count, roles(lightColorScheme()).size) + } + + /** + * Which baseline was copied stops being observable once every role is + * mapped — and that is the point. + * + * `light` still decides plenty, but it decides it in Rust, where it picks + * the flavour's colours. By the time a palette reaches Kotlin the + * light-or-dark question is already answered *inside* the colours, so two + * palettes that differ only in the flag have to produce the same scheme. If + * they ever stop doing so, something is reading `light` here rather than + * reading the colours. + */ + @Test + fun `the baseline no longer shows through`() { + assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true)))) + assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint) + } + + /** The fallback goes through the same mapping as any other palette. */ + @Test + fun `the shipped fallback is fully mapped too`() { + val scheme = schemeOf(ProtonFallback) + assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest) + assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer) + assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer) + } +} diff --git a/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt new file mode 100644 index 0000000..6a09296 --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt @@ -0,0 +1,109 @@ +package io.narl.protonstream.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * That the type ramp is the ramp, and the corner radius is the radius. + * + * The two clients share a palette because one Rust function resolves it for + * both. Type and shape have no such bridge — they are named in + * `pstr_app::theme::Role` and copied here — so what stops them drifting is this + * test and the numbers in it. Change the Rust ramp and this fails; that is the + * point of it. + * + * Every style is named rather than checked against a set, because "some rung" + * is not the property that matters: `bodyMedium`'s Material default is 14sp, + * which is a rung, so a `bodyMedium` nobody mapped would pass a looser check + * while sitting one step off where it belongs. + */ +class TypeRampTest { + /** `Role::size()`, in the order `theme.rs` declares it. */ + private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f) + + private val expected = mapOf( + "displayLarge" to 26f, + "displayMedium" to 26f, + "displaySmall" to 20f, + "headlineLarge" to 26f, + "headlineMedium" to 26f, + "headlineSmall" to 18f, + "titleLarge" to 20f, + "titleMedium" to 17f, + "titleSmall" to 15f, + "bodyLarge" to 14f, + "bodyMedium" to 13f, + "bodySmall" to 12f, + "labelLarge" to 13f, + "labelMedium" to 12f, + "labelSmall" to 11f, + ) + + @Test + fun `every text style sits on the shared ramp`() { + expected.forEach { (name, size) -> + assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f) + } + } + + /** + * Material adds type styles between versions, and one added tomorrow would + * arrive at whatever size Material chose for it. The list above is only as + * complete as the day it was written unless something says so. + */ + @Test + fun `no text style is left at Material's own size`() { + assertEquals(expected.keys, styles().keys) + } + + /** + * Leading is derived from the size, so the only thing worth asserting is + * that it *was* derived — a style that kept Material's line height carries + * a number with no relation to its own size. + */ + @Test + fun `line height follows the size it belongs to`() { + styles().forEach { (name, style) -> + assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f) + } + } + + @Test + fun `the ramp has no rung the styles never reach`() { + val drawn = styles().values.map { it.fontSize.value }.toSet() + ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) } + } + + @Test + fun `every surface is drawn at the desktop's radius`() { + listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape -> + // `CornerSize` resolves against a size and a density, and neither is + // available to a unit test; `toString` is the only handle on the + // value that does not need a composition. + assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER")) + } + } + + /** + * Every text style a caller can set, read by name, whatever the version + * names. + * + * Material 1.4 carries a second, `Emphasized` copy of all fifteen for its + * expressive components. Those are `internal` — the constructor takes them + * but Kotlin will not let this module pass them — so they keep Material's + * own sizes and nothing here can do anything about it. Their getters are + * name-mangled with `$material3`, which is how they are told apart from a + * public style that genuinely went unmapped. + */ + private fun styles(): Map = Typography::class.java.declaredMethods + .filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java } + .filterNot { '$' in it.name } + .associate { method -> + method.isAccessible = true + method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to + method.invoke(ProtonTypography) as TextStyle + } +} diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index c7bc12b..145a761 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -196,9 +196,40 @@ pub struct PaletteRecord { pub accent_dim: u32, pub on_accent: u32, pub danger: u32, + /// One step further from the page than `card_hover`. + /// + /// Not a colour the palette names, and not one the desktop needs: Material's + /// containment ladder has five rungs where the palette has three, and the + /// top one is what `Card` fills with. Derived here rather than in Kotlin so + /// that the blend is still the shared one — mixing in sRGB rather than in + /// linear light would put this rung on a different curve to every other + /// colour in the app. + pub elevated: u32, + /// `danger` taken down towards the page, for an error *container* rather + /// than error ink. + pub danger_dim: u32, pub light: bool, } +/// The stored palette, read without building an engine. +/// +/// [`AndroidEngine`] opens SQLite, spins a Tokio runtime and unlocks a Keystore +/// key — far too much to block an activity's `onCreate` on, and blocking it is +/// the point: a palette that arrives one frame late is a flash of the wrong +/// theme on every cold start. The choice is one small JSON file, so this reads +/// exactly that and resolves it. +/// +/// Infallible on purpose. A theme file that will not parse falls back to the +/// defaults here exactly as it does everywhere else — it must never be a reason +/// the library does not open. +#[uniffi::export] +pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord { + let appearance = AppDirs::from_paths(paths.config, paths.data, paths.cache) + .and_then(|dirs| pstr_core::appearance::load(&dirs)) + .unwrap_or_default(); + palette_record(&appearance_record(appearance)) +} + #[derive(Debug, Clone, uniffi::Record)] pub struct MetadataSettingsRecord { pub enabled: bool, @@ -2357,6 +2388,13 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance { fn palette_record(record: &AppearanceRecord) -> PaletteRecord { let palette = Palette::resolve(appearance_choice(record.clone())); + // The flavour's own step, taken once more: `card` to `card_hover` is the + // size of one rung as the flavour drew it, so extrapolating past the end of + // that pair gives a rung the same size and — unlike a fixed blend towards + // the ink — one that works in both polarities. A light flavour's ladder + // descends, and this descends with it. + let elevated = pstr_core::appearance::mix(palette.card, palette.card_hover, 2.0); + let danger_dim = pstr_core::appearance::mix(palette.danger, palette.background, 0.72); PaletteRecord { background: palette.background.argb(), surface: palette.surface.argb(), @@ -2371,6 +2409,8 @@ fn palette_record(record: &AppearanceRecord) -> PaletteRecord { accent_dim: palette.accent_dim.argb(), on_accent: palette.on_accent.argb(), danger: palette.danger.argb(), + elevated: elevated.argb(), + danger_dim: danger_dim.argb(), light: palette.light, } } @@ -2567,13 +2607,107 @@ mod tests { use pstr_stream::{MemoryBlocks, VideoStream}; use super::{ - AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, BridgeError, ChapterKind, - ChapterRecord, PartialMarker, chapter_plan, match_record, node_uid, normalized_language, + AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, + AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker, + chapter_plan, match_record, node_uid, normalized_language, palette_record, prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release, pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata, write_partial_marker, }; + fn appearance_of(flavor: FlavorChoice) -> AppearanceRecord { + AppearanceRecord { + flavor, + accent: AccentChoice::Mauve, + gradients: true, + } + } + + /// Every flavour, so that a rung that only behaves on the dark ones is + /// caught: Latte's ladder descends, and a derived colour written as "a bit + /// brighter" would climb out of it. + const FLAVORS: [FlavorChoice; 5] = [ + FlavorChoice::Proton, + FlavorChoice::Latte, + FlavorChoice::Frappe, + FlavorChoice::Macchiato, + FlavorChoice::Mocha, + ]; + + fn luminance(argb: u32) -> f32 { + pstr_core::appearance::luminance(pstr_core::appearance::Rgb::new( + ((argb >> 16) & 0xff) as u8, + ((argb >> 8) & 0xff) as u8, + (argb & 0xff) as u8, + )) + } + + /// Material's containment ladder has five rungs where the palette names + /// three, and Kotlin lays them out in this order. A rung that is not past + /// the one below it is a rung that reads as the same surface. + #[test] + fn the_derived_rung_continues_the_ladder_in_both_polarities() { + for flavor in FLAVORS { + let palette = palette_record(&appearance_of(flavor)); + let (card, hover, elevated) = ( + luminance(palette.card), + luminance(palette.card_hover), + luminance(palette.elevated), + ); + if palette.light { + assert!( + elevated < hover && hover < card, + "{flavor:?}: a light flavour's ladder descends, got {card} → {hover} → {elevated}" + ); + } else { + assert!( + elevated > hover && hover > card, + "{flavor:?}: a dark flavour's ladder climbs, got {card} → {hover} → {elevated}" + ); + } + } + } + + /// An error *container* is a surface, not ink: it has to sit near the page + /// so that `text` reads on it, which is what Kotlin pairs it with. + #[test] + fn the_dim_danger_sits_between_the_danger_and_the_page() { + for flavor in FLAVORS { + let palette = palette_record(&appearance_of(flavor)); + let (danger, dim, page) = ( + luminance(palette.danger), + luminance(palette.danger_dim), + luminance(palette.background), + ); + let between = (dim - danger).abs() > f32::EPSILON && (dim - page).abs() > f32::EPSILON; + assert!(between, "{flavor:?}: the dim danger is one of its own ends"); + assert!( + dim > danger.min(page) && dim < danger.max(page), + "{flavor:?}: {dim} is outside {danger}..{page}" + ); + } + } + + /// The read that runs before the first frame. A directory with no theme + /// file in it is a first launch, and it has to answer with the defaults + /// rather than fail — the whole point of it is that it cannot be a reason + /// the window does not open. + #[test] + fn the_stored_palette_falls_back_to_the_shipped_default() { + let root = std::env::temp_dir().join(format!("pstr-palette-{}", std::process::id())); + let paths = AndroidPaths { + config: root.join("config").to_string_lossy().into_owned(), + data: root.join("data").to_string_lossy().into_owned(), + cache: root.join("cache").to_string_lossy().into_owned(), + }; + let palette = super::stored_palette(paths); + let default = palette_record(&appearance_of(FlavorChoice::Proton)); + assert_eq!(palette.background, default.background); + assert_eq!(palette.accent, default.accent); + assert_eq!(palette.elevated, default.elevated); + let _ = std::fs::remove_dir_all(root); + } + #[derive(Default)] struct MemorySecrets(Mutex>);