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, + ) }