feat(ui): surface transfer progress, cancel, and delivery events

Make the transfer loop feel live: parse real core progress payloads, show
progress on send/receive surfaces, allow cancel/stop, retry failed receives,
and fix delivery-phase events so receiver completion updates the UI.
This commit is contained in:
2026-07-12 15:53:51 +02:00
parent 443aa7461c
commit e1f82c8ef3
20 changed files with 486 additions and 58 deletions

View File

@@ -36,7 +36,7 @@ bytes through Kotlin memory.
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
`stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
`access`, `transfer`, `download`, `export`, `lifecycle`, `error`.
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`.
- Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router.

View File

@@ -45,6 +45,8 @@ enum EventPhase {
Handshake,
Approval,
Transfer,
/// Delivery receipts from receivers (completed download acknowledgements).
Delivery,
}
impl EventPhase {
@@ -65,6 +67,7 @@ impl EventPhase {
"handshake" => Some(Self::Handshake),
"approval" => Some(Self::Approval),
"transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery),
_ => None,
}
}
@@ -86,6 +89,7 @@ impl EventPhase {
Self::Handshake => "handshake",
Self::Approval => "approval",
Self::Transfer => "transfer",
Self::Delivery => "delivery",
}
}
}

View File

@@ -55,6 +55,14 @@ fn public_share_receives_without_sender_approval() {
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
assert_eq!(deliveries[0].status, "completed");
assert!(deliveries[0].completed_at.is_some());
assert!(
sender.sink.events().iter().any(|event| {
event.phase == "delivery"
&& event.kind == "receiver-completed"
&& event.transfer_id == Some(share.transfer_id)
}),
"delivery receipts must emit a delivery phase event for UI live updates"
);
}
#[test]

View File

@@ -111,6 +111,16 @@
<string name="button_inspect_ticket">Inspect ticket</string>
<string name="button_receive">Receive</string>
<string name="button_receiving">Receiving...</string>
<string name="button_retry">Retry</string>
<string name="button_stop_sharing">Stop sharing</string>
<string name="button_cancel_receive">Cancel receive</string>
<string name="progress_receiving">Receiving files</string>
<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_event_downloading">Downloading files</string>
<string name="transfer_event_saving">Saving files</string>
<string name="transfer_event_connecting">Connecting to sender</string>
<string name="ticket_details_title">Ticket details</string>
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
<string name="settings_title">Settings</string>

View File

@@ -117,6 +117,8 @@ data class CoreState(
sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
/** Transfer status/history changed enough to re-read the durable snapshot. */
data class TransfersChanged(val transferId: ULong) : CoreSignal
}
interface CoreGateway {

View File

@@ -46,11 +46,15 @@ class CoreRepository(
override fun onEvent(event: CoreEvent) {
val model = event.toModel()
_state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
if (model.phase == "approval" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
}
if (model.phase == "delivery" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
val transferId = model.transferId
if (transferId != null) {
when (model.phase) {
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
}
if (model.shouldRefreshTransfers()) {
_signals.tryEmit(CoreSignal.TransfersChanged(transferId))
}
}
}
}
@@ -269,6 +273,13 @@ private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
dataJson = dataJson,
)
private fun CoreEventModel.shouldRefreshTransfers(): Boolean =
phase in setOf("lifecycle", "error", "ticket", "import", "download", "export", "handshake") &&
kind in setOf(
"started", "done", "created", "failed", "cancelled", "share-stopped",
"found-collection", "connected",
)
private fun StoredTransfer.toModel(): Transfer = Transfer(
localId = localId,
transferId = transferId,

View File

@@ -56,7 +56,8 @@ class ApprovalCoordinator(
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
is CoreSignal.ReceiverHistoryChanged -> Unit
is CoreSignal.ReceiverHistoryChanged,
is CoreSignal.TransfersChanged -> Unit
}
}
}

View File

@@ -27,6 +27,7 @@ fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) {
onInvitationResult = viewModel::onInvitationResult,
onWaitingForNfc = viewModel::setWaitingForNfc,
onReceive = viewModel::receive,
onCancelReceive = viewModel::cancelActiveReceive,
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
onRequestClearHistory = viewModel::requestClearHistory,
onDismissHistoryDelete = viewModel::dismissHistoryDelete,

View File

@@ -45,15 +45,18 @@ import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton
import com.vnidrop.app.ui.components.Field
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.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForTransfer
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.*
@@ -80,6 +83,7 @@ fun ReceiveScreen(
onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
onReceive: () -> Unit,
onCancelReceive: () -> Unit = {},
onRequestDeleteHistoryItem: (ULong) -> Unit,
onRequestClearHistory: () -> Unit,
onDismissHistoryDelete: () -> Unit,
@@ -102,7 +106,11 @@ fun ReceiveScreen(
}
}
items(transfers, key = Transfer::localId) { transfer ->
ReceiveTransferRow(transfer, onDelete = { onRequestDeleteHistoryItem(transfer.transferId) })
ReceiveTransferRow(
transfer = transfer,
progress = progressForTransfer(coreState.events, transfer.transferId),
onDelete = { onRequestDeleteHistoryItem(transfer.transferId) },
)
}
}
}
@@ -120,8 +128,10 @@ fun ReceiveScreen(
InvitationReviewPanel(
state = state,
coreInitialized = coreState.isInitialized,
events = coreState.events,
onReceiverNameChanged = onReceiverNameChanged,
onReceive = onReceive,
onCancelReceive = onCancelReceive,
)
}
}
@@ -230,7 +240,14 @@ private fun ReceiveMethodRow(icon: ImageVector, title: String, description: Stri
}
@Composable
private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean, onReceiverNameChanged: (String) -> Unit, onReceive: () -> Unit) {
private fun InvitationReviewPanel(
state: ReceiveState,
coreInitialized: Boolean,
events: List<com.vnidrop.app.core.CoreEventModel>,
onReceiverNameChanged: (String) -> Unit,
onReceive: () -> Unit,
onCancelReceive: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(Res.string.receive_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (state.isInspecting) Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
@@ -248,18 +265,41 @@ private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean,
color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
modifier = Modifier.fillMaxWidth(),
enabled = state.canReceive(coreInitialized),
)
if (state.isReceiving) {
val progressId = state.activeReceiveTransferId
?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId
val progress = progressId?.let { progressForTransfer(events, it) }
ProgressRow(
label = progress?.label ?: stringResource(Res.string.progress_receiving),
progress = progress?.progress,
detail = progress?.detail,
)
SecondaryButton(
stringResource(Res.string.button_cancel_receive),
onClick = onCancelReceive,
modifier = Modifier.fillMaxWidth(),
)
} else {
PrimaryButton(
stringResource(Res.string.button_receive),
onClick = onReceive,
modifier = Modifier.fillMaxWidth(),
enabled = state.canReceive(coreInitialized),
)
}
state.lastReceiveError?.let { error ->
Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
private fun ReceiveTransferRow(
transfer: Transfer,
progress: com.vnidrop.app.ui.state.TransferProgress?,
onDelete: () -> Unit,
) {
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
@@ -269,6 +309,9 @@ private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(transfer.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${formatBytes(transfer.totalSize)} · ${displayNameForStatus(transfer.status)}", color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
if (transfer.status == TransferStatus.Receiving && progress != null) {
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
}
}
if (transfer.status.isTerminalReceiveHistory()) {
IconButton(onClick = onDelete) {

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.receive
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_retry
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted
@@ -40,6 +42,8 @@ data class ReceiveState(
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isInspecting: Boolean = false,
val isReceiving: Boolean = false,
val activeReceiveTransferId: ULong? = null,
val lastReceiveError: String? = null,
val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false,
@@ -72,6 +76,20 @@ class ReceiveViewModel(
}
}
}
viewModelScope.launch {
repository.signals.collect { signal ->
when (signal) {
is CoreSignal.TransfersChanged -> {
repository.refresh()
if (_state.value.isReceiving && signal.transferId != 0UL) {
_state.update { it.copy(activeReceiveTransferId = signal.transferId) }
}
}
is CoreSignal.ApprovalChanged,
is CoreSignal.ReceiverHistoryChanged -> Unit
}
}
}
}
fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
@@ -133,7 +151,9 @@ class ReceiveViewModel(
val folder = current.receiveFolder ?: return
if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isReceiving = true) }
_state.update {
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
}
val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
@@ -150,13 +170,49 @@ class ReceiveViewModel(
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isReceiving = false) }
messages.error(error)
val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong."
_state.update {
it.copy(
isReceiving = false,
activeReceiveTransferId = null,
lastReceiveError = message,
)
}
messages.tryShow(
UiMessage(
text = UiText.Dynamic(message),
tone = UiMessageTone.Error,
actionLabel = UiText.Resource(Res.string.button_retry),
onAction = { receive() },
),
)
},
)
}
}
fun cancelActiveReceive() {
val transferId = _state.value.activeReceiveTransferId
?: coreState.value.transfers.firstOrNull {
it.direction == TransferDirection.Receive && it.status == TransferStatus.Receiving
}?.transferId
?: coreState.value.events.firstOrNull {
it.direction == "receive" && it.transferId != null
}?.transferId
?: return
viewModelScope.launch {
repository.cancel(transferId).fold(
onSuccess = {
_state.update {
it.copy(isReceiving = false, activeReceiveTransferId = null, lastReceiveError = null)
}
repository.refresh()
},
onFailure = messages::error,
)
}
}
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
@@ -188,6 +244,8 @@ class ReceiveViewModel(
inspection = null,
isInspecting = false,
isReceiving = false,
activeReceiveTransferId = null,
lastReceiveError = null,
isWaitingForNfc = false,
)
}

View File

@@ -34,14 +34,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
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.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForTransfer
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap
@@ -71,6 +74,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
internal fun TransferCatalog(
transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>,
events: List<CoreEventModel> = emptyList(),
windowClass: WindowClass,
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
@@ -97,7 +101,12 @@ internal fun TransferCatalog(
)
}
items(transfers, key = Transfer::localId) { transfer ->
TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) }
TransferListItem(
transfer = transfer,
thumbnailBytes = transferThumbnails[transfer.transferId],
progress = progressForTransfer(events, transfer.transferId),
onClick = { onTransferSelected(transfer.transferId) },
)
}
}
}
@@ -158,7 +167,12 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
}
@Composable
private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) {
private fun TransferListItem(
transfer: Transfer,
thumbnailBytes: ByteArray?,
progress: com.vnidrop.app.ui.state.TransferProgress?,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -169,7 +183,7 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
@@ -189,6 +203,9 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (transfer.status == TransferStatus.Importing && progress != null) {
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
}
}
Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))

View File

@@ -53,5 +53,6 @@ fun SendRoute(
onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
onCancelTransfer = viewModel::cancelSelectedTransfer,
)
}

View File

@@ -9,10 +9,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.canCancelTransfer
import com.vnidrop.app.ui.state.progressForTransfer
@Composable
fun SendScreen(
@@ -39,6 +42,7 @@ fun SendScreen(
onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
onCancelTransfer: () -> Unit = {},
) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
@@ -52,17 +56,24 @@ fun SendScreen(
TransferDetails(
transfer = selectedTransfer,
events = coreState.events,
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
progress = progressForTransfer(coreState.events, selectedTransfer.transferId),
pendingReceivers = state.receiverHistory.count {
it.status == ReceiverDeliveryStatus.Requested || it.status == ReceiverDeliveryStatus.Accepted
},
completedReceivers = state.receiverHistory.count { it.status == ReceiverDeliveryStatus.Completed },
canCancel = selectedTransfer.canCancelTransfer(),
onBack = onCloseTransferDetails,
onActivity = onActivity,
onReceivers = onReceivers,
onShare = onShare,
onDelete = onRequestDelete,
onCancel = onCancelTransfer,
)
} else {
TransferCatalog(
transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails,
events = coreState.events,
windowClass = windowClass,
onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected,

View File

@@ -24,8 +24,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
import vnidrop.shared.generated.resources.transfer_nfc_written
import vnidrop.shared.generated.resources.transfer_deleted
import vnidrop.shared.generated.resources.transfer_event_stopped
import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState(
val isComposerOpen: Boolean = false,
@@ -70,13 +71,19 @@ class SendViewModel(
init {
viewModelScope.launch {
repository.signals.collect { signal ->
val transferId = when (signal) {
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
is CoreSignal.ApprovalChanged -> signal.transferId
when (signal) {
is CoreSignal.TransfersChanged -> repository.refresh()
is CoreSignal.ReceiverHistoryChanged -> {
if (signal.transferId == _state.value.selectedTransferId) {
refreshReceivers(signal.transferId)
}
}
is CoreSignal.ApprovalChanged -> {
if (signal.transferId == _state.value.selectedTransferId) {
refreshReceivers(signal.transferId)
}
}
}
if (transferId == _state.value.selectedTransferId &&
_state.value.detailPanel == TransferDetailPanel.Receivers
) refreshReceivers(transferId)
}
}
viewModelScope.launch {
@@ -245,6 +252,21 @@ class SendViewModel(
}
}
fun cancelSelectedTransfer() {
val transferId = _state.value.selectedTransferId ?: return
viewModelScope.launch {
repository.cancel(transferId).fold(
onSuccess = {
repository.refresh()
messages.tryShow(
UiMessage(UiText.Resource(Res.string.transfer_event_stopped), UiMessageTone.Info),
)
},
onFailure = messages::error,
)
}
}
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}

View File

@@ -50,7 +50,9 @@ 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.theme.LocalVniDropColors
@@ -67,12 +69,16 @@ enum class InvitationAction { Export, Share, Nfc }
internal fun TransferDetails(
transfer: Transfer,
events: List<CoreEventModel>,
progress: TransferProgress? = null,
pendingReceivers: Int = 0,
completedReceivers: Int,
canCancel: Boolean = false,
onBack: () -> Unit,
onActivity: () -> Unit,
onReceivers: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
onCancel: () -> Unit = {},
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
@@ -100,6 +106,24 @@ internal fun TransferDetails(
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
if (progress != null && transfer.status.isLiveProgressStatus()) {
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
ProgressRow(
label = progress.label,
progress = progress.progress,
detail = progress.detail,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
if (canCancel) {
item {
SecondaryButton(
stringResource(Res.string.button_stop_sharing),
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
)
}
}
item {
@@ -114,8 +138,8 @@ internal fun TransferDetails(
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination(
title = stringResource(Res.string.transfer_receivers_title),
description = stringResource(Res.string.transfer_receivers_description),
count = completedReceivers,
description = receiversDescription(pendingReceivers, completedReceivers),
count = pendingReceivers + completedReceivers,
onClick = onReceivers,
)
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
@@ -130,6 +154,20 @@ internal fun TransferDetails(
}
}
@Composable
private fun receiversDescription(pending: Int, completed: Int): String = when {
pending > 0 && completed > 0 ->
"${stringResource(Res.string.transfer_receivers_pending, pending)} · ${stringResource(Res.string.transfer_receivers_completed_count, completed)}"
pending > 0 -> stringResource(Res.string.transfer_receivers_pending, pending)
completed > 0 -> stringResource(Res.string.transfer_receivers_completed_count, completed)
else -> stringResource(Res.string.transfer_receivers_description)
}
private fun com.vnidrop.app.core.TransferStatus.isLiveProgressStatus(): Boolean =
this == com.vnidrop.app.core.TransferStatus.Importing ||
this == com.vnidrop.app.core.TransferStatus.Sharing ||
this == com.vnidrop.app.core.TransferStatus.Receiving
@Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row(
@@ -314,6 +352,9 @@ private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status)
private fun CoreEventModel.isMeaningfulActivity() =
(phase == "import" && kind == "started") ||
(phase == "ticket" && kind == "created") ||
(phase == "network" && kind in setOf("connecting", "connected")) ||
(phase == "download" && kind == "found-collection") ||
(phase == "lifecycle" && kind in setOf("done", "cancelled", "share-stopped")) ||
kind in setOf(
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed",
@@ -323,11 +364,15 @@ private fun CoreEventModel.isMeaningfulActivity() =
private fun eventTitle(event: CoreEventModel) = stringResource(when {
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
event.phase == "network" -> Res.string.transfer_event_connecting
event.phase == "download" -> Res.string.transfer_event_downloading
event.phase == "export" -> Res.string.transfer_event_saving
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
event.kind == "share-stopped" || (event.phase == "lifecycle" && event.kind == "cancelled") ->
Res.string.transfer_event_stopped
event.kind == "failed" -> Res.string.transfer_event_failed
else -> Res.string.transfer_event_updated
})

View File

@@ -15,9 +15,42 @@ import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable
fun ProgressRow(label: String, progress: Float?, modifier: Modifier = Modifier) {
fun ProgressRow(
label: String,
progress: Float?,
modifier: Modifier = Modifier,
detail: String? = null,
) {
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (progress != null) {
Text(
"${(progress * 100).toInt()}%",
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.labelSmall,
)
}
}
if (detail != null) {
Text(
detail,
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
}

View File

@@ -24,8 +24,10 @@ fun useBottomNavigation(windowClass: WindowClass): Boolean =
data class TransferProgress(
val transferId: ULong?,
val phase: String,
val kind: String,
val label: String,
val progress: Float?,
val detail: String? = null,
)
fun displayNameForStatus(status: TransferStatus): String =
@@ -42,19 +44,36 @@ fun displayNameForStatus(status: TransferStatus): String =
fun Transfer.isActiveTransfer(): Boolean =
status in activeTransferStatuses
fun Transfer.canCancelTransfer(): Boolean =
status in setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
/**
* Latest progress snapshot for a transfer, derived from core events.
*
* Events are assumed newest-first (as stored by [com.vnidrop.app.core.CoreRepository]).
*/
fun progressForTransfer(events: List<CoreEventModel>, transferId: ULong): TransferProgress? {
val relevant = events.filter { event ->
event.transferId == transferId && event.phase in progressPhases && event.kind in progressKinds
}
val latest = relevant.firstOrNull() ?: return null
val sizeHint = findKnownSize(events, transferId)
return TransferProgress(
transferId = transferId,
phase = latest.phase,
kind = latest.kind,
label = humanProgressLabel(latest),
progress = parseProgress(latest.dataJson, sizeHint),
detail = progressDetail(latest),
)
}
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
events
.filter { event -> event.transferId != null && event.phase in progressPhases }
.distinctBy { event -> "${event.transferId}:${event.phase}" }
.mapNotNull { it.transferId }
.distinct()
.take(6)
.map { event ->
TransferProgress(
transferId = event.transferId,
phase = event.phase,
label = eventLabel(event),
progress = parseProgress(event.dataJson),
)
}
.mapNotNull { progressForTransfer(events, it) }
fun transferSubtitle(transfer: Transfer): String {
val pieces = listOfNotNull(
@@ -81,28 +100,103 @@ fun formatBytes(size: ULong): String {
}
}
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
private val progressPhases = setOf(
"import", "ticket", "access", "transfer", "download", "export",
"lifecycle", "network", "handshake", "error",
)
private fun eventLabel(event: CoreEventModel): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() }
val phase = event.phase.replaceFirstChar { it.uppercase() }
val kind = event.kind.replace('-', ' ')
return listOfNotNull(direction, phase, kind).joinToString(" - ")
private val progressKinds = setOf(
"started", "copy-progress", "copy-done", "outboard-progress", "done",
"created", "progress", "completed", "aborted", "failed",
"connecting", "connected", "found-collection",
"cancelled", "share-stopped",
)
private val activeTransferStatuses = setOf(
TransferStatus.Importing,
TransferStatus.Sharing,
TransferStatus.Receiving,
)
private fun humanProgressLabel(event: CoreEventModel): String = when {
event.phase == "import" && event.kind == "copy-progress" -> "Preparing files"
event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files"
event.phase == "import" && event.kind == "started" -> "Preparing transfer"
event.phase == "import" && event.kind == "done" -> "Files ready"
event.phase == "ticket" && event.kind == "created" -> "Share ready"
event.phase == "network" && event.kind == "connecting" -> "Connecting to sender"
event.phase == "network" && event.kind == "connected" -> "Connected"
event.phase == "handshake" -> "Requesting access"
event.phase == "download" && event.kind == "found-collection" -> "Found files"
event.phase == "download" && event.kind == "progress" -> "Downloading"
event.phase == "export" && event.kind == "progress" -> "Saving files"
event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver"
event.phase == "transfer" && event.kind == "started" -> "Receiver connected"
event.phase == "transfer" && event.kind == "completed" -> "Send completed"
event.phase == "lifecycle" && event.kind == "done" -> "Completed"
event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled"
event.kind == "failed" -> "Failed"
else -> listOfNotNull(
event.direction?.replaceFirstChar { it.uppercase() },
event.phase.replaceFirstChar { it.uppercase() },
event.kind.replace('-', ' '),
).joinToString(" · ")
}
private fun parseProgress(json: String): Float? {
val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written")
val total = findNumber(json, "total") ?: findNumber(json, "total_size")
private fun progressDetail(event: CoreEventModel): String? {
val fileName = findString(event.dataJson, "file_name")
val current = findNumber(event.dataJson, "current_file_index")?.toLong()
val totalFiles = findNumber(event.dataJson, "total_files")?.toLong()
return when {
fileName != null && current != null && totalFiles != null && totalFiles > 0 ->
"$fileName (${current + 1}/$totalFiles)"
fileName != null -> fileName
else -> null
}
}
internal fun parseProgress(json: String, sizeHint: Double? = null): Float? {
val transferred = findNumber(json, "exported")
?: findNumber(json, "downloaded")
?: findNumber(json, "offset")
?: findNumber(json, "end_offset")
?: findNumber(json, "transferred")
?: findNumber(json, "written")
val total = findNumber(json, "file_size")
?: findNumber(json, "total_size")
?: findNumber(json, "size")
?: findNumber(json, "total")
?: sizeHint
if (transferred == null || total == null || total <= 0.0) return null
return (transferred / total).toFloat().coerceIn(0f, 1f)
}
private fun findKnownSize(events: List<CoreEventModel>, transferId: ULong): Double? {
for (event in events) {
if (event.transferId != transferId) continue
findNumber(event.dataJson, "size")?.takeIf { it > 0 }?.let { return it }
findNumber(event.dataJson, "total_size")?.takeIf { it > 0 }?.let { return it }
findNumber(event.dataJson, "file_size")?.takeIf { it > 0 }?.let { return it }
}
return null
}
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 valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length
return json.substring(valueStart, valueEnd).trim().toDoubleOrNull()
val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull()
}
private fun findString(json: String, key: String): String? {
val marker = "\"$key\":"
val start = json.indexOf(marker)
if (start < 0) return null
val after = json.substring(start + marker.length).trimStart()
if (!after.startsWith('"')) return null
val end = after.indexOf('"', 1)
if (end <= 1) return null
return after.substring(1, end)
}

View File

@@ -364,6 +364,23 @@ class ViewModelsTest {
assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null)
assertEquals("sender refused", viewModel.state.value.lastReceiveError)
}
@Test
fun receiveViewModelCancelUsesActiveTransferId() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(
isInitialized = true,
transfers = listOf(receivedTransfer(33UL, TransferStatus.Receiving)),
)
}
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.cancelActiveReceive()
advanceUntilIdle()
assertEquals(listOf(33UL), core.cancelledTransfers)
}
@Test

View File

@@ -44,6 +44,7 @@ class FakeCoreGateway : CoreGateway {
var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>()
val cancelledTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0
var receiveCount = 0
var lastReceiveTicket: String? = null
@@ -127,7 +128,10 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
override suspend fun cancel(transferId: ULong): Result<Unit> {
cancelledTransfers += transferId
return Result.success(Unit)
}
override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) {
deletedTransfers += transferId

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app.ui.state
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.PickedShareFile
@@ -81,6 +82,34 @@ class AppUiModelsTest {
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
}
@Test
fun parseProgressUnderstandsCorePayloadKeys() {
assertEquals(0.5f, parseProgress("""{"offset":50,"size":100}"""))
assertEquals(0.25f, parseProgress("""{"exported":25,"file_size":100}"""))
assertEquals(0.8f, parseProgress("""{"downloaded":80,"total_size":100}"""))
assertEquals(0.1f, parseProgress("""{"end_offset":10}""", sizeHint = 100.0))
assertEquals(null, parseProgress("""{"end_offset":10}"""))
}
@Test
fun progressForTransferUsesNewestMatchingEvent() {
val events = listOf(
event(id = "new", phase = "export", kind = "progress", data = """{"exported":75,"file_size":100,"file_name":"a.bin"}"""),
event(id = "old", phase = "export", kind = "progress", data = """{"exported":10,"file_size":100,"file_name":"a.bin"}"""),
)
val progress = progressForTransfer(events, 7UL)
assertEquals(0.75f, progress?.progress)
assertEquals("Saving files", progress?.label)
assertEquals("a.bin", progress?.detail)
}
@Test
fun canCancelOnlyActiveStatuses() {
assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer())
assertTrue(storedTransfer(status = TransferStatus.Receiving).canCancelTransfer())
assertFalse(storedTransfer(status = TransferStatus.Done).canCancelTransfer())
}
private fun storedTransfer(status: TransferStatus): Transfer =
Transfer(
localId = "local-1",
@@ -97,4 +126,21 @@ class AppUiModelsTest {
createdAt = 1L,
updatedAt = 1L,
)
private fun event(
id: String,
phase: String,
kind: String,
data: String,
transferId: ULong = 7UL,
) = CoreEventModel(
id = id,
timestamp = 1L,
scope = "transfer",
transferId = transferId,
direction = "receive",
phase = phase,
kind = kind,
dataJson = data,
)
}