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.
This commit is contained in:
2026-07-12 18:16:59 +02:00
parent 50d43a3867
commit 07ec727c23
7 changed files with 383 additions and 17 deletions

View File

@@ -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<Self>,
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,
}),
),
}
}

View File

@@ -123,6 +123,7 @@
<string name="progress_preparing">Preparing transfer</string>
<string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_completed_count">%1$d completed</string>
<string name="transfer_receiver_sending">Sending</string>
<string name="transfer_event_downloading">Downloading files</string>
<string name="transfer_event_saving">Saving files</string>
<string name="transfer_event_connecting">Connecting to sender</string>

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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<ReceiverRequestModel>, loading: Boolean) {
internal fun ReceiverHistoryPanel(
receivers: List<ReceiverRequestModel>,
loading: Boolean,
events: List<CoreEventModel> = 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)
}

View File

@@ -68,6 +68,105 @@ fun progressForTransfer(events: List<CoreEventModel>, 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<CoreEventModel>,
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<CoreEventModel>,
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<CoreEventModel>): List<TransferProgress> =
events
.mapNotNull { it.transferId }
@@ -181,11 +280,107 @@ private fun findKnownSize(events: List<CoreEventModel>, 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<CoreEventModel>,
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<String, BlobState>()
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<CoreEventModel>, remoteEndpointId: String): Set<String> {
val ids = mutableSetOf<String>()
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<String>,
): 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

View File

@@ -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,