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`, - Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
`stopped`. `stopped`.
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`, - 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 - Events are sent to `CoreEventSink` immediately and persisted through the event
hub. `list_events` flushes queued persistence before reading SQLite. hub. `list_events` flushes queued persistence before reading SQLite.
- `shutdown()` is idempotent and flushes events before stopping the router. - `shutdown()` is idempotent and flushes events before stopping the router.

View File

@@ -45,6 +45,8 @@ enum EventPhase {
Handshake, Handshake,
Approval, Approval,
Transfer, Transfer,
/// Delivery receipts from receivers (completed download acknowledgements).
Delivery,
} }
impl EventPhase { impl EventPhase {
@@ -65,6 +67,7 @@ impl EventPhase {
"handshake" => Some(Self::Handshake), "handshake" => Some(Self::Handshake),
"approval" => Some(Self::Approval), "approval" => Some(Self::Approval),
"transfer" => Some(Self::Transfer), "transfer" => Some(Self::Transfer),
"delivery" => Some(Self::Delivery),
_ => None, _ => None,
} }
} }
@@ -86,6 +89,7 @@ impl EventPhase {
Self::Handshake => "handshake", Self::Handshake => "handshake",
Self::Approval => "approval", Self::Approval => "approval",
Self::Transfer => "transfer", 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].receiver_name.as_deref(), Some("Receiver"));
assert_eq!(deliveries[0].status, "completed"); assert_eq!(deliveries[0].status, "completed");
assert!(deliveries[0].completed_at.is_some()); 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] #[test]

View File

@@ -111,6 +111,16 @@
<string name="button_inspect_ticket">Inspect ticket</string> <string name="button_inspect_ticket">Inspect ticket</string>
<string name="button_receive">Receive</string> <string name="button_receive">Receive</string>
<string name="button_receiving">Receiving...</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_details_title">Ticket details</string>
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string> <string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>

View File

@@ -117,6 +117,8 @@ data class CoreState(
sealed interface CoreSignal { sealed interface CoreSignal {
data class ApprovalChanged(val transferId: ULong) : CoreSignal data class ApprovalChanged(val transferId: ULong) : CoreSignal
data class ReceiverHistoryChanged(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 { interface CoreGateway {

View File

@@ -46,11 +46,15 @@ class CoreRepository(
override fun onEvent(event: CoreEvent) { override fun onEvent(event: CoreEvent) {
val model = event.toModel() val model = event.toModel()
_state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) } _state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
if (model.phase == "approval" && model.transferId != null) { val transferId = model.transferId
_signals.tryEmit(CoreSignal.ApprovalChanged(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))
} }
if (model.phase == "delivery" && model.transferId != null) {
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
} }
} }
} }
@@ -269,6 +273,13 @@ private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
dataJson = dataJson, 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( private fun StoredTransfer.toModel(): Transfer = Transfer(
localId = localId, localId = localId,
transferId = transferId, transferId = transferId,

View File

@@ -56,7 +56,8 @@ class ApprovalCoordinator(
repository.signals.collect { signal -> repository.signals.collect { signal ->
when (signal) { when (signal) {
is CoreSignal.ApprovalChanged -> refresh(signal.transferId) 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, onInvitationResult = viewModel::onInvitationResult,
onWaitingForNfc = viewModel::setWaitingForNfc, onWaitingForNfc = viewModel::setWaitingForNfc,
onReceive = viewModel::receive, onReceive = viewModel::receive,
onCancelReceive = viewModel::cancelActiveReceive,
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem, onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
onRequestClearHistory = viewModel::requestClearHistory, onRequestClearHistory = viewModel::requestClearHistory,
onDismissHistoryDelete = viewModel::dismissHistoryDelete, 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.FolderAccessStatus
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection 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.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton import com.vnidrop.app.ui.components.DestructiveQuietButton
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForTransfer
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.* import vnidrop.shared.generated.resources.*
@@ -80,6 +83,7 @@ fun ReceiveScreen(
onInvitationResult: (ReceiveMethod, Result<String>) -> Unit, onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit, onWaitingForNfc: (Boolean) -> Unit,
onReceive: () -> Unit, onReceive: () -> Unit,
onCancelReceive: () -> Unit = {},
onRequestDeleteHistoryItem: (ULong) -> Unit, onRequestDeleteHistoryItem: (ULong) -> Unit,
onRequestClearHistory: () -> Unit, onRequestClearHistory: () -> Unit,
onDismissHistoryDelete: () -> Unit, onDismissHistoryDelete: () -> Unit,
@@ -102,7 +106,11 @@ fun ReceiveScreen(
} }
} }
items(transfers, key = Transfer::localId) { transfer -> 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( InvitationReviewPanel(
state = state, state = state,
coreInitialized = coreState.isInitialized, coreInitialized = coreState.isInitialized,
events = coreState.events,
onReceiverNameChanged = onReceiverNameChanged, onReceiverNameChanged = onReceiverNameChanged,
onReceive = onReceive, onReceive = onReceive,
onCancelReceive = onCancelReceive,
) )
} }
} }
@@ -230,7 +240,14 @@ private fun ReceiveMethodRow(icon: ImageVector, title: String, description: Stri
} }
@Composable @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)) { 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) 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() } 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, color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
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( PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive), stringResource(Res.string.button_receive),
onClick = onReceive, onClick = onReceive,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = state.canReceive(coreInitialized), enabled = state.canReceive(coreInitialized),
) )
} }
state.lastReceiveError?.let { error ->
Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall)
}
}
} }
} }
@Composable @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) { Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) { 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)) { 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(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) 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()) { if (transfer.status.isTerminalReceiveHistory()) {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {

View File

@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.receive
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res 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_completed
import vnidrop.shared.generated.resources.receive_history_cleared import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted import vnidrop.shared.generated.resources.transfer_deleted
@@ -40,6 +42,8 @@ data class ReceiveState(
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isInspecting: Boolean = false, val isInspecting: Boolean = false,
val isReceiving: Boolean = false, val isReceiving: Boolean = false,
val activeReceiveTransferId: ULong? = null,
val lastReceiveError: String? = null,
val isWaitingForNfc: Boolean = false, val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null, val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false, 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) } fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
@@ -133,7 +151,9 @@ class ReceiveViewModel(
val folder = current.receiveFolder ?: return val folder = current.receiveFolder ?: return
if (!current.canReceive(coreState.value.isInitialized)) return if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isReceiving = true) } _state.update {
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
}
val outputSink = fileSystemService.createReceiveOutputSink(folder) val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when { val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) 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)) messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
}, },
onFailure = { error -> onFailure = { error ->
_state.update { it.copy(isReceiving = false) } val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong."
messages.error(error) _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) { private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim() val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty")) if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
@@ -188,6 +244,8 @@ class ReceiveViewModel(
inspection = null, inspection = null,
isInspecting = false, isInspecting = false,
isReceiving = false, isReceiving = false,
activeReceiveTransferId = null,
lastReceiveError = null,
isWaitingForNfc = false, 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.PillTone import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.StatusPill import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.state.progressForTransfer
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.decodeToImageBitmap
@@ -71,6 +74,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
internal fun TransferCatalog( internal fun TransferCatalog(
transfers: List<Transfer>, transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>, transferThumbnails: Map<ULong, ByteArray>,
events: List<CoreEventModel> = emptyList(),
windowClass: WindowClass, windowClass: WindowClass,
onOpenComposer: () -> Unit, onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit, onTransferSelected: (ULong) -> Unit,
@@ -97,7 +101,12 @@ internal fun TransferCatalog(
) )
} }
items(transfers, key = Transfer::localId) { transfer -> 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 @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 val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) { Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { 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()) FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
} }
Spacer(Modifier.width(12.dp)) 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) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title), transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
@@ -189,6 +203,9 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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)) Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.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, onRequestDelete = viewModel::requestDeleteTransfer,
onDismissDelete = viewModel::dismissDeleteTransfer, onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer, 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.Modifier
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.canCancelTransfer
import com.vnidrop.app.ui.state.progressForTransfer
@Composable @Composable
fun SendScreen( fun SendScreen(
@@ -39,6 +42,7 @@ fun SendScreen(
onRequestDelete: () -> Unit = {}, onRequestDelete: () -> Unit = {},
onDismissDelete: () -> Unit = {}, onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {}, onConfirmDelete: () -> Unit = {},
onCancelTransfer: () -> Unit = {},
) { ) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
@@ -52,17 +56,24 @@ fun SendScreen(
TransferDetails( TransferDetails(
transfer = selectedTransfer, transfer = selectedTransfer,
events = coreState.events, 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, onBack = onCloseTransferDetails,
onActivity = onActivity, onActivity = onActivity,
onReceivers = onReceivers, onReceivers = onReceivers,
onShare = onShare, onShare = onShare,
onDelete = onRequestDelete, onDelete = onRequestDelete,
onCancel = onCancelTransfer,
) )
} else { } else {
TransferCatalog( TransferCatalog(
transfers = outgoingTransfers, transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails, transferThumbnails = state.transferThumbnails,
events = coreState.events,
windowClass = windowClass, windowClass = windowClass,
onOpenComposer = onOpenComposer, onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected, onTransferSelected = onTransferSelected,

View File

@@ -24,8 +24,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created 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_deleted
import vnidrop.shared.generated.resources.transfer_event_stopped
import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState( data class SendState(
val isComposerOpen: Boolean = false, val isComposerOpen: Boolean = false,
@@ -70,13 +71,19 @@ class SendViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
repository.signals.collect { signal -> repository.signals.collect { signal ->
val transferId = when (signal) { when (signal) {
is CoreSignal.ReceiverHistoryChanged -> signal.transferId is CoreSignal.TransfersChanged -> repository.refresh()
is CoreSignal.ApprovalChanged -> signal.transferId 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 { 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) { private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) } 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.AppCard
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.ProgressRow
import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.state.TransferProgress
import com.vnidrop.app.ui.state.displayNameForStatus import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@@ -67,12 +69,16 @@ enum class InvitationAction { Export, Share, Nfc }
internal fun TransferDetails( internal fun TransferDetails(
transfer: Transfer, transfer: Transfer,
events: List<CoreEventModel>, events: List<CoreEventModel>,
progress: TransferProgress? = null,
pendingReceivers: Int = 0,
completedReceivers: Int, completedReceivers: Int,
canCancel: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onActivity: () -> Unit, onActivity: () -> Unit,
onReceivers: () -> Unit, onReceivers: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onCancel: () -> Unit = {},
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
@@ -100,6 +106,24 @@ internal fun TransferDetails(
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize)) DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy)) 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 { item {
@@ -114,8 +138,8 @@ internal fun TransferDetails(
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailDestination( DetailDestination(
title = stringResource(Res.string.transfer_receivers_title), title = stringResource(Res.string.transfer_receivers_title),
description = stringResource(Res.string.transfer_receivers_description), description = receiversDescription(pendingReceivers, completedReceivers),
count = completedReceivers, count = pendingReceivers + completedReceivers,
onClick = onReceivers, onClick = onReceivers,
) )
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) 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 @Composable
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) { private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
Row( Row(
@@ -314,6 +352,9 @@ private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status)
private fun CoreEventModel.isMeaningfulActivity() = private fun CoreEventModel.isMeaningfulActivity() =
(phase == "import" && kind == "started") || (phase == "import" && kind == "started") ||
(phase == "ticket" && kind == "created") || (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( kind in setOf(
"receiver-requested", "receiver-accepted", "receiver-auto-approved", "receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed", "receiver-refused", "receiver-completed", "share-stopped", "failed",
@@ -323,11 +364,15 @@ private fun CoreEventModel.isMeaningfulActivity() =
private fun eventTitle(event: CoreEventModel) = stringResource(when { private fun eventTitle(event: CoreEventModel) = stringResource(when {
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready 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-requested" -> Res.string.transfer_event_requested
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved 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-refused" -> Res.string.transfer_event_refused
event.kind == "receiver-completed" -> Res.string.transfer_event_completed 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 event.kind == "failed" -> Res.string.transfer_event_failed
else -> Res.string.transfer_event_updated 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 import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable @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)) { 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()) if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth()) else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
} }

View File

@@ -24,8 +24,10 @@ fun useBottomNavigation(windowClass: WindowClass): Boolean =
data class TransferProgress( data class TransferProgress(
val transferId: ULong?, val transferId: ULong?,
val phase: String, val phase: String,
val kind: String,
val label: String, val label: String,
val progress: Float?, val progress: Float?,
val detail: String? = null,
) )
fun displayNameForStatus(status: TransferStatus): String = fun displayNameForStatus(status: TransferStatus): String =
@@ -42,20 +44,37 @@ fun displayNameForStatus(status: TransferStatus): String =
fun Transfer.isActiveTransfer(): Boolean = fun Transfer.isActiveTransfer(): Boolean =
status in activeTransferStatuses status in activeTransferStatuses
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> = fun Transfer.canCancelTransfer(): Boolean =
events status in setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
.filter { event -> event.transferId != null && event.phase in progressPhases }
.distinctBy { event -> "${event.transferId}:${event.phase}" } /**
.take(6) * Latest progress snapshot for a transfer, derived from core events.
.map { event -> *
TransferProgress( * Events are assumed newest-first (as stored by [com.vnidrop.app.core.CoreRepository]).
transferId = event.transferId, */
phase = event.phase, fun progressForTransfer(events: List<CoreEventModel>, transferId: ULong): TransferProgress? {
label = eventLabel(event), val relevant = events.filter { event ->
progress = parseProgress(event.dataJson), 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
.mapNotNull { it.transferId }
.distinct()
.take(6)
.mapNotNull { progressForTransfer(events, it) }
fun transferSubtitle(transfer: Transfer): String { fun transferSubtitle(transfer: Transfer): String {
val pieces = listOfNotNull( val pieces = listOfNotNull(
transfer.transferName, transfer.transferName,
@@ -81,28 +100,103 @@ fun formatBytes(size: ULong): String {
} }
} }
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") private val progressPhases = setOf(
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving) "import", "ticket", "access", "transfer", "download", "export",
"lifecycle", "network", "handshake", "error",
)
private fun eventLabel(event: CoreEventModel): String { private val progressKinds = setOf(
val direction = event.direction?.replaceFirstChar { it.uppercase() } "started", "copy-progress", "copy-done", "outboard-progress", "done",
val phase = event.phase.replaceFirstChar { it.uppercase() } "created", "progress", "completed", "aborted", "failed",
val kind = event.kind.replace('-', ' ') "connecting", "connected", "found-collection",
return listOfNotNull(direction, phase, kind).joinToString(" - ") "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? { private fun progressDetail(event: CoreEventModel): String? {
val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written") val fileName = findString(event.dataJson, "file_name")
val total = findNumber(json, "total") ?: findNumber(json, "total_size") 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 if (transferred == null || total == null || total <= 0.0) return null
return (transferred / total).toFloat().coerceIn(0f, 1f) 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? { private fun findNumber(json: String, key: String): Double? {
val marker = "\"$key\":" val marker = "\"$key\":"
val start = json.indexOf(marker) val start = json.indexOf(marker)
if (start < 0) return null if (start < 0) return null
val valueStart = start + marker.length val valueStart = start + marker.length
val valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
return json.substring(valueStart, valueEnd).trim().toDoubleOrNull() 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) assertEquals("ticket-xyz", viewModel.state.value.ticket)
assertFalse(viewModel.state.value.isReceiving) assertFalse(viewModel.state.value.isReceiving)
assertTrue(viewModel.state.value.inspection != null) 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 @Test

View File

@@ -44,6 +44,7 @@ class FakeCoreGateway : CoreGateway {
var deleteResult: Result<Unit> = Result.success(Unit) var deleteResult: Result<Unit> = Result.success(Unit)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL) var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
val cancelledTransfers = mutableListOf<ULong>()
var clearReceiveHistoryCount = 0 var clearReceiveHistoryCount = 0
var receiveCount = 0 var receiveCount = 0
var lastReceiveTicket: String? = null var lastReceiveTicket: String? = null
@@ -127,7 +128,10 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded() awaitReceiveIfNeeded()
return receiveResult 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> { override suspend fun delete(transferId: ULong): Result<Unit> {
if (deleteResult.isSuccess) { if (deleteResult.isSuccess) {
deletedTransfers += transferId 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.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
@@ -81,6 +82,34 @@ class AppUiModelsTest {
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer()) 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 = private fun storedTransfer(status: TransferStatus): Transfer =
Transfer( Transfer(
localId = "local-1", localId = "local-1",
@@ -97,4 +126,21 @@ class AppUiModelsTest {
createdAt = 1L, createdAt = 1L,
updatedAt = 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,
)
} }