diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 33356b5..3f622f3 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -42,6 +42,24 @@ final class FakeCoreGateway: CoreGateway { private(set) var resetUnrecoverableIdentityCount = 0 func setState(_ state: CoreState) { stateSubject.send(state) } + + /// Publishes the `created` lifecycle event the core emits once a targeted + /// transfer row exists, then the signal that follows it — the sequence that + /// lets a caller cancel a create still holding the serial lane. + func emitTargetedTransferCreated(id: String) { + var state = stateSubject.value + state.events.insert( + CoreEventModel( + id: "event-\(id)", revision: 1, timestamp: 1, scope: "endpoint", transferId: nil, + direction: nil, phase: EventPhase.targetedTransfer.rawValue, + kind: EventKind.created.rawValue, + dataJson: #"{"targeted_transfer_id":"\#(id)"}"# + ), + at: 0 + ) + stateSubject.send(state) + emit(.targetedTransferChanged) + } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } func initialize( @@ -185,10 +203,29 @@ final class FakeCoreGateway: CoreGateway { offerResponses.append((transferId, accepted)) return offerResponseResult } + /// Holds `createTargetedTransfer` open until `releaseTargetedCreate()`, so a + /// test can observe the model while a create is genuinely in flight — the + /// state the user is stuck in when the receiving device never answers. + var holdsTargetedCreate = false + private var targetedCreateGate: CheckedContinuation? + + /// True once the call is parked on the gate. `isCreatingSend` flips before the + /// task body runs, so releasing on that alone can resume nothing and hang. + var isHoldingTargetedCreate: Bool { targetedCreateGate != nil } + + func releaseTargetedCreate() { + let gate = targetedCreateGate + targetedCreateGate = nil + gate?.resume() + } + func createTargetedTransfer( receiverEndpointId: String, sources: [ShareSource], transferName: String? ) async -> Result { createdTargetedTransfers.append((receiverEndpointId, sources, transferName)) + if holdsTargetedCreate { + await withCheckedContinuation { targetedCreateGate = $0 } + } return createTargetedTransferResult } func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> { diff --git a/apple/Tests/SavedDevicesModelTests.swift b/apple/Tests/SavedDevicesModelTests.swift index 313823c..0f84057 100644 --- a/apple/Tests/SavedDevicesModelTests.swift +++ b/apple/Tests/SavedDevicesModelTests.swift @@ -373,6 +373,27 @@ final class SavedDevicesModelTests: XCTestCase { XCTAssertEqual(gateway.forgottenDevices, [Self.peer]) } + // MARK: - Transfer actions + + func testOnlyTheReceivingSideIsOfferedReceiveAndResume() async { + gateway.savedDevices = [savedDevice()] + gateway.targetedTransfers = [ + transfer(id: "in-approved", sender: Self.peer, receiver: Self.localEndpoint, state: .approved), + transfer(id: "out-approved", sender: Self.localEndpoint, receiver: Self.peer, state: .approved), + transfer(id: "in-interrupted", sender: Self.peer, receiver: Self.localEndpoint, state: .interrupted), + transfer(id: "out-interrupted", sender: Self.localEndpoint, receiver: Self.peer, state: .interrupted), + ] + let model = await makeModel() + let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) }) + + XCTAssertEqual(byId["in-approved"]?.canReceive, true) + XCTAssertEqual(byId["in-interrupted"]?.canResume, true) + // The sender has nothing to pull: it is the one holding the files. Offering + // "Receive" there asked it to download its own outgoing transfer. + XCTAssertEqual(byId["out-approved"]?.canReceive, false) + XCTAssertEqual(byId["out-interrupted"]?.canResume, false) + } + // MARK: - Targeted send private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile { @@ -415,6 +436,146 @@ final class SavedDevicesModelTests: XCTestCase { XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) } + /// Sets up a create that never returns until released — an unavailable peer, + /// where the core waits out its connection and offer timeouts. `announcesId` + /// mirrors the core emitting `created` once the row exists, which it does + /// before it ever contacts the peer. + private func stalledSend( + _ model: SavedDevicesModel, + id: String = "t-inflight", + announcesId: Bool = true + ) async { + gateway.holdsTargetedCreate = true + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("a.txt")]) + model.createTargetedTransfer() + await waitUntil { self.gateway.isHoldingTargetedCreate } + if announcesId { + gateway.emitTargetedTransferCreated(id: id) + await waitUntil { !model.state.isCreatingSend || model.knowsInFlightSendTransfer } + } + XCTAssertTrue(model.state.isCreatingSend) + } + + func testCancellingAnUnansweredSendReachesTheCoreWhileItIsStillRunning() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight")) + let model = await makeModel() + await stalledSend(model) + + model.abandonSend() + + // The point of cancelling: it must not wait for the create to finish. The + // cancel goes out while the core is still parked inside that very call. + await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty } + XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-inflight"]) + XCTAssertFalse(model.state.isCreatingSend) + XCTAssertNil(model.state.sendTargetPeerId) + + gateway.releaseTargetedCreate() + } + + func testCancelledSendIsDeletedRatherThanLeftInHistory() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight")) + let model = await makeModel() + await stalledSend(model) + + model.abandonSend() + await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty } + + // Cancelling is not "closing the sheet": the core would otherwise record a + // failed transfer, and the user would be told a send they called off failed. + XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-inflight"]) + gateway.releaseTargetedCreate() + } + + func testCancelledSendNeverReachesHistoryEvenIfARefreshRacesTheDelete() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight")) + // The core reports it as failed — the state an unanswered offer lands in. + gateway.targetedTransfers = [transfer(id: "t-inflight", state: .failed)] + let model = await makeModel() + await stalledSend(model) + + model.abandonSend() + gateway.releaseTargetedCreate() + await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty } + + // Nothing about the cancelled send may surface, or the notification + // coordinator announces a failure for work the user called off. + XCTAssertTrue(model.state.targetedTransfers.isEmpty) + } + + func testCancellingReleasesTheComposerBeforeTheCreateReturns() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight")) + let model = await makeModel() + await stalledSend(model) + + model.abandonSend() + + XCTAssertFalse(model.state.isCreatingSend) + XCTAssertTrue(model.state.sendFiles.isEmpty) + // The import still owns the sources until the call lands. + XCTAssertTrue(fileSystem.discardedFiles.isEmpty) + + gateway.releaseTargetedCreate() + await waitUntil { !self.fileSystem.discardedFiles.isEmpty } + XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) + } + + func testCancelledSendWithNoIdYetIsStillCleanedUpWhenTheCreateReturns() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-late")) + let model = await makeModel() + // The create beat its own `created` event, so cancelling has no id to use. + await stalledSend(model, announcesId: false) + model.abandonSend() + XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty) + + gateway.releaseTargetedCreate() + + // The result carries the id, so the cleanup still happens — just later. + await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty } + XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-late"]) + XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-late"]) + } + + func testCancelledSendThatNeverRegisteredHasNothingToCancel() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .failure(TestError.unimplemented) + let model = await makeModel() + await stalledSend(model, announcesId: false) + model.abandonSend() + + gateway.releaseTargetedCreate() + + await waitUntil { !self.fileSystem.discardedFiles.isEmpty } + XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty) + // The composition is gone, so a failure the user walked away from is not + // resurrected as an error they have to dismiss. + XCTAssertTrue(model.state.sendFiles.isEmpty) + } + + func testCancelledResultDoesNotDisturbANewerSend() async { + gateway.savedDevices = [savedDevice()] + gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight")) + let model = await makeModel() + await stalledSend(model) + model.abandonSend() + + // The user starts composing again while the first call is still pending. + model.beginSend(to: Self.peer) + model.onSendFilesPicked([picked("b.txt")]) + gateway.releaseTargetedCreate() + await waitUntil { !self.fileSystem.discardedFiles.isEmpty } + + XCTAssertEqual(model.state.sendTargetPeerId, Self.peer) + XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"]) + XCTAssertFalse(model.state.isCreatingSend) + } + func testSendFailureKeepsCompositionForRetry() async { gateway.savedDevices = [savedDevice()] gateway.createTargetedTransferResult = .failure(TestError.unimplemented) diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 1a2c224..04ad055 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -27,6 +27,30 @@ struct CoreEventModel: Equatable, Identifiable, Sendable { var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) } var eventPhase: EventPhase? { EventPhase(rawValue: phase) } var eventKind: EventKind? { EventKind(rawValue: kind) } + + /// Subject of a `.targetedTransfer` event. + /// + /// This is an identifier, not state: it says *which* transfer changed, which + /// is all a consumer may take from an event before re-reading durable state. + /// It exists because the id is otherwise unobtainable while `create` is still + /// running — that call occupies the serial lane, so no query can answer until + /// it returns, which is exactly when the user wants to cancel it. + var targetedTransferId: String? { + guard eventPhase == .targetedTransfer, let data = dataJson.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(TargetedTransferEventData.self, from: data).targetedTransferId + } +} + +/// Payload of a `.targetedTransfer` event. Named keys rather than a raw string +/// subscript so the wire contract lives in one declared place. +private struct TargetedTransferEventData: Decodable { + let targetedTransferId: String? + + private enum CodingKeys: String, CodingKey { + case targetedTransferId = "targeted_transfer_id" + } } /// Direction of a core event, matching the wire strings the core emits. diff --git a/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift b/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift index bd4fc83..6035106 100644 --- a/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift +++ b/apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift @@ -315,12 +315,12 @@ private struct TargetedTransferRow: View { let run: () -> Void } - /// At most one of receive/resume applies, and only in one state each. + /// At most one of receive/resume applies: one state each, receiving side only. private var primaryAction: TransferAction? { - if transfer.state.canReceive { + if transfer.canReceive { return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive) } - if transfer.state.canResume { + if transfer.canResume { return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume) } return nil @@ -468,14 +468,23 @@ private struct TargetedSendSheet: View { #endif .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(String(localized: L10n.Button.close), action: model.cancelSend) - .disabled(model.state.isCreatingSend) + // Never disabled: a create in flight can take minutes against an + // unavailable device, and a dead Close left no way out at all. + // It reads "Cancel" while waiting, because that is what leaving + // now does — abandoning keeps the sources alive for the core's + // import and cleans them up once the call lands. + Button(String(localized: model.state.isCreatingSend + ? L10n.Button.cancel + : L10n.Button.close)) { + if model.state.isCreatingSend { + model.abandonSend() + } else { + model.cancelSend() + } + } } } } - // 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) } } diff --git a/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift b/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift index 3f6fa07..3f87bea 100644 --- a/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift +++ b/apple/VniDrop/Features/SavedDevices/SavedDeviceExperienceModels.swift @@ -76,4 +76,12 @@ struct SavedDeviceTransferItem: Equatable, Identifiable, Sendable { guard totalSize > 0, state.isActive || state == .interrupted else { return nil } return min(1, Double(verifiedBytes) / Double(totalSize)) } + + /// Pulling content is the receiving side's move. Gating on state alone put a + /// "Receive" button on the sender's own outgoing transfer, offering to + /// download the files it was in the middle of sending. + var canReceive: Bool { direction == .incoming && state.canReceive } + + /// Resuming likewise pulls into a local folder, so it is receiver-only. + var canResume: Bool { direction == .incoming && state.canResume } } diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift index 43f9e34..68e6ded 100644 --- a/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicePresentation.swift @@ -46,7 +46,7 @@ extension TargetedTransferStateModel { /// hardware type, so showing a specific device silhouette would imply knowledge /// VniDrop does not have. struct DeviceAvatar: View { - var symbol: SFSymbol = .laptopcomputerAndIphone + var symbol: SFSymbol = .macbookAndIphone var tint: Color = .secondary var size: CGFloat = 40 diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift index 6bfcb37..0b0e9a3 100644 --- a/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift @@ -89,6 +89,18 @@ final class SavedDevicesModel: ObservableObject { /// 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 = [] + /// Identifies each create attempt so a result arriving after the user gave up + /// can tell whether it is the one that was abandoned. + private var sendGeneration = 0 + private var abandonedSendGeneration: Int? + /// The transfer the in-flight create registered, learned from its `created` + /// event. Cancelling needs an id, and the create does not return one until it + /// has already spent its timeouts against a device that may never answer. + private var inFlightSendTransferId: String? + /// Transfers the user walked away from. They are cancelled and deleted, but + /// until that lands they stay out of the published list so history and + /// notifications never mention a transfer the user called off. + private var abandonedTransferIds: Set = [] private var receiveFolder: ReceiveFolder? init( @@ -121,7 +133,10 @@ final class SavedDevicesModel: ObservableObject { switch signal { case .pairingChanged, .targetedTransferChanged: // Wake-up only: re-read durable state rather than trusting the - // event payload (see DESIGN-DEVICE-HISTORY.md §13). + // event payload (see DESIGN-DEVICE-HISTORY.md §13). The one + // exception is noting *which* transfer a create just made, which + // no query can answer while that create holds the serial lane. + self.noteInFlightSendTransferId() if self.repository.state.isInitialized { self.scheduleRefresh() } case .approvalChanged, .receiverHistoryChanged, .transfersChanged: // Invitation-share domain; owned by SendModel. @@ -300,6 +315,57 @@ final class SavedDevicesModel: ObservableObject { discardPickedFiles(discarded) } + /// Cancels a create that is still in flight. + /// + /// The create reaches out to the peer and only returns once the offer is + /// answered or its timeouts expire, which against an unavailable device is + /// minutes. So this cancels the transfer by id — off the serial lane, which + /// is what lets it reach a core busy inside that very call — and drops the + /// composer immediately. Cancelling is not merely closing the sheet: without + /// it the core would go on to record a failed transfer, then announce and + /// list a send the user had already called off. + func abandonSend() { + guard state.isCreatingSend else { return } + abandonedSendGeneration = sendGeneration + state.isCreatingSend = false + state.sendTargetPeerId = nil + state.sendFiles = [] + state.sendTransferName = "" + + guard let transferId = inFlightSendTransferId else { return } + abandonedTransferIds.insert(transferId) + Task { await cancelAndForget(transferId) } + } + + /// Whether the in-flight create has announced the transfer it registered, and + /// so whether cancelling can reach it now rather than after the call returns. + var knowsInFlightSendTransfer: Bool { inFlightSendTransferId != nil } + + /// Captures the id of the transfer the in-flight create just registered. The + /// core inserts the row and emits `created` before it contacts the peer, so + /// this lands well before the wait the user gives up on. + private func noteInFlightSendTransferId() { + guard state.isCreatingSend, inFlightSendTransferId == nil else { return } + let created = repository.state.events.first { + $0.eventPhase == .targetedTransfer && $0.eventKind == .created + } + guard let id = created?.targetedTransferId else { return } + inFlightSendTransferId = id + } + + /// Cancels an abandoned transfer and removes it from history. Both are best + /// effort: the user has moved on, so a failure here is logged rather than + /// raised as an error about work they already dismissed. + private func cancelAndForget(_ transferId: String) async { + if case .failure(let error) = await repository.cancelTargetedTransfer(id: transferId) { + AppLogger.error("saved-devices", "abandoned send cancel failed", error) + } + if case .failure(let error) = await repository.deleteTargetedTransfer(id: transferId) { + AppLogger.error("saved-devices", "abandoned send delete failed", error) + } + await refresh() + } + func selectSendFiles() { pendingFilePick = true } func selectSendFolder() { pendingFolderPick = true } @@ -347,6 +413,9 @@ final class SavedDevicesModel: ObservableObject { guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return } let files = state.sendFiles let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines) + sendGeneration &+= 1 + let generation = sendGeneration + inFlightSendTransferId = nil state.isCreatingSend = true Task { let result = await fileSystemService.sendPickedFilesToSavedDevice( @@ -355,7 +424,14 @@ final class SavedDevicesModel: ObservableObject { transferName: name, receiverEndpointId: peerId ) + // The user walked away from this attempt, and may already have started + // another one; touching the composer now would stomp that newer state. + if abandonedSendGeneration == generation { + await reconcileAbandonedSend(result, files: files) + return + } state.isCreatingSend = false + inFlightSendTransferId = nil switch result { case .success: messages.tryShow( @@ -375,6 +451,24 @@ final class SavedDevicesModel: ObservableObject { } } + /// Runs when an abandoned create finally returns. The cancel itself already + /// went out by id; this releases the sources, which the core's import owned + /// until now, and covers the case where the id never arrived — the create + /// beat its own `created` event, or failed before registering anything. + private func reconcileAbandonedSend( + _ result: Result, + files: [PickedShareFile] + ) async { + inFlightSendTransferId = nil + discardPickedFiles(files) + if case .success(let transfer) = result, !abandonedTransferIds.contains(transfer.id) { + abandonedTransferIds.insert(transfer.id) + await cancelAndForget(transfer.id) + return + } + await refresh() + } + private func defaultTransferName(_ files: [PickedShareFile]) -> String { guard let first = files.first else { return "" } return files.count == 1 @@ -545,7 +639,10 @@ final class SavedDevicesModel: ObservableObject { state.pendingRelationships = pendingRelationships state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt } state.targetedTransfers = transfers - .filter { $0.state != .deleted } + // A cancelled-and-deleted transfer can still be in this snapshot if + // the read raced the delete. Publishing it would put a send the user + // called off into history and fire a notification about it. + .filter { $0.state != .deleted && !abandonedTransferIds.contains($0.id) } .sorted { $0.updatedAt > $1.updatedAt } .map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) } // Leave a prompt mid-answer alone; replacing it would strand the diff --git a/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift b/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift index 688352e..82e13c5 100644 --- a/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift +++ b/apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift @@ -129,7 +129,7 @@ struct SavedDevicesScreen: View { /// 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) + Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .macbookAndIphone) } description: { Text(String(localized: L10n.Saved.devicesEmpty)) } diff --git a/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift b/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift index 5b95625..65097eb 100644 --- a/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift +++ b/apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift @@ -93,11 +93,25 @@ struct TargetedSendComposer: View { private var actions: some View { VStack(spacing: 10) { - PrimaryButton( - title: String(localized: L10n.Saved.devicesSendAction), - action: model.createTargetedTransfer, - enabled: state.canCreateTargetedTransfer - ) + if state.isCreatingSend { + // Reaching an unavailable device can take minutes, so the wait says + // so. Abandoning it lives on the toolbar's cancel item alone — a + // second button here would be the same action under a second name. + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(String(localized: L10n.Saved.devicesSendWaiting)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + 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 diff --git a/apple/VniDrop/UI/Navigation/AppDestination.swift b/apple/VniDrop/UI/Navigation/AppDestination.swift index dec17a2..1844b9f 100644 --- a/apple/VniDrop/UI/Navigation/AppDestination.swift +++ b/apple/VniDrop/UI/Navigation/AppDestination.swift @@ -24,7 +24,7 @@ enum AppDestination: String, CaseIterable, Identifiable { switch self { case .send: return .paperplane case .receive: return .trayAndArrowDown - case .savedDevices: return .laptopcomputerAndIphone + case .savedDevices: return .macbookAndIphone case .settings: return .gearshape } } diff --git a/localization/strings.json b/localization/strings.json index 83ee174..eab8291 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -5485,6 +5485,21 @@ "ru": "Transfer offer sent" } }, + "saved_devices_send_waiting": { + "context": "Progress label while a targeted transfer offer is waiting for the receiving device to answer.", + "targets": ["apple"], + "translations": { + "en": "Waiting for the device to answer…", + "fr": "Waiting for the device to answer…", + "es": "Waiting for the device to answer…", + "it": "Waiting for the device to answer…", + "de": "Waiting for the device to answer…", + "pt": "Waiting for the device to answer…", + "pl": "Waiting for the device to answer…", + "nl": "Waiting for the device to answer…", + "ru": "Waiting for the device to answer…" + } + }, "saved_devices_forgotten": { "context": "Snackbar after forgetting a saved device.", "translations": {