diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md
index 6184f33..7d91993 100644
--- a/crates/vnidrop/CORE_FLOW.md
+++ b/crates/vnidrop/CORE_FLOW.md
@@ -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.
diff --git a/crates/vnidrop/src/event_hub.rs b/crates/vnidrop/src/event_hub.rs
index a728762..61c734b 100644
--- a/crates/vnidrop/src/event_hub.rs
+++ b/crates/vnidrop/src/event_hub.rs
@@ -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",
}
}
}
diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs
index 0800f68..e20a090 100644
--- a/crates/vnidrop/tests/approval.rs
+++ b/crates/vnidrop/tests/approval.rs
@@ -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]
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 0cffd33..0a72f0f 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -111,6 +111,16 @@
Inspect ticket
Receive
Receiving...
+ Retry
+ Stop sharing
+ Cancel receive
+ Receiving files
+ Preparing transfer
+ %1$d waiting
+ %1$d completed
+ Downloading files
+ Saving files
+ Connecting to sender
Ticket details
This ticket does not include VniDrop metadata.
Settings
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
index 1791ee5..5368031 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt
@@ -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 {
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
index ce9ba71..7a249a7 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt
@@ -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,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
index 54c0871..da19e6f 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinator.kt
@@ -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
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt
index 1b2f7dc..17365ce 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveRoute.kt
@@ -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,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
index 74ca12c..6894a84 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt
@@ -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) -> 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,
+ 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) {
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
index eb8831a..c693195 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt
@@ -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,
)
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt
index ad7083d..c982b25 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt
@@ -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,
transferThumbnails: Map,
+ events: List = 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))
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
index 2312276..180837a 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
@@ -53,5 +53,6 @@ fun SendRoute(
onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer,
+ onCancelTransfer = viewModel::cancelSelectedTransfer,
)
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
index e13347f..9e52afd 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
@@ -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,
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
index 3f2e824..4f25dc7 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
@@ -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) }
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
index 61af8ad..702e2a7 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
@@ -50,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,
+ 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
})
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
index 6f04cdc..98b8196 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/TransferComponents.kt
@@ -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())
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
index 6f946c8..53d279a 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
@@ -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, 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): List =
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, 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)
}
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
index e926198..34af7a7 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
@@ -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
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
index 50bd2c0..0171acb 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
@@ -44,6 +44,7 @@ class FakeCoreGateway : CoreGateway {
var deleteResult: Result = Result.success(Unit)
var clearReceiveHistoryResult: Result = Result.success(0UL)
val deletedTransfers = mutableListOf()
+ val cancelledTransfers = mutableListOf()
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 {
+ cancelledTransfers += transferId
+ return Result.success(Unit)
+ }
override suspend fun delete(transferId: ULong): Result {
if (deleteResult.isSuccess) {
deletedTransfers += transferId
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
index aefe5a1..0aba42c 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
@@ -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,
+ )
}