fix(saved-devices): preserve targeted transfer direction

This commit is contained in:
2026-08-14 09:17:50 +02:00
parent 0ea9a8e49c
commit ce8f19b9ff
15 changed files with 103 additions and 24 deletions

View File

@@ -139,7 +139,14 @@ final class SavedDeviceCoreContractTests: XCTestCase {
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore (String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig ) = VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities() let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
let role: TargetedTransferRole = .sender
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1) XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
switch role {
case .sender:
break
case .receiver:
XCTFail("targeted-transfer role binding decoded the wrong case")
}
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices) XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
return return
} }
@@ -172,6 +179,8 @@ final class SavedDeviceCoreContractTests: XCTestCase {
XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig")) XCTAssertTrue(source.contains("resetUnrecoverableIdentityWithLimitsAndNetworkConfig"))
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities")) XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
XCTAssertTrue(source.contains("public func 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("setSavedDeviceLabel"))
XCTAssertTrue(source.contains("listSavedDevices")) XCTAssertTrue(source.contains("listSavedDevices"))
XCTAssertTrue(source.contains("revision")) XCTAssertTrue(source.contains("revision"))

View File

@@ -88,10 +88,18 @@ pub enum TargetedTransferState {
Deleted, 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. /// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct TargetedTransfer { pub struct TargetedTransfer {
pub id: String, pub id: String,
pub role: TargetedTransferRole,
pub sender_endpoint_id: String, pub sender_endpoint_id: String,
pub receiver_endpoint_id: String, pub receiver_endpoint_id: String,
pub manifest_id: String, pub manifest_id: String,

View File

@@ -32,8 +32,8 @@ pub use api::{
PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink,
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind, SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState, StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferRole,
TicketInspection, TransferAccessMode, TransferMetadata, TargetedTransferState, TicketInspection, TransferAccessMode, TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -11,6 +11,7 @@ mod state;
mod store; mod store;
mod store_outbox; mod store_outbox;
pub(crate) use crate::api::TargetedTransferRole;
pub(crate) use auth::{ pub(crate) use auth::{
auth_secret_material, reconstruct_authorization, TargetedAuthorization, auth_secret_material, reconstruct_authorization, TargetedAuthorization,
TargetedAuthorizationDraft, TargetedAuthorizationDraft,
@@ -18,6 +19,4 @@ pub(crate) use auth::{
pub(crate) use inbox::{RespondError, TargetedOfferInbox}; pub(crate) use inbox::{RespondError, TargetedOfferInbox};
pub(crate) use protocol::TargetedTransferProtocol; pub(crate) use protocol::TargetedTransferProtocol;
pub(crate) use schema::ensure_schema; pub(crate) use schema::ensure_schema;
pub(crate) use store::{ pub(crate) use store::{state_as_str, TargetedTransferRow, TargetedTransferStore};
state_as_str, TargetedTransferRole, TargetedTransferRow, TargetedTransferStore,
};

View File

@@ -6,7 +6,7 @@
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use crate::{ use crate::{
api::{TargetedTransfer, TargetedTransferState}, api::{TargetedTransfer, TargetedTransferRole, TargetedTransferState},
error::VnidropError, error::VnidropError,
util::now_ms, util::now_ms,
}; };
@@ -430,7 +430,7 @@ impl TargetedTransferStore {
pub(crate) async fn get(&self, id: &str) -> Result<Option<TargetedTransfer>, VnidropError> { pub(crate) async fn get(&self, id: &str) -> Result<Option<TargetedTransfer>, VnidropError> {
let row = sqlx::query( let row = sqlx::query(
r#" 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 file_count, total_size, verified_bytes, state, created_at, updated_at
FROM targeted_transfers WHERE id = ?1 FROM targeted_transfers WHERE id = ?1
"#, "#,
@@ -465,7 +465,7 @@ impl TargetedTransferStore {
pub(crate) async fn list(&self) -> Result<Vec<TargetedTransfer>, VnidropError> { pub(crate) async fn list(&self) -> Result<Vec<TargetedTransfer>, VnidropError> {
let rows = sqlx::query( let rows = sqlx::query(
r#" 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 file_count, total_size, verified_bytes, state, created_at, updated_at
FROM targeted_transfers FROM targeted_transfers
ORDER BY updated_at DESC 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)] #[derive(Debug, Clone)]
pub(crate) struct TargetedTransferRow { pub(crate) struct TargetedTransferRow {
pub(crate) id: String, pub(crate) id: String,
@@ -721,6 +715,7 @@ pub(crate) struct TargetedTransferRow {
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, VnidropError> { fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<TargetedTransfer, VnidropError> {
Ok(TargetedTransfer { Ok(TargetedTransfer {
id: row.get("id"), id: row.get("id"),
role: parse_role(&row.get::<String, _>("role"))?,
sender_endpoint_id: row.get("sender_endpoint_id"), sender_endpoint_id: row.get("sender_endpoint_id"),
receiver_endpoint_id: row.get("receiver_endpoint_id"), receiver_endpoint_id: row.get("receiver_endpoint_id"),
manifest_id: row.get("manifest_id"), manifest_id: row.get("manifest_id"),

View File

@@ -856,6 +856,11 @@ fn apple_public_bindings_omit_raw_secrets_and_generic_mutation() {
&& source.contains("public func savedDeviceCapabilities()"), && source.contains("public func savedDeviceCapabilities()"),
"Swift bindings must expose production saved-device capabilities" "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!( assert!(
source.contains("setSavedDeviceLabel"), source.contains("setSavedDeviceLabel"),
"Swift bindings must expose saved-device rename" "Swift bindings must expose saved-device rename"

View File

@@ -12,8 +12,8 @@ use crate::{
secure_secret::{FaultInjectingSecretStore, ReferenceStoreFailure}, secure_secret::{FaultInjectingSecretStore, ReferenceStoreFailure},
CoreEvent, CoreEventSink, CoreNetworkConfig, CoreRelayMode, DeviceRelationshipState, CoreEvent, CoreEventSink, CoreNetworkConfig, CoreRelayMode, DeviceRelationshipState,
PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferState, ReceivedLocatorKind, ShareMetadataInput, ShareSource, SourceKind, TargetedTransferRole,
TransferAccessMode, VnidropCore, VnidropError, TargetedTransferState, TransferAccessMode, VnidropCore, VnidropError,
}; };
struct RecordingSink { struct RecordingSink {
@@ -381,6 +381,7 @@ fn identity_reset_cancels_targeted_authorization_bound_to_the_lost_endpoint() {
.get_targeted_transfer(transfer.id.clone()) .get_targeted_transfer(transfer.id.clone())
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(snapshot.role, TargetedTransferRole::Sender);
assert_eq!(snapshot.state, TargetedTransferState::Cancelled); assert_eq!(snapshot.state, TargetedTransferState::Cancelled);
assert!(recovered assert!(recovered
.targeted_blob_ticket_for_test(transfer.id) .targeted_blob_ticket_for_test(transfer.id)

View File

@@ -4,7 +4,7 @@ use support::TestNode;
use vnidrop::{ use vnidrop::{
saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice, saved_device_capabilities, DeviceRelationship, DeviceRelationshipState, SavedDevice,
SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer, SavedDeviceCapabilities, ShareMetadataInput, ShareSource, SourceKind, TargetedTransfer,
TargetedTransferState, TransferAccessMode, VnidropError, TargetedTransferRole, TargetedTransferState, TransferAccessMode, VnidropError,
}; };
#[test] #[test]
@@ -38,6 +38,7 @@ fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() {
}; };
let transfer = TargetedTransfer { let transfer = TargetedTransfer {
id: "targeted-transfer-id".to_string(), id: "targeted-transfer-id".to_string(),
role: TargetedTransferRole::Sender,
sender_endpoint_id: "sender-endpoint".to_string(), sender_endpoint_id: "sender-endpoint".to_string(),
receiver_endpoint_id: device.endpoint_id.clone(), receiver_endpoint_id: device.endpoint_id.clone(),
manifest_id: "immutable-manifest-id".to_string(), manifest_id: "immutable-manifest-id".to_string(),

View File

@@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest {
assertTrue( assertTrue(
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"), source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
) )
assertTrue(source.contains("var `role`: TargetedTransferRole"))
assertTrue(source.contains("public enum class TargetedTransferRole"))
assertTrue( assertTrue(
source.contains("SavedDevice"), source.contains("SavedDevice"),
"SavedDevice model must remain on the public surface", "SavedDevice model must remain on the public surface",

View File

@@ -33,6 +33,7 @@ import uniffi.vnidrop.SourceKind
import uniffi.vnidrop.StoredTransfer import uniffi.vnidrop.StoredTransfer
import uniffi.vnidrop.TargetedOfferResponse import uniffi.vnidrop.TargetedOfferResponse
import uniffi.vnidrop.TargetedTransfer import uniffi.vnidrop.TargetedTransfer
import uniffi.vnidrop.TargetedTransferRole
import uniffi.vnidrop.TargetedTransferState import uniffi.vnidrop.TargetedTransferState
import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferMetadata
@@ -643,6 +644,7 @@ private fun PendingTargetedOffer.toModel(): PendingTargetedOfferModel = PendingT
private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel( private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransferModel(
id = id, id = id,
role = role.toModel(),
senderEndpointId = senderEndpointId, senderEndpointId = senderEndpointId,
receiverEndpointId = receiverEndpointId, receiverEndpointId = receiverEndpointId,
manifestId = manifestId, manifestId = manifestId,
@@ -655,6 +657,11 @@ private fun TargetedTransfer.toModel(): TargetedTransferModel = TargetedTransfer
updatedAt = updatedAt, updatedAt = updatedAt,
) )
private fun TargetedTransferRole.toModel(): TargetedTransferRoleModel = when (this) {
TargetedTransferRole.SENDER -> TargetedTransferRoleModel.Sender
TargetedTransferRole.RECEIVER -> TargetedTransferRoleModel.Receiver
}
private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) { private fun TargetedTransferState.toModel(): TargetedTransferStateModel = when (this) {
TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing TargetedTransferState.PREPARING -> TargetedTransferStateModel.Preparing
TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering TargetedTransferState.OFFERING -> TargetedTransferStateModel.Offering

View File

@@ -68,8 +68,14 @@ enum class TargetedTransferStateModel {
Deleted, Deleted,
} }
enum class TargetedTransferRoleModel {
Sender,
Receiver,
}
data class TargetedTransferModel( data class TargetedTransferModel(
val id: String, val id: String,
val role: TargetedTransferRoleModel,
val senderEndpointId: String, val senderEndpointId: String,
val receiverEndpointId: String, val receiverEndpointId: String,
val manifestId: String, val manifestId: String,

View File

@@ -12,6 +12,7 @@ import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.core.TargetedTransferModel import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferRoleModel
import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.PreferencesRepository import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessage
@@ -365,8 +366,7 @@ class SavedDevicesViewModel(
} }
private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem { private fun TargetedTransferModel.toExperienceItem(savedNames: Map<String, String>): SavedDeviceTransferItem {
val localEndpointId = repository.state.value.status?.endpointId val outgoing = role == TargetedTransferRoleModel.Sender
val outgoing = senderEndpointId == localEndpointId
val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId val peerEndpointId = if (outgoing) receiverEndpointId else senderEndpointId
return SavedDeviceTransferItem( return SavedDeviceTransferItem(
id = id, id = id,

View File

@@ -10,6 +10,7 @@ import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.SavedDeviceModel import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.TargetedOfferResponseModel import com.vnidrop.app.core.TargetedOfferResponseModel
import com.vnidrop.app.core.TargetedTransferModel import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferRoleModel
import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.AppPreferences
import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeCoreGateway
@@ -49,7 +50,15 @@ class SavedDevicesViewModelTest {
deviceRelationships = listOf(incoming("incoming")) deviceRelationships = listOf(incoming("incoming"))
savedDevices = listOf(device("peer", "Office PC", "Authenticated PC")) savedDevices = listOf(device("peer", "Office PC", "Authenticated PC"))
pendingTargetedOffers = listOf(offer("offer", "peer")) 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) val viewModel = createViewModel(core)
runCurrent() runCurrent()
@@ -74,6 +83,29 @@ class SavedDevicesViewModelTest {
assertEquals(false, state.loadFailed) 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 @Test
fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest { fun pairingPromptCommandsAndDismissalUseTheSameDurableLists() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -158,7 +190,15 @@ class SavedDevicesViewModelTest {
fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest { fun interruptedTransferResumeUsesThePlatformSinkWhenAvailable() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply { 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( val viewModel = createViewModel(
core, core,
@@ -180,9 +220,9 @@ class SavedDevicesViewModelTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = initializedCore().apply { val core = initializedCore().apply {
targetedTransfers = listOf( targetedTransfers = listOf(
transfer("resume", "peer", "local", TargetedTransferStateModel.Interrupted), transfer("resume", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Interrupted),
transfer("cancel", "local", "peer", TargetedTransferStateModel.AwaitingApproval), transfer("cancel", TargetedTransferRoleModel.Sender, "local", "peer", TargetedTransferStateModel.AwaitingApproval),
transfer("delete", "peer", "local", TargetedTransferStateModel.Completed), transfer("delete", TargetedTransferRoleModel.Receiver, "peer", "local", TargetedTransferStateModel.Completed),
) )
} }
val viewModel = createViewModel(core) val viewModel = createViewModel(core)
@@ -336,11 +376,13 @@ class SavedDevicesViewModelTest {
private fun transfer( private fun transfer(
id: String, id: String,
role: TargetedTransferRoleModel,
sender: String, sender: String,
receiver: String, receiver: String,
state: TargetedTransferStateModel, state: TargetedTransferStateModel,
) = TargetedTransferModel( ) = TargetedTransferModel(
id = id, id = id,
role = role,
senderEndpointId = sender, senderEndpointId = sender,
receiverEndpointId = receiver, receiverEndpointId = receiver,
manifestId = "manifest-$id", manifestId = "manifest-$id",

View File

@@ -6,6 +6,7 @@ import com.vnidrop.app.core.SavedDeviceModel
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TargetedTransferModel import com.vnidrop.app.core.TargetedTransferModel
import com.vnidrop.app.core.TargetedTransferRoleModel
import com.vnidrop.app.core.TargetedTransferStateModel import com.vnidrop.app.core.TargetedTransferStateModel
import com.vnidrop.app.support.FakeCoreGateway import com.vnidrop.app.support.FakeCoreGateway
import com.vnidrop.app.support.FakeFilePreviewRepository import com.vnidrop.app.support.FakeFilePreviewRepository
@@ -235,6 +236,7 @@ class TransferDraftViewModelTest {
private fun targeted(id: String, peerId: String) = TargetedTransferModel( private fun targeted(id: String, peerId: String) = TargetedTransferModel(
id = id, id = id,
role = TargetedTransferRoleModel.Sender,
senderEndpointId = "me", senderEndpointId = "me",
receiverEndpointId = peerId, receiverEndpointId = peerId,
manifestId = "manifest", manifestId = "manifest",

View File

@@ -44,6 +44,8 @@ class SavedDeviceCoreContractBindingHygieneTest {
assertTrue( assertTrue(
source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"), source.contains("public expect fun `savedDeviceCapabilities`(): SavedDeviceCapabilities"),
) )
assertTrue(source.contains("var `role`: TargetedTransferRole"))
assertTrue(source.contains("public enum class TargetedTransferRole"))
assertTrue( assertTrue(
source.contains("SavedDevice"), source.contains("SavedDevice"),
"SavedDevice model must remain on the public surface", "SavedDevice model must remain on the public surface",