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(
val loading: Boolean = true,
val refreshing: Boolean = false,
/** A crawl is running, which the banner offers to stop. */
val crawling: Boolean = false,
val query: String = "",
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 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 {
if (!NativeRuntime.tlsReady) {
mutableState.update {
@@ -187,7 +195,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
viewModelScope.launch {
busy("Refreshing the library…") {
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) } }
}
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() {
val newest = activities.lastOrNull()?.second
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"
busy("Reading $name…") {
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) } }
}
reloadNow()
@@ -309,10 +338,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
if (ids.isEmpty()) return
val before = mutableState.value.titles.map { it.key }.toSet()
val failed = mutableListOf<String>()
crawlStopped = false
for (id in ids) {
if (crawlStopped) break
val name = mutableState.value.shares.firstOrNull { it.id == id }?.name ?: "the new share"
busy("Reading $name…") {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } }
runCatching { crawlShares(id) }
.onFailure { failed += "$name: ${it.message}" }
}
}
@@ -76,6 +76,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkManager
import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.accentNavigationColors
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
@@ -355,7 +356,8 @@ fun ProtonStreamApp(
}
ActivityBanner(
state.activity,
Modifier
onStop = model::stopCrawl.takeIf { state.crawling },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = body.calculateBottomPadding() + 8.dp),
)
@@ -437,7 +439,7 @@ private fun MiniTransport(
* Not dismissible and not timed — it goes when the work does.
*/
@Composable
private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
private fun ActivityBanner(activity: String?, onStop: (() -> Unit)?, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = activity != null,
modifier = modifier,
@@ -458,7 +460,10 @@ private fun ActivityBanner(activity: String?, modifier: Modifier = Modifier) {
verticalAlignment = Alignment.CenterVertically,
) {
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>>>,
/// The whole library as bridge records, behind the catalog's write counter.
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.
share_publication: Mutex<()>,
/// Advances after every successful share-store mutation. A connection is
@@ -1039,6 +1041,7 @@ impl AndroidEngine {
connecting: tokio::sync::Mutex::new(()),
reusable_clients: Mutex::new(None),
library_cache: Mutex::new(None),
crawl_stop: tokio::sync::watch::channel(0).0,
share_publication: Mutex::new(()),
share_generation: AtomicU64::new(0),
thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY),
@@ -2017,6 +2020,13 @@ impl AndroidEngine {
.await
.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 {
@@ -2517,12 +2527,17 @@ impl AndroidEngine {
}
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 targets: Vec<String> = match share_id {
Some(id) => vec![id],
None => connection.library.share_ids().map(str::to_owned).collect(),
};
for id in targets {
if stop.has_changed().unwrap_or(false) {
return Ok(());
}
connection
.library
.refresh_session(&id)
@@ -2535,7 +2550,11 @@ impl AndroidEngine {
.lock()
.all_offline_files()
.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,
Err(error) => {
// 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 {
var loading = true
var refreshing = false
/// A crawl is running, which the banner offers to stop.
private(set) var crawling = false
private(set) var query = ""
var titles: [TitleRecord] = []
/// 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.
@ObservationIgnored private var activities: [(token: Int, line: String)] = []
@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
/// listing, and no upload.
@@ -137,6 +143,24 @@ final class AppModel {
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
/// this returns.
func crawlNow(_ shareId: String?) async {
@@ -144,7 +168,7 @@ final class AppModel {
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
message = nil
do {
try await NativeRuntime.engine().crawl(shareId: shareId)
try await crawlShares(shareId)
} catch {
message = errorMessage(error)
}
@@ -235,11 +259,13 @@ final class AppModel {
guard !ids.isEmpty else { return }
let before = Set(titles.map(\.key))
var failed: [String] = []
crawlStopped = false
for id in ids {
if crawlStopped { break }
let name = shares.first { $0.id == id }?.name ?? "the new share"
await busy("Reading \(name)…") {
do {
try await NativeRuntime.engine().crawl(shareId: id)
try await crawlShares(id)
} catch {
failed.append("\(name): \(errorMessage(error))")
}
@@ -272,7 +298,7 @@ final class AppModel {
do {
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 NativeRuntime.engine().crawl(shareId: id)
try await crawlShares(id)
} catch {
reportError(error)
}
+6 -1
View File
@@ -332,13 +332,18 @@ private struct ActivityBanner: View {
.foregroundStyle(scheme.onSurface)
.lineLimit(2)
.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(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: .combine)
.accessibilityElement(children: model.crawling ? .contain : .combine)
.accessibilityAddTraits(.updatesFrequently)
}
}