improved android client, feature parity with desktop

This commit is contained in:
nvrl committed 2026-08-09 16:40:37 +02:00
1 parent 352fdf1933
commit f1b3ad9637
56 files changed
+9727 -1799

No files matched your search

@@ -28,8 +28,11 @@ import io.narl.protonstream.native.NativeRuntime
import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.MatchSummary
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.EpisodeRecord
data class AppUiState(
val loading: Boolean = true,
@@ -44,6 +47,7 @@ data class AppUiState(
language = "en",
ready = true,
),
val storage: StorageUsageRecord = StorageUsageRecord(0uL, 0uL, 0uL, 0uL),
val message: String? = null,
)
@@ -55,6 +59,11 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val state: StateFlow<AppUiState> = mutableState.asStateFlow()
init {
if (!NativeRuntime.tlsReady) {
mutableState.update {
it.copy(message = "Certificate verification is unavailable; Proton requests will fail")
}
}
reload()
viewModelScope.launch {
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
@@ -83,6 +92,43 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
/**
* Recrawl one share rather than the whole library.
*
* A viewer who has just added files to one link should not have to pay for
* a walk of every other one — and a share whose link has expired makes a
* whole-library refresh fail, which used to leave no way to refresh the
* shares that still work.
*/
fun refreshShare(id: String) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
/**
* Run enrichment again.
*
* With [force] every title is looked up afresh, including ones that already
* matched — the way out of a library where a provider's early answers were
* wrong, or where a match was made against a provider that has since been
* changed.
*/
fun matchTitles(force: Boolean) {
viewModelScope.launch {
mutableState.update { it.copy(refreshing = true, message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } }
.onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
mutableState.update { it.copy(refreshing = false) }
reload()
}
}
fun addShare(name: String, url: String, password: String?) {
viewModelScope.launch {
runCatching {
@@ -96,6 +142,28 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
/**
* Re-supply the link behind a share whose stored secret cannot be read.
*
* The Keystore key is invalidated by a lockscreen change or a device
* restore, and after that every open of every share fails — including the
* read inside "remove this share", so the obvious way out is closed too.
* Re-entering the link rewrites the secret and leaves the catalog and the
* offline files alone.
*/
fun repairShare(id: String, url: String, password: String?) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
engine.repairShare(id, url, password?.takeIf(String::isNotBlank))
engine.crawl(id)
}
}.onFailure(::reportError)
reload()
}
}
fun removeShare(id: String) {
viewModelScope.launch {
runCatching {
@@ -132,8 +200,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
engine.setMetadataApiKey(provider, apiKey)
}
engine.setMetadataSettings(MetadataSettingsRecord(enabled, provider, language, true))
if (enabled) engine.matchTitles(false)
if (enabled) engine.matchTitles(false) else null
}
}.onSuccess { summary ->
summary?.let { mutableState.update { state -> state.copy(message = describe(it)) } }
}.onFailure(::reportError)
reload()
}
@@ -141,6 +211,23 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun reloadAfterMetadataChange() = reload()
/**
* What an enrichment pass did, in one line.
*
* Failures are reported even when most titles matched: a run that quietly
* dropped a third of the library is the case this counting exists for.
*/
private fun describe(summary: MatchSummary): String {
if (summary.matched == 0u && summary.unmatched == 0u && summary.failed == 0u) {
return "Everything is already matched"
}
val parts = mutableListOf("${summary.matched} matched")
if (summary.unmatched > 0u) parts += "${summary.unmatched} not found"
if (summary.episodes > 0u) parts += "${summary.episodes} episodes named"
if (summary.failed > 0u) parts += "${summary.failed} failed"
return parts.joinToString(", ")
}
fun removeOffline(file: OfflineRecord) {
viewModelScope.launch {
runCatching {
@@ -170,16 +257,96 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
}
}
/**
* Persist where an episode was left.
*
* The player calls this while it is still up and once more as it leaves, so
* it runs on the ViewModel's scope rather than the screen's — a save issued
* on the way out must not be cancelled by the screen going away.
*/
fun saveProgress(episode: EpisodeRecord, position: Double, duration: Double, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId,
episode.linkId,
position.coerceIn(0.0, duration),
duration,
watched,
)
}
}.onFailure(::reportError)
}
}
/**
* Mark an episode seen, or unseen, without playing it.
*
* Unwatching rewinds, matching the desktop client: an episode marked unseen
* that kept its position would come straight back as "resume at 19:04",
* which is not what the viewer asked for. Marking one seen puts the position
* at its duration where one is known, so the progress bar agrees with the
* tick.
*/
fun setWatched(episode: EpisodeRecord, watched: Boolean) {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val duration = engine.watchState(episode.shareId, episode.linkId)?.durationSecs
engine.saveWatchState(
episode.shareId,
episode.linkId,
if (watched) duration ?: 0.0 else 0.0,
duration,
watched,
)
}
}.onFailure(::reportError)
reload()
}
}
fun removeAllOffline() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
workManager.cancelAllWorkByTag(DownloadCoordinator.TAG).result.get()
NativeRuntime.engine().removeAllOffline()
DownloadStateStore(appContext).clear()
}
}.onFailure(::reportError)
reload()
}
}
fun clearBlockCache() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().clearBlockCache() }
}.onSuccess { reclaimed ->
mutableState.update { it.copy(message = "Reclaimed ${formatBytes(reclaimed)} of cache") }
}.onFailure(::reportError)
reload()
}
}
private fun reload() {
viewModelScope.launch {
runCatching {
withContext(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
engine.pruneOfflineFiles()
Reloaded(
engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)),
engine.offlineFiles(),
engine.metadataSettings(),
engine.storageUsage(),
)
}
}.onSuccess { result ->
@@ -189,6 +356,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
titles = result.titles,
offline = result.offline,
metadataSettings = result.metadataSettings,
storage = result.storage,
) }
}
.onFailure { error ->
@@ -215,6 +383,7 @@ private data class Reloaded(
val titles: List<TitleRecord>,
val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord,
)
private fun WorkManager.workInfosByTagFlow(tag: String) = callbackFlow {
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,9 @@
package io.narl.protonstream.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -19,50 +22,61 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import java.util.Collections
/** HTTPS-only artwork loader backed by the app-private cache directory. */
/**
* One file to pull Proton's own thumbnail from when no provider poster exists.
*
* Proton renders a thumbnail per file, so any episode of a show is a frame of
* the right show — the desktop client picks the first for the same reason.
*/
data class ThumbnailSource(val shareId: String, val volumeId: String, val linkId: String) {
val key: String get() = "$shareId/$linkId"
}
/**
* What the first episode of a title offers as a poster.
*
* Metadata lookups are off by default, so for a freshly crawled library this is
* the only artwork there is.
*/
internal val TitleRecord.thumbnailSource: ThumbnailSource?
get() = seasons.firstNotNullOfOrNull { season -> season.episodes.firstOrNull() }
?.let { ThumbnailSource(it.shareId, it.volumeId, it.linkId) }
/** What one episode offers as a still. */
internal val EpisodeRecord.thumbnailSource: ThumbnailSource
get() = ThumbnailSource(shareId, volumeId, linkId)
/**
* HTTPS-only artwork loader backed by the app-private cache directory.
*
* [fallback] is used only when there is no provider artwork or it fails to
* load, which — with metadata off — is the common case rather than the rare
* one.
*/
@Composable
internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier = Modifier) {
internal fun RemoteArtwork(
url: String?,
description: String,
modifier: Modifier = Modifier,
fallback: ThumbnailSource? = null,
) {
val context = LocalContext.current
var image by remember(url) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url) {
image = url?.let { requested ->
withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val bytes = if (cached.isFile) cached.readBytes() else {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
connection.inputStream.use(::readBounded).also { downloaded ->
require(downloaded.size <= MAX_ART_BYTES) { "Artwork is too large" }
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(downloaded)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
}
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
}
}
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(url, fallback) {
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
}
Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) {
image?.let {
@@ -71,7 +85,153 @@ internal fun RemoteArtwork(url: String?, description: String, modifier: Modifier
}
}
/**
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
*
* The media notification needs a bitmap rather than an [ImageBitmap], and needs
* it from a service with no composition of its own — but it wants exactly this
* loader's protocol checks and exactly this disk cache, so the artwork the shade
* shows costs no second download.
*/
internal suspend fun loadArtwork(context: Context, url: String?): Bitmap? = url?.let { requested ->
decoded.get(requested) ?: withContext(Dispatchers.IO) {
runCatching {
val uri = URI(requested)
require(uri.scheme.equals("https", ignoreCase = true)) { "Artwork must use HTTPS" }
val directory = context.cacheDir.resolve("metadata-art").apply { mkdirs() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(requested.toByteArray()).joinToString("") { "%02x".format(it) }
val cached = directory.resolve("$digest.img")
val fresh = !cached.isFile
val bytes = if (fresh) download(uri) else cached.readBytes()
val bitmap = requireNotNull(decodeSampled(bytes)) { "Artwork is not a decodable image" }
if (fresh) {
// Written only once it has decoded. Caching the bytes first is
// what turns one hostile poster into a crash on every launch
// that survives until app data is cleared.
val temporary = directory.resolve("$digest.part")
temporary.writeBytes(bytes)
check(temporary.renameTo(cached)) { "Unable to cache artwork" }
prune(directory)
} else {
cached.setLastModified(System.currentTimeMillis())
}
bitmap
}.getOrNull()?.also { decoded.put(requested, it) }
}
}
/**
* Proton's own thumbnail for one file, decoded.
*
* The bridge holds the disk cache, so what is worth keeping here is the decoded
* bitmap and the *absence* of one: a file with no thumbnail is common, and
* without remembering that, every scroll back over the grid pays a round trip
* per tile to be told so again. A failed call is not remembered — an unreachable
* share or a session that has to be reopened is transient, and the next
* composition should try again.
*/
internal suspend fun loadThumbnail(source: ThumbnailSource?): Bitmap? {
val file = source ?: return null
val key = file.key
decoded.get(key)?.let { return it }
if (key in missing) return null
val fetched = withContext(Dispatchers.IO) {
runCatching { NativeRuntime.engine().thumbnail(file.shareId, file.volumeId, file.linkId) }
}
val bytes = fetched.getOrElse { return null }
val bitmap = bytes?.let { withContext(Dispatchers.Default) { decodeSampled(it) } }
if (bitmap == null) {
missing.add(key)
return null
}
decoded.put(key, bitmap)
return bitmap
}
private fun download(uri: URI): ByteArray {
val connection = uri.toURL().openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
val status = connection.responseCode
require(status in 200..299) { "Artwork request failed ($status)" }
require(connection.url.protocol.equals("https", ignoreCase = true)) {
"Artwork redirect must use HTTPS"
}
return connection.inputStream.use(::readBounded)
.also { require(it.size <= MAX_ART_BYTES) { "Artwork is too large" } }
}
/**
* Decode within a bound the display can actually use.
*
* [MAX_ART_BYTES] bounds the *compressed* image, which says nothing about what
* it costs decoded: a 12 MiB PNG can be 12000×12000, and that is a 576 MB
* `ARGB_8888` allocation on a device that will not give it. The bounds pass
* costs no allocation and settles a sample size before anything is committed.
*/
private fun decodeSampled(bytes: ByteArray): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
if (longest <= 0) return null
var sample = 1
while (longest / sample > MAX_ART_EDGE) sample *= 2
return BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample },
)
}
/**
* Hold the cache directory under [MAX_ART_CACHE_BYTES], oldest first.
*
* `cacheDir` is reclaimable by the system, but only under storage pressure and
* only wholesale — a poster cache that grows with every title ever browsed is
* still the app's own doing.
*/
private fun prune(directory: File) {
val files = directory.listFiles()?.sortedBy(File::lastModified) ?: return
var total = files.sumOf(File::length)
for (file in files) {
if (total <= MAX_ART_CACHE_BYTES) return
total -= file.length()
file.delete()
}
}
/**
* Files known to have no Proton thumbnail, so the answer is paid for once.
*
* Deliberately not persisted: a re-upload can add one, and the cost of being
* wrong for a session is a placeholder that a restart clears.
*/
private val missing: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
/**
* Decoded posters, so scrolling the grid re-reads neither disk nor decoder.
*
* Keyed by artwork URL or by `share/link` for a Proton thumbnail; the two
* cannot collide, since one is always an `https:` URL.
*
* `remember(url)` is per-composition, and a `LazyVerticalGrid` drops and
* recreates items as they leave the viewport — without this, every scroll back
* is a full-resolution decode per item.
*/
private val decoded = object : LruCache<String, Bitmap>(
(Runtime.getRuntime().maxMemory() / 8).coerceIn(4L * 1024 * 1024, 32L * 1024 * 1024).toInt(),
) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
}
private const val MAX_ART_BYTES = 12 * 1024 * 1024
private const val MAX_ART_CACHE_BYTES = 48L * 1024 * 1024
/** Longest edge worth decoding: a poster on a phone, or a notification icon. */
private const val MAX_ART_EDGE = 1_024
/** Reads at most [MAX_ART_BYTES] plus one byte without requiring API 33's readNBytes. */
private fun readBounded(input: InputStream): ByteArray {
@@ -1,24 +1,103 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import uniffi.pstr_android.PaletteRecord
private val ProtonColors = darkColorScheme(
primary = Color(0xFFCBA6F7),
onPrimary = Color(0xFF1E1E2E),
secondary = Color(0xFFF5C2E7),
background = Color(0xFF1E1E2E),
surface = Color(0xFF181825),
surfaceVariant = Color(0xFF313244),
onBackground = Color(0xFFCDD6F4),
onSurface = Color(0xFFCDD6F4),
outline = Color(0xFF6C7086),
error = Color(0xFFF38BA8),
)
/**
* The palette in force, shared across every composition.
*
* The choice is stored by Rust and resolved by Rust — the same flavours, the
* same accent pairings and the same contrast rule the desktop client uses — so
* what lives here is only the resolved answer and a way to replace it when the
* viewer picks something else.
*/
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. */
suspend fun load() {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
/** Repaint immediately, before the write that stores the choice lands. */
fun apply(palette: PaletteRecord) {
current.value = palette
}
}
@Composable
fun ProtonStreamTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = ProtonColors, content = content)
val palette by AppearanceState.palette.collectAsState()
// Before the first read, and if it fails, the palette this app shipped
// 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)
}
/**
* One resolved palette as Material's roles.
*
* 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.
*/
private 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),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
secondaryContainer = color(palette.cardHover),
onSecondaryContainer = 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),
error = color(palette.danger),
onError = color(palette.background),
)
}
/** 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),
)