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.
This commit is contained in:
nvrl committed 2026-08-10 01:10:08 +02:00
1 parent cb03f96eca
commit 1a9a4624f8
11 files changed
+918 -31

No files matched your search

@@ -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<NativeMpvHost?>(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
@@ -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))
}
@@ -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
@@ -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
@@ -29,8 +29,29 @@ object AppearanceState {
private val current = MutableStateFlow<PaletteRecord?>(null)
val palette: StateFlow<PaletteRecord?> = 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,
)
@@ -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,
)
@@ -0,0 +1,9 @@
<resources>
<!--
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
and the only colour in this file. It is what the window is painted before
the stored palette has been read; from then on
MainActivity.dressWindow overrides it with the flavour in force.
-->
<color name="window_background">#0E0E12</color>
</resources>
+16 -1
View File
@@ -1,8 +1,23 @@
<resources>
<!--
The window before Compose owns it.
Everything about the *look* is decided by the stored palette and applied
in MainActivity.dressWindow: the background colour here is only the one
the shipped default resolves to, so that a first-ever launch — before
there is a theme file to read — starts on the right colour rather than on
the platform's white. The bar polarity is likewise set from the palette;
what is left here is the dark reading, which is right for four of the five
flavours and is corrected in the same frame for the fifth.
A hex literal from one specific flavour used to live here, and it was the
reason every cold start flashed Catppuccin Mocha's base regardless of what
the viewer had chosen.
-->
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
<item name="android:windowBackground">#1E1E2E</item>
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
@@ -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<String, Color> =
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)
}
}
@@ -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<String, TextStyle> = 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
}
}
+136 -2
View File
@@ -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<HashMap<String, String>>);