mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password, then a second-factor code and a mailbox password where the account has them; a CAPTCHA gate is solved in the Android app) stores one session blob in the OS credential store under `proton-account`, writing back every refreshed token since refresh tokens are single-use. With an account, folders of the viewer's own Drive — My Files, other devices, shared with me — can be added as shares through the same `ShareClient` surface as a public link. Watch history syncs through a `proton-stream` device in that Drive: one `watch-history/<installation>.json` per installation, so no two devices ever write the same file. Each sync reads the others and merges per episode, newer `updated_at` wins, with a deterministic tie-break. It runs at launch, after sign-in, whenever the player closes and every five minutes. A session that can no longer refresh signs the app out instead of failing on every launch. The CLI gains `login`, `logout`, `account`, `browse` and `sync`. Also in this change: - Android offline downloads no longer look stuck (B64): two transfer at once, through a copy stream with no read-ahead, with a smoothed transfer rate. - Choosing a metadata match by hand now enriches the title too, instead of keeping the old match's episodes and artwork. The catalog schema is unchanged.
This commit is contained in:
1 parent
6cdab2fde1
commit
0b2813cd42
37 files changed
+4111
-159
No files matched your search
@@ -13,6 +13,8 @@ data class RetainedDownload(
|
||||
val total: Long = 0,
|
||||
val status: String = STATUS_QUEUED,
|
||||
val error: String? = null,
|
||||
/** Recent transfer rate while running; 0 when unknown or not running. */
|
||||
val bytesPerSecond: Long = 0,
|
||||
) {
|
||||
val key: String get() = "$shareId\u001f$linkId"
|
||||
|
||||
@@ -99,6 +101,7 @@ class DownloadStateStore(context: Context) {
|
||||
.put("total", record.total)
|
||||
.put("status", record.status)
|
||||
.put("error", record.error)
|
||||
.put("rate", record.bytesPerSecond)
|
||||
.toString()
|
||||
|
||||
private fun decode(encoded: String): RetainedDownload {
|
||||
@@ -112,6 +115,7 @@ class DownloadStateStore(context: Context) {
|
||||
total = json.optLong("total"),
|
||||
status = json.optString("status", RetainedDownload.STATUS_QUEUED),
|
||||
error = json.optString("error").takeIf(String::isNotBlank),
|
||||
bytesPerSecond = json.optLong("rate"),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,20 +25,24 @@ class OfflineDownloadWorker(
|
||||
) : CoroutineWorker(appContext, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
createNotificationChannel()
|
||||
setForeground(createForegroundInfo(0))
|
||||
setForeground(createForegroundInfo(null))
|
||||
val shareId = inputData.getString(KEY_SHARE_ID) ?: return Result.failure(error("missing share"))
|
||||
val volumeId = inputData.getString(KEY_VOLUME_ID) ?: return Result.failure(error("missing volume"))
|
||||
val linkId = inputData.getString(KEY_LINK_ID) ?: return Result.failure(error("missing link"))
|
||||
val label = inputData.getString(KEY_LABEL) ?: linkId
|
||||
val store = DownloadStateStore(applicationContext)
|
||||
// Queued, not running: the engine lets two downloads transfer at once
|
||||
// and holds the rest back, and only the first progress report means
|
||||
// this one has a slot.
|
||||
var retained = store.get(shareId, linkId)
|
||||
?: RetainedDownload(shareId, volumeId, linkId, label)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_RUNNING, error = null)
|
||||
retained = retained.copy(status = RetainedDownload.STATUS_QUEUED, error = null, bytesPerSecond = 0)
|
||||
store.put(retained)
|
||||
|
||||
val observer = object : DownloadObserver {
|
||||
private var reportedAt = 0L
|
||||
private var reportedPercent = -1
|
||||
private val rate = TransferRate()
|
||||
|
||||
/**
|
||||
* Called once per 4 MiB block — a dozen times a second on a fast
|
||||
@@ -51,6 +55,7 @@ class OfflineDownloadWorker(
|
||||
val percent = if (total == 0UL) 0 else ((downloaded * 100UL) / total).toInt()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val complete = total > 0UL && downloaded >= total
|
||||
val bytesPerSecond = rate.sample(downloaded.toLong(), now)
|
||||
if (!complete && percent == reportedPercent && now - reportedAt < PROGRESS_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
@@ -66,6 +71,7 @@ class OfflineDownloadWorker(
|
||||
current.copy(
|
||||
downloaded = downloaded.toLong(),
|
||||
total = total.toLong(),
|
||||
bytesPerSecond = bytesPerSecond,
|
||||
status = when (current.status) {
|
||||
RetainedDownload.STATUS_PAUSED,
|
||||
RetainedDownload.STATUS_CANCELLED,
|
||||
@@ -114,11 +120,14 @@ class OfflineDownloadWorker(
|
||||
)
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int): ForegroundInfo {
|
||||
/** [progress] is a percentage, or null while waiting for a download slot. */
|
||||
private fun createForegroundInfo(progress: Int?): ForegroundInfo {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(io.narl.protonstream.R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle("Making episode available offline")
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setContentTitle(
|
||||
if (progress == null) "Waiting to download" else "Making episode available offline",
|
||||
)
|
||||
.setProgress(100, progress ?: 0, progress == null || progress == 0)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return ForegroundInfo(
|
||||
@@ -146,5 +155,35 @@ class OfflineDownloadWorker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes per second over roughly the last few seconds.
|
||||
*
|
||||
* Progress arrives once per 4 MiB block, so on a slow link the bar moves in
|
||||
* steps seconds apart; a rate beside it is what tells a slow download from a
|
||||
* stuck one. Smoothed, because one block that took a little longer than the
|
||||
* last would otherwise halve the figure for a moment.
|
||||
*/
|
||||
internal class TransferRate {
|
||||
private var lastBytes = -1L
|
||||
private var lastAt = 0L
|
||||
private var smoothed = 0.0
|
||||
|
||||
/** Record [bytes] transferred at [now] (milliseconds) and return the rate. */
|
||||
fun sample(bytes: Long, now: Long): Long {
|
||||
if (lastBytes < 0 || bytes < lastBytes) {
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
val elapsed = now - lastAt
|
||||
if (elapsed <= 0) return smoothed.toLong()
|
||||
val instant = (bytes - lastBytes) * 1000.0 / elapsed
|
||||
smoothed = if (smoothed > 0.0) 0.7 * smoothed + 0.3 * instant else instant
|
||||
lastBytes = bytes
|
||||
lastAt = now
|
||||
return smoothed.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun stableNotificationId(workId: UUID): Int =
|
||||
(workId.hashCode() and Int.MAX_VALUE).coerceAtLeast(1)
|
||||
@@ -0,0 +1,496 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.FolderShared
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.DriveEntryRecord
|
||||
import uniffi.pstr_android.DrivePlaceRecord
|
||||
import uniffi.pstr_android.PlaceType
|
||||
import uniffi.pstr_android.verificationToken
|
||||
|
||||
/** What the account card and its dialogs can ask the model for. */
|
||||
internal data class AccountActions(
|
||||
val signIn: (String, String, String?) -> Unit,
|
||||
val submitSecondFactor: (String) -> Unit,
|
||||
val submitMailboxPassword: (String) -> Unit,
|
||||
val cancelSignIn: () -> Unit,
|
||||
val dismissVerification: () -> Unit,
|
||||
val signOut: () -> Unit,
|
||||
val syncNow: () -> Unit,
|
||||
val addFolder: (String, String, String) -> Unit,
|
||||
val onError: (Throwable) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Proton account, at the top of the shares page: why one would sign in,
|
||||
* or who is signed in and how watch-history sync is doing.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AccountCard(
|
||||
account: AccountUiState,
|
||||
actions: AccountActions,
|
||||
onSignIn: () -> Unit,
|
||||
onBrowse: () -> Unit,
|
||||
) {
|
||||
val state = account.state ?: return
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
if (state is AccountState.SignedIn) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text(state.username, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = { Text(syncLine(account, System.currentTimeMillis())) },
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
},
|
||||
trailingContent = {
|
||||
Box {
|
||||
IconButton(onClick = { menu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Account actions")
|
||||
}
|
||||
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sync now") },
|
||||
leadingIcon = { Icon(Icons.Default.Sync, contentDescription = null) },
|
||||
onClick = { menu = false; actions.syncNow() },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Sign out") },
|
||||
leadingIcon = { Icon(Icons.Default.Logout, contentDescription = null) },
|
||||
onClick = { menu = false; actions.signOut() },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
TonalButton(
|
||||
onClick = onBrowse,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Folder, contentDescription = null)
|
||||
Text("Add from your Drive", Modifier.padding(start = 8.dp))
|
||||
}
|
||||
} else {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Proton account", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Optional. Signed in, watch history is kept in your own Drive so every device " +
|
||||
"resumes where another left off, and folders of your Drive can join the library.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TonalButton(onClick = onSignIn) { Text("Sign in") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Synced 3 min ago · 2 from other devices", or why it did not. */
|
||||
internal fun syncLine(account: AccountUiState, now: Long): String {
|
||||
account.syncError?.let { return "Watch history did not sync: $it" }
|
||||
val at = account.syncedAt ?: return "Watch history syncs with your Drive"
|
||||
val minutes = ((now - at).coerceAtLeast(0) / 60_000).toInt()
|
||||
val ago = when {
|
||||
minutes < 1 -> "just now"
|
||||
minutes < 60 -> "$minutes min ago"
|
||||
else -> "${minutes / 60} h ago"
|
||||
}
|
||||
val applied = if (account.applied > 0) " · ${account.applied} from other devices" else ""
|
||||
return "Synced $ago$applied"
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing in, one step at a time: address and password, then a second-factor
|
||||
* code or the mailbox password if the account has them, with Proton's CAPTCHA
|
||||
* over the top when it asks for one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SignInDialog(account: AccountUiState, actions: AccountActions, onDismiss: () -> Unit) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var mailbox by remember { mutableStateOf("") }
|
||||
val state = account.state
|
||||
LaunchedEffect(state) {
|
||||
if (state is AccountState.SignedIn) onDismiss()
|
||||
}
|
||||
val dismiss = {
|
||||
if (state == AccountState.SecondFactor || state == AccountState.MailboxPassword) actions.cancelSignIn()
|
||||
onDismiss()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = dismiss,
|
||||
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||
title = { Text("Sign in to Proton") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> OutlinedTextField(
|
||||
code,
|
||||
{ code = it },
|
||||
label = { Text("Two-factor code") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
)
|
||||
AccountState.MailboxPassword -> {
|
||||
Text(
|
||||
"This account has a separate mailbox password. It unlocks your files.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
PasswordField(mailbox, { mailbox = it }, "Mailbox password")
|
||||
}
|
||||
else -> {
|
||||
OutlinedTextField(
|
||||
username,
|
||||
{ username = it },
|
||||
label = { Text("Address") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
)
|
||||
PasswordField(password, { password = it }, "Password")
|
||||
}
|
||||
}
|
||||
account.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
if (account.busy) CircularProgressIndicator(Modifier.padding(top = 4.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (state) {
|
||||
AccountState.SecondFactor -> AccentButton(
|
||||
onClick = { actions.submitSecondFactor(code.trim()) },
|
||||
enabled = code.isNotBlank() && !account.busy,
|
||||
) { Text("Continue") }
|
||||
AccountState.MailboxPassword -> AccentButton(
|
||||
onClick = { actions.submitMailboxPassword(mailbox) },
|
||||
enabled = mailbox.isNotEmpty() && !account.busy,
|
||||
) { Text("Unlock") }
|
||||
else -> AccentButton(
|
||||
onClick = { actions.signIn(username.trim(), password, null) },
|
||||
enabled = username.isNotBlank() && password.isNotEmpty() && !account.busy,
|
||||
) { Text("Sign in") }
|
||||
}
|
||||
},
|
||||
dismissButton = { TonalButton(onClick = dismiss) { Text("Cancel") } },
|
||||
)
|
||||
account.verificationUrl?.let { url ->
|
||||
VerificationDialog(
|
||||
url = url,
|
||||
onToken = { token -> actions.signIn(username.trim(), password, token) },
|
||||
onDismiss = actions.dismissVerification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(value: String, onChange: (String) -> Unit, label: String) = OutlinedTextField(
|
||||
value,
|
||||
onChange,
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
|
||||
/**
|
||||
* Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
|
||||
* recognise — a new network, a VPN exit.
|
||||
*
|
||||
* In a WebView rather than the browser because the page reports success by
|
||||
* posting a message to its host, not by redirecting: a browser has nowhere to
|
||||
* post it back to. The page targets `window.parent`, which for a top-level
|
||||
* page is the page itself, so a listener on its own window hears it.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled") // The verification page is a script; it does not run without.
|
||||
@Composable
|
||||
private fun VerificationDialog(url: String, onToken: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
Card(Modifier.fillMaxWidth().padding(16.dp).height(560.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Confirm you are human",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f).padding(start = 16.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
var answered = false
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun post(message: String) {
|
||||
// Every message the page posts lands here,
|
||||
// most of them its own chatter.
|
||||
val token = verificationToken(message) ?: return
|
||||
main.post {
|
||||
if (!answered) {
|
||||
answered = true
|
||||
onToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pstrVerification",
|
||||
)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
view.evaluateJavascript(VERIFICATION_BRIDGE, null)
|
||||
}
|
||||
|
||||
// The bridge above is reachable by whatever the
|
||||
// frame shows, so the frame stays on Proton.
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) =
|
||||
!isProtonPage(request.url.scheme, request.url.host)
|
||||
}
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Installed once per page, at start and again at finish in case the first ran too early. */
|
||||
private const val VERIFICATION_BRIDGE = """
|
||||
if (!window.__pstrVerification) {
|
||||
window.__pstrVerification = true;
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
pstrVerification.post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
"""
|
||||
|
||||
/** Whether a page is Proton's own, over HTTPS. */
|
||||
internal fun isProtonPage(scheme: String?, host: String?): Boolean =
|
||||
scheme == "https" && host != null && (host == "proton.me" || host.endsWith(".proton.me"))
|
||||
|
||||
/**
|
||||
* Walk the account's Drive and add a folder of it to the library.
|
||||
*
|
||||
* Full screen, because a Drive is deep and a dialog's height is a few rows of
|
||||
* it. Listings are asked for here rather than through the model: they are
|
||||
* this dialog's alone, and gone when it closes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DriveBrowserDialog(
|
||||
actions: AccountActions,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val trail = remember { mutableStateListOf<Pair<String, DrivePlaceRecord>>() }
|
||||
var places by remember { mutableStateOf<List<DrivePlaceRecord>?>(null) }
|
||||
var entries by remember { mutableStateOf<List<DriveEntryRecord>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val current = trail.lastOrNull()?.second
|
||||
|
||||
LaunchedEffect(current) {
|
||||
error = null
|
||||
if (current == null) {
|
||||
if (places == null) {
|
||||
runCatching { NativeRuntime.engine().drivePlaces() }
|
||||
.onSuccess { places = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
} else {
|
||||
entries = null
|
||||
runCatching { NativeRuntime.engine().driveFolder(current.volumeId, current.linkId) }
|
||||
.onSuccess { entries = it }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
|
||||
BackHandler(enabled = trail.isNotEmpty()) { trail.removeAt(trail.lastIndex) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
trail.lastOrNull()?.first ?: "Your Drive",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (trail.isEmpty()) onDismiss() else trail.removeAt(trail.lastIndex) }) {
|
||||
Icon(
|
||||
if (trail.isEmpty()) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = if (trail.isEmpty()) "Close" else "Up a folder",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// A folder already in the library is refused by the engine,
|
||||
// with a message saying so.
|
||||
val folder = current ?: return@Scaffold
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.End) {
|
||||
AccentButton(
|
||||
onClick = {
|
||||
actions.addFolder(trail.last().first, folder.volumeId, folder.linkId)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text("Add this folder") }
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
val loading = error == null && (if (current == null) places == null else entries == null)
|
||||
when {
|
||||
error != null -> EmptyState("Could not list this", error.orEmpty())
|
||||
loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
current == null -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(places.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { place ->
|
||||
DriveRow(
|
||||
icon = when (place.kind) {
|
||||
PlaceType.MY_FILES -> Icons.Default.Storage
|
||||
PlaceType.DEVICE -> Icons.Default.Computer
|
||||
PlaceType.SHARED_WITH_ME -> Icons.Default.FolderShared
|
||||
},
|
||||
name = place.name,
|
||||
detail = null,
|
||||
onClick = { trail.add(place.name to place) },
|
||||
)
|
||||
}
|
||||
}
|
||||
entries.orEmpty().isEmpty() -> EmptyState("Empty folder", "Nothing to add from here.")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
|
||||
items(entries.orEmpty(), key = { "${it.volumeId}/${it.linkId}" }) { entry ->
|
||||
DriveRow(
|
||||
icon = when {
|
||||
entry.isFolder -> Icons.Default.Folder
|
||||
entry.isVideo -> Icons.Default.Movie
|
||||
else -> Icons.Default.InsertDriveFile
|
||||
},
|
||||
name = entry.name,
|
||||
detail = entry.size?.let(::formatBytes),
|
||||
onClick = if (entry.isFolder) {
|
||||
{
|
||||
trail.add(
|
||||
entry.name to DrivePlaceRecord(
|
||||
PlaceType.MY_FILES,
|
||||
entry.name,
|
||||
entry.volumeId,
|
||||
entry.linkId,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
name: String,
|
||||
detail: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(name, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = detail?.let { { Text(it) } },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (onClick != null) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier,
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,10 @@ import uniffi.pstr_android.MetadataSettingsRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.WatchStateRecord
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.SignInOutcome
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
data class AppUiState(
|
||||
val loading: Boolean = true,
|
||||
@@ -62,6 +66,25 @@ data class AppUiState(
|
||||
val message: String? = null,
|
||||
/** What Undo on [message] puts back, when the message offers one. */
|
||||
val undo: List<WatchSnapshot>? = null,
|
||||
val account: AccountUiState = AccountUiState(),
|
||||
)
|
||||
|
||||
/** The Proton account: where a sign-in stands, and how sync is doing. */
|
||||
data class AccountUiState(
|
||||
/** Null until the bridge has said. */
|
||||
val state: AccountState? = null,
|
||||
/** A sign-in step is out and not answered yet. */
|
||||
val busy: Boolean = false,
|
||||
/** Why the last sign-in step was refused. */
|
||||
val error: String? = null,
|
||||
/** The CAPTCHA page Proton wants solved before it accepts the sign-in. */
|
||||
val verificationUrl: String? = null,
|
||||
/** When watch history last synced, in epoch milliseconds. */
|
||||
val syncedAt: Long? = null,
|
||||
/** Positions the last sync took from other devices. */
|
||||
val applied: Int = 0,
|
||||
/** Why the last sync failed, until one succeeds. */
|
||||
val syncError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -84,6 +107,18 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}
|
||||
}
|
||||
reload()
|
||||
viewModelScope.launch {
|
||||
val account = runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().accountState() } }
|
||||
.getOrDefault(AccountState.SignedOut)
|
||||
updateAccount { it.copy(state = account) }
|
||||
// While the app is alive, on top of after sign-in and whenever the
|
||||
// player closes. Cheap when nothing changed: one folder listing,
|
||||
// and no upload.
|
||||
while (isActive) {
|
||||
syncWatchHistory()
|
||||
delay(SYNC_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
searchQuery.debounce(300).distinctUntilChanged().collect { reloadLibrary(it) }
|
||||
}
|
||||
@@ -212,6 +247,148 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
|
||||
fun dismissMessage() = mutableState.update { it.copy(message = null, undo = null) }
|
||||
|
||||
private fun updateAccount(change: (AccountUiState) -> AccountUiState) =
|
||||
mutableState.update { it.copy(account = change(it.account)) }
|
||||
|
||||
/**
|
||||
* Start signing in, or start again with [verificationToken] once the
|
||||
* CAPTCHA from a first try is solved.
|
||||
*/
|
||||
fun signIn(username: String, password: String, verificationToken: String? = null) =
|
||||
signInStep { engine -> engine.signIn(username, password, verificationToken) }
|
||||
|
||||
fun submitSecondFactor(code: String) = signInStep { engine -> engine.submitSecondFactor(code) }
|
||||
|
||||
fun submitMailboxPassword(password: String) =
|
||||
signInStep(keepStepOnFailure = true) { engine -> engine.submitMailboxPassword(password) }
|
||||
|
||||
fun cancelSignIn() {
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
viewModelScope.launch { runCatching { NativeRuntime.engine().cancelSignIn() } }
|
||||
}
|
||||
|
||||
/** The CAPTCHA page closed without a token. */
|
||||
fun dismissVerification() = updateAccount { it.copy(verificationUrl = null, busy = false) }
|
||||
|
||||
private fun signInStep(
|
||||
keepStepOnFailure: Boolean = false,
|
||||
step: suspend (uniffi.pstr_android.AndroidEngine) -> SignInOutcome,
|
||||
) {
|
||||
updateAccount { it.copy(busy = true, error = null, verificationUrl = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { step(NativeRuntime.engine()) } }
|
||||
.onSuccess { outcome ->
|
||||
when (outcome) {
|
||||
is SignInOutcome.Done -> {
|
||||
updateAccount {
|
||||
AccountUiState(state = AccountState.SignedIn(outcome.username))
|
||||
}
|
||||
mutableState.update { it.copy(message = "Signed in as ${outcome.username}") }
|
||||
syncWatchHistory()
|
||||
reload()
|
||||
}
|
||||
SignInOutcome.SecondFactor ->
|
||||
updateAccount { it.copy(state = AccountState.SecondFactor, busy = false) }
|
||||
SignInOutcome.MailboxPassword ->
|
||||
updateAccount { it.copy(state = AccountState.MailboxPassword, busy = false) }
|
||||
is SignInOutcome.HumanVerification ->
|
||||
updateAccount { it.copy(verificationUrl = outcome.url) }
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
updateAccount {
|
||||
it.copy(
|
||||
busy = false,
|
||||
error = error.message ?: "Sign-in failed",
|
||||
// A refused code spends the half-made session.
|
||||
state = if (keepStepOnFailure) it.state else AccountState.SignedOut,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun signOut() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().signOut() } }
|
||||
.onFailure(::reportError)
|
||||
updateAccount { AccountUiState(state = AccountState.SignedOut) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync now, from a tap: says so when it is done. */
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
if (syncWatchHistory()) {
|
||||
val applied = mutableState.value.account.applied
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
message = if (applied > 0) {
|
||||
"Watch history synced, $applied from other devices"
|
||||
} else {
|
||||
"Watch history is up to date"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||
* on a timer, and a failure is shown on the account card rather than as a
|
||||
* snackbar every five minutes of an offline evening. Returns whether it
|
||||
* synced.
|
||||
*/
|
||||
private suspend fun syncWatchHistory(): Boolean {
|
||||
if (mutableState.value.account.state !is AccountState.SignedIn) return false
|
||||
return runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().syncWatchHistory() } }
|
||||
.fold(
|
||||
onSuccess = { report ->
|
||||
if (report == null) return false
|
||||
updateAccount {
|
||||
it.copy(
|
||||
syncedAt = System.currentTimeMillis(),
|
||||
applied = report.applied.toInt(),
|
||||
syncError = null,
|
||||
)
|
||||
}
|
||||
if (report.applied > 0u) reload()
|
||||
true
|
||||
},
|
||||
onFailure = { error ->
|
||||
val state = runCatching { NativeRuntime.engine().accountState() }.getOrNull()
|
||||
updateAccount { it.copy(state = state ?: it.state, syncError = error.message) }
|
||||
// Signed out by the engine: the session ended, and that is
|
||||
// worth interrupting for, unlike a dropped connection.
|
||||
if (state == AccountState.SignedOut) reportError(error)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** After the player closes: where it stopped is what another device wants next. */
|
||||
fun playerClosed() {
|
||||
reload()
|
||||
viewModelScope.launch { syncWatchHistory() }
|
||||
}
|
||||
|
||||
fun addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
viewModelScope.launch {
|
||||
mutableState.update { it.copy(refreshing = true) }
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val engine = NativeRuntime.engine()
|
||||
val share = engine.addAccountFolder(name, volumeId, linkId)
|
||||
engine.crawl(share.id)
|
||||
}
|
||||
}.onFailure(::reportError)
|
||||
mutableState.update { it.copy(refreshing = false) }
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a title watched or unwatched, with an Undo: a whole
|
||||
* title's positions are what this throws away, and a mis-tap on a tile's
|
||||
@@ -501,6 +678,10 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
|
||||
}.onFailure { error -> mutableState.update { it.copy(message = error.message) } }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SYNC_INTERVAL_MS = 5 * 60 * 1000L
|
||||
}
|
||||
|
||||
class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = AppViewModel(context, workManager) as T
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import io.narl.protonstream.download.RetainedDownload
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
|
||||
/**
|
||||
@@ -120,6 +121,20 @@ internal fun formatBytes(bytes: ULong): String {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line under a download that has not finished: "Running · 120.0 MiB of
|
||||
* 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot.
|
||||
*/
|
||||
internal fun downloadStatusLine(download: RetainedDownload): String = listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L && download.downloaded > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
download.bytesPerSecond
|
||||
.takeIf { it > 0L && download.status == RetainedDownload.STATUS_RUNNING }
|
||||
?.let { "${formatBytes(it.toULong())}/s" },
|
||||
).joinToString(" · ")
|
||||
|
||||
/**
|
||||
* What an episode row is headed with: "1. Mother and Children".
|
||||
*
|
||||
|
||||
@@ -204,14 +204,7 @@ private fun PartialRow(
|
||||
headlineContent = { Text(download.label, maxLines = 2, overflow = TextOverflow.Ellipsis) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
listOfNotNull(
|
||||
download.status.replaceFirstChar { it.uppercase() },
|
||||
download.total.takeIf { it > 0L }?.let {
|
||||
"${formatBytes(download.downloaded.toULong())} of ${formatBytes(it.toULong())}"
|
||||
},
|
||||
).joinToString(" · "),
|
||||
)
|
||||
Text(downloadStatusLine(download))
|
||||
if (download.total > 0L) {
|
||||
AccentProgress(
|
||||
progress = { (download.downloaded.toFloat() / download.total).coerceIn(0f, 1f) },
|
||||
|
||||
@@ -270,6 +270,18 @@ fun ProtonStreamApp(
|
||||
shareLink,
|
||||
onShareLinkTaken,
|
||||
body,
|
||||
account = state.account,
|
||||
accountActions = AccountActions(
|
||||
signIn = model::signIn,
|
||||
submitSecondFactor = model::submitSecondFactor,
|
||||
submitMailboxPassword = model::submitMailboxPassword,
|
||||
cancelSignIn = model::cancelSignIn,
|
||||
dismissVerification = model::dismissVerification,
|
||||
signOut = model::signOut,
|
||||
syncNow = model::syncNow,
|
||||
addFolder = model::addAccountFolder,
|
||||
onError = model::reportError,
|
||||
),
|
||||
)
|
||||
Destination.History -> HistoryScreen(
|
||||
state,
|
||||
@@ -308,7 +320,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -338,7 +350,7 @@ fun ProtonStreamApp(
|
||||
onClose = {
|
||||
playingTitleKey = null
|
||||
playerMinimized = false
|
||||
model.reloadAfterMetadataChange()
|
||||
model.playerClosed()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.narl.protonstream.ui.theme.AccentButton
|
||||
import io.narl.protonstream.ui.theme.TonalButton
|
||||
import uniffi.pstr_android.AccountState
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
@@ -56,16 +57,40 @@ internal fun SharesScreen(
|
||||
incomingLink: String?,
|
||||
onIncomingLinkTaken: () -> Unit,
|
||||
padding: PaddingValues,
|
||||
account: AccountUiState = AccountUiState(),
|
||||
accountActions: AccountActions? = null,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var signingIn by remember { mutableStateOf(false) }
|
||||
var browsing by remember { mutableStateOf(false) }
|
||||
var repairing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
var removing by remember { mutableStateOf<ShareRecord?>(null) }
|
||||
TabPage("Shares", padding) { body ->
|
||||
Box(Modifier.fillMaxSize().padding(body)) {
|
||||
if (shares.isEmpty()) {
|
||||
if (shares.isEmpty() && account.state == null) {
|
||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||
} else {
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
if (accountActions != null) {
|
||||
item(key = "account") {
|
||||
AccountCard(
|
||||
account,
|
||||
accountActions,
|
||||
onSignIn = { signingIn = true },
|
||||
onBrowse = { browsing = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shares.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
"No shares yet. Add a public link, or a folder of your Drive once signed in.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(shares, key = { it.id }) { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
@@ -95,6 +120,14 @@ internal fun SharesScreen(
|
||||
onAdd = onAdd,
|
||||
)
|
||||
}
|
||||
if (accountActions != null && (signingIn || account.state == AccountState.SecondFactor ||
|
||||
account.state == AccountState.MailboxPassword)
|
||||
) {
|
||||
SignInDialog(account, accountActions, onDismiss = { signingIn = false })
|
||||
}
|
||||
if (accountActions != null && browsing) {
|
||||
DriveBrowserDialog(accountActions, onDismiss = { browsing = false })
|
||||
}
|
||||
repairing?.let { share ->
|
||||
RepairShareDialog(
|
||||
share = share,
|
||||
@@ -139,7 +172,13 @@ private fun ShareRow(
|
||||
ListItem(
|
||||
headlineContent = { Text(share.name, fontWeight = FontWeight.SemiBold) },
|
||||
supportingContent = {
|
||||
Text(if (share.hasCustomPassword) "Link and custom password, stored securely" else "Public link")
|
||||
Text(
|
||||
when {
|
||||
share.fromAccount -> "Folder of your Drive"
|
||||
share.hasCustomPassword -> "Link and custom password, stored securely"
|
||||
else -> "Public link"
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
@@ -164,11 +203,13 @@ private fun ShareRow(
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either, since removal deletes a secret
|
||||
// the store can no longer touch.
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
if (!share.fromAccount) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-enter link") },
|
||||
leadingIcon = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
onClick = { menu = false; onRepair() },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove") },
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
|
||||
@@ -636,8 +636,7 @@ private fun EpisodeRow(
|
||||
fill = solid(MaterialTheme.colorScheme.tertiary),
|
||||
)
|
||||
Text(
|
||||
"${active.status.replaceFirstChar { it.uppercase() }} · " +
|
||||
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
|
||||
downloadStatusLine(active),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -654,6 +653,8 @@ internal fun ChangeMatchDialog(
|
||||
) {
|
||||
var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
AlertDialog(
|
||||
@@ -672,16 +673,26 @@ internal fun ChangeMatchDialog(
|
||||
searching = false
|
||||
}
|
||||
}) { Text(if (searching) "Searching…" else "Search") }
|
||||
if (choosing) {
|
||||
Text(
|
||||
"Fetching its episodes…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
|
||||
items(options, key = { "${it.provider}:${it.remoteId}" }) { option ->
|
||||
EdgedButton(
|
||||
onClick = {
|
||||
choosing = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) }
|
||||
}.onSuccess { onChanged() }.onFailure(onError)
|
||||
choosing = false
|
||||
}
|
||||
},
|
||||
enabled = !choosing,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package io.narl.protonstream.download
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TransferRateTest {
|
||||
@Test
|
||||
fun `the first sample has no rate to report`() {
|
||||
assertEquals(0L, TransferRate().sample(4L * 1024 * 1024, 1_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a steady transfer reports its rate`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
assertEquals(1_000_000L, rate.sample(1_000_000, 1_000))
|
||||
assertEquals(1_000_000L, rate.sample(2_000_000, 2_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one slow block moves the rate without halving it`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
val after = rate.sample(2_000_000, 3_000)
|
||||
assertTrue(after in 600_000L..999_999L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resumed download starting below the last sample starts over`() {
|
||||
val rate = TransferRate()
|
||||
rate.sample(0, 0)
|
||||
rate.sample(1_000_000, 1_000)
|
||||
assertEquals(1_000_000L, rate.sample(0, 2_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccountUiTest {
|
||||
@Test
|
||||
fun `the sync line says how long ago and what came in`() {
|
||||
val minute = 60_000L
|
||||
assertEquals("Watch history syncs with your Drive", syncLine(AccountUiState(), 0))
|
||||
assertEquals("Synced just now", syncLine(AccountUiState(syncedAt = 0), 30_000))
|
||||
assertEquals("Synced 5 min ago", syncLine(AccountUiState(syncedAt = 0), 5 * minute))
|
||||
assertEquals(
|
||||
"Synced 2 h ago · 3 from other devices",
|
||||
syncLine(AccountUiState(syncedAt = 0, applied = 3), 120 * minute),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed sync is said in place of when it last worked`() {
|
||||
assertEquals(
|
||||
"Watch history did not sync: offline",
|
||||
syncLine(AccountUiState(syncedAt = 0, syncError = "offline"), 0),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the verification page may only navigate within Proton over https`() {
|
||||
assertTrue(isProtonPage("https", "verify.proton.me"))
|
||||
assertTrue(isProtonPage("https", "proton.me"))
|
||||
assertFalse(isProtonPage("http", "verify.proton.me"))
|
||||
assertFalse(isProtonPage("https", "proton.me.example.com"))
|
||||
assertFalse(isProtonPage("https", "notproton.me"))
|
||||
assertFalse(isProtonPage("https", null))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user