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.connection_id,
message.inner.request_id, message.inner.request_id,
message.rx, message.rx,
); )
.await;
} }
let _ = message.tx.send(Ok(())).await; let _ = message.tx.send(Ok(())).await;
} }
@@ -1497,7 +1498,8 @@ impl CoreInner {
message.inner.connection_id, message.inner.connection_id,
message.inner.request_id, message.inner.request_id,
message.rx, message.rx,
); )
.await;
} }
} }
ProviderMessage::GetManyRequestReceived(message) => { ProviderMessage::GetManyRequestReceived(message) => {
@@ -1531,7 +1533,8 @@ impl CoreInner {
message.inner.connection_id, message.inner.connection_id,
message.inner.request_id, message.inner.request_id,
message.rx, message.rx,
); )
.await;
} }
let _ = message.tx.send(Ok(())).await; let _ = message.tx.send(Ok(())).await;
} }
@@ -1545,7 +1548,8 @@ impl CoreInner {
message.inner.connection_id, message.inner.connection_id,
message.inner.request_id, message.inner.request_id,
message.rx, message.rx,
); )
.await;
} }
} }
ProviderMessage::ObserveRequestReceived(message) => { ProviderMessage::ObserveRequestReceived(message) => {
@@ -1618,7 +1622,7 @@ impl CoreInner {
.await .await
} }
fn track_request_updates( async fn track_request_updates(
self: &Arc<Self>, self: &Arc<Self>,
transfer_id: u64, transfer_id: u64,
connection_id: u64, connection_id: u64,
@@ -1628,6 +1632,15 @@ impl CoreInner {
// Request update tasks are tied to individual provider streams. Router // Request update tasks are tied to individual provider streams. Router
// shutdown closes those streams; only the long-lived provider receiver // shutdown closes those streams; only the long-lived provider receiver
// is tracked directly for explicit shutdown. // 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(); let core = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(Some(update)) = rx.recv().await { while let Ok(Some(update)) = rx.recv().await {
@@ -1640,6 +1653,7 @@ impl CoreInner {
json!({ json!({
"connection_id": connection_id, "connection_id": connection_id,
"request_id": request_id, "request_id": request_id,
"endpoint_id": endpoint_id,
"hash": started.hash.to_string(), "hash": started.hash.to_string(),
"size": started.size, "size": started.size,
"index": started.index, "index": started.index,
@@ -1653,6 +1667,7 @@ impl CoreInner {
json!({ json!({
"connection_id": connection_id, "connection_id": connection_id,
"request_id": request_id, "request_id": request_id,
"endpoint_id": endpoint_id,
"end_offset": progress.end_offset, "end_offset": progress.end_offset,
}), }),
), ),
@@ -1661,14 +1676,22 @@ impl CoreInner {
"send", "send",
"transfer", "transfer",
"completed", "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( RequestUpdate::Aborted(_) => core.emit_transfer(
transfer_id, transfer_id,
"send", "send",
"transfer", "transfer",
"aborted", "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="progress_preparing">Preparing transfer</string>
<string name="transfer_receivers_pending">%1$d waiting</string> <string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_completed_count">%1$d completed</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_downloading">Downloading files</string>
<string name="transfer_event_saving">Saving files</string> <string name="transfer_event_saving">Saving files</string>
<string name="transfer_event_connecting">Connecting to sender</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.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.StatusPill 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.WindowClass
import com.vnidrop.app.ui.state.activeSendProgress
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForTransfer import com.vnidrop.app.ui.state.progressForTransfer
@@ -101,10 +103,15 @@ internal fun TransferCatalog(
) )
} }
items(transfers, key = Transfer::localId) { transfer -> 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( TransferListItem(
transfer = transfer, transfer = transfer,
thumbnailBytes = transferThumbnails[transfer.transferId], thumbnailBytes = transferThumbnails[transfer.transferId],
progress = progressForTransfer(events, transfer.transferId), progress = progress,
onClick = { onTransferSelected(transfer.transferId) }, onClick = { onTransferSelected(transfer.transferId) },
) )
} }
@@ -170,7 +177,7 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
private fun TransferListItem( private fun TransferListItem(
transfer: Transfer, transfer: Transfer,
thumbnailBytes: ByteArray?, thumbnailBytes: ByteArray?,
progress: com.vnidrop.app.ui.state.TransferProgress?, progress: TransferProgress?,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
@@ -203,7 +210,7 @@ private fun TransferListItem(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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) ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
} }
} }

View File

@@ -99,7 +99,12 @@ fun SendScreen(
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
when (state.detailPanel) { when (state.detailPanel) {
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) 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( TransferDetailPanel.Share -> TransferSharePanel(
selectedTransfer, selectedTransfer,
shareActions, 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.AppCard
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton 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.components.SecondaryButton
import com.vnidrop.app.ui.state.TransferProgress
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForReceiver
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -159,28 +162,55 @@ private fun DetailDestination(title: String, description: String, count: Int? =
} }
@Composable @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)) { PanelContainer(stringResource(Res.string.transfer_receivers_title)) {
when { when {
loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } loading -> Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter) receivers.isEmpty() -> Text(stringResource(Res.string.transfer_no_receivers), color = LocalVniDropColors.current.foregroundLighter)
else -> receivers.forEachIndexed { index, receiver -> else -> receivers.forEachIndexed { index, receiver ->
if (index > 0) HorizontalDivider(color = LocalVniDropColors.current.borderDefault) 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 @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) 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) Text(name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
receiver.receiverDeviceName?.takeIf { it != name }?.let { receiver.receiverDeviceName?.takeIf { it != name }?.let {
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) 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 -> receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) 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> = fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
events events
.mapNotNull { it.transferId } .mapNotNull { it.transferId }
@@ -181,11 +280,107 @@ private fun findKnownSize(events: List<CoreEventModel>, transferId: ULong): Doub
return null 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? { private fun findNumber(json: String, key: String): Double? {
val marker = "\"$key\":" val marker = "\"$key\":"
val start = json.indexOf(marker) val start = json.indexOf(marker)
if (start < 0) return null if (start < 0) return null
val valueStart = start + marker.length 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 val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull() 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) val start = json.indexOf(marker)
if (start < 0) return null if (start < 0) return null
val after = json.substring(start + marker.length).trimStart() val after = json.substring(start + marker.length).trimStart()
if (after.startsWith("null")) return null
if (!after.startsWith('"')) return null if (!after.startsWith('"')) return null
val end = after.indexOf('"', 1) val end = after.indexOf('"', 1)
if (end <= 1) return null if (end <= 1) return null

View File

@@ -103,6 +103,109 @@ class AppUiModelsTest {
assertEquals("a.bin", progress?.detail) 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 @Test
fun canCancelOnlyActiveStatuses() { fun canCancelOnlyActiveStatuses() {
assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer()) assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer())
@@ -133,12 +236,13 @@ class AppUiModelsTest {
kind: String, kind: String,
data: String, data: String,
transferId: ULong = 7UL, transferId: ULong = 7UL,
direction: String = "receive",
) = CoreEventModel( ) = CoreEventModel(
id = id, id = id,
timestamp = 1L, timestamp = 1L,
scope = "transfer", scope = "transfer",
transferId = transferId, transferId = transferId,
direction = "receive", direction = direction,
phase = phase, phase = phase,
kind = kind, kind = kind,
dataJson = data, dataJson = data,