From ce8f19b9ffaae98e7962344313febfc4432adff7 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 14 Aug 2026 09:17:50 +0200 Subject: [PATCH] fix(saved-devices): preserve targeted transfer direction --- .../Tests/SavedDeviceCoreContractTests.swift | 9 ++++ crates/vnidrop/src/api.rs | 8 +++ crates/vnidrop/src/lib.rs | 4 +- crates/vnidrop/src/targeted_transfer/mod.rs | 5 +- crates/vnidrop/src/targeted_transfer/store.rs | 13 ++--- .../src/tests/platform_contract_apple.rs | 5 ++ crates/vnidrop/src/tests/targeted_transfer.rs | 5 +- crates/vnidrop/tests/saved_device_domain.rs | 3 +- ...vedDeviceCoreContractBindingHygieneTest.kt | 2 + .../com/vnidrop/app/core/CoreRepository.kt | 7 +++ .../com/vnidrop/app/core/SavedDeviceModels.kt | 6 +++ .../saveddevices/SavedDevicesViewModel.kt | 4 +- .../saveddevices/SavedDevicesViewModelTest.kt | 52 +++++++++++++++++-- .../send/TransferDraftViewModelTest.kt | 2 + ...vedDeviceCoreContractBindingHygieneTest.kt | 2 + 15 files changed, 103 insertions(+), 24 deletions(-) diff --git a/apple/Tests/SavedDeviceCoreContractTests.swift b/apple/Tests/SavedDeviceCoreContractTests.swift index d4ed9bd..d366075 100644 --- a/apple/Tests/SavedDeviceCoreContractTests.swift +++ b/apple/Tests/SavedDeviceCoreContractTests.swift @@ -139,7 +139,14 @@ final class SavedDeviceCoreContractTests: XCTestCase { (String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore ) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities() + let role: TargetedTransferRole = .sender XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1) + switch role { + case .sender: + break + case .receiver: + XCTFail("targeted-transfer role binding decoded the wrong case") + } XCTAssertNotNil(defaultCoreLimits().maxSavedDevices) return } @@ -172,6 +179,8 @@ final class SavedDeviceCoreContractTests: XCTestCase { XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig")) XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities")) XCTAssertTrue(source.contains("public func savedDeviceCapabilities()")) + XCTAssertTrue(source.contains("public var role: TargetedTransferRole")) + XCTAssertTrue(source.contains("public enum TargetedTransferRole")) XCTAssertTrue(source.contains("setSavedDeviceLabel")) XCTAssertTrue(source.contains("listSavedDevices")) XCTAssertTrue(source.contains("revision")) diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index 92492ad..071844d 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -88,10 +88,18 @@ pub enum TargetedTransferState { Deleted, } +/// This installation's immutable role in a Targeted transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum TargetedTransferRole { + Sender, + Receiver, +} + /// Immutable recipient-bound transfer snapshot, separate from an ordinary share. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] pub struct TargetedTransfer { pub id: String, + pub role: TargetedTransferRole, pub sender_endpoint_id: String, pub receiver_endpoint_id: String, pub manifest_id: String, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index e6b8657..c319bc6 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -32,8 +32,8 @@ pub use api::{ PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind, - StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState, - TicketInspection, TransferAccessMode, TransferMetadata, + StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferRole, + TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/targeted_transfer/mod.rs b/crates/vnidrop/src/targeted_transfer/mod.rs index 7fd892e..492afe4 100644 --- a/crates/vnidrop/src/targeted_transfer/mod.rs +++ b/crates/vnidrop/src/targeted_transfer/mod.rs @@ -11,6 +11,7 @@ mod state; mod store; mod store_outbox; +pub(crate) use crate::api::TargetedTransferRole; pub(crate) use auth::{ auth_secret_material, reconstruct_authorization, TargetedAuthorization, TargetedAuthorizationDraft, @@ -18,6 +19,4 @@ pub(crate) use auth::{ pub(crate) use inbox::{RespondError, TargetedOfferInbox}; pub(crate) use protocol::TargetedTransferProtocol; pub(crate) use schema::ensure_schema; -pub(crate) use store::{ - state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore, -}; +pub(crate) use store::{state_as_str, TargetedTransferRow, TargetedTransferStore}; diff --git a/crates/vnidrop/src/targeted_transfer/store.rs b/crates/vnidrop/src/targeted_transfer/store.rs index 609fa8a..fed7bb1 100644 --- a/crates/vnidrop/src/targeted_transfer/store.rs +++ b/crates/vnidrop/src/targeted_transfer/store.rs @@ -6,7 +6,7 @@ use sqlx::{Row, SqlitePool}; use crate::{ - api::{TargetedTransfer, TargetedTransferState}, + api::{TargetedTransfer, TargetedTransferRole, TargetedTransferState}, error::VnidropError, util::now_ms, }; @@ -430,7 +430,7 @@ impl TargetedTransferStore { pub(crate) async fn get(&self, id: &str) -> Result, VnidropError> { let row = sqlx::query( r#" - SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name, + SELECT id, role, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name, file_count, total_size, verified_bytes, state, created_at, updated_at FROM targeted_transfers WHERE id = ?1 "#, @@ -465,7 +465,7 @@ impl TargetedTransferStore { pub(crate) async fn list(&self) -> Result, VnidropError> { let rows = sqlx::query( r#" - SELECT id, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name, + SELECT id, role, sender_endpoint_id, receiver_endpoint_id, manifest_id, transfer_name, file_count, total_size, verified_bytes, state, created_at, updated_at FROM targeted_transfers ORDER BY updated_at DESC @@ -692,12 +692,6 @@ impl TargetedTransferStore { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TargetedTransferRole { - Sender, - Receiver, -} - #[derive(Debug, Clone)] pub(crate) struct TargetedTransferRow { pub(crate) id: String, @@ -721,6 +715,7 @@ pub(crate) struct TargetedTransferRow { fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result { Ok(TargetedTransfer { id: row.get("id"), + role: parse_role(&row.get::("role"))?, sender_endpoint_id: row.get("sender_endpoint_id"), receiver_endpoint_id: row.get("receiver_endpoint_id"), manifest_id: row.get("manifest_id"), diff --git a/crates/vnidrop/src/tests/platform_contract_apple.rs b/crates/vnidrop/src/tests/platform_contract_apple.rs index e9e1399..541b123 100644 --- a/crates/vnidrop/src/tests/platform_contract_apple.rs +++ b/crates/vnidrop/src/tests/platform_contract_apple.rs @@ -856,6 +856,11 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() { && source.contains("public func savedDeviceCapabilities()"), "Swift bindings must expose production saved-device capabilities" ); + assert!( + source.contains("public var role: TargetedTransferRole") + && source.contains("public enum TargetedTransferRole"), + "Swift targeted-transfer snapshots must expose their persisted role" + ); assert!( source.contains("setSavedDeviceLabel"), "Swift bindings must expose saved-device rename" diff --git a/crates/vnidrop/src/tests/targeted_transfer.rs b/crates/vnidrop/src/tests/targeted_transfer.rs index a93b95b..c1ac0e4 100644 --- a/crates/vnidrop/src/tests/targeted_transfer.rs +++ b/crates/vnidrop/src/tests/targeted_transfer.rs @@ -12,8 +12,8 @@ use crate::{ secure_secret::{FaultInjectingSecretStore, ReferenceStoreFailure}, CoreEvent, CoreEventSink, CoreNetworkConfig, CoreRelayMode, DeviceRelationshipState, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, - ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, - TransferAccessMode, VnidropCore, VnidropError, + ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferRole, + TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError, }; struct RecordingSink { @@ -381,6 +381,7 @@ fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() { .get_targeted_transfer(transfer.id.clone()) .unwrap() .unwrap(); + assert_eq!(snapshot.role, TargetedTransferRole::Sender); assert_eq!(snapshot.state, TargetedTransferState::Cancelled); assert!(recovered .targeted_blob_ticket_for_test(transfer.id) diff --git a/crates/vnidrop/tests/saved_device_domain.rs b/crates/vnidrop/tests/saved_device_domain.rs index 1d64698..8d28225 100644 --- a/crates/vnidrop/tests/saved_device_domain.rs +++ b/crates/vnidrop/tests/saved_device_domain.rs @@ -4,7 +4,7 @@ use support::TestNode; use vnidrop::{ saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer, - TargetedTransferState, TransferAccessMode, VnidropError, + TargetedTransferRole, TargetedTransferState, TransferAccessMode, VnidropError, }; #[test] @@ -38,6 +38,7 @@ fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() { }; let transfer = TargetedTransfer { id: "targeted-transfer-id".to_string(), + role: TargetedTransferRole::Sender, sender_endpoint_id: "sender-endpoint".to_string(), receiver_endpoint_id: device.endpoint_id.clone(), manifest_id: "immutable-manifest-id".to_string(), diff --git a/shared/src/androidHostTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt b/shared/src/androidHostTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt index 377a7a6..e878829 100644 --- a/shared/src/androidHostTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt +++ b/shared/src/androidHostTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt @@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest { assertTrue( source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"), ) + assertTrue(source.contains("var `role`: TargetedTransferRole")) + assertTrue(source.contains("public enum class TargetedTransferRole")) assertTrue( source.contains("SavedDevice"), "SavedDevice model must remain on the public surface", 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 8cf0003..3dc300c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -33,6 +33,7 @@ import uniffi.vnidrop.SourceKind import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.TargetedOfferResponse import uniffi.vnidrop.TargetedTransfer +import uniffi.vnidrop.TargetedTransferRole import uniffi.vnidrop.TargetedTransferState import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata @@ -643,6 +644,7 @@ private fun PendingTargetedOffer.toModel(): PendingTargetedOfferModel = PendingT private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel( id = id, + role = role.toModel(), senderEndpointId = senderEndpointId, receiverEndpointId = receiverEndpointId, manifestId = manifestId, @@ -655,6 +657,11 @@ private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransfer updatedAt = updatedAt, ) +private fun TargetedTransferRole.toModel(): TargetedTransferRoleModel = when (this) { + TargetedTransferRole.SENDER -> TargetedTransferRoleModel.Sender + TargetedTransferRole.RECEIVER -> TargetedTransferRoleModel.Receiver +} + private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) { TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt index afeaf42..3096131 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/SavedDeviceModels.kt @@ -68,8 +68,14 @@ enum class TargetedTransferStateModel { Deleted, } +enum class TargetedTransferRoleModel { + Sender, + Receiver, +} + data class TargetedTransferModel( val id: String, + val role: TargetedTransferRoleModel, val senderEndpointId: String, val receiverEndpointId: String, val manifestId: String, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt index 80600ef..43e397b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModel.kt @@ -12,6 +12,7 @@ import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.TargetedOfferResponseModel import com.vnidrop.app.core.TargetedTransferModel +import com.vnidrop.app.core.TargetedTransferRoleModel import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.ui.feedback.UiMessage @@ -365,8 +366,7 @@ class SavedDevicesViewModel( } private fun TargetedTransferModel.toExperienceItem(savedNames: Map): SavedDeviceTransferItem { - val localEndpointId = repository.state.value.status?.endpointId - val outgoing = senderEndpointId == localEndpointId + val outgoing = role == TargetedTransferRoleModel.Sender val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId return SavedDeviceTransferItem( id = id, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt index 282b92d..bc0f835 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/saveddevices/SavedDevicesViewModelTest.kt @@ -10,6 +10,7 @@ import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.TargetedOfferResponseModel import com.vnidrop.app.core.TargetedTransferModel +import com.vnidrop.app.core.TargetedTransferRoleModel import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.support.FakeCoreGateway @@ -49,7 +50,15 @@ class SavedDevicesViewModelTest { deviceRelationships = listOf(incoming("incoming")) savedDevices = listOf(device("peer", "Office PC", "Authenticated PC")) pendingTargetedOffers = listOf(offer("offer", "peer")) - targetedTransfers = listOf(transfer("history", "local", "peer", TargetedTransferStateModel.AwaitingApproval)) + targetedTransfers = listOf( + transfer( + "history", + TargetedTransferRoleModel.Sender, + "local", + "peer", + TargetedTransferStateModel.AwaitingApproval, + ), + ) } val viewModel = createViewModel(core) runCurrent() @@ -74,6 +83,29 @@ class SavedDevicesViewModelTest { assertEquals(false, state.loadFailed) } + @Test + fun persistedOutgoingTransferKeepsDirectionAfterIdentityReset() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = initializedCore().apply { + targetedTransfers = listOf( + transfer( + id = "past-send", + role = TargetedTransferRoleModel.Sender, + sender = "retired-local-identity", + receiver = "peer", + state = TargetedTransferStateModel.Completed, + ), + ) + } + val viewModel = createViewModel(core) + runCurrent() + advanceUntilIdle() + + val transfer = viewModel.state.value.targetedTransfers.single() + assertEquals(SavedDeviceTransferDirection.Outgoing, transfer.direction) + assertEquals("peer", transfer.peerEndpointId) + } + @Test fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -158,7 +190,15 @@ class SavedDevicesViewModelTest { fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val core = initializedCore().apply { - targetedTransfers = listOf(transfer("resume-sink", "peer", "local", TargetedTransferStateModel.Interrupted)) + targetedTransfers = listOf( + transfer( + "resume-sink", + TargetedTransferRoleModel.Receiver, + "peer", + "local", + TargetedTransferStateModel.Interrupted, + ), + ) } val viewModel = createViewModel( core, @@ -180,9 +220,9 @@ class SavedDevicesViewModelTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val core = initializedCore().apply { targetedTransfers = listOf( - transfer("resume", "peer", "local", TargetedTransferStateModel.Interrupted), - transfer("cancel", "local", "peer", TargetedTransferStateModel.AwaitingApproval), - transfer("delete", "peer", "local", TargetedTransferStateModel.Completed), + transfer("resume", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Interrupted), + transfer("cancel", TargetedTransferRoleModel.Sender, "local", "peer", TargetedTransferStateModel.AwaitingApproval), + transfer("delete", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Completed), ) } val viewModel = createViewModel(core) @@ -336,11 +376,13 @@ class SavedDevicesViewModelTest { private fun transfer( id: String, + role: TargetedTransferRoleModel, sender: String, receiver: String, state: TargetedTransferStateModel, ) = TargetedTransferModel( id = id, + role = role, senderEndpointId = sender, receiverEndpointId = receiver, manifestId = "manifest-$id", diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferDraftViewModelTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferDraftViewModelTest.kt index e5665de..c10572e 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferDraftViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferDraftViewModelTest.kt @@ -6,6 +6,7 @@ import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.TargetedTransferModel +import com.vnidrop.app.core.TargetedTransferRoleModel import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeFilePreviewRepository @@ -235,6 +236,7 @@ class TransferDraftViewModelTest { private fun targeted(id: String, peerId: String) = TargetedTransferModel( id = id, + role = TargetedTransferRoleModel.Sender, senderEndpointId = "me", receiverEndpointId = peerId, manifestId = "manifest", diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt index 377a7a6..e878829 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/SavedDeviceCoreContractBindingHygieneTest.kt @@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest { assertTrue( source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"), ) + assertTrue(source.contains("var `role`: TargetedTransferRole")) + assertTrue(source.contains("public enum class TargetedTransferRole")) assertTrue( source.contains("SavedDevice"), "SavedDevice model must remain on the public surface",