From 07ec727c2308a12614a2b88b3f338e8c6a4d6533 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 12 Jul 2026 18:16:59 +0200 Subject: [PATCH] feat(send): show live progress per receiver Attribute provider transfer progress to each remote endpoint so the receivers panel and catalog can show per-peer send bars for multi-file collections while a share is active. --- crates/vnidrop/src/runtime.rs | 37 +++- .../composeResources/values/strings.xml | 1 + .../vnidrop/app/feature/send/SendCatalog.kt | 13 +- .../vnidrop/app/feature/send/SendScreen.kt | 7 +- .../app/feature/send/TransferDetails.kt | 40 +++- .../com/vnidrop/app/ui/state/AppUiModels.kt | 196 ++++++++++++++++++ .../vnidrop/app/ui/state/AppUiModelsTest.kt | 106 +++++++++- 7 files changed, 383 insertions(+), 17 deletions(-) diff --git a/crates/vnidrop/src/runtime.rs b/crates/vnidrop/src/runtime.rs index 9a8b75b..04c3db6 100644 --- a/crates/vnidrop/src/runtime.rs +++ b/crates/vnidrop/src/runtime.rs @@ -1485,7 +1485,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } let _ = message.tx.send(Ok(())).await; } @@ -1497,7 +1498,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } } ProviderMessage::GetManyRequestReceived(message) => { @@ -1531,7 +1533,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } let _ = message.tx.send(Ok(())).await; } @@ -1545,7 +1548,8 @@ impl CoreInner { message.inner.connection_id, message.inner.request_id, message.rx, - ); + ) + .await; } } ProviderMessage::ObserveRequestReceived(message) => { @@ -1618,7 +1622,7 @@ impl CoreInner { .await } - fn track_request_updates( + async fn track_request_updates( self: &Arc, transfer_id: u64, connection_id: u64, @@ -1628,6 +1632,15 @@ impl CoreInner { // Request update tasks are tied to individual provider streams. Router // shutdown closes those streams; only the long-lived provider receiver // is tracked directly for explicit shutdown. + // + // Attach the remote endpoint id when known so the send UI can attribute + // byte progress to a specific receiver (not just an opaque connection). + let endpoint_id = self + .connection_endpoints + .lock() + .await + .get(&connection_id) + .cloned(); let core = self.clone(); tokio::spawn(async move { while let Ok(Some(update)) = rx.recv().await { @@ -1640,6 +1653,7 @@ impl CoreInner { json!({ "connection_id": connection_id, "request_id": request_id, + "endpoint_id": endpoint_id, "hash": started.hash.to_string(), "size": started.size, "index": started.index, @@ -1653,6 +1667,7 @@ impl CoreInner { json!({ "connection_id": connection_id, "request_id": request_id, + "endpoint_id": endpoint_id, "end_offset": progress.end_offset, }), ), @@ -1661,14 +1676,22 @@ impl CoreInner { "send", "transfer", "completed", - json!({ "connection_id": connection_id, "request_id": request_id }), + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + }), ), RequestUpdate::Aborted(_) => core.emit_transfer( transfer_id, "send", "transfer", "aborted", - json!({ "connection_id": connection_id, "request_id": request_id }), + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + }), ), } } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 1295340..28749d3 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -123,6 +123,7 @@ Preparing transfer %1$d waiting %1$d completed + Sending Downloading files Saving files Connecting to sender diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index c982b25..dd419e3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -41,7 +41,9 @@ import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.StatusPill +import com.vnidrop.app.ui.state.TransferProgress import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.ui.state.activeSendProgress import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.progressForTransfer @@ -101,10 +103,15 @@ internal fun TransferCatalog( ) } items(transfers, key = Transfer::localId) { transfer -> + val progress = when (transfer.status) { + TransferStatus.Importing -> progressForTransfer(events, transfer.transferId) + TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize) + else -> null + } TransferListItem( transfer = transfer, thumbnailBytes = transferThumbnails[transfer.transferId], - progress = progressForTransfer(events, transfer.transferId), + progress = progress, onClick = { onTransferSelected(transfer.transferId) }, ) } @@ -170,7 +177,7 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) { private fun TransferListItem( transfer: Transfer, thumbnailBytes: ByteArray?, - progress: com.vnidrop.app.ui.state.TransferProgress?, + progress: TransferProgress?, onClick: () -> Unit, ) { val colors = LocalVniDropColors.current @@ -203,7 +210,7 @@ private fun TransferListItem( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (transfer.status == TransferStatus.Importing && progress != null) { + if (progress != null && transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) { ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index caea4fd..2cade4b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -99,7 +99,12 @@ fun SendScreen( AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { when (state.detailPanel) { TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) - TransferDetailPanel.Receivers -> ReceiverHistoryPanel(state.receiverHistory, state.isLoadingReceivers) + TransferDetailPanel.Receivers -> ReceiverHistoryPanel( + receivers = state.receiverHistory, + loading = state.isLoadingReceivers, + events = coreState.events, + transferTotalSize = selectedTransfer.totalSize, + ) TransferDetailPanel.Share -> TransferSharePanel( selectedTransfer, shareActions, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 8027b23..4468fc1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -50,9 +50,12 @@ import com.vnidrop.app.core.Transfer import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.state.TransferProgress import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.state.progressForReceiver import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.stringResource @@ -159,28 +162,55 @@ private fun DetailDestination(title: String, description: String, count: Int? = } @Composable -internal fun ReceiverHistoryPanel(receivers: List, loading: Boolean) { +internal fun ReceiverHistoryPanel( + receivers: List, + loading: Boolean, + events: List = emptyList(), + transferTotalSize: ULong? = null, +) { PanelContainer(stringResource(Res.string.transfer_receivers_title)) { when { loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter) else -> receivers.forEachIndexed { index, receiver -> if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault) - ReceiverRow(receiver) + val sendProgress = when (receiver.status) { + ReceiverDeliveryStatus.Accepted, ReceiverDeliveryStatus.Requested -> + progressForReceiver(events, receiver.transferId, receiver.remoteEndpointId, transferTotalSize) + else -> null + } + ReceiverRow(receiver, sendProgress) } } } } @Composable -private fun ReceiverRow(receiver: ReceiverRequestModel) { +private fun ReceiverRow(receiver: ReceiverRequestModel, sendProgress: TransferProgress? = null) { val name = receiver.receiverName ?: receiver.receiverDeviceName ?: stringResource(Res.string.transfer_nearby_device) - Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val showLiveSend = sendProgress != null && + receiver.status != ReceiverDeliveryStatus.Completed && + receiver.status != ReceiverDeliveryStatus.Refused && + receiver.status != ReceiverDeliveryStatus.Expired + Column(Modifier.fillMaxWidth().padding(vertical = 13.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) receiver.receiverDeviceName?.takeIf { it != name }?.let { Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) } - Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + if (showLiveSend) { + ProgressRow( + label = stringResource(Res.string.transfer_receiver_sending), + progress = sendProgress.progress, + detail = sendProgress.detail, + ) + } else { + Text( + receiverStatusText(receiver.status), + color = receiverStatusColor(receiver.status), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } receiver.reason?.takeIf { it.isNotBlank() }?.let { reason -> Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt index 53d279a..b068ea0 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt @@ -68,6 +68,105 @@ fun progressForTransfer(events: List, transferId: ULong): Transf ) } +/** + * Live byte progress for one receiver on an outgoing share. + * + * Core emits `transfer` phase events per provider connection with + * `endpoint_id` (and `connection_id`). Multiple blob requests for the same + * receiver are aggregated so multi-file collections show a single bar. + * + * Falls back to mapping `connection_id` → endpoint via provider + * `client-connected` events when older events lack `endpoint_id`. + */ +fun progressForReceiver( + events: List, + transferId: ULong, + remoteEndpointId: String, + totalSizeHint: ULong? = null, +): TransferProgress? { + if (remoteEndpointId.isBlank()) return null + val connectionIds = connectionIdsForEndpoint(events, remoteEndpointId) + val transferEvents = events.filter { event -> + event.transferId == transferId && + event.direction == "send" && + event.phase == "transfer" && + event.kind in setOf("started", "progress", "completed", "aborted") && + eventBelongsToReceiver(event, remoteEndpointId, connectionIds) + } + if (transferEvents.isEmpty()) return null + + val latest = transferEvents.first() + if (latest.kind == "aborted") { + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = "aborted", + label = "Send interrupted", + progress = null, + detail = null, + ) + } + if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) { + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = "completed", + label = "Send completed", + progress = 1f, + detail = null, + ) + } + + val progress = aggregateReceiverProgress(transferEvents, totalSizeHint) + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = latest.kind, + label = "Sending", + progress = progress, + detail = progressDetail(latest), + ) +} + +/** + * Best send-side progress for a transfer (any receiver), used on catalog cards + * while status is Sharing. + */ +fun activeSendProgress( + events: List, + transferId: ULong, + totalSizeHint: ULong? = null, +): TransferProgress? { + val endpointIds = events + .asSequence() + .filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" } + .mapNotNull { findString(it.dataJson, "endpoint_id") } + .distinct() + .toList() + if (endpointIds.isEmpty()) { + // Fall back to connection-scoped events without endpoint attribution. + val relevant = events.filter { + it.transferId == transferId && + it.direction == "send" && + it.phase == "transfer" && + it.kind in setOf("started", "progress") + } + if (relevant.isEmpty()) return null + return TransferProgress( + transferId = transferId, + phase = "transfer", + kind = relevant.first().kind, + label = "Sending to receiver", + progress = aggregateReceiverProgress(relevant, totalSizeHint), + detail = progressDetail(relevant.first()), + ) + } + return endpointIds + .mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) } + .firstOrNull { it.kind == "progress" || it.kind == "started" } + ?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull() +} + fun summarizeProgress(events: List): List = events .mapNotNull { it.transferId } @@ -181,11 +280,107 @@ private fun findKnownSize(events: List, transferId: ULong): Doub return null } +/** + * Aggregate multi-blob provider progress for one receiver connection. + * + * For each `request_id`, take the latest known size/offset/completion, then + * sum transferred / sum sizes. Prefer the transfer's total size when the + * collection size is known and larger than the sum of observed blob sizes. + */ +private fun aggregateReceiverProgress( + events: List, + totalSizeHint: ULong?, +): Float? { + // Events are newest-first; walk oldest→newest so later states win. + val chronological = events.asReversed() + data class BlobState(var size: Double? = null, var offset: Double = 0.0, var completed: Boolean = false, var aborted: Boolean = false) + val byRequest = linkedMapOf() + var connectionScopedOffset: Double? = null + var connectionScopedSize: Double? = null + + for (event in chronological) { + val requestKey = findNumber(event.dataJson, "request_id")?.toLong()?.toString() + ?: findString(event.dataJson, "request_id") + val size = findNumber(event.dataJson, "size") + val endOffset = findNumber(event.dataJson, "end_offset") + ?: findNumber(event.dataJson, "offset") + ?: findNumber(event.dataJson, "transferred") + + if (requestKey != null) { + val state = byRequest.getOrPut(requestKey) { BlobState() } + if (size != null && size > 0) state.size = size + when (event.kind) { + "progress", "started" -> { + if (endOffset != null) state.offset = maxOf(state.offset, endOffset) + state.aborted = false + } + "completed" -> { + state.completed = true + state.size?.let { state.offset = it } + } + "aborted" -> state.aborted = true + } + } else { + if (size != null && size > 0) connectionScopedSize = size + if (endOffset != null) connectionScopedOffset = endOffset + } + } + + if (byRequest.isNotEmpty()) { + val active = byRequest.values.filterNot { it.aborted } + if (active.isEmpty()) return null + val transferred = active.sumOf { state -> + when { + state.completed -> state.size ?: state.offset + else -> state.offset + } + } + val observedSize = active.mapNotNull { it.size }.sum() + val total = totalSizeHint?.toDouble()?.takeIf { it > 0 } + ?: observedSize.takeIf { it > 0 } + if (total == null || total <= 0.0) return null + return (transferred / total).toFloat().coerceIn(0f, 1f) + } + + val total = totalSizeHint?.toDouble()?.takeIf { it > 0 } + ?: connectionScopedSize?.takeIf { it > 0 } + val transferred = connectionScopedOffset + if (transferred == null || total == null || total <= 0.0) return null + return (transferred / total).toFloat().coerceIn(0f, 1f) +} + +private fun connectionIdsForEndpoint(events: List, remoteEndpointId: String): Set { + val ids = mutableSetOf() + for (event in events) { + val endpoint = findString(event.dataJson, "endpoint_id") ?: continue + if (endpoint != remoteEndpointId) continue + findNumber(event.dataJson, "connection_id")?.toLong()?.toString()?.let(ids::add) + findString(event.dataJson, "connection_id")?.let(ids::add) + } + return ids +} + +private fun eventBelongsToReceiver( + event: CoreEventModel, + remoteEndpointId: String, + connectionIds: Set, +): Boolean { + val endpoint = findString(event.dataJson, "endpoint_id") + if (endpoint != null) return endpoint == remoteEndpointId + val connectionId = findNumber(event.dataJson, "connection_id")?.toLong()?.toString() + ?: findString(event.dataJson, "connection_id") + ?: return false + return connectionId in connectionIds +} + private fun findNumber(json: String, key: String): Double? { val marker = "\"$key\":" val start = json.indexOf(marker) if (start < 0) return null val valueStart = start + marker.length + val raw = json.substring(valueStart).trimStart() + // Skip JSON null so endpoint_id:null does not parse as a number key neighbor. + if (raw.startsWith("null")) return null val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull() } @@ -195,6 +390,7 @@ private fun findString(json: String, key: String): String? { val start = json.indexOf(marker) if (start < 0) return null val after = json.substring(start + marker.length).trimStart() + if (after.startsWith("null")) return null if (!after.startsWith('"')) return null val end = after.indexOf('"', 1) if (end <= 1) return null diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index 5765f96..c783ce7 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -103,6 +103,109 @@ class AppUiModelsTest { assertEquals("a.bin", progress?.detail) } + @Test + fun progressForReceiverAggregatesMultiBlobSendByEndpoint() { + val events = listOf( + // newest first + event( + id = "p2", + phase = "transfer", + kind = "progress", + data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","end_offset":40}""", + direction = "send", + ), + event( + id = "s2", + phase = "transfer", + kind = "started", + data = """{"connection_id":9,"request_id":2,"endpoint_id":"peer-a","size":50}""", + direction = "send", + ), + event( + id = "c1", + phase = "transfer", + kind = "completed", + data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a"}""", + direction = "send", + ), + event( + id = "s1", + phase = "transfer", + kind = "started", + data = """{"connection_id":9,"request_id":1,"endpoint_id":"peer-a","size":50}""", + direction = "send", + ), + // different receiver should not mix in + event( + id = "other", + phase = "transfer", + kind = "progress", + data = """{"connection_id":3,"request_id":7,"endpoint_id":"peer-b","end_offset":99}""", + direction = "send", + ), + ) + // blob1 complete 50 + blob2 40 = 90 / total hint 100 + val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL) + assertEquals(0.9f, progress?.progress) + assertEquals("Sending", progress?.label) + assertEquals(null, progressForReceiver(events, 7UL, "missing")) + } + + @Test + fun progressForReceiverFallsBackToConnectionMap() { + val events = listOf( + event( + id = "prog", + phase = "transfer", + kind = "progress", + data = """{"connection_id":4,"request_id":1,"end_offset":25}""", + direction = "send", + ), + event( + id = "start", + phase = "transfer", + kind = "started", + data = """{"connection_id":4,"request_id":1,"size":100}""", + direction = "send", + ), + CoreEventModel( + id = "conn", + timestamp = 1L, + scope = "endpoint", + transferId = null, + direction = null, + phase = "provider", + kind = "client-connected", + dataJson = """{"connection_id":4,"endpoint_id":"peer-z"}""", + ), + ) + val progress = progressForReceiver(events, 7UL, "peer-z") + assertEquals(0.25f, progress?.progress) + } + + @Test + fun activeSendProgressPicksLiveReceiverSend() { + val events = listOf( + event( + id = "p", + phase = "transfer", + kind = "progress", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":30}""", + direction = "send", + ), + event( + id = "s", + phase = "transfer", + kind = "started", + data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""", + direction = "send", + ), + ) + val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL) + assertEquals(0.3f, progress?.progress) + assertEquals("Sending", progress?.label) + } + @Test fun canCancelOnlyActiveStatuses() { assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer()) @@ -133,12 +236,13 @@ class AppUiModelsTest { kind: String, data: String, transferId: ULong = 7UL, + direction: String = "receive", ) = CoreEventModel( id = id, timestamp = 1L, scope = "transfer", transferId = transferId, - direction = "receive", + direction = direction, phase = phase, kind = kind, dataJson = data,