mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
fix(transfer): finalize delivery completion
This commit is contained in:
@@ -50,7 +50,7 @@ class CoreRepository(
|
||||
val transferId = model.transferId
|
||||
if (transferId != null) {
|
||||
when (model.phase) {
|
||||
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||
"approval", "access" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||
}
|
||||
if (model.shouldRefreshTransfers()) {
|
||||
|
||||
@@ -35,6 +35,8 @@ 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.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.ui.components.PillTone
|
||||
@@ -79,6 +81,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
|
||||
internal fun TransferCatalog(
|
||||
transfers: List<Transfer>,
|
||||
transferThumbnails: Map<ULong, ByteArray>,
|
||||
receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||
events: List<CoreEventModel> = emptyList(),
|
||||
windowClass: WindowClass,
|
||||
onOpenComposer: () -> Unit,
|
||||
@@ -107,9 +110,18 @@ internal fun TransferCatalog(
|
||||
)
|
||||
}
|
||||
items(transfers, key = Transfer::localId) { transfer ->
|
||||
val activeReceiverEndpointIds = receiversByTransfer[transfer.transferId]
|
||||
.orEmpty()
|
||||
.filter { it.status == ReceiverDeliveryStatus.Accepted }
|
||||
.mapTo(mutableSetOf()) { it.remoteEndpointId }
|
||||
val progress = when (transfer.status) {
|
||||
TransferStatus.Importing -> progressForTransfer(events, transfer.transferId)
|
||||
TransferStatus.Sharing -> activeSendProgress(events, transfer.transferId, transfer.totalSize)
|
||||
TransferStatus.Sharing -> activeSendProgress(
|
||||
events,
|
||||
transfer.transferId,
|
||||
activeReceiverEndpointIds,
|
||||
transfer.totalSize,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
TransferListItem(
|
||||
|
||||
@@ -69,6 +69,7 @@ fun SendScreen(
|
||||
TransferCatalog(
|
||||
transfers = outgoingTransfers,
|
||||
transferThumbnails = state.transferThumbnails,
|
||||
receiversByTransfer = state.receiversByTransfer,
|
||||
events = coreState.events,
|
||||
windowClass = windowClass,
|
||||
onOpenComposer = onOpenComposer,
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
@@ -39,6 +41,7 @@ data class SendState(
|
||||
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||
val detailPanel: TransferDetailPanel? = null,
|
||||
val receiverHistory: List<ReceiverRequestModel> = emptyList(),
|
||||
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||
val isLoadingReceivers: Boolean = false,
|
||||
val isDeleteConfirmationOpen: Boolean = false,
|
||||
val isDeleting: Boolean = false,
|
||||
@@ -82,15 +85,24 @@ class SendViewModel(
|
||||
if (signal.transferId == _state.value.selectedTransferId) {
|
||||
refreshReceivers(signal.transferId)
|
||||
}
|
||||
refreshReceiverStatuses(signal.transferId)
|
||||
}
|
||||
is CoreSignal.ApprovalChanged -> {
|
||||
if (signal.transferId == _state.value.selectedTransferId) {
|
||||
refreshReceivers(signal.transferId)
|
||||
}
|
||||
refreshReceiverStatuses(signal.transferId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
coreState.map { core ->
|
||||
core.transfers
|
||||
.filter { it.direction == TransferDirection.Send && it.status in setOf(TransferStatus.Importing, TransferStatus.Sharing) }
|
||||
.mapTo(mutableSetOf()) { it.transferId }
|
||||
}.distinctUntilChanged().collect(::syncSharingReceivers)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
filePreviewRepository.previews.collect { previews ->
|
||||
_state.update { it.copy(transferThumbnails = previews) }
|
||||
@@ -316,4 +328,21 @@ class SendViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncSharingReceivers(transferIds: Set<ULong>) {
|
||||
_state.update { current ->
|
||||
current.copy(receiversByTransfer = current.receiversByTransfer.filterKeys { it in transferIds })
|
||||
}
|
||||
transferIds.forEach(::refreshReceiverStatuses)
|
||||
}
|
||||
|
||||
private fun refreshReceiverStatuses(transferId: ULong) {
|
||||
viewModelScope.launch {
|
||||
repository.receiverRequests(transferId).onSuccess { requests ->
|
||||
_state.update { current ->
|
||||
current.copy(receiversByTransfer = current.receiversByTransfer + (transferId to requests))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,6 @@ internal fun SettingsOverview(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Storage,
|
||||
title = stringResource(Res.string.storage_title),
|
||||
selected = state.selectedSection == SettingsSection.Storage,
|
||||
onClick = { onSectionSelected(SettingsSection.Storage) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = AppIcon.User,
|
||||
title = stringResource(Res.string.preferences_title),
|
||||
@@ -65,6 +58,13 @@ internal fun SettingsOverview(
|
||||
onClick = { onSectionSelected(SettingsSection.Notifications) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = AppIcon.Storage,
|
||||
title = stringResource(Res.string.storage_title),
|
||||
selected = state.selectedSection == SettingsSection.Storage,
|
||||
onClick = { onSectionSelected(SettingsSection.Storage) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SettingsRow(
|
||||
icon = AppIcon.Info,
|
||||
title = stringResource(Res.string.about_title),
|
||||
|
||||
@@ -138,7 +138,8 @@ fun progressForReceiver(
|
||||
detail = null,
|
||||
)
|
||||
}
|
||||
if (latest.kind == "completed" && transferEvents.none { it.kind == "progress" || it.kind == "started" }) {
|
||||
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
|
||||
if (latest.kind == "completed" && (progress == null || progress >= 0.999f)) {
|
||||
return TransferProgress(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
@@ -149,7 +150,6 @@ fun progressForReceiver(
|
||||
)
|
||||
}
|
||||
|
||||
val progress = aggregateReceiverProgress(transferEvents, totalSizeHint)
|
||||
return TransferProgress(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
@@ -167,36 +167,21 @@ fun progressForReceiver(
|
||||
fun activeSendProgress(
|
||||
events: List<CoreEventModel>,
|
||||
transferId: ULong,
|
||||
activeReceiverEndpointIds: Set<String>,
|
||||
totalSizeHint: ULong? = null,
|
||||
): TransferProgress? {
|
||||
if (activeReceiverEndpointIds.isEmpty()) return null
|
||||
val endpointIds = events
|
||||
.asSequence()
|
||||
.filter { it.transferId == transferId && it.direction == "send" && it.phase == "transfer" }
|
||||
.mapNotNull { findString(it.dataJson, "endpoint_id") }
|
||||
.filter { it in activeReceiverEndpointIds }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (endpointIds.isEmpty()) {
|
||||
// Fall back to connection-scoped events without endpoint attribution.
|
||||
val relevant = events.filter {
|
||||
it.transferId == transferId &&
|
||||
it.direction == "send" &&
|
||||
it.phase == "transfer" &&
|
||||
it.kind in setOf("started", "progress")
|
||||
}
|
||||
if (relevant.isEmpty()) return null
|
||||
return TransferProgress(
|
||||
transferId = transferId,
|
||||
phase = "transfer",
|
||||
kind = relevant.first().kind,
|
||||
label = Res.string.progress_sending,
|
||||
progress = aggregateReceiverProgress(relevant, totalSizeHint),
|
||||
detail = progressDetail(relevant.first()),
|
||||
)
|
||||
}
|
||||
if (endpointIds.isEmpty()) return null
|
||||
return endpointIds
|
||||
.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }
|
||||
.firstOrNull { it.kind == "progress" || it.kind == "started" }
|
||||
?: endpointIds.mapNotNull { progressForReceiver(events, transferId, it, totalSizeHint) }.firstOrNull()
|
||||
}
|
||||
|
||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.vnidrop.app.feature
|
||||
import com.vnidrop.app.DeviceInfo
|
||||
import com.vnidrop.app.PlatformEnvironment
|
||||
import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.CoreSignal
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiveFolderKind
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.Share
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.Transfer
|
||||
@@ -251,6 +254,38 @@ class ViewModelsTest {
|
||||
assertEquals(listOf(selected), fileSystem.discardedPickedFiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendViewModelTracksReceiverCompletionForCatalogProgress() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val accepted = ReceiverRequestModel(
|
||||
id = "request-7",
|
||||
transferId = 7UL,
|
||||
remoteEndpointId = "peer-a",
|
||||
transferName = "Photo",
|
||||
receiverName = "Receiver",
|
||||
receiverDeviceName = null,
|
||||
appVersion = "1.0",
|
||||
status = ReceiverDeliveryStatus.Accepted,
|
||||
reason = null,
|
||||
requestedAt = 1L,
|
||||
respondedAt = 2L,
|
||||
completedAt = null,
|
||||
)
|
||||
val core = FakeCoreGateway().apply {
|
||||
mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL)))
|
||||
requests[7UL] = listOf(accepted)
|
||||
}
|
||||
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
|
||||
advanceUntilIdle()
|
||||
assertEquals(ReceiverDeliveryStatus.Accepted, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
|
||||
|
||||
core.requests[7UL] = listOf(accepted.copy(status = ReceiverDeliveryStatus.Completed, completedAt = 3L))
|
||||
core.mutableSignals.emit(CoreSignal.ReceiverHistoryChanged(7UL))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(ReceiverDeliveryStatus.Completed, viewModel.state.value.receiversByTransfer.getValue(7UL).single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
@@ -722,6 +757,22 @@ class ViewModelsTest {
|
||||
updatedAt = 1L,
|
||||
)
|
||||
|
||||
private fun sentTransfer(id: ULong) = Transfer(
|
||||
localId = "send-$id",
|
||||
transferId = id,
|
||||
direction = TransferDirection.Send,
|
||||
status = TransferStatus.Sharing,
|
||||
peerId = null,
|
||||
transferName = "Sent $id",
|
||||
contentHash = "hash-$id",
|
||||
fileCount = 1UL,
|
||||
totalSize = 42UL,
|
||||
ticket = "ticket-$id",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L,
|
||||
)
|
||||
|
||||
private fun sampleTicketInspection() = com.vnidrop.app.core.TicketInspectionModel(
|
||||
kind = "vnidrop",
|
||||
metadata = com.vnidrop.app.core.TransferMetadataModel(1UL, "Photo", null, "hash", 1UL, 42UL),
|
||||
|
||||
@@ -211,9 +211,10 @@ class AppUiModelsTest {
|
||||
direction = "send",
|
||||
),
|
||||
)
|
||||
val progress = activeSendProgress(events, 7UL, totalSizeHint = 100UL)
|
||||
val progress = activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL)
|
||||
assertEquals(0.3f, progress?.progress)
|
||||
assertEquals(Res.string.progress_sending, progress?.label)
|
||||
assertEquals(null, activeSendProgress(events, 7UL, emptySet(), totalSizeHint = 100UL))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -270,6 +271,39 @@ class AppUiModelsTest {
|
||||
assertEquals(1f, progress?.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressForReceiverCompletedAfterProgressUsesCompletedLabel() {
|
||||
val events = listOf(
|
||||
event(
|
||||
id = "done",
|
||||
phase = "transfer",
|
||||
kind = "completed",
|
||||
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a"}""",
|
||||
direction = "send",
|
||||
),
|
||||
event(
|
||||
id = "progress",
|
||||
phase = "transfer",
|
||||
kind = "progress",
|
||||
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","end_offset":100}""",
|
||||
direction = "send",
|
||||
),
|
||||
event(
|
||||
id = "started",
|
||||
phase = "transfer",
|
||||
kind = "started",
|
||||
data = """{"connection_id":1,"request_id":1,"endpoint_id":"peer-a","size":100}""",
|
||||
direction = "send",
|
||||
),
|
||||
)
|
||||
|
||||
val progress = progressForReceiver(events, 7UL, "peer-a", totalSizeHint = 100UL)
|
||||
assertEquals("completed", progress?.kind)
|
||||
assertEquals(Res.string.progress_completed, progress?.label)
|
||||
assertEquals(1f, progress?.progress)
|
||||
assertEquals(null, activeSendProgress(events, 7UL, setOf("peer-a"), totalSizeHint = 100UL))
|
||||
}
|
||||
|
||||
private fun storedTransfer(status: TransferStatus): Transfer =
|
||||
Transfer(
|
||||
localId = "local-1",
|
||||
|
||||
Reference in New Issue
Block a user