mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -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,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user