mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
feat(apple): saved devices and targeted transfers UI
Adds the native SwiftUI Saved Devices experience on top of the production saved-device core, as a top-level destination in the iOS tab bar and the macOS sidebar. Core seam: - App-facing saved-device domain models mirroring core/SavedDeviceModels.kt, with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views never hand-roll state checks. - 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping. cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial lane: each must reach the core while a targeted receive is blocking it. - Payload-free pairingChanged/targetedTransferChanged signals, dispatched before the numeric-transferId guard since saved-device events identify their subject by peer endpoint or a string transfer id. Experience: - Screen lists saved devices and outstanding consent requests only; the global targeted-transfer history stays out, reachable per device. - Details as a sheet with detents on compact layouts and a native inspector on macOS, owning Send, label, forget/block and that device's transfers. - Label editing is transactional: the draft and editor survive a failed write, conflicting actions are refused while saving, and the editor closes only after the core confirms. - Pairing and targeted-offer consent hosted at the app root, answerable from any tab and suppressed while a transfer approval is up. Dismissing a pairing prompt suppresses locally without consuming the single-use eligibility; dismissing an offer declines it, since an unanswered offer holds a slot in the core's bounded per-sender queue. - Targeted send reuses the invitation composer's affordances with file, folder, rename, replace and cleanup parity. Picker copies are released on replace/remove/clear/cancel and after a successful create, but kept after a failure so retry does not require re-picking. - Notifications for pairing requests and offers (withdrawn once answered) and for terminal targeted transfers. Wording follows direction: on the sending device the peer finished receiving, not us. Localization: - Widens 52 saved-device keys from kmp-only to both platforms. - Five keys carried a literal %1$s with no declared args, which Compose renders positionally but the Apple generator emits as a plain constant, leaking the placeholder into the UI. They now use named args; Compose output is byte-identical. - Adds targeted_offer_title/body. Reusing the invitation approval copy stated the roles backwards, announcing the sender as the receiver. Also surfaces core startup failures: the startup overlay is drawn above the snackbar host, so a failed initialize() was indistinguishable from an app that never finished loading. AppModel now keeps the reason, logs it, and the overlay shows it with a retry, plus the technical detail in DEBUG builds. Send and receive between two devices is verified only partially; a missing endpoint-identity credential currently blocks startup on the test device.
This commit is contained in:
@@ -81,6 +81,122 @@ final class FakeCoreGateway: CoreGateway {
|
||||
return responseResult
|
||||
}
|
||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||
|
||||
// MARK: - Saved devices
|
||||
|
||||
// Stubbed results
|
||||
var savedDevices: [SavedDeviceModel] = []
|
||||
/// Overrides `savedDevices` when set, so a test can fail one leg of the
|
||||
/// five-read snapshot without stubbing the rest.
|
||||
var savedDevicesResult: Result<[SavedDeviceModel], Error>?
|
||||
var deviceRelationships: [DeviceRelationshipModel] = []
|
||||
var pairingEligibilities: [PairingEligibilityModel] = []
|
||||
var blockedDevices: [String] = []
|
||||
var pendingTargetedOffers: [PendingTargetedOfferModel] = []
|
||||
var targetedTransfers: [TargetedTransferModel] = []
|
||||
var setLabelResult: Result<Void, Error> = .success(())
|
||||
var forgetResult: Result<Void, Error> = .success(())
|
||||
var blockResult: Result<Void, Error> = .success(())
|
||||
var requestPairingResult: Result<Bool, Error> = .success(true)
|
||||
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||
var offerResponseResult: Result<TargetedOfferResponseModel, Error> = .success(.declined)
|
||||
var createTargetedTransferResult: Result<TargetedTransferModel, Error> = .failure(TestError.unimplemented)
|
||||
var targetedReceiveResult: Result<Void, Error> = .success(())
|
||||
var targetedCancelResult: Result<Void, Error> = .success(())
|
||||
var targetedDeleteResult: Result<Void, Error> = .success(())
|
||||
|
||||
// Recorded calls
|
||||
private(set) var setLabels: [(peerEndpointId: String, label: String?)] = []
|
||||
private(set) var forgottenDevices: [String] = []
|
||||
private(set) var blockedCalls: [String] = []
|
||||
private(set) var unblockedCalls: [String] = []
|
||||
private(set) var declinedEligibilities: [String] = []
|
||||
private(set) var requestedPairings: [String] = []
|
||||
private(set) var pairingResponses: [(peerEndpointId: String, accepted: Bool)] = []
|
||||
private(set) var offerResponses: [(transferId: String, accepted: Bool)] = []
|
||||
private(set) var createdTargetedTransfers: [(receiverEndpointId: String, sources: [ShareSource], transferName: String?)] = []
|
||||
private(set) var targetedReceives: [(transferId: String, outputDirectoryUrl: String)] = []
|
||||
private(set) var targetedResumes: [(id: String, outputDirectoryUrl: String)] = []
|
||||
private(set) var cancelledTargetedTransfers: [String] = []
|
||||
private(set) var deletedTargetedTransfers: [String] = []
|
||||
|
||||
func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> {
|
||||
.success(pairingEligibilities)
|
||||
}
|
||||
func declinePairingEligibility(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
declinedEligibilities.append(peerEndpointId)
|
||||
return .success(())
|
||||
}
|
||||
func requestSavedDevicePairing(peerEndpointId: String) async -> Result<Bool, Error> {
|
||||
requestedPairings.append(peerEndpointId)
|
||||
return requestPairingResult
|
||||
}
|
||||
func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||
pairingResponses.append((peerEndpointId, accepted))
|
||||
return respondToPairingResult
|
||||
}
|
||||
func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> {
|
||||
.success(deviceRelationships)
|
||||
}
|
||||
func listSavedDevices() async -> Result<[SavedDeviceModel], Error> {
|
||||
savedDevicesResult ?? .success(savedDevices)
|
||||
}
|
||||
func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result<Void, Error> {
|
||||
setLabels.append((peerEndpointId, label))
|
||||
return setLabelResult
|
||||
}
|
||||
func forgetSavedDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
forgottenDevices.append(peerEndpointId)
|
||||
return forgetResult
|
||||
}
|
||||
func blockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
blockedCalls.append(peerEndpointId)
|
||||
return blockResult
|
||||
}
|
||||
func unblockDevice(peerEndpointId: String) async -> Result<Void, Error> {
|
||||
unblockedCalls.append(peerEndpointId)
|
||||
return .success(())
|
||||
}
|
||||
func listBlockedDevices() async -> Result<[String], Error> { .success(blockedDevices) }
|
||||
|
||||
// MARK: - Targeted transfers
|
||||
|
||||
func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> {
|
||||
.success(pendingTargetedOffers)
|
||||
}
|
||||
func respondToTargetedOffer(
|
||||
transferId: String, accepted: Bool
|
||||
) async -> Result<TargetedOfferResponseModel, Error> {
|
||||
offerResponses.append((transferId, accepted))
|
||||
return offerResponseResult
|
||||
}
|
||||
func createTargetedTransfer(
|
||||
receiverEndpointId: String, sources: [ShareSource], transferName: String?
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
|
||||
return createTargetedTransferResult
|
||||
}
|
||||
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {
|
||||
.success(targetedTransfers)
|
||||
}
|
||||
func receiveTargetedTransfer(
|
||||
transferId: String, outputDirectoryUrl: String
|
||||
) async -> Result<Void, Error> {
|
||||
targetedReceives.append((transferId, outputDirectoryUrl))
|
||||
return targetedReceiveResult
|
||||
}
|
||||
func resumeTargetedTransfer(id: String, outputDirectoryUrl: String) async -> Result<Void, Error> {
|
||||
targetedResumes.append((id, outputDirectoryUrl))
|
||||
return targetedReceiveResult
|
||||
}
|
||||
func cancelTargetedTransfer(id: String) async -> Result<Void, Error> {
|
||||
cancelledTargetedTransfers.append(id)
|
||||
return targetedCancelResult
|
||||
}
|
||||
func deleteTargetedTransfer(id: String) async -> Result<Void, Error> {
|
||||
deletedTargetedTransfers.append(id)
|
||||
return targetedDeleteResult
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||
@@ -103,6 +219,31 @@ final class FakeFileSystemService: FileSystemService {
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
|
||||
/// Records targeted sends and forwards to the gateway so its recorders and
|
||||
/// stubbed result drive the assertions.
|
||||
private(set) var targetedSends: [(files: [PickedShareFile], transferName: String, receiver: String)] = []
|
||||
|
||||
func sendPickedFilesToSavedDevice(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
receiverEndpointId: String
|
||||
) async -> Result<TargetedTransferModel, Error> {
|
||||
targetedSends.append((files, transferName, receiverEndpointId))
|
||||
return await repository.createTargetedTransfer(
|
||||
receiverEndpointId: receiverEndpointId,
|
||||
sources: [],
|
||||
transferName: transferName.isEmpty ? nil : transferName
|
||||
)
|
||||
}
|
||||
|
||||
/// Picker copies released via `discardPickedFiles`.
|
||||
private(set) var discardedFiles: [String] = []
|
||||
|
||||
func discardPickedFiles(_ files: [PickedShareFile]) async {
|
||||
discardedFiles.append(contentsOf: files.map(\.value))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user