feat(android,ios): stop a crawl from the phones, as the desktop can

The bridge gains stop_crawl(). A running crawl watches for it and returns
without an error. Shares already read stay stored, and a queue of new
shares stops instead of going on to the next. The activity banner shows
Stop while a crawl runs, because listing a large share can take minutes
and holds the connection the player wants.
This commit is contained in:
nvrl committed 2026-10-13 15:34:55 +02:00
1 parent 6ddbcea429
commit fdf6b0359b
5 files changed
+97 -11

No files matched your search

@@ -47,6 +47,8 @@ import kotlinx.coroutines.isActive
data class AppUiState( data class AppUiState(
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "", val query: String = "",
val titles: List<TitleRecord> = emptyList(), val titles: List<TitleRecord> = emptyList(),
/** /**
@@ -118,6 +120,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
private val activities = mutableListOf<Pair<Long, String>>() private val activities = mutableListOf<Pair<Long, String>>()
private var nextActivity = 0L private var nextActivity = 0L
/** Crawls running now; main thread only, like [activities]. */
private var crawlsRunning = 0
/** Set by [stopCrawl], so a queue of shares stops rather than going on to the next. */
private var crawlStopped = false
init { init {
if (!NativeRuntime.tlsReady) { if (!NativeRuntime.tlsReady) {
mutableState.update { mutableState.update {
@@ -187,7 +195,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch { viewModelScope.launch {
busy("Refreshing the library…") { busy("Refreshing the library…") {
mutableState.update { it.copy(message = null) } mutableState.update { it.copy(message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } runCatching { crawlShares(null) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reloadNow() reloadNow()
@@ -225,6 +233,27 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
} }
/** Crawl one share, or all with null, with the banner offering Stop meanwhile. */
private suspend fun crawlShares(id: String?) {
crawlsRunning++
mutableState.update { it.copy(crawling = true) }
try {
withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) }
} finally {
crawlsRunning--
mutableState.update { it.copy(crawling = crawlsRunning > 0) }
}
}
/**
* Stop the crawls running now. Shares already read stay in the library;
* the one being read keeps what it had.
*/
fun stopCrawl() {
crawlStopped = true
viewModelScope.launch(Dispatchers.IO) { NativeRuntime.engine().stopCrawl() }
}
private fun showActivity() { private fun showActivity() {
val newest = activities.lastOrNull()?.second val newest = activities.lastOrNull()?.second
mutableState.update { it.copy(activity = newest, refreshing = newest != null) } mutableState.update { it.copy(activity = newest, refreshing = newest != null) }
@@ -243,7 +272,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share" val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the share"
busy("Reading $name…") { busy("Reading $name…") {
mutableState.update { it.copy(message = null) } mutableState.update { it.copy(message = null) }
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } runCatching { crawlShares(id) }
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } }
} }
reloadNow() reloadNow()
@@ -309,10 +338,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
if (ids.isEmpty()) return if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet() val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>() val failed = mutableListOf<String>()
crawlStopped = false
for (id in ids) { for (id in ids) {
if (crawlStopped) break
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share" val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") { busy("Reading $name…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } runCatching { crawlShares(id) }
.onFailure { failed += "$name: ${it.message}" } .onFailure { failed += "$name: ${it.message}" }
} }
} }
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -355,7 +356,8 @@ fun ProtonStreamApp(
} }
ActivityBanner( ActivityBanner(
state.activity, state.activity,
Modifier onStop = model::stopCrawl.takeIf { state.crawling },
modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp), .padding(bottom = body.calculateBottomPadding() + 8.dp),
) )
@@ -437,7 +439,7 @@ private fun MiniTransport(
* Not dismissible and not timed — it goes when the work does. * Not dismissible and not timed — it goes when the work does.
*/ */
@Composable @Composable
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) { private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
AnimatedVisibility( AnimatedVisibility(
visible = activity != null, visible = activity != null,
modifier = modifier, modifier = modifier,
@@ -458,7 +460,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2) Text(shown, style = MaterialTheme.typography.bodyMedium, maxLines = 2, modifier = Modifier.weight(1f))
// A crawl of a large share can take minutes, and is the viewer's
// to call off; what it has read so far stays.
onStop?.let { QuietButton(onClick = it) { Text("Stop") } }
} }
} }
} }
+20 -1
View File
@@ -963,6 +963,8 @@ pub struct AndroidEngine {
reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>, reusable_clients: Mutex<Option<BTreeMap<String, ShareClient>>>,
/// The whole library as bridge records, behind the catalog's write counter. /// The whole library as bridge records, behind the catalog's write counter.
library_cache: Mutex<Option<CachedLibrary>>, library_cache: Mutex<Option<CachedLibrary>>,
/// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops.
crawl_stop: tokio::sync::watch::Sender<u64>,
/// Serializes offline publication with share removal cleanup. /// Serializes offline publication with share removal cleanup.
share_publication: Mutex<()>, share_publication: Mutex<()>,
/// Advances after every successful share-store mutation. A connection is /// Advances after every successful share-store mutation. A connection is
@@ -1039,6 +1041,7 @@ impl AndroidEngine {
connecting: tokio::sync::Mutex::new(()), connecting: tokio::sync::Mutex::new(()),
reusable_clients: Mutex::new(None), reusable_clients: Mutex::new(None),
library_cache: Mutex::new(None), library_cache: Mutex::new(None),
crawl_stop: tokio::sync::watch::channel(0).0,
share_publication: Mutex::new(()), share_publication: Mutex::new(()),
share_generation: AtomicU64::new(0), share_generation: AtomicU64::new(0),
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
@@ -2017,6 +2020,13 @@ impl AndroidEngine {
.await .await
.map_err(BridgeError::from_display)? .map_err(BridgeError::from_display)?
} }
/// Stop every crawl running now. Shares already listed stay stored; the
/// one being listed keeps what it had. The stopped `crawl` returns
/// without an error, since whoever stopped it knows why.
pub fn stop_crawl(&self) {
self.crawl_stop.send_modify(|generation| *generation += 1);
}
} }
impl AndroidEngine { impl AndroidEngine {
@@ -2517,12 +2527,17 @@ impl AndroidEngine {
} }
async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> { async fn crawl_inner(&self, share_id: Option<String>) -> Result<(), BridgeError> {
let mut stop = self.crawl_stop.subscribe();
stop.borrow_and_update();
let connection = self.connection().await?; let connection = self.connection().await?;
let targets: Vec<String> = match share_id { let targets: Vec<String> = match share_id {
Some(id) => vec![id], Some(id) => vec![id],
None => connection.library.share_ids().map(str::to_owned).collect(), None => connection.library.share_ids().map(str::to_owned).collect(),
}; };
for id in targets { for id in targets {
if stop.has_changed().unwrap_or(false) {
return Ok(());
}
connection connection
.library .library
.refresh_session(&id) .refresh_session(&id)
@@ -2535,7 +2550,11 @@ impl AndroidEngine {
.lock() .lock()
.all_offline_files() .all_offline_files()
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let nodes = match connection.library.crawl(&id).await { let crawled = tokio::select! {
crawled = connection.library.crawl(&id) => crawled,
_ = stop.changed() => return Ok(()),
};
let nodes = match crawled {
Ok(nodes) => nodes, Ok(nodes) => nodes,
Err(error) => { Err(error) => {
// On the share's row until a crawl succeeds: a link revoked // On the share's row until a crawl succeeds: a link revoked
+29 -3
View File
@@ -38,6 +38,8 @@ struct WatchSnapshot: Equatable {
final class AppModel { final class AppModel {
var loading = true var loading = true
var refreshing = false var refreshing = false
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = "" private(set) var query = ""
var titles: [TitleRecord] = [] var titles: [TitleRecord] = []
/// The grid: `titles` ordered, each franchise folded into one tile when /// The grid: `titles` ordered, each franchise folded into one tile when
@@ -68,6 +70,10 @@ final class AppModel {
/// Every banner line whose work is still running, oldest first. /// Every banner line whose work is still running, oldest first.
@ObservationIgnored private var activities: [(token: Int, line: String)] = [] @ObservationIgnored private var activities: [(token: Int, line: String)] = []
@ObservationIgnored private var nextActivity = 0 @ObservationIgnored private var nextActivity = 0
/// Crawls running now.
@ObservationIgnored private var crawlsRunning = 0
/// Set by `stopCrawl`, so a queue of shares stops rather than going on to the next.
@ObservationIgnored private var crawlStopped = false
/// Five minutes, as on Android: cheap when nothing changed — one folder /// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload. /// listing, and no upload.
@@ -137,6 +143,24 @@ final class AppModel {
Task { await crawlNow(shareId) } Task { await crawlNow(shareId) }
} }
/// Crawl one share, or all with nil, with the banner offering Stop meanwhile.
private func crawlShares(_ id: String?) async throws {
crawlsRunning += 1
crawling = true
defer {
crawlsRunning -= 1
crawling = crawlsRunning > 0
}
try await NativeRuntime.engine().crawl(shareId: id)
}
/// Stop the crawls running now. Shares already read stay in the library;
/// the one being read keeps what it had.
func stopCrawl() {
crawlStopped = true
NativeRuntime.engine().stopCrawl()
}
/// The crawl itself, for pull to refresh, which holds its spinner until /// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns. /// this returns.
func crawlNow(_ shareId: String?) async { func crawlNow(_ shareId: String?) async {
@@ -144,7 +168,7 @@ final class AppModel {
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") { await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
message = nil message = nil
do { do {
try await NativeRuntime.engine().crawl(shareId: shareId) try await crawlShares(shareId)
} catch { } catch {
message = errorMessage(error) message = errorMessage(error)
} }
@@ -235,11 +259,13 @@ final class AppModel {
guard !ids.isEmpty else { return } guard !ids.isEmpty else { return }
let before = Set(titles.map(\.key)) let before = Set(titles.map(\.key))
var failed: [String] = [] var failed: [String] = []
crawlStopped = false
for id in ids { for id in ids {
if crawlStopped { break }
let name = shares.first { $0.id == id }?.name ?? "the new share" let name = shares.first { $0.id == id }?.name ?? "the new share"
await busy("Reading \(name)…") { await busy("Reading \(name)…") {
do { do {
try await NativeRuntime.engine().crawl(shareId: id) try await crawlShares(id)
} catch { } catch {
failed.append("\(name): \(errorMessage(error))") failed.append("\(name): \(errorMessage(error))")
} }
@@ -272,7 +298,7 @@ final class AppModel {
do { do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } _ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id) try await crawlShares(id)
} catch { } catch {
reportError(error) reportError(error)
} }
+6 -1
View File
@@ -332,13 +332,18 @@ private struct ActivityBanner: View {
.foregroundStyle(scheme.onSurface) .foregroundStyle(scheme.onSurface)
.lineLimit(2) .lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// A crawl of a large share can take minutes, and is the
// viewer's to call off; what it has read so far stays.
if model.crawling {
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight)) .background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: .combine) .accessibilityElement(children: model.crawling ? .contain : .combine)
.accessibilityAddTraits(.updatesFrequently) .accessibilityAddTraits(.updatesFrequently)
} }
} }