diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index cb0ae37..f62ed5f 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -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 = emptyList(), /** @@ -118,6 +120,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie private val activities = mutableListOf>() 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() + 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}" } } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 3f30f3f..c4278d9 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -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") } } } } } diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index b84e53d..699935e 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -963,6 +963,8 @@ pub struct AndroidEngine { reusable_clients: Mutex>>, /// The whole library as bridge records, behind the catalog's write counter. library_cache: Mutex>, + /// Advanced by [`AndroidEngine::stop_crawl`]; a crawl watching it stops. + crawl_stop: tokio::sync::watch::Sender, /// 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) -> Result<(), BridgeError> { + let mut stop = self.crawl_stop.subscribe(); + stop.borrow_and_update(); let connection = self.connection().await?; let targets: Vec = 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 diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index f0df162..3838379 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -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) } diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index f528500..13ea33f 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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) } }