feat(apple): notify the sender when a receiver's delivery fails

plannedReceiverNotifications only fired for completed receivers, so a failed
delivery produced no notification. Add a receiverFailed kind wired through the
planner, id, and deliver paths, with localized notifications_receiver_failed_*
strings and a unit test.
This commit is contained in:
2026-07-24 18:45:02 +02:00
parent 35f06a0b6b
commit f513a6118e
3 changed files with 68 additions and 3 deletions

View File

@@ -41,4 +41,11 @@ final class TransferNotificationTests: XCTestCase {
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)] let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty) XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
} }
func testReceiverNotificationsFireForFailedReceivers() {
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
let planned = plannedReceiverNotifications(requests, published: [])
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
XCTAssertEqual(planned.first?.kind, .receiverFailed)
}
} }

View File

@@ -7,6 +7,7 @@ enum TransferNotificationKind: Equatable {
case receiveCompleted // An incoming transfer finished downloading. case receiveCompleted // An incoming transfer finished downloading.
case receiveFailed // An incoming transfer failed. case receiveFailed // An incoming transfer failed.
case receiverCompleted // A receiver finished downloading your shared transfer. case receiverCompleted // A receiver finished downloading your shared transfer.
case receiverFailed // A receiver's download of your shared transfer failed.
} }
/// A notification resolved from core state but not yet published. `transferName` /// A notification resolved from core state but not yet published. `transferName`
@@ -39,11 +40,17 @@ func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String
/// transfer, excluding already-published ids. /// transfer, excluding already-published ids.
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] { func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
requests.compactMap { request in requests.compactMap { request in
guard request.status == .completed else { return nil } let kind: TransferNotificationKind
let id = "receiver-completed-\(request.id)" let idPrefix: String
switch request.status {
case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed"
case .failed: kind = .receiverFailed; idPrefix = "receiver-failed"
default: return nil
}
let id = "\(idPrefix)-\(request.id)"
guard !published.contains(id) else { return nil } guard !published.contains(id) else { return nil }
return PlannedNotification( return PlannedNotification(
id: id, kind: .receiverCompleted, id: id, kind: kind,
transferName: request.transferName, transferName: request.transferName,
receiver: request.receiverName ?? request.receiverDeviceName receiver: request.receiverName ?? request.receiverDeviceName
) )
@@ -56,6 +63,7 @@ private func transferNotificationId(_ kind: TransferNotificationKind, transferId
case .receiveCompleted: return "receive-completed-\(transferId)" case .receiveCompleted: return "receive-completed-\(transferId)"
case .receiveFailed: return "receive-failed-\(transferId)" case .receiveFailed: return "receive-failed-\(transferId)"
case .receiverCompleted: return "receiver-completed-\(transferId)" case .receiverCompleted: return "receiver-completed-\(transferId)"
case .receiverFailed: return "receiver-failed-\(transferId)"
} }
} }
@@ -176,6 +184,12 @@ final class TransferNotificationCoordinator: ObservableObject {
id: plan.id, id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle), title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name)) body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
case .receiverFailed:
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverFailedTitle),
body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name))
} }
if case .failure(let error) = await notifications.publish(notification) { if case .failure(let error) = await notifications.publish(notification) {
messages.error(error) messages.error(error)

View File

@@ -1948,6 +1948,50 @@
"ru": "Передача получена" "ru": "Передача получена"
} }
}, },
"notifications_receiver_failed_body": {
"context": "Notification body shown to the sender when a receiver's download fails. {receiver} = receiver name, {transferName} = transfer name.",
"targets": [
"apple"
],
"args": [
{
"name": "receiver",
"type": "string"
},
{
"name": "transferName",
"type": "string"
}
],
"translations": {
"en": "{receiver} couldn't receive “{transferName}”",
"fr": "{receiver} n'a pas pu recevoir « {transferName} »",
"es": "{receiver} no pudo recibir «{transferName}»",
"it": "{receiver} non ha potuto ricevere “{transferName}”",
"de": "{receiver} konnte „{transferName}“ nicht empfangen",
"pt": "{receiver} não conseguiu receber “{transferName}”",
"pl": "{receiver} nie mógł odebrać „{transferName}”",
"nl": "{receiver} kon “{transferName}” niet ontvangen",
"ru": "{receiver} не удалось получить «{transferName}»"
}
},
"notifications_receiver_failed_title": {
"context": "Notification title shown to the sender when a receiver's download fails.",
"targets": [
"apple"
],
"translations": {
"en": "Delivery failed",
"fr": "Échec de l'envoi",
"es": "Error en la entrega",
"it": "Consegna non riuscita",
"de": "Übertragung fehlgeschlagen",
"pt": "Falha na entrega",
"pl": "Dostarczenie nie powiodło się",
"nl": "Levering mislukt",
"ru": "Ошибка доставки"
}
},
"notifications_send_failed_body": { "notifications_send_failed_body": {
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.", "context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
"targets": [ "targets": [