From 8bb1442338ac470fcfdbcad522072e802e538a9e Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:42:37 +0200 Subject: [PATCH] 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. --- apple/Tests/Fakes.swift | 141 ++++ .../Tests/SavedDeviceNotificationTests.swift | 167 +++++ apple/Tests/SavedDevicesModelTests.swift | 508 ++++++++++++++ apple/VniDrop/App/RootView.swift | 98 ++- apple/VniDrop/Core/CoreGateway.swift | 47 ++ apple/VniDrop/Core/CoreModels.swift | 13 + apple/VniDrop/Core/CoreRepository.swift | 241 +++++++ apple/VniDrop/Core/FileSystemService.swift | 9 + apple/VniDrop/Core/SavedDeviceModels.swift | 182 ++++++ apple/VniDrop/Features/App/AppModel.swift | 42 +- .../SavedDeviceNotificationCoordinator.swift | 264 ++++++++ .../TransferNotificationCoordinator.swift | 4 + .../SavedDevices/SavedDeviceDetailsView.swift | 568 ++++++++++++++++ .../SavedDeviceExperienceModels.swift | 79 +++ .../SavedDevicePresentation.swift | 75 +++ .../SavedDevices/SavedDevicePrompts.swift | 127 ++++ .../SavedDevices/SavedDevicesModel.swift | 618 ++++++++++++++++++ .../SavedDevices/SavedDevicesScreen.swift | 248 +++++++ .../SavedDevices/TargetedSendComposer.swift | 216 ++++++ apple/VniDrop/Features/Send/SendModel.swift | 5 + .../Platform/FileSystemService+iOS.swift | 18 + .../Platform/FileSystemService+macOS.swift | 47 +- .../UI/Navigation/AppDestination.swift | 3 + localization/strings.json | 346 ++++------ .../composeResources/values-de/strings.xml | 2 + .../composeResources/values-es/strings.xml | 2 + .../composeResources/values-fr/strings.xml | 2 + .../composeResources/values-it/strings.xml | 2 + .../composeResources/values-nl/strings.xml | 2 + .../composeResources/values-pl/strings.xml | 2 + .../composeResources/values-pt/strings.xml | 2 + .../composeResources/values-ru/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + 33 files changed, 3837 insertions(+), 247 deletions(-) create mode 100644 apple/Tests/SavedDeviceNotificationTests.swift create mode 100644 apple/Tests/SavedDevicesModelTests.swift create mode 100644 apple/VniDrop/Core/SavedDeviceModels.swift create mode 100644 apple/VniDrop/Features/Notifications/SavedDeviceNotificationCoordinator.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift create mode 100644 apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift create mode 100644 apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 5c3f515..e286f65 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -81,6 +81,122 @@ final class FakeCoreGateway: CoreGateway { return responseResult } func refresh() async -> Result { .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 = .success(()) + var forgetResult: Result = .success(()) + var blockResult: Result = .success(()) + var requestPairingResult: Result = .success(true) + var respondToPairingResult: Result = .success(true) + var offerResponseResult: Result = .success(.declined) + var createTargetedTransferResult: Result = .failure(TestError.unimplemented) + var targetedReceiveResult: Result = .success(()) + var targetedCancelResult: Result = .success(()) + var targetedDeleteResult: Result = .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 { + declinedEligibilities.append(peerEndpointId) + return .success(()) + } + func requestSavedDevicePairing(peerEndpointId: String) async -> Result { + requestedPairings.append(peerEndpointId) + return requestPairingResult + } + func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result { + 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 { + setLabels.append((peerEndpointId, label)) + return setLabelResult + } + func forgetSavedDevice(peerEndpointId: String) async -> Result { + forgottenDevices.append(peerEndpointId) + return forgetResult + } + func blockDevice(peerEndpointId: String) async -> Result { + blockedCalls.append(peerEndpointId) + return blockResult + } + func unblockDevice(peerEndpointId: String) async -> Result { + 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 { + offerResponses.append((transferId, accepted)) + return offerResponseResult + } + func createTargetedTransfer( + receiverEndpointId: String, sources: [ShareSource], transferName: String? + ) async -> Result { + createdTargetedTransfers.append((receiverEndpointId, sources, transferName)) + return createTargetedTransferResult + } + func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> { + .success(targetedTransfers) + } + func receiveTargetedTransfer( + transferId: String, outputDirectoryUrl: String + ) async -> Result { + targetedReceives.append((transferId, outputDirectoryUrl)) + return targetedReceiveResult + } + func resumeTargetedTransfer(id: String, outputDirectoryUrl: String) async -> Result { + targetedResumes.append((id, outputDirectoryUrl)) + return targetedReceiveResult + } + func cancelTargetedTransfer(id: String) async -> Result { + cancelledTargetedTransfers.append(id) + return targetedCancelResult + } + func deleteTargetedTransfer(id: String) async -> Result { + 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 { + 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 diff --git a/apple/Tests/SavedDeviceNotificationTests.swift b/apple/Tests/SavedDeviceNotificationTests.swift new file mode 100644 index 0000000..4fd0a85 --- /dev/null +++ b/apple/Tests/SavedDeviceNotificationTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import VniDrop + +@MainActor +final class SavedDeviceNotificationTests: XCTestCase { + private static let peer = "peer-endpoint" + + private func state( + pendingRelationships: [DeviceRelationshipModel] = [], + eligibilities: [PairingEligibilityModel] = [], + savedDevices: [SavedDeviceModel] = [], + offers: [PendingTargetedOfferModel] = [], + senderDisplayNames: [String: String] = [:], + transfers: [SavedDeviceTransferItem] = [] + ) -> SavedDevicesState { + var state = SavedDevicesState() + state.isLoading = false + state.pendingRelationships = pendingRelationships + state.eligibilities = eligibilities + state.savedDevices = savedDevices + state.targetedOffers.pending = offers + state.targetedOffers.senderDisplayNames = senderDisplayNames + state.targetedTransfers = transfers + return state + } + + private func relationship(_ state: DeviceRelationshipStateModel) -> DeviceRelationshipModel { + DeviceRelationshipModel( + remoteEndpointId: Self.peer, state: state, generation: 1, + minimumProtocolVersion: 1, createdAt: 1, updatedAt: 1 + ) + } + + private func offer(_ transferId: String = "t1") -> PendingTargetedOfferModel { + PendingTargetedOfferModel( + transferId: transferId, senderEndpointId: Self.peer, receiverEndpointId: "me", + manifestId: "m", contentHash: "h", transferName: "Photos", fileCount: 1, + totalSize: 10, protocolVersion: 1, receivedAt: 1 + ) + } + + private func transferItem( + id: String = "t1", + state: TargetedTransferStateModel, + direction: SavedDeviceTransferDirection = .incoming + ) -> SavedDeviceTransferItem { + SavedDeviceTransferItem( + id: id, peerEndpointId: Self.peer, peerDisplayName: "Studio Mac", + direction: direction, transferName: "Photos", fileCount: 1, totalSize: 10, + verifiedBytes: 10, state: state, createdAt: 1, updatedAt: 1 + ) + } + + // MARK: - Prompts + + func testIncomingPairingRequestIsPlanned() { + let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingIncoming)])) + + XCTAssertEqual(planned.map(\.kind), [.pairingRequest]) + XCTAssertEqual(planned.first?.id, "pairing-request-\(Self.peer)") + } + + func testOutgoingPairingRequestIsNotPlanned() { + // We are the ones waiting; there is nothing for the user to answer. + let planned = plannedSavedDevicePrompts(state(pendingRelationships: [relationship(.pendingOutgoing)])) + + XCTAssertTrue(planned.isEmpty) + } + + func testPairingRequestUsesEligibilityNameWhenAvailable() { + let eligibility = PairingEligibilityModel( + peerEndpointId: Self.peer, remoteDisplayName: "Alice's Mac", sessionId: "s", + protocolVersion: 1, createdAt: 1, expiresAt: 2 + ) + let planned = plannedSavedDevicePrompts(state( + pendingRelationships: [relationship(.pendingIncoming)], + eligibilities: [eligibility] + )) + + XCTAssertEqual(planned.first?.deviceName, "Alice's Mac") + } + + func testPendingOfferIsPlannedWithSenderNameOnlyWhenSaved() { + let unnamed = plannedSavedDevicePrompts(state(offers: [offer()])) + XCTAssertEqual(unnamed.map(\.kind), [.targetedOffer]) + // An unsaved sender has no name we can vouch for. + XCTAssertNil(unnamed.first?.deviceName) + + let named = plannedSavedDevicePrompts(state( + offers: [offer()], + senderDisplayNames: [Self.peer: "Studio Mac"] + )) + XCTAssertEqual(named.first?.deviceName, "Studio Mac") + } + + func testPromptIdsAreStablePerSubject() { + // Stable ids are what let the coordinator withdraw a prompt once answered. + let first = plannedSavedDevicePrompts(state(offers: [offer()])) + let second = plannedSavedDevicePrompts(state(offers: [offer()])) + + XCTAssertEqual(first.map(\.id), second.map(\.id)) + } + + // MARK: - Terminal outcomes + + func testCompletedAndFailedTransfersArePlanned() { + let planned = plannedTargetedOutcomes( + [transferItem(id: "a", state: .completed), transferItem(id: "b", state: .failed)], + published: [] + ) + + XCTAssertEqual(planned.map(\.kind), [.targetedReceiveCompleted, .targetedReceiveFailed]) + } + + func testOutcomeWordingFollowsDirection() { + // On the sending device it is the *peer* that finished downloading. Using + // the receive wording here told the sender it had downloaded its own files. + let outgoing = plannedTargetedOutcomes( + [ + transferItem(id: "a", state: .completed, direction: .outgoing), + transferItem(id: "b", state: .failed, direction: .outgoing), + ], + published: [] + ) + XCTAssertEqual(outgoing.map(\.kind), [.targetedSendCompleted, .targetedSendFailed]) + + let incoming = plannedTargetedOutcomes( + [transferItem(id: "c", state: .completed, direction: .incoming)], + published: [] + ) + XCTAssertEqual(incoming.map(\.kind), [.targetedReceiveCompleted]) + } + + func testIdsDoNotCollideAcrossDirections() { + let incoming = plannedTargetedOutcomes([transferItem(state: .completed)], published: []) + let outgoing = plannedTargetedOutcomes( + [transferItem(state: .completed, direction: .outgoing)], published: [] + ) + + XCTAssertNotEqual(incoming.first?.id, outgoing.first?.id) + } + + func testUserDrivenTerminalStatesAreNotPlanned() { + // The user already knows: they cancelled or declined it themselves. + let planned = plannedTargetedOutcomes( + [ + transferItem(id: "a", state: .cancelled), + transferItem(id: "b", state: .declined), + transferItem(id: "c", state: .transferring), + ], + published: [] + ) + + XCTAssertTrue(planned.isEmpty) + } + + func testAlreadyPublishedOutcomesAreNotReplanned() { + let first = plannedTargetedOutcomes([transferItem(state: .completed)], published: []) + XCTAssertEqual(first.count, 1) + + let second = plannedTargetedOutcomes( + [transferItem(state: .completed)], + published: Set(first.map(\.id)) + ) + XCTAssertTrue(second.isEmpty) + } +} diff --git a/apple/Tests/SavedDevicesModelTests.swift b/apple/Tests/SavedDevicesModelTests.swift new file mode 100644 index 0000000..313823c --- /dev/null +++ b/apple/Tests/SavedDevicesModelTests.swift @@ -0,0 +1,508 @@ +import XCTest +import Combine +@testable import VniDrop + +@MainActor +final class SavedDevicesModelTests: XCTestCase { + private var gateway: FakeCoreGateway! + private var fileSystem: FakeFileSystemService! + private var messages: UiMessageController! + + override func setUp() async throws { + gateway = FakeCoreGateway() + fileSystem = FakeFileSystemService() + messages = UiMessageController() + } + + /// Builds the model and lets the initial refresh settle. The model loads on the + /// combined (preferences, isInitialized) signal, so the core must look ready. + private func makeModel() async -> SavedDevicesModel { + var state = CoreState() + state.isInitialized = true + state.status = CoreStatus(endpointId: Self.localEndpoint, activeTransfers: 0, activeShares: 0) + gateway.setState(state) + let model = SavedDevicesModel( + repository: gateway, + fileSystemService: fileSystem, + preferences: Fixtures.preferences(), + messages: messages + ) + await waitUntil { !model.state.isLoading } + return model + } + + private static let localEndpoint = "local-endpoint" + private static let peer = "peer-endpoint" + + private func savedDevice( + _ endpointId: String = peer, + localLabel: String? = nil, + remoteDisplayName: String? = "Remote Name", + createdAt: Int64 = 1 + ) -> SavedDeviceModel { + SavedDeviceModel( + endpointId: endpointId, localLabel: localLabel, remoteDisplayName: remoteDisplayName, + createdAt: createdAt, lastAuthenticatedAt: nil + ) + } + + private func transfer( + id: String = "t1", + sender: String = localEndpoint, + receiver: String = peer, + state: TargetedTransferStateModel = .completed, + verifiedBytes: UInt64 = 0, + totalSize: UInt64 = 100, + updatedAt: Int64 = 1 + ) -> TargetedTransferModel { + TargetedTransferModel( + id: id, senderEndpointId: sender, receiverEndpointId: receiver, manifestId: "m", + transferName: "Photos", fileCount: 2, totalSize: totalSize, verifiedBytes: verifiedBytes, + state: state, createdAt: 1, updatedAt: updatedAt + ) + } + + private func relationship( + _ endpointId: String = peer, + state: DeviceRelationshipStateModel, + updatedAt: Int64 = 1 + ) -> DeviceRelationshipModel { + DeviceRelationshipModel( + remoteEndpointId: endpointId, state: state, generation: 1, + minimumProtocolVersion: 1, createdAt: 1, updatedAt: updatedAt + ) + } + + private func eligibility( + _ endpointId: String = peer, + remoteDisplayName: String? = "Eligible Device", + createdAt: Int64 = 1 + ) -> PairingEligibilityModel { + PairingEligibilityModel( + peerEndpointId: endpointId, remoteDisplayName: remoteDisplayName, sessionId: "s", + protocolVersion: 1, createdAt: createdAt, expiresAt: createdAt + 86_400 + ) + } + + // MARK: - Snapshot composition + + func testLoadsSnapshotAndKeepsTransfersOutOfTheDeviceList() async { + gateway.savedDevices = [savedDevice()] + gateway.deviceRelationships = [relationship(state: .saved)] + gateway.targetedTransfers = [transfer()] + let model = await makeModel() + + XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer]) + // A saved relationship is not "pending" and must not appear as one. + XCTAssertTrue(model.state.pendingRelationships.isEmpty) + // Transfers are reachable only per-device, never as a global list on screen. + XCTAssertEqual(model.state.transfers(for: Self.peer).map(\.id), ["t1"]) + } + + func testResolvesDirectionAgainstLocalEndpoint() async { + gateway.savedDevices = [savedDevice()] + gateway.targetedTransfers = [ + transfer(id: "out", sender: Self.localEndpoint, receiver: Self.peer), + transfer(id: "in", sender: Self.peer, receiver: Self.localEndpoint), + ] + let model = await makeModel() + + let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) }) + XCTAssertEqual(byId["out"]?.direction, .outgoing) + XCTAssertEqual(byId["in"]?.direction, .incoming) + // Either way the peer is the *other* device. + XCTAssertEqual(byId["out"]?.peerEndpointId, Self.peer) + XCTAssertEqual(byId["in"]?.peerEndpointId, Self.peer) + } + + func testHidesDeletedTransfers() async { + gateway.targetedTransfers = [transfer(id: "kept"), transfer(id: "gone", state: .deleted)] + let model = await makeModel() + + XCTAssertEqual(model.state.targetedTransfers.map(\.id), ["kept"]) + } + + func testPrefersLocalLabelOverRemoteDisplayName() async { + gateway.savedDevices = [savedDevice(localLabel: "My Laptop", remoteDisplayName: "hostname")] + gateway.targetedTransfers = [transfer()] + let model = await makeModel() + + XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "My Laptop") + } + + func testBlankLocalLabelFallsBackToRemoteDisplayName() async { + gateway.savedDevices = [savedDevice(localLabel: " ", remoteDisplayName: "hostname")] + gateway.targetedTransfers = [transfer()] + let model = await makeModel() + + XCTAssertEqual(model.state.targetedTransfers.first?.peerDisplayName, "hostname") + } + + func testFailedLoadIsReportedRatherThanRenderedPartially() async { + gateway.savedDevicesResult = .failure(TestError.unimplemented) + let model = await makeModel() + + XCTAssertTrue(model.state.loadFailed) + XCTAssertTrue(model.state.savedDevices.isEmpty) + } + + // MARK: - Pairing prompt + + func testIncomingRequestOutranksEligibility() async { + gateway.deviceRelationships = [relationship(state: .pendingIncoming)] + gateway.pairingEligibilities = [eligibility("other-peer")] + let model = await makeModel() + + guard case .incomingRequest(let peerId, _) = model.state.pairingPrompt.prompt else { + return XCTFail("expected an incoming request to take priority") + } + XCTAssertEqual(peerId, Self.peer) + } + + func testDismissingEligibilityDoesNotConsumeItInTheCore() async { + gateway.pairingEligibilities = [eligibility()] + let model = await makeModel() + XCTAssertNotNil(model.state.pairingPrompt.prompt) + + model.dismissPairingPrompt() + + XCTAssertNil(model.state.pairingPrompt.prompt) + // Dismissal is local suppression, not a decline: the single-use capability + // must survive so the device stays actionable from the list. + XCTAssertTrue(gateway.declinedEligibilities.isEmpty) + XCTAssertEqual(model.state.eligibilities.count, 1) + } + + func testDismissedEligibilityDoesNotReappearOnRefresh() async { + gateway.pairingEligibilities = [eligibility()] + let model = await makeModel() + model.dismissPairingPrompt() + + gateway.emit(.pairingChanged) + await waitUntil { !model.state.isLoading } + + XCTAssertNil(model.state.pairingPrompt.prompt) + } + + func testDecliningEligibilityConsumesItInTheCore() async { + gateway.pairingEligibilities = [eligibility()] + let model = await makeModel() + + model.declinePairingPrompt() + await waitUntil { !model.state.pairingPrompt.busy } + + XCTAssertEqual(gateway.declinedEligibilities, [Self.peer]) + } + + // MARK: - Label editing + + func testLabelSaveTrimsAndClosesEditorOnSuccess() async { + gateway.savedDevices = [savedDevice()] + let model = await makeModel() + model.openLabelEditor(Self.peer) + model.setLabelDraft(" Studio Mac ") + + model.saveLabel() + await waitUntil { !model.state.isSavingLabel } + + XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"]) + XCTAssertNil(model.state.labelingPeerId) + XCTAssertEqual(model.state.labelDraft, "") + } + + func testLabelFailurePreservesDraftAndEditor() async { + gateway.savedDevices = [savedDevice()] + gateway.setLabelResult = .failure(TestError.unimplemented) + let model = await makeModel() + model.openLabelEditor(Self.peer) + model.setLabelDraft("Studio Mac") + + model.saveLabel() + await waitUntil { !model.state.isSavingLabel } + + // The retry path depends on both surviving. + XCTAssertEqual(model.state.labelingPeerId, Self.peer) + XCTAssertEqual(model.state.labelDraft, "Studio Mac") + } + + func testEditorCannotBeDismissedOrEditedWhileSaving() async { + gateway.savedDevices = [savedDevice()] + let model = await makeModel() + model.openLabelEditor(Self.peer) + model.setLabelDraft("Studio Mac") + + model.saveLabel() + // Still in flight: conflicting actions must be refused, not queued. + model.setLabelDraft("Something Else") + model.dismissLabelEditor() + XCTAssertEqual(model.state.labelDraft, "Studio Mac") + XCTAssertEqual(model.state.labelingPeerId, Self.peer) + + await waitUntil { !model.state.isSavingLabel } + XCTAssertEqual(gateway.setLabels.map(\.label), ["Studio Mac"]) + } + + func testClearingLabelSendsNil() async { + gateway.savedDevices = [savedDevice(localLabel: "Old")] + let model = await makeModel() + model.openLabelEditor(Self.peer) + + model.clearLabel() + await waitUntil { !model.state.isSavingLabel } + + XCTAssertEqual(gateway.setLabels.count, 1) + XCTAssertNil(gateway.setLabels[0].label) + } + + func testBlankDraftClearsTheLabel() async { + gateway.savedDevices = [savedDevice(localLabel: "Old")] + let model = await makeModel() + model.openLabelEditor(Self.peer) + model.setLabelDraft(" ") + + model.saveLabel() + await waitUntil { !model.state.isSavingLabel } + + XCTAssertNil(gateway.setLabels[0].label) + } + + // MARK: - Targeted offers + + func testApprovingOfferStartsTheReceivePull() async { + gateway.offerResponseResult = .success(.approved(transferId: "t1")) + let model = await makeModel() + + model.acceptTargetedOffer("t1") + await waitUntil { self.gateway.offerResponses.count == 1 && !model.state.isLoading } + + XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true]) + XCTAssertEqual(gateway.targetedReceives.map(\.transferId), ["t1"]) + } + + func testDecliningOfferDoesNotPull() async { + gateway.offerResponseResult = .success(.declined) + let model = await makeModel() + + model.declineTargetedOffer("t1") + await waitUntil { self.gateway.offerResponses.count == 1 } + + XCTAssertEqual(gateway.offerResponses.map(\.accepted), [false]) + XCTAssertTrue(gateway.targetedReceives.isEmpty) + } + + func testAlreadySettledOfferDoesNotStartASecondPull() async { + // The idempotent replay path returns the existing result; re-pulling would + // duplicate work against a transfer that is already resolved. + gateway.offerResponseResult = .success(.alreadySettled(transferId: "t1")) + let model = await makeModel() + + model.acceptTargetedOffer("t1") + await waitUntil { self.gateway.offerResponses.count == 1 } + + XCTAssertTrue(gateway.targetedReceives.isEmpty) + } + + func testConcurrentResponsesToTheSameOfferAreIgnored() async { + gateway.offerResponseResult = .success(.declined) + let model = await makeModel() + + model.declineTargetedOffer("t1") + model.declineTargetedOffer("t1") + await waitUntil { !model.state.targetedOffers.respondingIds.contains("t1") } + + XCTAssertEqual(gateway.offerResponses.count, 1) + } + + // MARK: - Transfer lifecycle + + func testResumeUsesTheResumePath() async { + let model = await makeModel() + + model.resumeTargetedTransfer("t1") + await waitUntil { !model.state.busyTransferIds.contains("t1") } + + XCTAssertEqual(gateway.targetedResumes.map(\.id), ["t1"]) + XCTAssertTrue(gateway.targetedReceives.isEmpty) + } + + func testCancelAndDeleteReachTheCore() async { + let model = await makeModel() + + model.cancelTargetedTransfer("t1") + await waitUntil { !model.state.busyTransferIds.contains("t1") } + model.deleteTargetedTransfer("t2") + await waitUntil { !model.state.busyTransferIds.contains("t2") } + + XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"]) + XCTAssertEqual(gateway.deletedTargetedTransfers, ["t2"]) + } + + func testBusyTransferIgnoresRepeatedCommands() async { + let model = await makeModel() + + model.cancelTargetedTransfer("t1") + model.cancelTargetedTransfer("t1") + await waitUntil { !model.state.busyTransferIds.contains("t1") } + + XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t1"]) + } + + // MARK: - Destructive actions + + func testForgetAndBlockReachTheCoreAndRefresh() async { + gateway.savedDevices = [savedDevice()] + let model = await makeModel() + + model.forget(Self.peer) + await waitUntil { !model.state.busyPeerIds.contains(Self.peer) } + model.block(Self.peer) + await waitUntil { !model.state.busyPeerIds.contains(Self.peer) } + + XCTAssertEqual(gateway.forgottenDevices, [Self.peer]) + XCTAssertEqual(gateway.blockedCalls, [Self.peer]) + } + + func testBusyPeerIgnoresRepeatedCommands() async { + gateway.savedDevices = [savedDevice()] + let model = await makeModel() + + model.forget(Self.peer) + model.forget(Self.peer) + await waitUntil { !model.state.busyPeerIds.contains(Self.peer) } + + XCTAssertEqual(gateway.forgottenDevices, [Self.peer]) + } + + // MARK: - Targeted send + + private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile { + PickedShareFile( + value: "/tmp/\(name)", displayName: name, sizeBytes: 10, + isTemporaryCopy: true, isDirectory: isDirectory + ) + } + + func testSendRequiresDestinationSourcesAndName() async { + gateway.savedDevices = [savedDevice()] + let model = await makeModel() + XCTAssertFalse(model.state.canCreateTargetedTransfer) + + model.beginSend(to: Self.peer) + XCTAssertFalse(model.state.canCreateTargetedTransfer, "no sources yet") + + model.onSendFilesPicked([picked("a.txt")]) + XCTAssertTrue(model.state.canCreateTargetedTransfer) + + model.setSendTransferName(" ") + XCTAssertFalse(model.state.canCreateTargetedTransfer, "a blank name is not a name") + } + + func testSendCreatesTargetedTransferAndClosesComposition() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer()) + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt")]) + + model.createTargetedTransfer() + await waitUntil { !model.state.isCreatingSend } + + XCTAssertEqual(gateway.createdTargetedTransfers.map(\.receiverEndpointId), [Self.peer]) + XCTAssertEqual(gateway.createdTargetedTransfers.first?.transferName, "a.txt") + XCTAssertNil(model.state.sendTargetPeerId) + XCTAssertTrue(model.state.sendFiles.isEmpty) + // The core owns the bytes once the transfer exists; the picker copy goes. + XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) + } + + func testSendFailureKeepsCompositionForRetry() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .failure(TestError.unimplemented) + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt")]) + model.setSendTransferName("Report") + + model.createTargetedTransfer() + await waitUntil { !model.state.isCreatingSend } + + // Retrying must not require re-picking the sources. + XCTAssertEqual(model.state.sendTargetPeerId, Self.peer) + XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"]) + XCTAssertEqual(model.state.sendTransferName, "Report") + XCTAssertTrue(fileSystem.discardedFiles.isEmpty, "sources must survive a failed create") + } + + func testRemovingASourceRederivesOnlyAGeneratedName() async { + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt"), picked("b.txt")]) + model.setSendTransferName("My Name") + + model.removeSendFile("/tmp/b.txt") + + XCTAssertEqual(model.state.sendTransferName, "My Name") + XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/a.txt"]) + } + + func testReplacingSelectionDiscardsThePreviousPickerCopies() async { + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt")]) + + model.onSendFilesPicked([picked("b.txt")]) + await waitUntil { !self.fileSystem.discardedFiles.isEmpty } + + XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) + XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"]) + } + + func testCancellingSendDiscardsSources() async { + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt")]) + + model.cancelSend() + await waitUntil { !self.fileSystem.discardedFiles.isEmpty } + + XCTAssertNil(model.state.sendTargetPeerId) + XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) + } + + func testFolderSourcesAreSupported() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer()) + let model = await makeModel() + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("Photos", isDirectory: true)]) + + model.createTargetedTransfer() + await waitUntil { !model.state.isCreatingSend } + + XCTAssertEqual(fileSystem.targetedSends.first?.files.first?.isDirectory, true) + } + + // MARK: - Signals + + func testPairingSignalTriggersRefresh() async { + let model = await makeModel() + gateway.savedDevices = [savedDevice()] + + gateway.emit(.pairingChanged) + await waitUntil { model.state.savedDevices.count == 1 } + + XCTAssertEqual(model.state.savedDevices.map(\.endpointId), [Self.peer]) + } + + func testInvitationSignalsDoNotTriggerRefresh() async { + let model = await makeModel() + gateway.savedDevices = [savedDevice()] + + gateway.emit(.transfersChanged(transferId: 1)) + gateway.emit(.approvalChanged(transferId: 1)) + try? await Task.sleep(nanoseconds: 100_000_000) + + // The saved-device domain is unaffected by invitation-share activity. + XCTAssertTrue(model.state.savedDevices.isEmpty) + } +} diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 485cb84..a916a60 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -9,6 +9,9 @@ struct RootView: View { @StateObject private var sendModel: SendModel @StateObject private var receiveModel: ReceiveModel @StateObject private var settingsModel: SettingsModel + @StateObject private var savedDevicesModel: SavedDevicesModel + /// Held so it stays alive for the app's lifetime; it has no view of its own. + @StateObject private var savedDeviceNotifications: SavedDeviceNotificationCoordinator @Environment(\.scenePhase) private var scenePhase @@ -44,6 +47,21 @@ struct RootView: View { messages: graph.messages, bugReports: NoopBugReportService() )) + let savedDevices = SavedDevicesModel( + repository: graph.coreRepository, + fileSystemService: dependencies.fileSystemService, + preferences: graph.preferencesRepository, + messages: graph.messages + ) + _savedDevicesModel = StateObject(wrappedValue: savedDevices) + // Reads the model's snapshot rather than the core directly, so it observes + // exactly the state the UI is showing. + _savedDeviceNotifications = StateObject(wrappedValue: SavedDeviceNotificationCoordinator( + model: savedDevices, + notifications: dependencies.notificationService, + visibility: graph.visibility, + messages: graph.messages + )) } var body: some View { @@ -52,6 +70,13 @@ struct RootView: View { let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark) ZStack { navigation(windowClass: windowClass) + // Hosted at the root so a pairing request or targeted offer is + // answerable from any tab, and suppressed while a transfer approval + // is up so two blocking decisions never stack. + .savedDevicePrompts( + model: savedDevicesModel, + suppressed: graph.approvalCoordinator.state.current != nil + ) // Observe the coordinator/messages from the *persisted* `graph` // StateObject. Deriving them in `init` bound the view to a throwaway // AppGraph rebuilt on every re-init, whose coordinator never receives @@ -68,7 +93,11 @@ struct RootView: View { // A small, unobtrusive indicator while the core finishes its async // startup — otherwise the lists look empty and the app feels stalled. if !sendModel.coreState.isInitialized { - CoreStartingOverlay() + CoreStartingOverlay( + error: appModel.startupError, + detail: appModel.startupErrorDetail, + onRetry: appModel.retryStartup + ) } } .animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized) @@ -167,6 +196,8 @@ struct RootView: View { switch destination { case .send: SendScreen(model: sendModel, windowClass: windowClass) case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass) + case .savedDevices: + SavedDevicesScreen(model: savedDevicesModel, windowClass: windowClass) case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass) } @@ -253,21 +284,65 @@ private struct ApprovalLayer: View { } } -/// A full-window cover with a centered spinner shown while the core is starting. +/// A full-window cover shown while the core is starting — or, when startup fails, +/// the reason and a retry. This overlay sits above the snackbar host, so a failure +/// reported only through a toast would be invisible behind it and the app would +/// look like it was loading forever. private struct CoreStartingOverlay: View { + let error: UiText? + /// Debug builds only; nil in Release. + let detail: String? + let onRetry: () -> Void + var body: some View { ZStack { backgroundColor.ignoresSafeArea() - VStack(spacing: 16) { - ProgressView().controlSize(.large) - Text(String(localized: L10n.App.starting)) - .font(.headline) - .foregroundStyle(.secondary) + if let error { + VStack(spacing: 16) { + Image(systemSymbol: .exclamationmarkTriangleFill) + .font(.system(size: 34)) + .foregroundStyle(.orange) + // Not "Starting…": startup has stopped, and saying otherwise + // while showing an error contradicts itself. + Text(String(localized: L10n.Error.initialization)) + .font(.headline) + .multilineTextAlignment(.center) + Text(error.resolved()) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .textSelection(.enabled) + .frame(maxWidth: 420) + if let detail { + ScrollView { + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + .frame(maxWidth: 420, maxHeight: 180) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10)) + } + Button(String(localized: L10n.Button.retry), action: onRetry) + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(32) + } else { + VStack(spacing: 16) { + ProgressView().controlSize(.large) + Text(String(localized: L10n.App.starting)) + .font(.headline) + .foregroundStyle(.secondary) + } } } .transition(.opacity) .accessibilityElement(children: .combine) - .accessibilityLabel(Text(String(localized: L10n.App.starting))) + .accessibilityLabel(Text(error?.resolved() ?? String(localized: L10n.App.starting))) } private var backgroundColor: Color { @@ -284,10 +359,3 @@ import UIKit #else import AppKit #endif - -/// Hosts the device-history consent prompts, alongside `ApprovalLayer`. -/// -/// Separate from the approval layer because the two never compete: an approval -/// belongs to a transfer this device is sending, and these belong to a device -/// asking to reach it. Both are suppressed while the other is up so the user is -/// never answering two modals at once. diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index e7a5620..7622e05 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -47,4 +47,51 @@ protocol CoreGateway: AnyObject { func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result func refresh() async -> Result + + // MARK: - Saved devices + + func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> + func declinePairingEligibility(peerEndpointId: String) async -> Result + /// Asks a peer to pair. Returns false when no valid eligibility exists, which + /// the core rejects silently so a stranger cannot provoke a prompt. + func requestSavedDevicePairing(peerEndpointId: String) async -> Result + func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result + func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> + func listSavedDevices() async -> Result<[SavedDeviceModel], Error> + /// Sets or clears (`nil`) the user-owned local label. + func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result + func forgetSavedDevice(peerEndpointId: String) async -> Result + func blockDevice(peerEndpointId: String) async -> Result + /// Removes only the deny rule. Grants, relationships, and cancelled transfers + /// are not restored — re-saving needs another qualifying transfer and consent. + func unblockDevice(peerEndpointId: String) async -> Result + func listBlockedDevices() async -> Result<[String], Error> + + // MARK: - Targeted transfers + + func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> + func respondToTargetedOffer( + transferId: String, + accepted: Bool + ) async -> Result + func createTargetedTransfer( + receiverEndpointId: String, + sources: [ShareSource], + transferName: String? + ) async -> Result + func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> + /// Pulls an approved transfer into a security-scoped destination, holding + /// access for the duration of the stream (mirrors the invitation receive path). + func receiveTargetedTransfer( + transferId: String, + outputDirectoryUrl: String + ) async -> Result + /// Resumes an interrupted transfer. The same immutable transfer continues from + /// its verified progress and is not re-approved. + func resumeTargetedTransfer( + id: String, + outputDirectoryUrl: String + ) async -> Result + func cancelTargetedTransfer(id: String) async -> Result + func deleteTargetedTransfer(id: String) async -> Result } diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index a95ea51..1a2c224 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -47,6 +47,12 @@ enum EventPhase: String, Equatable, Sendable { case network case handshake case error + /// Saved-device consent lifecycle (eligibility, relationships, grants). + case pairing + /// Targeted-transfer offer and lifecycle. Its events identify the transfer by + /// a string `targeted_transfer_id`, not the numeric `transferId` used by + /// invitation shares. + case targetedTransfer = "targeted_transfer" } /// Kind of a core progress event (the `kind` wire field). @@ -174,6 +180,13 @@ enum CoreSignal: Equatable, Sendable { case receiverHistoryChanged(transferId: UInt64) /// Transfer status/history changed enough to re-read the durable snapshot. case transfersChanged(transferId: UInt64) + /// Pairing / saved-device state changed; refresh eligibility, relationships, + /// and the saved list. Carries no payload: core events are wake-ups, not + /// authoritative state, so consumers re-query rather than apply a delta. + case pairingChanged + /// Targeted-transfer offer or lifecycle changed; refresh pending offers and + /// transfers. Payload-free for the same reason as `pairingChanged`. + case targetedTransferChanged } // MARK: - Transfer helpers (ported from AppUiModels.kt) diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 27678ae..ffeff93 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -293,6 +293,140 @@ final class CoreRepository: ObservableObject, CoreGateway { if let snapshot { self.applySnapshot(snapshot) } } } + // MARK: - Saved devices + + func listPairingEligibilities() async -> Result<[PairingEligibilityModel], Error> { + await runCore { try self.requireCore().listPairingEligibilities().map { $0.toModel() } } + } + + func declinePairingEligibility(peerEndpointId: String) async -> Result { + await runCore { try self.requireCore().declinePairingEligibility(peerEndpointId: peerEndpointId) } + } + + func requestSavedDevicePairing(peerEndpointId: String) async -> Result { + await runCore { try self.requireCore().requestSavedDevicePairing(peerEndpointId: peerEndpointId) } + } + + func respondToDevicePairing(peerEndpointId: String, accepted: Bool) async -> Result { + await runCore { + try self.requireCore().respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: accepted) + } + } + + func listDeviceRelationships() async -> Result<[DeviceRelationshipModel], Error> { + await runCore { try self.requireCore().listDeviceRelationships().map { $0.toModel() } } + } + + func listSavedDevices() async -> Result<[SavedDeviceModel], Error> { + await runCore { try self.requireCore().listSavedDevices().map { $0.toModel() } } + } + + func setSavedDeviceLabel(peerEndpointId: String, label: String?) async -> Result { + await runCore { try self.requireCore().setSavedDeviceLabel(peerEndpointId: peerEndpointId, label: label) } + } + + func forgetSavedDevice(peerEndpointId: String) async -> Result { + // Forget cancels the peer's active/resumable targeted transfers, so it can + // run while one is streaming on the serial lane — same reasoning as `cancel`. + await runInterrupt { try self.requireCore().forgetSavedDevice(peerEndpointId: peerEndpointId) } + } + + func blockDevice(peerEndpointId: String) async -> Result { + // Block is immediate and identity-wide, cancelling traffic in flight. + await runInterrupt { try self.requireCore().blockDevice(peerEndpointId: peerEndpointId) } + } + + func unblockDevice(peerEndpointId: String) async -> Result { + await runCore { try self.requireCore().unblockDevice(peerEndpointId: peerEndpointId) } + } + + func listBlockedDevices() async -> Result<[String], Error> { + await runCore { try self.requireCore().listBlockedDevices() } + } + + // MARK: - Targeted transfers + + func listPendingTargetedOffers() async -> Result<[PendingTargetedOfferModel], Error> { + // `listPendingTargetedOffers` is itself non-throwing, but `requireCore` is. + await runCore { try self.requireCore().listPendingTargetedOffers().map { $0.toModel() } } + } + + func respondToTargetedOffer( + transferId: String, + accepted: Bool + ) async -> Result { + await runCore { + try self.requireCore() + .respondToTargetedOffer(transferId: transferId, accepted: accepted) + .toModel() + } + } + + func createTargetedTransfer( + receiverEndpointId: String, + sources: [ShareSource], + transferName: String? + ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + guard !sources.isEmpty else { + return .failure(InvitationError.shareEmpty) + } + return await runCore { + try self.requireCore().createTargetedTransfer( + receiverEndpointId: receiverEndpointId, + sources: sources, + transferName: transferName + ).toModel() + } + } + + func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> { + await runCore { try self.requireCore().listTargetedTransfers().map { $0.toModel() } } + } + + func receiveTargetedTransfer( + transferId: String, + outputDirectoryUrl: String + ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + return await runCore { + try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) { + try self.requireCore().receiveTargetedTransfer( + transferId: transferId, + outputDir: outputDirectoryUrl + ) + } + } + } + + func resumeTargetedTransfer( + id: String, + outputDirectoryUrl: String + ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + return await runCore { + try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) { + try self.requireCore().resumeTargetedTransfer(id: id, outputDir: outputDirectoryUrl) + } + } + } + + func cancelTargetedTransfer(id: String) async -> Result { + // Off the serial lane: an in-flight targeted receive is blocking it, and the + // cancel must reach the core to unblock that receive (see `cancel`). + await runInterrupt { try self.requireCore().cancelTargetedTransfer(id: id) } + } + + func deleteTargetedTransfer(id: String) async -> Result { + await runCore { try self.requireCore().deleteTargetedTransfer(id: id) } + } + // MARK: - Event sink handling (ported from CoreRepository.sink) private func handle(event: CoreEvent) { @@ -302,6 +436,21 @@ final class CoreRepository: ObservableObject, CoreGateway { if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) } state.events = events + // Saved-device events identify their subject by peer endpoint or a string + // `targeted_transfer_id`, so they carry no numeric `transferId` and must be + // dispatched before the guard below. Both are payload-free wake-ups: the + // consumer re-reads durable state rather than trusting the event. + switch model.eventPhase { + case .pairing: + signalsSubject.send(.pairingChanged) + return + case .targetedTransfer: + signalsSubject.send(.targetedTransferChanged) + return + default: + break + } + guard let transferId = model.transferId else { return } switch model.phase { case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId)) @@ -497,6 +646,98 @@ private extension TransferMetadata { } } +// MARK: - Saved-device mapping (ported from CoreRepository.kt) + +private extension SavedDevice { + func toModel() -> SavedDeviceModel { + SavedDeviceModel( + endpointId: endpointId, localLabel: localLabel, remoteDisplayName: remoteDisplayName, + createdAt: createdAt, lastAuthenticatedAt: lastAuthenticatedAt + ) + } +} + +private extension DeviceRelationshipState { + func toModel() -> DeviceRelationshipStateModel { + switch self { + case .pendingOutgoing: return .pendingOutgoing + case .pendingIncoming: return .pendingIncoming + case .saved: return .saved + case .revoked: return .revoked + case .blocked: return .blocked + } + } +} + +private extension DeviceRelationship { + func toModel() -> DeviceRelationshipModel { + DeviceRelationshipModel( + remoteEndpointId: remoteEndpointId, state: state.toModel(), generation: generation, + minimumProtocolVersion: minimumProtocolVersion, createdAt: createdAt, updatedAt: updatedAt + ) + } +} + +private extension PairingEligibilitySummary { + func toModel() -> PairingEligibilityModel { + PairingEligibilityModel( + peerEndpointId: peerEndpointId, remoteDisplayName: remoteDisplayName, sessionId: sessionId, + protocolVersion: protocolVersion, createdAt: createdAt, expiresAt: expiresAt + ) + } +} + +private extension PendingTargetedOffer { + func toModel() -> PendingTargetedOfferModel { + PendingTargetedOfferModel( + transferId: transferId, senderEndpointId: senderEndpointId, + receiverEndpointId: receiverEndpointId, manifestId: manifestId, contentHash: contentHash, + transferName: transferName, fileCount: fileCount, totalSize: totalSize, + protocolVersion: protocolVersion, receivedAt: receivedAt + ) + } +} + +private extension TargetedTransferState { + func toModel() -> TargetedTransferStateModel { + switch self { + case .preparing: return .preparing + case .offering: return .offering + case .awaitingApproval: return .awaitingApproval + case .approved: return .approved + case .connecting: return .connecting + case .transferring: return .transferring + case .interrupted: return .interrupted + case .completed: return .completed + case .declined: return .declined + case .cancelled: return .cancelled + case .failed: return .failed + case .deleted: return .deleted + } + } +} + +private extension TargetedTransfer { + func toModel() -> TargetedTransferModel { + TargetedTransferModel( + id: id, senderEndpointId: senderEndpointId, receiverEndpointId: receiverEndpointId, + manifestId: manifestId, transferName: transferName, fileCount: fileCount, + totalSize: totalSize, verifiedBytes: verifiedBytes, state: state.toModel(), + createdAt: createdAt, updatedAt: updatedAt + ) + } +} + +private extension TargetedOfferResponse { + func toModel() -> TargetedOfferResponseModel { + switch self { + case .approved(let transferId): return .approved(transferId: transferId) + case .declined: return .declined + case .alreadySettled(let transferId): return .alreadySettled(transferId: transferId) + } + } +} + private extension ReceiverRequest { func toModel() -> ReceiverRequestModel { ReceiverRequestModel( diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index d6e1af7..7526e6e 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -43,6 +43,15 @@ protocol FileSystemService { senderName: String, destination: ShareDestination ) async -> Result + /// Sends a picked selection straight to one saved device. Separate from + /// `sharePickedFiles` because a targeted transfer is its own domain with its + /// own result type — it is not an access mode on an invitation share. + func sendPickedFilesToSavedDevice( + repository: CoreGateway, + files: [PickedShareFile], + transferName: String, + receiverEndpointId: String + ) async -> Result } extension FileSystemService { diff --git a/apple/VniDrop/Core/SavedDeviceModels.swift b/apple/VniDrop/Core/SavedDeviceModels.swift new file mode 100644 index 0000000..59f6def --- /dev/null +++ b/apple/VniDrop/Core/SavedDeviceModels.swift @@ -0,0 +1,182 @@ +import Foundation + +/// App-facing saved-device domain models, ported from `core/SavedDeviceModels.kt`. +/// The repository maps the generated UniFFI records into these so the UI never +/// depends on the binding surface directly. +/// +/// A saved device is a remote VniDrop *app-installation identity*, not a person, +/// account, or piece of hardware. Display names and platform hints are untrusted +/// peer-supplied hints and must never be used to merge or match identities. + +struct SavedDeviceModel: Equatable, Identifiable, Sendable { + /// The remote iroh endpoint identity. Stable, cryptographic, and the only + /// safe way to identify a peer. + let endpointId: String + /// User-owned local label. Takes precedence over `remoteDisplayName`. + let localLabel: String? + /// Untrusted display-name hint supplied by the peer. + let remoteDisplayName: String? + let createdAt: Int64 + let lastAuthenticatedAt: Int64? + + var id: String { endpointId } + + /// The name to show, or nil when neither side supplied one. Callers fall back + /// to `L10n.SavedDevices.unnamed` (see `SavedDeviceTransferHistory.kt`). + var displayNameOrNil: String? { + if let localLabel, !localLabel.trimmed.isEmpty { return localLabel } + if let remoteDisplayName, !remoteDisplayName.trimmed.isEmpty { return remoteDisplayName } + return nil + } +} + +/// Durable consent lifecycle for one remote app-installation identity. A +/// relationship is usable only in `saved`; the pending states are bounded +/// operations that cannot initiate a transfer. +enum DeviceRelationshipStateModel: Equatable, Sendable { + case pendingOutgoing + case pendingIncoming + case saved + case revoked + case blocked +} + +struct DeviceRelationshipModel: Equatable, Identifiable, Sendable { + let remoteEndpointId: String + let state: DeviceRelationshipStateModel + let generation: UInt64 + let minimumProtocolVersion: UInt16 + let createdAt: Int64 + let updatedAt: Int64 + + var id: String { remoteEndpointId } +} + +/// Single-use permission to *ask* to pair, created by a fully completed +/// authenticated transfer and expiring 24 hours later. Consumed by pairing, +/// declining, expiry, forget, block, or reset. +struct PairingEligibilityModel: Equatable, Identifiable, Sendable { + let peerEndpointId: String + /// Untrusted display-name hint from the qualifying transfer. Usually the only + /// name available for a peer that is not saved yet. + let remoteDisplayName: String? + let sessionId: String + let protocolVersion: UInt16 + let createdAt: Int64 + let expiresAt: Int64 + + var id: String { peerEndpointId } +} + +/// A pre-approval targeted offer awaiting a local approve/decline. Lives only in +/// the core's bounded live-session queue — a restart, timeout, disconnect, or +/// sender cancellation removes it, so it is never durable UI state. +struct PendingTargetedOfferModel: Equatable, Identifiable, Sendable { + let transferId: String + let senderEndpointId: String + let receiverEndpointId: String + let manifestId: String + let contentHash: String + /// Peer-supplied and untrusted; render it as text, never as a path. + let transferName: String + let fileCount: UInt64 + let totalSize: UInt64 + let protocolVersion: UInt16 + let receivedAt: Int64 + + var id: String { transferId } +} + +/// Durable targeted-transfer lifecycle. Rust validates every transition; the UI +/// only invokes typed operations and renders the snapshot it gets back. +enum TargetedTransferStateModel: Equatable, Sendable { + case preparing + case offering + case awaitingApproval + case approved + case connecting + case transferring + case interrupted + case completed + case declined + case cancelled + case failed + case deleted +} + +struct TargetedTransferModel: Equatable, Identifiable, Sendable { + let id: String + let senderEndpointId: String + let receiverEndpointId: String + let manifestId: String + /// Peer-supplied and untrusted; render it as text, never as a path. + let transferName: String + let fileCount: UInt64 + let totalSize: UInt64 + /// Bytes verified so far; survives interruption for resume. + let verifiedBytes: UInt64 + let state: TargetedTransferStateModel + let createdAt: Int64 + let updatedAt: Int64 +} + +/// Outcome of responding to a targeted offer. `alreadySettled` is the idempotent +/// replay path — the core returns the existing result rather than creating a +/// duplicate approval. +enum TargetedOfferResponseModel: Equatable, Sendable { + case approved(transferId: String) + case declined + case alreadySettled(transferId: String) +} + +// MARK: - Lifecycle helpers + +extension TargetedTransferStateModel { + /// States where bytes may still move, so progress is meaningful. + var isActive: Bool { + switch self { + case .connecting, .transferring: return true + default: return false + } + } + + /// No further transition is possible without creating a new transfer. + var isTerminal: Bool { + switch self { + case .completed, .declined, .cancelled, .failed, .deleted: return true + default: return false + } + } + + /// Cancellation withdraws the offer before approval and stops authorization + /// plus active streaming after it. Terminal transfers have nothing to stop. + var canCancel: Bool { !isTerminal } + + /// An interrupted transfer keeps its verified progress and resumes the same + /// immutable transfer without asking for approval again. + var canResume: Bool { self == .interrupted } + + /// The receiver pulls content once the sender's authorization is in place. + var canReceive: Bool { self == .approved } + + /// Deletion makes authorization unusable and removes resumable state. Offered + /// on anything already terminal except an entry that is itself deleted. + var canDelete: Bool { isTerminal && self != .deleted } +} + +extension TargetedTransferModel { + /// Fraction of verified payload in `0...1`, or nil when the total is unknown + /// or the state carries no meaningful progress. + var progressFraction: Double? { + guard totalSize > 0, state.isActive || state == .interrupted else { return nil } + return min(1, Double(verifiedBytes) / Double(totalSize)) + } +} + +extension PairingEligibilityModel { + func isExpired(now: Int64) -> Bool { now >= expiresAt } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } +} diff --git a/apple/VniDrop/Features/App/AppModel.swift b/apple/VniDrop/Features/App/AppModel.swift index 8357f26..2059aac 100644 --- a/apple/VniDrop/Features/App/AppModel.swift +++ b/apple/VniDrop/Features/App/AppModel.swift @@ -7,10 +7,19 @@ import Combine final class AppModel: ObservableObject { @Published private(set) var destination: AppDestination = .send @Published private(set) var themeMode: ThemeMode = .system + /// Why startup failed, or nil while it is still in progress or has succeeded. + /// The startup overlay covers the snackbar, so without this a failed + /// `initialize` was indistinguishable from an app that never finished loading. + @Published private(set) var startupError: UiText? + /// Untranslated failure detail, kept for the debug overlay only. The friendly + /// message alone cannot distinguish a missing keychain item from a database + /// fault, which makes a startup failure undiagnosable on a real device. + @Published private(set) var startupErrorDetail: String? private let environment: PlatformEnvironment private let repository: CoreGateway private let messages: UiMessageController + private let relayConfiguration: RelayConfiguration private var cancellables = Set() init( @@ -22,16 +31,11 @@ final class AppModel: ObservableObject { self.environment = environment self.repository = repository self.messages = messages + self.relayConfiguration = preferences.preferences.relayConfiguration AppLogger.info("lifecycle", "app started", ["platform": environment.name]) - Task { - let result = await repository.initialize( - appDataDir: environment.defaultCoreDataDir, - networkConfiguration: preferences.preferences.relayConfiguration - ) - if case .failure(let error) = result { messages.error(error) } - } + Task { await initializeCore() } preferences.$preferences .map(\.themeMode) @@ -40,6 +44,30 @@ final class AppModel: ObservableObject { .store(in: &cancellables) } + /// Runs core startup, keeping the failure reason for the overlay to show. + /// Also logged, because a user-facing message alone is not diagnosable. + func initializeCore() async { + startupError = nil + startupErrorDetail = nil + let result = await repository.initialize( + appDataDir: environment.defaultCoreDataDir, + networkConfiguration: relayConfiguration + ) + if case .failure(let error) = result { + AppLogger.error("lifecycle", "core initialization failed", error) + startupError = error.toUiText() + #if DEBUG + startupErrorDetail = error.technicalDetail + #endif + messages.error(error) + } + } + + func retryStartup() { + guard startupError != nil else { return } + Task { await initializeCore() } + } + func selectDestination(_ destination: AppDestination) { guard destination != self.destination else { return } self.destination = destination diff --git a/apple/VniDrop/Features/Notifications/SavedDeviceNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/SavedDeviceNotificationCoordinator.swift new file mode 100644 index 0000000..ef45d2a --- /dev/null +++ b/apple/VniDrop/Features/Notifications/SavedDeviceNotificationCoordinator.swift @@ -0,0 +1,264 @@ +import Combine +import Foundation + +/// A saved-device moment worth a local notification. +enum SavedDeviceNotificationKind: Equatable { + /// A peer asked to pair and is waiting on an answer. + case pairingRequest + /// A targeted offer is waiting on approve/decline. + case targetedOffer + /// An incoming targeted transfer finished downloading here. + case targetedReceiveCompleted + /// An incoming targeted transfer failed here. + case targetedReceiveFailed + /// A transfer we sent finished downloading on the peer. + case targetedSendCompleted + /// A transfer we sent failed on the peer. + case targetedSendFailed +} + +extension SavedDeviceNotificationKind { + /// Stable id component. Distinct per kind so a transfer cannot collide with + /// itself across directions. + var idPrefix: String { + switch self { + case .pairingRequest: return "pairing-request" + case .targetedOffer: return "offer" + case .targetedReceiveCompleted: return "receive-completed" + case .targetedReceiveFailed: return "receive-failed" + case .targetedSendCompleted: return "send-completed" + case .targetedSendFailed: return "send-failed" + } + } +} + +/// A saved-device notification resolved from model state but not yet published. +struct PlannedSavedDeviceNotification: Equatable { + let id: String + let kind: SavedDeviceNotificationKind + /// Peer display name, already resolved; nil when we have no trustworthy one. + let deviceName: String? + let transferName: String? +} + +/// Pure: notifications for consent decisions currently waiting on the user. +/// These are *pending* moments — they are withdrawn when the underlying request +/// disappears, so the caller cancels ids that stop appearing here. +func plannedSavedDevicePrompts(_ state: SavedDevicesState) -> [PlannedSavedDeviceNotification] { + var planned: [PlannedSavedDeviceNotification] = [] + for relationship in state.pendingRelationships where relationship.state == .pendingIncoming { + planned.append(PlannedSavedDeviceNotification( + id: "pairing-request-\(relationship.remoteEndpointId)", + kind: .pairingRequest, + deviceName: state.eligibilities + .first { $0.peerEndpointId == relationship.remoteEndpointId }? + .remoteDisplayName + ?? state.device(relationship.remoteEndpointId)?.displayNameOrNil, + transferName: nil + )) + } + for offer in state.targetedOffers.pending { + planned.append(PlannedSavedDeviceNotification( + id: "targeted-offer-\(offer.transferId)", + kind: .targetedOffer, + // Only a saved sender has a name we can vouch for. + deviceName: state.targetedOffers.senderDisplayNames[offer.senderEndpointId], + transferName: offer.transferName + )) + } + return planned +} + +/// Pure: notifications for targeted transfers that reached a terminal outcome, +/// excluding already-published ids. Terminal moments notify at most once. +func plannedTargetedOutcomes( + _ transfers: [SavedDeviceTransferItem], + published: Set +) -> [PlannedSavedDeviceNotification] { + transfers.compactMap { transfer in + // Direction decides the wording: on the receiving device the transfer + // "finished downloading", while on the sending device it is the *peer* + // that finished. Ignoring direction told the sender it had downloaded + // its own outgoing files. + let kind: SavedDeviceNotificationKind + switch (transfer.direction, transfer.state) { + case (.incoming, .completed): kind = .targetedReceiveCompleted + case (.incoming, .failed): kind = .targetedReceiveFailed + case (.outgoing, .completed): kind = .targetedSendCompleted + case (.outgoing, .failed): kind = .targetedSendFailed + // Cancelled and declined are the user's own doing on one side or the + // other; notifying about them would be noise. + default: return nil + } + let id = "targeted-\(kind.idPrefix)-\(transfer.id)" + guard !published.contains(id) else { return nil } + return PlannedSavedDeviceNotification( + id: id, kind: kind, + deviceName: transfer.peerDisplayName, + transferName: transfer.transferName + ) + } +} + +/// Fires local notifications for the saved-device domain: pairing requests and +/// targeted offers waiting on a decision, and targeted transfers reaching a +/// terminal outcome. Invitation-share moments belong to +/// `TransferNotificationCoordinator`; approval prompts to `ApprovalCoordinator`. +/// +/// Reads `SavedDevicesModel`'s published snapshot rather than querying the core +/// again — the model already refreshes on every saved-device signal, and a second +/// read loop would race it. +@MainActor +final class SavedDeviceNotificationCoordinator: ObservableObject { + private let notifications: LocalNotificationService + private let visibility: AppVisibility + private let messages: UiMessageController + + /// Prompt ids currently published, so they can be withdrawn when answered. + private var publishedPrompts = Set() + /// Terminal ids already seen; never re-published. + private var publishedOutcomes = Set() + private var primedOutcomes = false + private var cancellables = Set() + + init( + model: SavedDevicesModel, + notifications: LocalNotificationService, + visibility: AppVisibility, + messages: UiMessageController + ) { + self.notifications = notifications + self.visibility = visibility + self.messages = messages + + // Recompute when any input changes: the snapshot itself, foregrounding, or + // the permission being granted later. + Publishers.CombineLatest3( + model.$state, + visibility.$isForeground, + notifications.$permission + ) + .sink { [weak self] state, _, _ in + guard let self else { return } + Task { await self.synchronize(state) } + } + .store(in: &cancellables) + } + + /// iOS suppresses notifications while the user is in the app (the modal or the + /// screen shows instead); macOS presents them even when active, since the app + /// window is usually open. Mirrors the other two coordinators. + private var canPublish: Bool { + guard notifications.permission == .granted else { return false } + #if os(iOS) + return !visibility.isForeground + #else + return true + #endif + } + + private func synchronize(_ state: SavedDevicesState) async { + await synchronizePrompts(state) + await synchronizeOutcomes(state) + } + + private func synchronizePrompts(_ state: SavedDevicesState) async { + let planned = plannedSavedDevicePrompts(state) + let plannedIds = Set(planned.map(\.id)) + + // Withdraw notifications whose request is gone — answered here, answered on + // the peer, expired, or dropped when the core restarted. Leaving them would + // invite the user to act on a decision that no longer exists. + for id in publishedPrompts.subtracting(plannedIds) { + notifications.cancel(id: id) + publishedPrompts.remove(id) + } + + guard canPublish else { + // Suppressed: withdraw anything already showing, but keep the request + // itself pending so it can notify again later. + for id in publishedPrompts { notifications.cancel(id: id) } + publishedPrompts.removeAll() + return + } + + for plan in planned where !publishedPrompts.contains(plan.id) { + // Reserve the id *before* awaiting: CombineLatest can fire repeatedly in + // quick succession, and a repeated add of an in-flight identifier is + // coalesced into a silent update with no banner. + publishedPrompts.insert(plan.id) + if case .failure(let error) = await publish(plan) { + publishedPrompts.remove(plan.id) + messages.error(error) + } + } + } + + private func synchronizeOutcomes(_ state: SavedDevicesState) async { + let planned = plannedTargetedOutcomes(state.targetedTransfers, published: publishedOutcomes) + guard primedOutcomes else { + // The first snapshot carries existing history; mark those terminal + // transfers seen without notifying so only new transitions notify. + primedOutcomes = true + for plan in planned { publishedOutcomes.insert(plan.id) } + return + } + for plan in planned { + // Mark seen unconditionally — a terminal moment notifies at most once, + // whether or not the gate let it through. + publishedOutcomes.insert(plan.id) + guard canPublish else { continue } + if case .failure(let error) = await publish(plan) { + messages.error(error) + } + } + } + + private func publish(_ plan: PlannedSavedDeviceNotification) async -> Result { + let device = plan.deviceName ?? String(localized: L10n.Approval.nearbyDevice) + let transferName = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer) + let notification: LocalNotification + switch plan.kind { + case .pairingRequest: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Pairing.requestTitle), + body: L10n.Pairing.requestBody(device: device) + ) + case .targetedOffer: + // The sender is offering to send to us — not the invitation-approval + // case, where the remote device asks to receive from us. + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Targeted.offerTitle), + body: L10n.Targeted.offerBody(device: device, transferName: transferName) + ) + case .targetedReceiveCompleted: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiveCompletedTitle), + body: L10n.Notifications.receiveCompletedBody(transferName: transferName) + ) + case .targetedReceiveFailed: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiveFailedTitle), + body: L10n.Notifications.receiveFailedBody(transferName: transferName) + ) + case .targetedSendCompleted: + // We are the sender: the *peer* finished downloading, not us. + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiverCompletedTitle), + body: L10n.Notifications.receiverCompletedBody(receiver: device, transferName: transferName) + ) + case .targetedSendFailed: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiverFailedTitle), + body: L10n.Notifications.receiverFailedBody(receiver: device, transferName: transferName) + ) + } + return await notifications.publish(notification) + } +} diff --git a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift index 4c52fb7..79ee818 100644 --- a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift +++ b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift @@ -113,6 +113,10 @@ final class TransferNotificationCoordinator: ObservableObject { Task { await self.syncReceivers(transferId: transferId) } case .approvalChanged: break + case .pairingChanged, .targetedTransferChanged: + // Saved-device notifications are owned by the saved-device + // coordinator, which tracks its own notification identifiers. + break } } .store(in: &cancellables) diff --git a/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift b/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift new file mode 100644 index 0000000..bd4fc83 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift @@ -0,0 +1,568 @@ +import SwiftUI +import SFSafeSymbols + +/// Per-device details: Send, label/forget/block, and this device's targeted +/// transfers with their lifecycle actions. Presented as a sheet on compact +/// layouts and as a native inspector on macOS. +struct SavedDeviceDetailsView: View { + @ObservedObject var model: SavedDevicesModel + let peerEndpointId: String + let windowClass: WindowClass + let onClose: () -> Void + + /// Which destructive action is awaiting confirmation. + @State private var confirming: DestructiveAction? + + private enum DestructiveAction: String, Identifiable { + case forget + case block + var id: String { rawValue } + } + + private var state: SavedDevicesState { model.state } + private var device: SavedDeviceModel? { state.device(peerEndpointId) } + private var busy: Bool { state.busyPeerIds.contains(peerEndpointId) } + private var transfers: [SavedDeviceTransferItem] { state.transfers(for: peerEndpointId) } + + var body: some View { + Group { + if let device { + content(device) + } else { + // The device can disappear underneath us — forget, block, or a peer + // reinstall all remove it. Close rather than render a stale identity. + Color.clear.onAppear(perform: onClose) + } + } + .confirmationDialog( + Text(String(localized: confirming == .block + ? L10n.Saved.devicesBlockConfirmTitle + : L10n.Saved.devicesForgetConfirmTitle)), + isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), + titleVisibility: .visible + ) { + Button(String(localized: L10n.Button.cancel), role: .cancel) { confirming = nil } + Button( + String(localized: confirming == .block + ? L10n.Saved.devicesBlockAction + : L10n.Saved.devicesForgetAction), + role: .destructive + ) { + let action = confirming + confirming = nil + switch action { + case .forget: model.forget(peerEndpointId) + case .block: model.block(peerEndpointId) + case nil: break + } + } + } message: { + let name = device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed) + Text(confirming == .block + ? L10n.Saved.devicesBlockConfirmBody(device: name) + : L10n.Saved.devicesForgetConfirmBody(device: name)) + } + .sheet(isPresented: Binding( + get: { state.labelingPeerId == peerEndpointId }, + set: { if !$0 { model.dismissLabelEditor() } } + )) { + LabelEditorSheet(model: model, windowClass: windowClass) + } + .sheet(isPresented: Binding( + get: { state.sendTargetPeerId == peerEndpointId }, + set: { if !$0 { model.cancelSend() } } + )) { + TargetedSendSheet( + model: model, + windowClass: windowClass, + deviceName: device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed) + ) + } + } + + @ViewBuilder + private func content(_ device: SavedDeviceModel) -> some View { + List { + Section { DeviceHeader(device: device) } + + Section { + Button { + model.beginSend(to: peerEndpointId) + } label: { + ActionRow( + symbol: .paperplaneFill, + title: String(localized: L10n.Saved.devicesSendAction), + tint: VniDropColors.brandPurple + ) + } + .disabled(busy) + + Button { + model.openLabelEditor(peerEndpointId) + } label: { + ActionRow( + symbol: .pencil, + title: String(localized: L10n.Saved.devicesLabelAction), + // Showing the current label makes it clear this renames + // locally rather than changing what the peer calls itself. + detail: device.localLabel?.isEmpty == false ? device.localLabel : nil + ) + } + .disabled(busy) + } + + Section { + if transfers.isEmpty { + Text(String(localized: L10n.Saved.devicesTransferEmpty)) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 2) + } else { + ForEach(transfers) { transfer in + TargetedTransferRow( + transfer: transfer, + busy: state.busyTransferIds.contains(transfer.id), + onReceive: { model.receiveTargetedTransfer(transfer.id) }, + onResume: { model.resumeTargetedTransfer(transfer.id) }, + onCancel: { model.cancelTargetedTransfer(transfer.id) }, + onDelete: { model.deleteTargetedTransfer(transfer.id) } + ) + } + } + } header: { + Text(String(localized: L10n.Saved.devicesTransfersTitle)) + } + + Section { + Button(role: .destructive) { + confirming = .forget + } label: { + ActionRow( + symbol: .trash, + title: String(localized: L10n.Saved.devicesForgetAction), + tint: .red + ) + } + .disabled(busy) + + Button(role: .destructive) { + confirming = .block + } label: { + ActionRow( + symbol: .nosign, + title: String(localized: L10n.Saved.devicesBlockAction), + tint: .red + ) + } + .disabled(busy) + } header: { + Text(L10n.Saved.devicesMoreActions(device: device.displayName)) + } + } + #if os(macOS) + .listStyle(.inset) + #else + .listStyle(.insetGrouped) + #endif + .buttonStyle(.plain) + .overlay(alignment: .top) { + if busy { ProgressView().controlSize(.small).padding(.top, 6) } + } + } +} + +// MARK: - Pieces + +private struct DeviceHeader: View { + let device: SavedDeviceModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 14) { + DeviceAvatar(size: 52) + VStack(alignment: .leading, spacing: 3) { + Text(device.displayName) + .font(.title3) + .fontWeight(.semibold) + .lineLimit(2) + // The authenticated peer-supplied name, shown only when a local + // label overrides it, so the user can tell the two apart. + if device.localLabel?.isEmpty == false, let remote = device.remoteDisplayName { + Text(L10n.Saved.devicesAuthenticatedName(name: remote)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + EndpointIdLabel(endpointId: device.endpointId) + } + .padding(.vertical, 6) + } +} + +/// A tappable row that reads as a native list action rather than bare tinted text. +private struct ActionRow: View { + let symbol: SFSymbol + let title: String + var detail: String? = nil + var tint: Color = .primary + + var body: some View { + HStack(spacing: 12) { + Image(systemSymbol: symbol) + .font(.system(size: 15)) + .foregroundStyle(tint == .primary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint)) + .frame(width: 22) + Text(title).foregroundStyle(tint) + Spacer(minLength: 8) + if let detail { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .contentShape(Rectangle()) + .padding(.vertical, 2) + } +} + +/// One targeted transfer. The action its state actually calls for is a visible +/// button; everything else lives behind an overflow menu so a list of transfers +/// does not turn into a wall of buttons. +private struct TargetedTransferRow: View { + let transfer: SavedDeviceTransferItem + let busy: Bool + let onReceive: () -> Void + let onResume: () -> Void + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemSymbol: transfer.direction == .outgoing ? .arrowUpCircle : .arrowDownCircle) + .font(.callout) + .foregroundStyle(.secondary) + Text(name) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + if busy { + ProgressView().controlSize(.small) + } else { + StatusPill(label: transfer.state.label, tone: transfer.state.tone) + } + } + + HStack(spacing: 8) { + Text(L10n.Saved.devicesTransferFiles( + count: "\(transfer.fileCount)", + size: formatBytes(transfer.totalSize) + )) + .font(.caption) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + if let primary = primaryAction { + Button(String(localized: primary.title), action: primary.run) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(busy) + } + if !overflowActions.isEmpty { + Menu { + ForEach(overflowActions, id: \.id) { action in + Button(String(localized: action.title), role: .destructive, action: action.run) + } + } label: { + Image(systemSymbol: .ellipsisCircle) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .disabled(busy) + } + } + + if let progress = transfer.progressFraction { + ProgressView(value: progress) + Text(L10n.Saved.devicesTransferProgress( + verified: formatBytes(transfer.verifiedBytes), + total: formatBytes(transfer.totalSize) + )) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private var name: String { + transfer.transferName.isEmpty + ? String(localized: L10n.Saved.devicesUnnamed) + : transfer.transferName + } + + private struct TransferAction { + let id: String + let title: String.LocalizationValue + let run: () -> Void + } + + /// At most one of receive/resume applies, and only in one state each. + private var primaryAction: TransferAction? { + if transfer.state.canReceive { + return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive) + } + if transfer.state.canResume { + return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume) + } + return nil + } + + private var overflowActions: [TransferAction] { + var actions: [TransferAction] = [] + if transfer.state.canCancel { + actions.append(TransferAction(id: "cancel", title: L10n.Saved.devicesTransferCancel, run: onCancel)) + } + if transfer.state.canDelete { + actions.append(TransferAction(id: "delete", title: L10n.Saved.devicesTransferDelete, run: onDelete)) + } + return actions + } +} + +// MARK: - Sheets + +/// Label editor. Stays open and keeps its draft when the write fails, and blocks +/// its own dismissal while saving, so the retry path always survives. +/// +/// Split per platform on purpose: macOS gets a compact dialog with one trailing +/// button row, iOS gets the native Cancel/Save toolbar. A single shared layout +/// produced two competing button rows and a lot of dead space. +private struct LabelEditorSheet: View { + @ObservedObject var model: SavedDevicesModel + let windowClass: WindowClass + + private var isSaving: Bool { model.state.isSavingLabel } + + /// The peer's own authenticated name, shown so it is obvious the label is a + /// local override rather than a rename on the other device. + private var remoteName: String? { + guard let peerId = model.state.labelingPeerId else { return nil } + guard let remote = model.state.device(peerId)?.remoteDisplayName, !remote.isEmpty else { + return nil + } + return remote + } + + private var field: some View { + TextField( + String(localized: L10n.Saved.devicesLabelPlaceholder), + text: Binding( + get: { model.state.labelDraft }, + set: { model.setLabelDraft($0) } + ) + ) + .textFieldStyle(.roundedBorder) + .labelsHidden() + .disabled(isSaving) + .onSubmit { if model.state.canSaveLabel { model.saveLabel() } } + } + + var body: some View { + #if os(macOS) + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: L10n.Saved.devicesLabelTitle)) + .font(.headline) + if let remoteName { + Text(L10n.Saved.devicesAuthenticatedName(name: remoteName)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + field + HStack(spacing: 10) { + Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) { + model.clearLabel() + } + .buttonStyle(.link) + // Nothing to clear when the device has no label yet. + .disabled(isSaving || !model.state.hasExistingLabel) + Spacer(minLength: 12) + if isSaving { ProgressView().controlSize(.small) } + Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor) + .keyboardShortcut(.cancelAction) + .disabled(isSaving) + Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(isSaving || !model.state.canSaveLabel) + } + } + .padding(20) + .frame(width: 420) + .interactiveDismissDisabled(isSaving) + #else + NavigationStack { + Form { + Section { + field + } footer: { + if let remoteName { + Text(L10n.Saved.devicesAuthenticatedName(name: remoteName)) + } + } + Section { + Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) { + model.clearLabel() + } + .disabled(isSaving || !model.state.hasExistingLabel) + } + } + .navigationTitle(Text(String(localized: L10n.Saved.devicesLabelTitle))) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor) + .disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + if isSaving { + ProgressView().controlSize(.small) + } else { + Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel) + .disabled(!model.state.canSaveLabel) + } + } + } + } + .sheetSize(windowClass: windowClass, minWidth: 380, minHeight: 240) + .interactiveDismissDisabled(isSaving) + #endif + } +} + +/// Hosts the targeted-send composer. +private struct TargetedSendSheet: View { + @ObservedObject var model: SavedDevicesModel + let windowClass: WindowClass + let deviceName: String + + var body: some View { + NavigationStack { + ScrollView { + TargetedSendComposer(model: model, deviceName: deviceName) + } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: L10n.Button.close), action: model.cancelSend) + .disabled(model.state.isCreatingSend) + } + } + } + // A create in flight owns the picked sources; discarding them mid-call + // would pull the files out from under the core's import. + .interactiveDismissDisabled(model.state.isCreatingSend) + .sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 480) + } +} + +private extension View { + /// Sizes a sheet for its host. A phone must never get a `minWidth` — forcing + /// one wider than the screen pushes the content out of bounds instead of + /// growing the sheet, which is exactly what a fixed 460pt did. + @ViewBuilder + func sheetSize(windowClass: WindowClass, minWidth: CGFloat, minHeight: CGFloat) -> some View { + if windowClass == .phone { + self + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } else { + frame(minWidth: minWidth, minHeight: minHeight) + } + } +} + +// MARK: - Adaptive presentation + +extension View { + /// Presents the details surface the way each platform expects: a sheet with + /// detents on compact layouts, a native inspector on macOS. + func savedDeviceDetails( + model: SavedDevicesModel, + windowClass: WindowClass, + selectedPeerId: Binding + ) -> some View { + modifier(SavedDeviceDetailsPresentation( + model: model, windowClass: windowClass, selectedPeerId: selectedPeerId + )) + } +} + +private struct SavedDeviceDetailsPresentation: ViewModifier { + @ObservedObject var model: SavedDevicesModel + let windowClass: WindowClass + @Binding var selectedPeerId: String? + + func body(content: Content) -> some View { + #if os(macOS) + content.inspector(isPresented: Binding( + get: { selectedPeerId != nil }, + set: { if !$0 { selectedPeerId = nil } } + )) { + detail + .inspectorColumnWidth(min: 300, ideal: 360, max: 480) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + selectedPeerId = nil + } label: { + Label(String(localized: L10n.Button.close), systemSymbol: .sidebarRight) + } + } + } + } + #else + content.sheet(isPresented: Binding( + get: { selectedPeerId != nil }, + set: { if !$0 { selectedPeerId = nil } } + )) { + NavigationStack { + detail + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: L10n.Button.close)) { selectedPeerId = nil } + } + } + } + .sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 520) + } + #endif + } + + @ViewBuilder + private var detail: some View { + if let peerId = selectedPeerId { + SavedDeviceDetailsView( + model: model, + peerEndpointId: peerId, + windowClass: windowClass, + onClose: { selectedPeerId = nil } + ) + } + } +} diff --git a/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift b/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift new file mode 100644 index 0000000..3f6fa07 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Presentation-level saved-device models, ported from +/// `feature/saveddevices/SavedDeviceExperienceModels.kt`. + +/// The one consent question currently worth asking. Only one is shown at a time: +/// an incoming request always outranks an eligibility we could act on ourselves. +enum PairingPrompt: Equatable, Identifiable { + /// We completed a qualifying transfer and may ask this peer to pair. + case eligibility(peerEndpointId: String, remoteDisplayName: String?) + /// This peer asked us; we approve or decline. + case incomingRequest(peerEndpointId: String, remoteDisplayName: String?) + + var peerEndpointId: String { + switch self { + case .eligibility(let id, _), .incomingRequest(let id, _): return id + } + } + + var remoteDisplayName: String? { + switch self { + case .eligibility(_, let name), .incomingRequest(_, let name): return name + } + } + + var id: String { + switch self { + case .eligibility(let id, _): return "eligibility-\(id)" + case .incomingRequest(let id, _): return "incoming-\(id)" + } + } +} + +struct PairingPromptState: Equatable { + var prompt: PairingPrompt? + var busy = false +} + +struct TargetedOfferState: Equatable { + var pending: [PendingTargetedOfferModel] = [] + /// Display names for senders we already have saved. A sender we have not + /// saved has no trustworthy name, so the UI falls back to a generic label + /// rather than rendering a peer-supplied string as if it were verified. + var senderDisplayNames: [String: String] = [:] + var respondingIds: Set = [] + + var current: PendingTargetedOfferModel? { pending.first } + + var currentSenderDisplayName: String? { + guard let current else { return nil } + return senderDisplayNames[current.senderEndpointId] + } +} + +enum SavedDeviceTransferDirection: Equatable, Sendable { + case outgoing + case incoming +} + +/// One targeted transfer as the details surface renders it: resolved against the +/// local endpoint so "peer" means the *other* device, whichever side we are on. +struct SavedDeviceTransferItem: Equatable, Identifiable, Sendable { + let id: String + let peerEndpointId: String + let peerDisplayName: String? + let direction: SavedDeviceTransferDirection + let transferName: String + let fileCount: UInt64 + let totalSize: UInt64 + let verifiedBytes: UInt64 + let state: TargetedTransferStateModel + let createdAt: Int64 + let updatedAt: Int64 + + var progressFraction: Double? { + guard totalSize > 0, state.isActive || state == .interrupted else { return nil } + return min(1, Double(verifiedBytes) / Double(totalSize)) + } +} diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift new file mode 100644 index 0000000..43f9e34 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift @@ -0,0 +1,75 @@ +import SwiftUI +import SFSafeSymbols + +/// Shared presentation helpers for the saved-device surfaces. + +extension SavedDeviceModel { + /// `localLabel`, else the peer's untrusted display-name hint, else a generic + /// placeholder. Never falls back to the endpoint ID, which stays diagnostic. + var displayName: String { + displayNameOrNil ?? String(localized: L10n.Saved.devicesUnnamed) + } +} + +extension TargetedTransferStateModel { + var label: String { + switch self { + case .preparing: return String(localized: L10n.Status.preparing) + case .offering: return String(localized: L10n.Status.offering) + case .awaitingApproval: return String(localized: L10n.Status.awaitingApproval) + case .approved: return String(localized: L10n.Status.approved) + case .connecting: return String(localized: L10n.Status.connecting) + case .transferring: return String(localized: L10n.Status.transferring) + case .interrupted: return String(localized: L10n.Status.interrupted) + case .completed: return String(localized: L10n.Status.completed) + case .declined: return String(localized: L10n.Status.declined) + case .cancelled: return String(localized: L10n.Status.cancelled) + case .failed: return String(localized: L10n.Status.failed) + // Deleted transfers are filtered out of the snapshot; label it defensively + // rather than crashing if one ever reaches the UI. + case .deleted: return String(localized: L10n.Status.cancelled) + } + } + + var tone: PillTone { + switch self { + case .completed: return .success + case .failed, .declined: return .destructive + case .interrupted: return .warning + case .transferring, .connecting, .approved: return .brand + default: return .neutral + } + } +} + +/// A device identity avatar. Deliberately generic: the core exposes no trustworthy +/// hardware type, so showing a specific device silhouette would imply knowledge +/// VniDrop does not have. +struct DeviceAvatar: View { + var symbol: SFSymbol = .laptopcomputerAndIphone + var tint: Color = .secondary + var size: CGFloat = 40 + + var body: some View { + Image(systemSymbol: symbol) + .font(.system(size: size * 0.45)) + .foregroundStyle(tint) + .frame(width: size, height: size) + .background(.quaternary, in: RoundedRectangle(cornerRadius: size * 0.225)) + } +} + +/// The endpoint ID, shown as secondary diagnostic detail. Truncated in the middle +/// so both ends stay recognizable, and never used as a device's name. +struct EndpointIdLabel: View { + let endpointId: String + + var body: some View { + Text(L10n.Saved.devicesEndpoint(deviceId: endpointId)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } +} diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift new file mode 100644 index 0000000..2a694d2 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift @@ -0,0 +1,127 @@ +import SwiftUI +import SFSafeSymbols + +/// Consent prompts for the saved-device domain: the pairing question and the +/// targeted-offer approval. Both are blocking decisions, so each is a native +/// alert rather than an inline banner that could be scrolled past. +/// +/// These live outside Experimental Settings — saving a device and approving a +/// targeted transfer are top-level product decisions. + +extension View { + /// Hosts both saved-device consent prompts. `suppressed` is set while the + /// transfer-approval modal is up, so the user is never answering two blocking + /// decisions at once — the approval belongs to a transfer this device is + /// sending, these belong to a device asking to reach it. + func savedDevicePrompts(model: SavedDevicesModel, suppressed: Bool) -> some View { + modifier(PairingPromptHost(model: model, suppressed: suppressed)) + .modifier(TargetedOfferPromptHost(model: model, suppressed: suppressed)) + } +} + +private struct PairingPromptHost: ViewModifier { + @ObservedObject var model: SavedDevicesModel + let suppressed: Bool + + private var prompt: PairingPrompt? { + suppressed ? nil : model.state.pairingPrompt.prompt + } + + func body(content: Content) -> some View { + content.alert( + Text(String(localized: title)), + isPresented: Binding( + get: { prompt != nil }, + // A swipe/escape dismissal is not an answer: suppress locally + // without consuming the core's single-use eligibility. The + // `suppressed` guard matters — hiding the alert for the approval + // modal also fires this setter, and must not count as a dismissal. + set: { if !$0, !suppressed { model.dismissPairingPrompt() } } + ), + presenting: prompt + ) { prompt in + Button(String(localized: acceptLabel(prompt)), action: model.acceptPairingPrompt) + Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: model.declinePairingPrompt) + Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissPairingPrompt) + } message: { prompt in + messageText(prompt) + } + } + + private var title: String.LocalizationValue { + switch prompt { + case .incomingRequest: return L10n.Pairing.requestTitle + case .eligibility, nil: return L10n.Pairing.allowTitle + } + } + + /// The incoming-request copy names the asking device; the eligibility copy is + /// a fixed explanation of what remembering allows. + @ViewBuilder + private func messageText(_ prompt: PairingPrompt) -> some View { + switch prompt { + case .incomingRequest: + Text(L10n.Pairing.requestBody(device: deviceName(prompt))) + case .eligibility: + Text(String(localized: L10n.Pairing.allowBody)) + } + } + + private func acceptLabel(_ prompt: PairingPrompt) -> String.LocalizationValue { + switch prompt { + case .incomingRequest: return L10n.Pairing.accept + case .eligibility: return L10n.Saved.devicesRememberAction + } + } + + /// Falls back to a neutral placeholder: an unsaved peer's name is an untrusted + /// hint, and there may be none at all. + private func deviceName(_ prompt: PairingPrompt) -> String { + prompt.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed) + } +} + +private struct TargetedOfferPromptHost: ViewModifier { + @ObservedObject var model: SavedDevicesModel + let suppressed: Bool + + private var offer: PendingTargetedOfferModel? { + suppressed ? nil : model.state.targetedOffers.current + } + + func body(content: Content) -> some View { + content.alert( + // Not the invitation-approval copy: there the remote device asks to + // *receive* from us, here it is offering to *send* to us. + Text(String(localized: L10n.Targeted.offerTitle)), + isPresented: Binding( + get: { offer != nil }, + // Dismissal declines: an unanswered offer would otherwise hold a + // slot in the core's bounded per-sender queue. Never while + // `suppressed`, though — being hidden behind the approval modal + // must not silently decline the sender. + set: { if !$0, !suppressed, let offer { model.declineTargetedOffer(offer.transferId) } } + ), + presenting: offer + ) { offer in + Button(String(localized: L10n.Button.approve)) { + model.acceptTargetedOffer(offer.transferId) + } + Button(String(localized: L10n.Button.refuse), role: .destructive) { + model.declineTargetedOffer(offer.transferId) + } + } message: { offer in + Text(L10n.Targeted.offerBody( + device: senderName, + transferName: offer.transferName + )) + } + } + + /// Only a *saved* sender has a name we can vouch for; anything else stays + /// generic rather than rendering a peer-supplied string as verified. + private var senderName: String { + model.state.targetedOffers.currentSenderDisplayName + ?? String(localized: L10n.Approval.nearbyDevice) + } +} diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift new file mode 100644 index 0000000..6bfcb37 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift @@ -0,0 +1,618 @@ +import Foundation +import Combine + +/// Saved-device feature state, ported from `SavedDevicesViewModel.kt` +/// (`SavedDevicesState`). One snapshot drives the list, the details surface, the +/// pairing prompt, and the targeted-offer modal. +struct SavedDevicesState: Equatable { + var isLoading = true + var loadFailed = false + var eligibilities: [PairingEligibilityModel] = [] + /// Relationships still awaiting consent on one side or the other. Rendered on + /// the main screen alongside saved devices; never usable for a transfer. + var pendingRelationships: [DeviceRelationshipModel] = [] + var savedDevices: [SavedDeviceModel] = [] + var targetedTransfers: [SavedDeviceTransferItem] = [] + var pairingPrompt = PairingPromptState() + var targetedOffers = TargetedOfferState() + /// Peers with a mutation in flight; their row actions are disabled. + var busyPeerIds: Set = [] + var busyTransferIds: Set = [] + /// Peer whose label editor is open, or nil when closed. + var labelingPeerId: String? + var labelDraft = "" + var isSavingLabel = false + /// Peer the user chose to send to, or nil when no composition is open. + var sendTargetPeerId: String? + /// Sources chosen for the pending targeted send. + var sendFiles: [PickedShareFile] = [] + var sendTransferName = "" + var isCreatingSend = false + + /// True when the device being labelled already has one, so "Clear" has + /// something to clear. + var hasExistingLabel: Bool { + guard let peerId = labelingPeerId else { return false } + return device(peerId)?.localLabel?.isEmpty == false + } + + /// Saving is pointless when the draft matches what is already stored. + var canSaveLabel: Bool { + guard let peerId = labelingPeerId, !isSavingLabel else { return false } + let draft = labelDraft.trimmingCharacters(in: .whitespacesAndNewlines) + let current = device(peerId)?.localLabel ?? "" + return draft != current + } + + /// A targeted transfer needs a destination, at least one source, and a name — + /// the same composition rules as an invitation share. + var canCreateTargetedTransfer: Bool { + sendTargetPeerId != nil + && !sendFiles.isEmpty + && !sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isCreatingSend + } + + var isEmpty: Bool { + savedDevices.isEmpty && pendingRelationships.isEmpty && eligibilities.isEmpty + } + + func transfers(for peerEndpointId: String) -> [SavedDeviceTransferItem] { + targetedTransfers.filter { $0.peerEndpointId == peerEndpointId } + } + + func device(_ peerEndpointId: String) -> SavedDeviceModel? { + savedDevices.first { $0.endpointId == peerEndpointId } + } +} + +/// Product-level Saved-device experience, ported from `SavedDevicesViewModel.kt`. +/// Views observe one snapshot and issue named commands; pairing, targeted offers, +/// transfer history, and receive destinations stay internal. +@MainActor +final class SavedDevicesModel: ObservableObject { + @Published private(set) var state = SavedDevicesState() + + /// Requests a file/folder pick, consumed by the view layer (mirrors SendModel). + @Published var pendingFilePick = false + @Published var pendingFolderPick = false + + private let repository: CoreGateway + private let fileSystemService: FileSystemService + private let messages: UiMessageController + private var cancellables = Set() + + /// Serializes `refresh` so overlapping signals cannot interleave their reads + /// and publish a torn snapshot (the `refreshMutex` in the KMP model). + private var refreshTask: Task? + /// Eligibilities the user dismissed this session. Dismissal is not a decline: + /// it suppresses the prompt locally without consuming the core's single-use + /// capability, so the device stays actionable from the list. + private var dismissedEligibility: Set = [] + private var receiveFolder: ReceiveFolder? + + init( + repository: CoreGateway, + fileSystemService: FileSystemService, + preferences: AppPreferencesRepository, + messages: UiMessageController + ) { + self.repository = repository + self.fileSystemService = fileSystemService + self.messages = messages + + // Re-resolve the destination whenever the configured folder changes, and + // load once the core is up. Both inputs gate the first refresh: a receive + // has nowhere to land until the folder is known. + preferences.$preferences + .map(\.receiveFolder) + .combineLatest(repository.statePublisher.map(\.isInitialized)) + .removeDuplicates { $0 == $1 } + .sink { [weak self] folder, isInitialized in + guard let self else { return } + self.receiveFolder = self.fileSystemService.effectiveReceiveFolder(folder) + if isInitialized { self.scheduleRefresh() } + } + .store(in: &cancellables) + + repository.signals + .sink { [weak self] signal in + guard let self else { return } + switch signal { + case .pairingChanged, .targetedTransferChanged: + // Wake-up only: re-read durable state rather than trusting the + // event payload (see DESIGN-DEVICE-HISTORY.md §13). + if self.repository.state.isInitialized { self.scheduleRefresh() } + case .approvalChanged, .receiverHistoryChanged, .transfersChanged: + // Invitation-share domain; owned by SendModel. + break + } + } + .store(in: &cancellables) + } + + // MARK: - Loading + + func retry() { + guard repository.state.isInitialized, !state.isLoading else { return } + scheduleRefresh() + } + + // MARK: - Pairing prompt + + func acceptPairingPrompt() { + respondToPrompt(accepted: true) + } + + func declinePairingPrompt() { + respondToPrompt(accepted: false) + } + + /// Hides the prompt without answering it. Only meaningful for an eligibility — + /// an incoming request stays until it is explicitly answered. + func dismissPairingPrompt() { + guard let prompt = state.pairingPrompt.prompt else { return } + if case .eligibility(let peerId, _) = prompt { dismissedEligibility.insert(peerId) } + state.pairingPrompt.prompt = nil + } + + private func respondToPrompt(accepted: Bool) { + guard let prompt = state.pairingPrompt.prompt, !state.pairingPrompt.busy else { return } + state.pairingPrompt.busy = true + Task { + let result: Result + switch (prompt, accepted) { + case (.eligibility(let peerId, _), true): + result = await repository.requestSavedDevicePairing(peerEndpointId: peerId).map { _ in () } + case (.eligibility(let peerId, _), false): + result = await repository.declinePairingEligibility(peerEndpointId: peerId) + case (.incomingRequest(let peerId, _), _): + result = await repository + .respondToDevicePairing(peerEndpointId: peerId, accepted: accepted) + .map { _ in () } + } + state.pairingPrompt.busy = false + switch result { + case .success: await refresh() + case .failure(let error): messages.error(error) + } + } + } + + // MARK: - Per-device consent actions + + func rememberEligible(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + await self.repository.requestSavedDevicePairing(peerEndpointId: peerEndpointId).map { _ in () } + } + } + + func declineEligible(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + await self.repository.declinePairingEligibility(peerEndpointId: peerEndpointId) + } + } + + func acceptIncoming(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + await self.repository + .respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: true) + .map { _ in () } + } + } + + func declineIncoming(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + await self.repository + .respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: false) + .map { _ in () } + } + } + + // MARK: - Targeted offers + + func acceptTargetedOffer(_ transferId: String) { + respondToTargetedOffer(transferId, accepted: true) + } + + func declineTargetedOffer(_ transferId: String) { + respondToTargetedOffer(transferId, accepted: false) + } + + private func respondToTargetedOffer(_ transferId: String, accepted: Bool) { + guard !state.targetedOffers.respondingIds.contains(transferId) else { return } + state.targetedOffers.respondingIds.insert(transferId) + Task { + let response = await repository.respondToTargetedOffer( + transferId: transferId, accepted: accepted + ) + switch response { + case .success(let outcome): + // Approval only authorizes the pull; the receiver still has to run + // it. `alreadySettled` is the idempotent replay path and must not + // start a second one. + if accepted, case .approved(let approvedId) = outcome { + if case .failure(let error) = await pullTargetedTransfer(approvedId, resume: false) { + messages.error(error) + } + } + await refresh() + case .failure(let error): + messages.error(error) + } + state.targetedOffers.respondingIds.remove(transferId) + } + } + + // MARK: - Targeted transfer lifecycle + + func receiveTargetedTransfer(_ transferId: String) { + mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: false) } + } + + func resumeTargetedTransfer(_ transferId: String) { + mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: true) } + } + + func cancelTargetedTransfer(_ transferId: String) { + mutateTransfer(transferId) { await self.repository.cancelTargetedTransfer(id: transferId) } + } + + func deleteTargetedTransfer(_ transferId: String) { + mutateTransfer(transferId) { await self.repository.deleteTargetedTransfer(id: transferId) } + } + + private func pullTargetedTransfer(_ transferId: String, resume: Bool) async -> Result { + guard let folder = receiveFolder else { + // Preferences have not resolved a usable destination yet; the pull has + // nowhere to land. Surfaces as the generic filesystem error. + return .failure(InvitationError.filesystemUnavailable) + } + let result = resume + ? await repository.resumeTargetedTransfer(id: transferId, outputDirectoryUrl: folder.value) + : await repository.receiveTargetedTransfer( + transferId: transferId, outputDirectoryUrl: folder.value + ) + if case .success = result { + messages.tryShow(UiMessage(text: .resource(L10n.Receive.completed), tone: .success)) + } + return result + } + + // MARK: - Send + + /// Opens targeted-send composition for a saved device. + func beginSend(to peerEndpointId: String) { + guard !state.busyPeerIds.contains(peerEndpointId) else { return } + let discarded = state.sendFiles + state.sendTargetPeerId = peerEndpointId + state.sendFiles = [] + state.sendTransferName = "" + discardPickedFiles(discarded) + } + + func cancelSend() { + guard !state.isCreatingSend else { return } + let discarded = state.sendFiles + state.sendTargetPeerId = nil + state.sendFiles = [] + state.sendTransferName = "" + discardPickedFiles(discarded) + } + + func selectSendFiles() { pendingFilePick = true } + func selectSendFolder() { pendingFolderPick = true } + + func onSendFilesPicked(_ files: [PickedShareFile]) { + guard !files.isEmpty else { return } + // Replacing a selection discards the picker copies the previous one owned. + let selectedValues = Set(files.map(\.value)) + let discarded = state.sendFiles.filter { !selectedValues.contains($0.value) } + state.sendFiles = files + state.sendTransferName = defaultTransferName(files) + discardPickedFiles(discarded) + } + + func onSendFilePickFailed(_ reason: String) { + messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason)) + } + + func removeSendFile(_ value: String) { + let discarded = state.sendFiles.filter { $0.value == value } + let remaining = state.sendFiles.filter { $0.value != value } + // Keep a name the user typed; only re-derive one we generated. + let wasDefault = state.sendTransferName == defaultTransferName(state.sendFiles) + state.sendFiles = remaining + state.sendTransferName = remaining.isEmpty + ? "" + : (wasDefault ? defaultTransferName(remaining) : state.sendTransferName) + discardPickedFiles(discarded) + } + + func clearSendFiles() { + let discarded = state.sendFiles + state.sendFiles = [] + state.sendTransferName = "" + discardPickedFiles(discarded) + } + + func setSendTransferName(_ value: String) { + guard !state.isCreatingSend else { return } + state.sendTransferName = value + } + + /// Creates the targeted transfer. The receiver still has to approve it — a + /// saved device never grants automatic receipt. + func createTargetedTransfer() { + guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return } + let files = state.sendFiles + let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines) + state.isCreatingSend = true + Task { + let result = await fileSystemService.sendPickedFilesToSavedDevice( + repository: repository, + files: files, + transferName: name, + receiverEndpointId: peerId + ) + state.isCreatingSend = false + switch result { + case .success: + messages.tryShow( + UiMessage(text: .resource(L10n.Saved.devicesSendStarted), tone: .success) + ) + state.sendTargetPeerId = nil + state.sendFiles = [] + state.sendTransferName = "" + // The core owns the bytes now; release any picker copies. + discardPickedFiles(files) + await refresh() + case .failure(let error): + // Keep the composition intact so the user can retry without + // re-picking, mirroring the label editor's failure behavior. + messages.error(error) + } + } + } + + private func defaultTransferName(_ files: [PickedShareFile]) -> String { + guard let first = files.first else { return "" } + return files.count == 1 + ? first.displayName + : L10n.Send.selectedFilesCount(count: files.count) + } + + private func discardPickedFiles(_ files: [PickedShareFile]) { + guard !files.isEmpty else { return } + Task { await fileSystemService.discardPickedFiles(files) } + } + + // MARK: - Label editing + + func openLabelEditor(_ peerEndpointId: String) { + guard !state.isSavingLabel, !state.busyPeerIds.contains(peerEndpointId) else { return } + state.labelingPeerId = peerEndpointId + state.labelDraft = state.device(peerEndpointId)?.localLabel ?? "" + } + + func setLabelDraft(_ value: String) { + // Frozen while saving so the committed value cannot drift from the draft + // the user is looking at. + guard !state.isSavingLabel else { return } + state.labelDraft = value + } + + func dismissLabelEditor() { + // Refuse to close mid-save: the draft must survive to be retried. + guard !state.isSavingLabel else { return } + state.labelingPeerId = nil + state.labelDraft = "" + } + + func saveLabel() { + let trimmed = state.labelDraft.trimmingCharacters(in: .whitespacesAndNewlines) + commitLabel(trimmed.isEmpty ? nil : trimmed) + } + + func clearLabel() { + commitLabel(nil) + } + + /// Transactional from the UI's perspective: on failure the draft and the + /// editor survive so the user can retry; the editor closes only after the + /// core confirms the write. + private func commitLabel(_ label: String?) { + guard let peerId = state.labelingPeerId else { return } + guard !state.isSavingLabel, !state.busyPeerIds.contains(peerId) else { return } + state.isSavingLabel = true + state.busyPeerIds.insert(peerId) + Task { + let result = await repository.setSavedDeviceLabel(peerEndpointId: peerId, label: label) + switch result { + case .success: + await refresh() + messages.tryShow(UiMessage(text: .resource(L10n.Saved.devicesLabeled), tone: .success)) + case .failure(let error): + messages.error(error) + } + state.busyPeerIds.remove(peerId) + state.isSavingLabel = false + // Only close the editor the user still has open on this peer — they may + // have switched to another device while the write was in flight. + if case .success = result, state.labelingPeerId == peerId { + state.labelingPeerId = nil + state.labelDraft = "" + } + } + } + + // MARK: - Destructive actions + + func forget(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + let result = await self.repository.forgetSavedDevice(peerEndpointId: peerEndpointId) + if case .success = result { + self.messages.tryShow( + UiMessage(text: .resource(L10n.Saved.devicesForgotten), tone: .success) + ) + } + return result + } + } + + func block(_ peerEndpointId: String) { + mutatePeer(peerEndpointId) { + let result = await self.repository.blockDevice(peerEndpointId: peerEndpointId) + if case .success = result { + self.messages.tryShow( + UiMessage(text: .resource(L10n.Saved.devicesBlocked), tone: .success) + ) + } + return result + } + } + + // MARK: - Mutation helpers + + private func mutatePeer( + _ peerEndpointId: String, + _ block: @escaping () async -> Result + ) { + guard !state.busyPeerIds.contains(peerEndpointId) else { return } + state.busyPeerIds.insert(peerEndpointId) + Task { + switch await block() { + case .success: await refresh() + case .failure(let error): messages.error(error) + } + state.busyPeerIds.remove(peerEndpointId) + } + } + + private func mutateTransfer( + _ transferId: String, + _ block: @escaping () async -> Result + ) { + guard !state.busyTransferIds.contains(transferId) else { return } + state.busyTransferIds.insert(transferId) + Task { + switch await block() { + case .success: await refresh() + case .failure(let error): messages.error(error) + } + state.busyTransferIds.remove(transferId) + } + } + + // MARK: - Refresh + + /// Coalesces refreshes onto one serial chain. Signals can arrive in bursts; + /// without this their reads interleave and publish a torn snapshot. + private func scheduleRefresh() { + let previous = refreshTask + refreshTask = Task { + await previous?.value + await refresh() + } + } + + private func refresh() async { + state.isLoading = true + state.loadFailed = false + + // Five reads make one snapshot; if any fails the snapshot is incomplete, so + // surface the failure rather than render a partial list. Sequential by + // design: the gateway funnels core calls through one serial dispatcher, so + // issuing these concurrently would queue behind each other anyway. + do { + let eligibilities = try await repository.listPairingEligibilities().get() + let relationships = try await repository.listDeviceRelationships().get() + let savedDevices = try await repository.listSavedDevices().get() + let pendingOffers = try await repository.listPendingTargetedOffers().get() + let transfers = try await repository.listTargetedTransfers().get() + + let savedNames = savedDevices.reduce(into: [String: String]()) { names, device in + if let name = device.displayNameOrNil { names[device.endpointId] = name } + } + let localEndpointId = repository.state.status?.endpointId + let pendingRelationships = relationships + .filter { $0.state == .pendingIncoming || $0.state == .pendingOutgoing } + .sorted { $0.updatedAt > $1.updatedAt } + + state.isLoading = false + state.loadFailed = false + state.eligibilities = eligibilities.sorted { $0.createdAt > $1.createdAt } + state.pendingRelationships = pendingRelationships + state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt } + state.targetedTransfers = transfers + .filter { $0.state != .deleted } + .sorted { $0.updatedAt > $1.updatedAt } + .map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) } + // Leave a prompt mid-answer alone; replacing it would strand the + // in-flight request behind a prompt the user never saw. + if !state.pairingPrompt.busy { + state.pairingPrompt = PairingPromptState( + prompt: nextPairingPrompt( + relationships: pendingRelationships, + eligibilities: eligibilities, + savedNames: savedNames + ) + ) + } + state.targetedOffers.pending = pendingOffers.sorted { $0.receivedAt < $1.receivedAt } + state.targetedOffers.senderDisplayNames = savedNames + } catch { + state.isLoading = false + state.loadFailed = true + messages.error(error) + } + } + + /// An incoming request outranks an eligibility: the peer is waiting on us, + /// and answering it is the only action that unblocks them. + private func nextPairingPrompt( + relationships: [DeviceRelationshipModel], + eligibilities: [PairingEligibilityModel], + savedNames: [String: String] + ) -> PairingPrompt? { + if let incoming = relationships.first(where: { $0.state == .pendingIncoming }) { + let name = eligibilities + .first { $0.peerEndpointId == incoming.remoteEndpointId }? + .remoteDisplayName + ?? savedNames[incoming.remoteEndpointId] + return .incomingRequest(peerEndpointId: incoming.remoteEndpointId, remoteDisplayName: name) + } + guard let eligibility = eligibilities.first(where: { + !dismissedEligibility.contains($0.peerEndpointId) + }) else { return nil } + return .eligibility( + peerEndpointId: eligibility.peerEndpointId, + remoteDisplayName: eligibility.remoteDisplayName + ) + } +} + +private extension TargetedTransferModel { + /// Resolves the transfer against the local endpoint so the UI can speak in + /// terms of "the peer" and a direction. + func toExperienceItem( + localEndpointId: String?, + savedNames: [String: String] + ) -> SavedDeviceTransferItem { + let outgoing = senderEndpointId == localEndpointId + let peerEndpointId = outgoing ? receiverEndpointId : senderEndpointId + return SavedDeviceTransferItem( + id: id, + peerEndpointId: peerEndpointId, + peerDisplayName: savedNames[peerEndpointId], + direction: outgoing ? .outgoing : .incoming, + transferName: transferName, + fileCount: fileCount, + totalSize: totalSize, + verifiedBytes: verifiedBytes, + state: state, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift new file mode 100644 index 0000000..688352e --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift @@ -0,0 +1,248 @@ +import SwiftUI +import SFSafeSymbols + +/// Saved devices screen. A native `List` of saved devices and outstanding consent +/// requests; selecting one opens the per-device details surface. The global +/// targeted-transfer history is deliberately absent — transfers belong to a +/// device, and are reachable only through it. +struct SavedDevicesScreen: View { + @ObservedObject var model: SavedDevicesModel + let windowClass: WindowClass + + /// Endpoint ID of the device whose details are open. + @State private var selectedPeerId: String? + + private var state: SavedDevicesState { model.state } + + var body: some View { + NavigationStack { + Group { + if state.isLoading && state.isEmpty { + loadingState + } else if state.loadFailed && state.isEmpty { + failedState + } else if state.isEmpty { + emptyState + } else { + deviceList + } + } + // Title-only header once populated: explanatory copy belongs in the + // first-use empty state, not above a list the user already understands. + .navigationTitle(Text(String(localized: L10n.Saved.devicesListTitle))) + } + // The consent prompts are hosted at the app root, not here: they must be + // answerable from any tab, not only while this screen is showing. + .savedDeviceDetails(model: model, windowClass: windowClass, selectedPeerId: $selectedPeerId) + } + + // MARK: - List + + private var deviceList: some View { + List { + if !state.eligibilities.isEmpty || !state.pendingRelationships.isEmpty { + Section { + ForEach(state.pendingRelationships) { relationship in + PendingRelationshipRow( + relationship: relationship, + busy: state.busyPeerIds.contains(relationship.remoteEndpointId), + onAccept: { model.acceptIncoming(relationship.remoteEndpointId) }, + onDecline: { model.declineIncoming(relationship.remoteEndpointId) } + ) + } + ForEach(pendingEligibilities) { eligibility in + EligibilityRow( + eligibility: eligibility, + busy: state.busyPeerIds.contains(eligibility.peerEndpointId), + onRemember: { model.rememberEligible(eligibility.peerEndpointId) }, + onDecline: { model.declineEligible(eligibility.peerEndpointId) } + ) + } + } header: { + Text(String(localized: L10n.Saved.devicesAttentionTitle)) + } + } + + if !state.savedDevices.isEmpty { + Section { + ForEach(state.savedDevices) { device in + Button { selectedPeerId = device.endpointId } label: { + SavedDeviceRow( + device: device, + transferCount: state.transfers(for: device.endpointId).count, + busy: state.busyPeerIds.contains(device.endpointId) + ) + } + .buttonStyle(.plain) + .contextMenu { deviceMenu(device) } + } + } header: { + Text(String(localized: L10n.Saved.devicesPendingTitle)) + } + } + } + } + + /// Eligibilities whose peer already has a pending relationship are represented + /// by that relationship row instead, so the same device never appears twice. + private var pendingEligibilities: [PairingEligibilityModel] { + let pendingIds = Set(state.pendingRelationships.map(\.remoteEndpointId)) + return state.eligibilities.filter { !pendingIds.contains($0.peerEndpointId) } + } + + @ViewBuilder + private func deviceMenu(_ device: SavedDeviceModel) -> some View { + Button { + selectedPeerId = device.endpointId + } label: { + Label(String(localized: L10n.Saved.devicesSendAction), systemSymbol: .paperplane) + } + Button { + selectedPeerId = device.endpointId + model.openLabelEditor(device.endpointId) + } label: { + Label(String(localized: L10n.Saved.devicesLabelAction), systemSymbol: .pencil) + } + } + + // MARK: - Placeholder states + + private var loadingState: some View { + VStack(spacing: 12) { + ProgressView() + Text(String(localized: L10n.Saved.devicesLoading)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var failedState: some View { + ContentUnavailableView { + Label(String(localized: L10n.Saved.devicesLoadFailed), systemSymbol: .exclamationmarkTriangleFill) + } actions: { + Button(String(localized: L10n.Button.retry), action: model.retry) + .buttonStyle(.borderedProminent) + } + } + + /// First-use state: the only place that explains what a saved device is. + private var emptyState: some View { + ContentUnavailableView { + Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .laptopcomputerAndIphone) + } description: { + Text(String(localized: L10n.Saved.devicesEmpty)) + } + } +} + +// MARK: - Rows + +private struct SavedDeviceRow: View { + let device: SavedDeviceModel + let transferCount: Int + let busy: Bool + + var body: some View { + HStack(spacing: 12) { + DeviceAvatar() + VStack(alignment: .leading, spacing: 3) { + Text(device.displayName) + .font(.body) + .lineLimit(1) + .truncationMode(.tail) + EndpointIdLabel(endpointId: device.endpointId) + } + Spacer(minLength: 8) + if busy { + ProgressView().controlSize(.small) + } else if transferCount > 0 { + Text(verbatim: "\(transferCount)") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Image(systemSymbol: .chevronRight) + .font(.caption) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } +} + +/// A relationship awaiting consent. Incoming requests get accept/decline; an +/// outgoing request is informational until the peer answers. +private struct PendingRelationshipRow: View { + let relationship: DeviceRelationshipModel + let busy: Bool + let onAccept: () -> Void + let onDecline: () -> Void + + private var isIncoming: Bool { relationship.state == .pendingIncoming } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + DeviceAvatar(symbol: .personBadgeClock, tint: .orange) + VStack(alignment: .leading, spacing: 3) { + Text(String(localized: isIncoming + ? L10n.Saved.devicesPendingIncoming + : L10n.Saved.devicesPendingOutgoing)) + .font(.body) + .lineLimit(2) + EndpointIdLabel(endpointId: relationship.remoteEndpointId) + } + Spacer(minLength: 0) + if busy { ProgressView().controlSize(.small) } + } + if isIncoming { + HStack(spacing: 10) { + Button(String(localized: L10n.Saved.devicesAcceptPairingAction), action: onAccept) + .buttonStyle(.borderedProminent) + Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline) + .buttonStyle(.bordered) + } + .controlSize(.small) + .disabled(busy) + } + } + .padding(.vertical, 4) + } +} + +/// A peer we completed a transfer with and may ask to pair. Naming it uses the +/// peer's untrusted hint — the only name available before the device is saved. +private struct EligibilityRow: View { + let eligibility: PairingEligibilityModel + let busy: Bool + let onRemember: () -> Void + let onDecline: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + DeviceAvatar(symbol: .checkmarkSealFill, tint: VniDropColors.brandPurple) + VStack(alignment: .leading, spacing: 3) { + Text(eligibility.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed)) + .font(.body) + .lineLimit(1) + Text(String(localized: L10n.Saved.devicesEligibilityTitle)) + .font(.caption) + .foregroundStyle(.secondary) + EndpointIdLabel(endpointId: eligibility.peerEndpointId) + } + Spacer(minLength: 0) + if busy { ProgressView().controlSize(.small) } + } + HStack(spacing: 10) { + Button(String(localized: L10n.Saved.devicesRememberAction), action: onRemember) + .buttonStyle(.borderedProminent) + Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline) + .buttonStyle(.bordered) + } + .controlSize(.small) + .disabled(busy) + } + .padding(.vertical, 4) + } +} diff --git a/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift b/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift new file mode 100644 index 0000000..5b95625 --- /dev/null +++ b/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift @@ -0,0 +1,216 @@ +import SwiftUI +import SFSafeSymbols + +/// Source composition for a targeted send. Deliberately mirrors the invitation +/// composer's file/folder/rename/replace/clear affordances — the two domains stay +/// distinct after creation, but choosing what to send works the same way. +struct TargetedSendComposer: View { + @ObservedObject var model: SavedDevicesModel + let deviceName: String + + private var state: SavedDevicesState { model.state } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if state.sendFiles.isEmpty { + chooseStep + } else { + reviewStep + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .savedDeviceSendPickers(model: model) + } + + private var chooseStep: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: L10n.Send.chooseFileTitle)) + .font(.title2) + .fontWeight(.semibold) + Text(recipientLine) + .font(.subheadline) + .foregroundStyle(.secondary) + VStack(spacing: 14) { + Image(systemSymbol: .doc) + .font(.system(size: 30)) + .foregroundStyle(.tint) + PrimaryButton( + title: String(localized: L10n.Button.chooseFiles), + action: model.selectSendFiles + ) + .fixedSize() + QuietButton( + title: String(localized: L10n.Button.chooseFolder), + action: model.selectSendFolder + ) + } + .frame(maxWidth: .infinity) + .padding(28) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16)) + } + } + + private var reviewStep: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(localized: L10n.Send.reviewTitle)) + .font(.title2) + .fontWeight(.semibold) + Text(recipientLine) + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(state.sendFiles) { file in + TargetedSourceCard( + file: file, + canRemove: state.sendFiles.count > 1 && !state.isCreatingSend, + onRemove: { model.removeSendFile(file.value) } + ) + } + + Field( + label: String(localized: L10n.Field.transferName), + value: Binding( + get: { state.sendTransferName }, + set: { model.setSendTransferName($0) } + ), + enabled: !state.isCreatingSend + ) + + // Every targeted transfer still needs the receiver to approve it; + // saying so here sets the right expectation before sending. + Label( + String(localized: L10n.Send.accessApprovalDescription), + systemSymbol: .checkmarkShield + ) + .font(.caption) + .foregroundStyle(.secondary) + + actions + } + } + + private var actions: some View { + VStack(spacing: 10) { + PrimaryButton( + title: String(localized: L10n.Saved.devicesSendAction), + action: model.createTargetedTransfer, + enabled: state.canCreateTargetedTransfer + ) + HStack(spacing: 10) { + sourceButton( + title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles + ) + sourceButton( + title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectSendFolder + ) + sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSendFiles) + } + } + } + + private func sourceButton( + title: String.LocalizationValue, + symbol: SFSymbol, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(String(localized: title), systemSymbol: symbol) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity) + .frame(minHeight: 20) + } + .buttonStyle(.bordered) + .controlSize(.large) + .tint(.secondary) + .disabled(state.isCreatingSend) + } + + private var recipientLine: String { + L10n.Saved.devicesTransferDirectionOutgoing(device: deviceName) + } +} + +private struct TargetedSourceCard: View { + let file: PickedShareFile + let canRemove: Bool + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemSymbol: file.isDirectory ? .folder : .doc) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 3) { + Text(file.displayName).lineLimit(1) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if canRemove { + Button(role: .destructive, action: onRemove) { + Image(systemSymbol: .trash) + } + .buttonStyle(.borderless) + .tint(.red) + } + } + .padding(14) + .frame(maxWidth: .infinity) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14)) + } + + private var subtitle: String { + if file.isDirectory { return String(localized: L10n.Send.folderLabel) } + if let size = file.sizeBytes { return formatBytes(size) } + return String(localized: L10n.Send.fileSizeUnknown) + } +} + +/// File/folder picker for targeted send. Attached to the composer so it presents +/// from the composer's own sheet rather than the already-presenting root — the +/// same constraint `SendPickers` documents. +private struct SavedDeviceSendPickers: ViewModifier { + @ObservedObject var model: SavedDevicesModel + + func body(content: Content) -> some View { + // One .fileImporter switched between files and folders: stacking two on a + // single view silently breaks, with the second shadowing the first. + content.fileImporter( + isPresented: Binding( + get: { model.pendingFilePick || model.pendingFolderPick }, + set: { presented in + if !presented { + model.pendingFilePick = false + model.pendingFolderPick = false + } + } + ), + allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item], + allowsMultipleSelection: !model.pendingFolderPick + ) { result in + let isDirectory = model.pendingFolderPick + model.pendingFilePick = false + model.pendingFolderPick = false + switch result { + case .success(let urls): + let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) } + if files.isEmpty { + model.onSendFilePickFailed("") + } else { + model.onSendFilesPicked(files) + } + case .failure(let error): + if !error.isUserCancellation { model.onSendFilePickFailed(error.technicalDetail) } + } + } + } +} + +extension View { + func savedDeviceSendPickers(model: SavedDevicesModel) -> some View { + modifier(SavedDeviceSendPickers(model: model)) + } +} diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index ae1d4d8..965dc1d 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -96,6 +96,11 @@ final class SendModel: ObservableObject { case .receiverHistoryChanged(let id), .approvalChanged(let id): if id == self.state.selectedTransferId { self.refreshReceivers(id) } self.refreshReceiverStatuses(for: id) + case .pairingChanged, .targetedTransferChanged: + // Saved-device domain; owned by SavedDevicesModel. Listed + // explicitly rather than via `default` so a new signal still + // forces a decision here. + break } } .store(in: &cancellables) diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index 31b8834..8deabae 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -73,6 +73,24 @@ struct IosFileSystemService: FileSystemService { ) } + func sendPickedFilesToSavedDevice( + repository: CoreGateway, + files: [PickedShareFile], + transferName: String, + receiverEndpointId: String + ) async -> Result { + guard !files.isEmpty else { + return .failure(InvitationError.shareEmpty) + } + // iOS picks by copying into the container, so no security scope has to be + // re-acquired here (unlike macOS). + return await repository.createTargetedTransfer( + receiverEndpointId: receiverEndpointId, + sources: files.map { $0.toIosShareSource() }, + transferName: transferName.isEmpty ? nil : transferName + ) + } + private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus { let url = URL(string: value) ?? URL(fileURLWithPath: value) let started = url.startAccessingSecurityScopedResource() diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index e445d47..65dd83d 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -44,10 +44,42 @@ struct MacFileSystemService: FileSystemService { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } - // Re-acquire security-scoped access to every picked source (from the bookmark - // captured at pick time) and hold it across the whole share call. The core - // imports the bytes during shareFiles(), so access only needs to survive that - // call; without this, the import fails with EPERM under the App Store sandbox. + guard case .invitation(let accessPolicy) = destination else { + return .failure(InvitationError.unsupportedOperation) + } + return await withScopedSources(files) { sources in + await repository.shareSources( + sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy + ) + } + } + + func sendPickedFilesToSavedDevice( + repository: CoreGateway, + files: [PickedShareFile], + transferName: String, + receiverEndpointId: String + ) async -> Result { + guard !files.isEmpty else { + return .failure(InvitationError.shareEmpty) + } + return await withScopedSources(files) { sources in + await repository.createTargetedTransfer( + receiverEndpointId: receiverEndpointId, + sources: sources, + transferName: transferName.isEmpty ? nil : transferName + ) + } + } + + /// Re-acquires security-scoped access to every picked source (from the bookmark + /// captured at pick time) and holds it across `body`. The core imports the bytes + /// during that call, so access only needs to survive it; without this the import + /// fails with EPERM under the App Store sandbox. + private func withScopedSources( + _ files: [PickedShareFile], + _ body: ([ShareSource]) async -> Result + ) async -> Result { var scopedURLs: [URL] = [] for file in files { guard let bookmark = file.securityScopeBookmark else { continue } @@ -63,12 +95,7 @@ struct MacFileSystemService: FileSystemService { let sources = files.map { ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory) } - guard case .invitation(let accessPolicy) = destination else { - return .failure(InvitationError.unsupportedOperation) - } - return await repository.shareSources( - sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy - ) + return await body(sources) } } #endif diff --git a/apple/VniDrop/UI/Navigation/AppDestination.swift b/apple/VniDrop/UI/Navigation/AppDestination.swift index de8804d..dec17a2 100644 --- a/apple/VniDrop/UI/Navigation/AppDestination.swift +++ b/apple/VniDrop/UI/Navigation/AppDestination.swift @@ -5,6 +5,7 @@ import SFSafeSymbols enum AppDestination: String, CaseIterable, Identifiable { case send case receive + case savedDevices case settings var id: String { rawValue } @@ -13,6 +14,7 @@ enum AppDestination: String, CaseIterable, Identifiable { switch self { case .send: return L10n.Nav.send case .receive: return L10n.Nav.receive + case .savedDevices: return L10n.Nav.savedDevices case .settings: return L10n.Nav.settings } } @@ -22,6 +24,7 @@ enum AppDestination: String, CaseIterable, Identifiable { switch self { case .send: return .paperplane case .receive: return .trayAndArrowDown + case .savedDevices: return .laptopcomputerAndIphone case .settings: return .gearshape } } diff --git a/localization/strings.json b/localization/strings.json index 63119b9..947a6eb 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1696,10 +1696,7 @@ "pl": "Urządzenia", "nl": "Apparaten", "ru": "Устройства" - }, - "targets": [ - "kmp" - ] + } }, "nav_send": { "context": "Bottom navigation: Send tab label.", @@ -3970,6 +3967,35 @@ "ru": "Все передачи удалены" } }, + "targeted_offer_body": { + "context": "Targeted transfer offer prompt: body. {device} is the sending saved device, {transferName} is the transfer name.", + "args": [{"name": "device", "type": "string"}, {"name": "transferName", "type": "string"}], + "translations": { + "en": "{device} wants to send you “{transferName}”.", + "fr": "{device} veut vous envoyer « {transferName} ».", + "es": "{device} quiere enviarle “{transferName}”.", + "it": "{device} vuole inviarle “{transferName}”.", + "de": "{device} möchte Ihnen „{transferName}“ senden.", + "pt": "{device} quer enviar-lhe “{transferName}”.", + "pl": "{device} chce wysłać Ci „{transferName}”.", + "nl": "{device} wil u “{transferName}” sturen.", + "ru": "{device} хочет отправить вам «{transferName}»." + } + }, + "targeted_offer_title": { + "context": "Targeted transfer offer prompt: title when a saved device wants to send files to this device.", + "translations": { + "en": "Incoming transfer", + "fr": "Transfert entrant", + "es": "Transferencia entrante", + "it": "Trasferimento in arrivo", + "de": "Eingehende Übertragung", + "pt": "Transferência a receber", + "pl": "Transfer przychodzący", + "nl": "Inkomende overdracht", + "ru": "Входящая передача" + } + }, "transfer_activity_description": { "context": "Transfer details: description under the Activity section.", "translations": { @@ -4840,8 +4866,7 @@ "pl": "Wymaga uwagi", "nl": "Aandacht vereist", "ru": "Требует внимания" - }, - "targets": ["kmp"] + } }, "saved_devices_transfers_title": { "context": "Saved devices screen: heading for durable targeted-transfer history.", @@ -4855,8 +4880,7 @@ "pl": "Transfery bezpośrednie", "nl": "Directe overdrachten", "ru": "Прямые передачи" - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_empty": { "context": "Saved devices screen: empty targeted-transfer history.", @@ -4870,8 +4894,7 @@ "pl": "Brak transferów bezpośrednich.", "nl": "Nog geen directe overdrachten.", "ru": "Прямых передач пока нет." - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_direction_incoming": { "context": "Targeted transfer card: incoming direction followed by the saved-device display name.", @@ -4886,8 +4909,7 @@ "pl": "Od {device}", "nl": "Van {device}", "ru": "От {device}" - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_direction_outgoing": { "context": "Targeted transfer card: outgoing direction followed by the saved-device display name.", @@ -4902,8 +4924,7 @@ "pl": "Do {device}", "nl": "Naar {device}", "ru": "Для {device}" - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_files": { "context": "Targeted transfer card metadata. First placeholder is file count, second is formatted total size.", @@ -4918,8 +4939,7 @@ "pl": "{count} plików · {size}", "nl": "{count} bestanden · {size}", "ru": "{count} файлов · {size}" - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_progress": { "context": "Targeted transfer card progress. First placeholder is verified bytes, second is total size.", @@ -4934,97 +4954,81 @@ "pl": "{verified} z {total}", "nl": "{verified} van {total}", "ru": "{verified} из {total}" - }, - "targets": ["kmp"] + } }, "saved_devices_transfer_receive": { "context": "Targeted transfer history action: receive an approved transfer.", - "translations": {"en": "Receive", "fr": "Recevoir", "es": "Recibir", "it": "Ricevi", "de": "Empfangen", "pt": "Receber", "pl": "Odbierz", "nl": "Ontvangen", "ru": "Получить"}, - "targets": ["kmp"] + "translations": {"en": "Receive", "fr": "Recevoir", "es": "Recibir", "it": "Ricevi", "de": "Empfangen", "pt": "Receber", "pl": "Odbierz", "nl": "Ontvangen", "ru": "Получить"} }, "saved_devices_transfer_resume": { "context": "Targeted transfer history action: resume an interrupted transfer.", - "translations": {"en": "Resume", "fr": "Reprendre", "es": "Reanudar", "it": "Riprendi", "de": "Fortsetzen", "pt": "Retomar", "pl": "Wznów", "nl": "Hervatten", "ru": "Продолжить"}, - "targets": ["kmp"] + "translations": {"en": "Resume", "fr": "Reprendre", "es": "Reanudar", "it": "Riprendi", "de": "Fortsetzen", "pt": "Retomar", "pl": "Wznów", "nl": "Hervatten", "ru": "Продолжить"} }, "saved_devices_transfer_cancel": { "context": "Targeted transfer history action: cancel an active transfer.", - "translations": {"en": "Cancel", "fr": "Annuler", "es": "Cancelar", "it": "Annulla", "de": "Abbrechen", "pt": "Cancelar", "pl": "Anuluj", "nl": "Annuleren", "ru": "Отменить"}, - "targets": ["kmp"] + "translations": {"en": "Cancel", "fr": "Annuler", "es": "Cancelar", "it": "Annulla", "de": "Abbrechen", "pt": "Cancelar", "pl": "Anuluj", "nl": "Annuleren", "ru": "Отменить"} }, "saved_devices_transfer_delete": { "context": "Targeted transfer history action: delete a terminal transfer record.", - "translations": {"en": "Delete", "fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "de": "Löschen", "pt": "Eliminar", "pl": "Usuń", "nl": "Verwijderen", "ru": "Удалить"}, - "targets": ["kmp"] + "translations": {"en": "Delete", "fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "de": "Löschen", "pt": "Eliminar", "pl": "Usuń", "nl": "Verwijderen", "ru": "Удалить"} }, "status_offering": { "context": "Targeted transfer status: offering to the saved device.", - "translations": {"en": "Offering", "fr": "Proposition", "es": "Ofreciendo", "it": "Proposta", "de": "Wird angeboten", "pt": "A propor", "pl": "Oferowanie", "nl": "Aanbieden", "ru": "Предложение"}, - "targets": ["kmp"] + "translations": {"en": "Offering", "fr": "Proposition", "es": "Ofreciendo", "it": "Proposta", "de": "Wird angeboten", "pt": "A propor", "pl": "Oferowanie", "nl": "Aanbieden", "ru": "Предложение"} }, "status_awaiting_approval": { "context": "Targeted transfer status: waiting for the saved device to approve.", - "translations": {"en": "Waiting for approval", "fr": "En attente d’approbation", "es": "Esperando aprobación", "it": "In attesa di approvazione", "de": "Wartet auf Genehmigung", "pt": "A aguardar aprovação", "pl": "Oczekiwanie na zgodę", "nl": "Wachten op goedkeuring", "ru": "Ожидание подтверждения"}, - "targets": ["kmp"] + "translations": {"en": "Waiting for approval", "fr": "En attente d’approbation", "es": "Esperando aprobación", "it": "In attesa di approvazione", "de": "Wartet auf Genehmigung", "pt": "A aguardar aprovação", "pl": "Oczekiwanie na zgodę", "nl": "Wachten op goedkeuring", "ru": "Ожидание подтверждения"} }, "status_approved": { "context": "Targeted transfer status: approved by the receiving saved device.", - "translations": {"en": "Approved", "fr": "Approuvé", "es": "Aprobado", "it": "Approvato", "de": "Genehmigt", "pt": "Aprovado", "pl": "Zatwierdzony", "nl": "Goedgekeurd", "ru": "Подтверждено"}, - "targets": ["kmp"] + "translations": {"en": "Approved", "fr": "Approuvé", "es": "Aprobado", "it": "Approvato", "de": "Genehmigt", "pt": "Aprovado", "pl": "Zatwierdzony", "nl": "Goedgekeurd", "ru": "Подтверждено"} }, "status_connecting": { "context": "Targeted transfer status: connecting to the saved device.", - "translations": {"en": "Connecting", "fr": "Connexion", "es": "Conectando", "it": "Connessione", "de": "Verbindung wird hergestellt", "pt": "A ligar", "pl": "Łączenie", "nl": "Verbinden", "ru": "Подключение"}, - "targets": ["kmp"] + "translations": {"en": "Connecting", "fr": "Connexion", "es": "Conectando", "it": "Connessione", "de": "Verbindung wird hergestellt", "pt": "A ligar", "pl": "Łączenie", "nl": "Verbinden", "ru": "Подключение"} }, "status_interrupted": { "context": "Targeted transfer status: interrupted and resumable.", - "translations": {"en": "Interrupted", "fr": "Interrompu", "es": "Interrumpido", "it": "Interrotto", "de": "Unterbrochen", "pt": "Interrompido", "pl": "Przerwany", "nl": "Onderbroken", "ru": "Прервано"}, - "targets": ["kmp"] + "translations": {"en": "Interrupted", "fr": "Interrompu", "es": "Interrumpido", "it": "Interrotto", "de": "Unterbrochen", "pt": "Interrompido", "pl": "Przerwany", "nl": "Onderbroken", "ru": "Прервано"} }, "status_transferring": { "context": "Targeted transfer status: authenticated content transfer is active.", - "translations": {"en": "Transferring", "fr": "Transfert", "es": "Transfiriendo", "it": "Trasferimento", "de": "Übertragung läuft", "pt": "A transferir", "pl": "Przesyłanie", "nl": "Overdragen", "ru": "Передача"}, - "targets": ["kmp"] + "translations": {"en": "Transferring", "fr": "Transfert", "es": "Transfiriendo", "it": "Trasferimento", "de": "Übertragung läuft", "pt": "A transferir", "pl": "Przesyłanie", "nl": "Overdragen", "ru": "Передача"} }, "status_declined": { "context": "Targeted transfer status: declined by the saved device.", - "translations": {"en": "Declined", "fr": "Refusé", "es": "Rechazado", "it": "Rifiutato", "de": "Abgelehnt", "pt": "Recusado", "pl": "Odrzucony", "nl": "Geweigerd", "ru": "Отклонено"}, - "targets": ["kmp"] + "translations": {"en": "Declined", "fr": "Refusé", "es": "Rechazado", "it": "Rifiutato", "de": "Abgelehnt", "pt": "Recusado", "pl": "Odrzucony", "nl": "Geweigerd", "ru": "Отклонено"} }, "saved_devices_authenticated_name": { "context": "Saved-device card: authenticated name shared by the remote app when a local label is also shown.", + "args": [{"name": "name", "type": "string"}], "translations": { - "en": "Remote name: %1$s", - "fr": "Nom distant : %1$s", - "es": "Nombre remoto: %1$s", - "it": "Nome remoto: %1$s", - "de": "Remote-Name: %1$s", - "pt": "Nome remoto: %1$s", - "pl": "Nazwa zdalna: %1$s", - "nl": "Externe naam: %1$s", - "ru": "Имя устройства: %1$s" - }, - "targets": [ - "kmp" - ] + "en": "Remote name: {name}", + "fr": "Nom distant : {name}", + "es": "Nombre remoto: {name}", + "it": "Nome remoto: {name}", + "de": "Remote-Name: {name}", + "pt": "Nome remoto: {name}", + "pl": "Nazwa zdalna: {name}", + "nl": "Externe naam: {name}", + "ru": "Имя устройства: {name}" + } }, "saved_devices_block_confirm_body": { "context": "Confirmation message before blocking a saved device. Placeholder is the display name.", + "args": [{"name": "device", "type": "string"}], "translations": { - "en": "Block %1$s and reject future saved-device transfers?", - "fr": "Bloquer %1$s et refuser les futurs transferts d’appareil enregistré ?", - "es": "¿Bloquear a %1$s y rechazar futuros envíos de dispositivos guardados?", - "it": "Bloccare %1$s e rifiutare i futuri trasferimenti da dispositivi salvati?", - "de": "%1$s blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?", - "pt": "Bloquear %1$s e rejeitar futuras transferências de dispositivos guardados?", - "pl": "Zablokować %1$s i odrzucać przyszłe transfery z zapisanych urządzeń?", - "nl": "%1$s blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?", - "ru": "Заблокировать %1$s и отклонять будущие передачи с сохранённых устройств?" - }, - "targets": [ - "kmp" - ] + "en": "Block {device} and reject future saved-device transfers?", + "fr": "Bloquer {device} et refuser les futurs transferts d’appareil enregistré ?", + "es": "¿Bloquear a {device} y rechazar futuros envíos de dispositivos guardados?", + "it": "Bloccare {device} e rifiutare i futuri trasferimenti da dispositivi salvati?", + "de": "{device} blockieren und künftige Übertragungen gespeicherter Geräte ablehnen?", + "pt": "Bloquear {device} e rejeitar futuras transferências de dispositivos guardados?", + "pl": "Zablokować {device} i odrzucać przyszłe transfery z zapisanych urządzeń?", + "nl": "{device} blokkeren en toekomstige overdrachten van opgeslagen apparaten weigeren?", + "ru": "Заблокировать {device} и отклонять будущие передачи с сохранённых устройств?" + } }, "saved_devices_block_confirm_title": { "context": "Confirmation dialog title before blocking a saved device.", @@ -5038,27 +5042,22 @@ "pl": "Zablokować urządzenie?", "nl": "Apparaat blokkeren?", "ru": "Заблокировать устройство?" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_endpoint": { "context": "Saved-device screen: secondary diagnostic endpoint identity. Placeholder is a shortened endpoint ID.", + "args": [{"name": "deviceId", "type": "string"}], "translations": { - "en": "Device ID: %1$s", - "fr": "ID de l’appareil : %1$s", - "es": "ID del dispositivo: %1$s", - "it": "ID dispositivo: %1$s", - "de": "Geräte-ID: %1$s", - "pt": "ID do dispositivo: %1$s", - "pl": "ID urządzenia: %1$s", - "nl": "Apparaat-ID: %1$s", - "ru": "ID устройства: %1$s" - }, - "targets": [ - "kmp" - ] + "en": "Device ID: {deviceId}", + "fr": "ID de l’appareil : {deviceId}", + "es": "ID del dispositivo: {deviceId}", + "it": "ID dispositivo: {deviceId}", + "de": "Geräte-ID: {deviceId}", + "pt": "ID do dispositivo: {deviceId}", + "pl": "ID urządzenia: {deviceId}", + "nl": "Apparaat-ID: {deviceId}", + "ru": "ID устройства: {deviceId}" + } }, "saved_devices_empty": { "context": "Saved devices screen: guidance shown when no device has been saved.", @@ -5072,10 +5071,7 @@ "pl": "Ukończ transfer, a następnie zapisz drugie urządzenie.", "nl": "Voltooi een overdracht en kies daarna om het andere apparaat te onthouden.", "ru": "Завершите передачу, затем сохраните другое устройство." - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_empty_title": { "context": "Saved devices screen: title for the empty state.", @@ -5089,25 +5085,22 @@ "pl": "Brak zapisanych urządzeń", "nl": "Geen opgeslagen apparaten", "ru": "Нет сохранённых устройств" - }, - "targets": ["kmp"] + } }, "saved_devices_forget_confirm_body": { "context": "Confirmation message before forgetting a saved device. Placeholder is the display name.", + "args": [{"name": "device", "type": "string"}], "translations": { - "en": "Forget %1$s? You will both need to approve saving again before another direct transfer.", - "fr": "Oublier %1$s ? Vous devrez tous les deux approuver à nouveau l’enregistrement avant un autre transfert direct.", - "es": "¿Olvidar a %1$s? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.", - "it": "Dimenticare %1$s? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.", - "de": "%1$s vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.", - "pt": "Esquecer %1$s? Ambos terão de voltar a aprovar antes de outra transferência direta.", - "pl": "Zapomnieć %1$s? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.", - "nl": "%1$s vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.", - "ru": "Забыть %1$s? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны." - }, - "targets": [ - "kmp" - ] + "en": "Forget {device}? You will both need to approve saving again before another direct transfer.", + "fr": "Oublier {device} ? Vous devrez tous les deux approuver à nouveau l’enregistrement avant un autre transfert direct.", + "es": "¿Olvidar a {device}? Ambos deberán volver a aprobar el guardado antes de otra transferencia directa.", + "it": "Dimenticare {device}? Entrambi dovrete approvare nuovamente il salvataggio prima di un altro trasferimento diretto.", + "de": "{device} vergessen? Vor einer weiteren direkten Übertragung müssen beide das Speichern erneut bestätigen.", + "pt": "Esquecer {device}? Ambos terão de voltar a aprovar antes de outra transferência direta.", + "pl": "Zapomnieć {device}? Przed kolejnym transferem bezpośrednim obie strony muszą ponownie zatwierdzić zapisanie.", + "nl": "{device} vergeten? Jullie moeten het opslaan allebei opnieuw goedkeuren voor een volgende directe overdracht.", + "ru": "Забыть {device}? Перед следующей прямой передачей сохранение снова должны подтвердить обе стороны." + } }, "saved_devices_forget_confirm_title": { "context": "Confirmation dialog title before forgetting a saved device.", @@ -5121,10 +5114,7 @@ "pl": "Zapomnieć urządzenie?", "nl": "Apparaat vergeten?", "ru": "Забыть устройство?" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_eligibility_title": { "context": "Saved devices screen: section for peers eligible to pair after a transfer.", @@ -5138,10 +5128,7 @@ "pl": "Ready to remember", "nl": "Ready to remember", "ru": "Ready to remember" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_pending_title": { "context": "Saved devices screen: section for in-progress pairing.", @@ -5155,10 +5142,7 @@ "pl": "Pending pairing", "nl": "Pending pairing", "ru": "Pending pairing" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_list_title": { "context": "Saved devices screen title and saved-device list heading.", @@ -5172,10 +5156,7 @@ "pl": "Saved devices", "nl": "Saved devices", "ru": "Saved devices" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_load_failed": { "context": "Saved devices screen: inline error when device state could not be loaded.", @@ -5189,10 +5170,7 @@ "pl": "Nie udało się wczytać zapisanych urządzeń.", "nl": "Opgeslagen apparaten konden niet worden geladen.", "ru": "Не удалось загрузить сохранённые устройства." - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_loading": { "context": "Saved devices screen: loading state label.", @@ -5206,27 +5184,22 @@ "pl": "Wczytywanie zapisanych urządzeń…", "nl": "Opgeslagen apparaten laden…", "ru": "Загрузка сохранённых устройств…" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_more_actions": { "context": "Accessibility label for the saved-device overflow menu. Placeholder is the display name.", + "args": [{"name": "device", "type": "string"}], "translations": { - "en": "More actions for %1$s", - "fr": "Plus d’actions pour %1$s", - "es": "Más acciones para %1$s", - "it": "Altre azioni per %1$s", - "de": "Weitere Aktionen für %1$s", - "pt": "Mais ações para %1$s", - "pl": "Więcej działań dla %1$s", - "nl": "Meer acties voor %1$s", - "ru": "Другие действия для %1$s" - }, - "targets": [ - "kmp" - ] + "en": "More actions for {device}", + "fr": "Plus d’actions pour {device}", + "es": "Más acciones para {device}", + "it": "Altre azioni per {device}", + "de": "Weitere Aktionen für {device}", + "pt": "Mais ações para {device}", + "pl": "Więcej działań dla {device}", + "nl": "Meer acties voor {device}", + "ru": "Другие действия для {device}" + } }, "saved_devices_no_pending": { "context": "Desktop Saved devices screen: status when no pairing decision is waiting.", @@ -5240,10 +5213,7 @@ "pl": "Żadne prośby o sparowanie nie wymagają uwagi.", "nl": "Er zijn geen koppelverzoeken die aandacht nodig hebben.", "ru": "Нет запросов на сопряжение, требующих внимания." - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_unnamed": { "context": "Fallback display when a saved device has no local label or remote name.", @@ -5257,10 +5227,7 @@ "pl": "Saved device", "nl": "Saved device", "ru": "Saved device" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_remember_action": { "context": "Action that starts mutual-consent pairing for an eligible peer.", @@ -5274,10 +5241,7 @@ "pl": "Remember", "nl": "Remember", "ru": "Remember" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_decline_action": { "context": "Action that declines pairing eligibility for a peer.", @@ -5291,10 +5255,7 @@ "pl": "Decline", "nl": "Decline", "ru": "Decline" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_accept_pairing_action": { "context": "Action that accepts an incoming pairing request.", @@ -5308,10 +5269,7 @@ "pl": "Accept", "nl": "Accept", "ru": "Accept" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_send_action": { "context": "Action that starts a targeted send to a saved device.", @@ -5325,10 +5283,7 @@ "pl": "Send files", "nl": "Send files", "ru": "Send files" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_label_action": { "context": "Action that edits the local label for a saved device.", @@ -5342,10 +5297,7 @@ "pl": "Label", "nl": "Label", "ru": "Label" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_forget_action": { "context": "Action that forgets a saved device relationship.", @@ -5359,10 +5311,7 @@ "pl": "Forget", "nl": "Forget", "ru": "Forget" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_block_action": { "context": "Action that blocks a device identity.", @@ -5376,10 +5325,7 @@ "pl": "Block", "nl": "Block", "ru": "Block" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_label_title": { "context": "Dialog title for editing a saved device local label.", @@ -5393,10 +5339,7 @@ "pl": "Device label", "nl": "Device label", "ru": "Device label" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_label_placeholder": { "context": "Placeholder for the local label text field.", @@ -5410,10 +5353,7 @@ "pl": "Label", "nl": "Label", "ru": "Label" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_label_save": { "context": "Button that saves the local device label.", @@ -5427,10 +5367,7 @@ "pl": "Save", "nl": "Save", "ru": "Save" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_label_clear": { "context": "Button that clears the local device label.", @@ -5444,10 +5381,7 @@ "pl": "Clear label", "nl": "Clear label", "ru": "Clear label" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_pending_outgoing": { "context": "Status text while waiting for the peer to accept pairing.", @@ -5461,10 +5395,7 @@ "pl": "Waiting for the other device", "nl": "Waiting for the other device", "ru": "Waiting for the other device" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_pending_incoming": { "context": "Status text when this device must accept a pairing request.", @@ -5478,10 +5409,7 @@ "pl": "Wants to remember this device", "nl": "Wants to remember this device", "ru": "Wants to remember this device" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_send_started": { "context": "Snackbar after creating a targeted transfer offer.", @@ -5495,10 +5423,7 @@ "pl": "Transfer offer sent", "nl": "Transfer offer sent", "ru": "Transfer offer sent" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_forgotten": { "context": "Snackbar after forgetting a saved device.", @@ -5512,10 +5437,7 @@ "pl": "Saved device forgotten", "nl": "Saved device forgotten", "ru": "Saved device forgotten" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_blocked": { "context": "Snackbar after blocking a device.", @@ -5529,10 +5451,7 @@ "pl": "Device blocked", "nl": "Device blocked", "ru": "Device blocked" - }, - "targets": [ - "kmp" - ] + } }, "saved_devices_labeled": { "context": "Snackbar after updating a saved device label.", @@ -5546,10 +5465,7 @@ "pl": "Label updated", "nl": "Label updated", "ru": "Label updated" - }, - "targets": [ - "kmp" - ] + } } } } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 8c7c90a..7089001 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -265,6 +265,8 @@ Gesamt Übertragungsdaten Alle Übertragungen gelöscht + %1$s möchte Ihnen „%2$s“ senden. + Eingehende Übertragung Wichtige Aktualisierungen zu dieser Übertragung ansehen Aktivität „%1$s“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 26091e3..0034a9c 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -265,6 +265,8 @@ Total Datos de transferencia Todas las transferencias eliminadas + %1$s quiere enviarle “%2$s”. + Transferencia entrante Vea las actualizaciones importantes de esta transferencia Actividad «%1$s» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 86308d6..e71faeb 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -265,6 +265,8 @@ Total Données de transfert Tous les transferts supprimés + %1$s veut vous envoyer « %2$s ». + Transfert entrant Consultez les mises à jour importantes de ce transfert Activité « %1$s » cessera d’être partagé et son historique de transfert sera retiré de cet appareil. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index eff673a..f8e0cd1 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -265,6 +265,8 @@ Totale Dati di trasferimento Tutti i trasferimenti eliminati + %1$s vuole inviarle “%2$s”. + Trasferimento in arrivo Veda gli aggiornamenti importanti di questo trasferimento Attività «%1$s» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 108cfd1..0c31e76 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -265,6 +265,8 @@ Totaal Overdrachtsgegevens Alle overdrachten verwijderd + %1$s wil u “%2$s” sturen. + Inkomende overdracht Bekijk belangrijke updates voor deze overdracht Activiteit ‘%1$s’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 5e2b5ac..c15813b 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -265,6 +265,8 @@ Łącznie Dane transferu Usunięto wszystkie transfery + %1$s chce wysłać Ci „%2$s”. + Transfer przychodzący Zobacz ważne aktualizacje tego transferu Aktywność „%1$s” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 4bc978c..2d7df8a 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -265,6 +265,8 @@ Total Dados de transferência Todas as transferências eliminadas + %1$s quer enviar-lhe “%2$s”. + Transferência a receber Ver as atualizações importantes desta transferência Atividade «%1$s» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index e46d893..f2e1236 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -265,6 +265,8 @@ Всего Данные передачи Все передачи удалены + %1$s хочет отправить вам «%2$s». + Входящая передача Просматривайте важные обновления этой передачи Активность Общий доступ к «%1$s» будет остановлен, а история передачи будет удалена с этого устройства. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 5298f53..7db3918 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -265,6 +265,8 @@ Total Transfer data All transfers deleted + %1$s wants to send you “%2$s”. + Incoming transfer See important updates for this transfer Activity “%1$s” will stop being shared and its transfer history will be removed from this device.