feat(apple): local notifications for transfer lifecycle events

Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:

  - a receive finished downloading            (receive -> done)
  - a receive failed / was interrupted         (receive -> failed)
  - a share you own failed                      (send -> failed)
  - a receiver finished downloading your share  (receiver status completed)

A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.

Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.

macOS delivery fixes:
  - add a UNUserNotificationCenterDelegate so banners present even while the
    app is active (the app window is usually open on macOS)
  - present-when-active on macOS, suppress-when-foregrounded on iOS
  - reserve the notification id before awaiting publish: the CombineLatest
    fired several times and re-added the same identifier, which macOS
    coalesces into a silent update with no banner
  - LocalNotificationService seeds its permission at init so gating can't
    race a not-yet-refreshed .notDetermined

Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
This commit is contained in:
2026-07-23 23:29:40 +02:00
parent 0e0d43bc4d
commit 3469b122c2
20 changed files with 482 additions and 98 deletions

View File

@@ -0,0 +1,184 @@
import Combine
import Foundation
/// A transfer-lifecycle moment worth a local notification.
enum TransferNotificationKind: Equatable {
case sendFailed // A share you own failed.
case receiveCompleted // An incoming transfer finished downloading.
case receiveFailed // An incoming transfer failed.
case receiverCompleted // A receiver finished downloading your shared transfer.
}
/// A notification resolved from core state but not yet published. `transferName`
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
struct PlannedNotification: Equatable {
let id: String
let kind: TransferNotificationKind
let transferName: String?
let receiver: String?
}
/// Pure: transfer-status notifications for this snapshot, excluding already-published
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
transfers.compactMap { transfer in
let kind: TransferNotificationKind
switch (transfer.direction, transfer.status) {
case (.send, .failed): kind = .sendFailed
case (.receive, .done): kind = .receiveCompleted
case (.receive, .failed): kind = .receiveFailed
default: return nil
}
let id = transferNotificationId(kind, transferId: transfer.transferId)
guard !published.contains(id) else { return nil }
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
}
}
/// Pure: one notification per receiver that has finished downloading a shared
/// transfer, excluding already-published ids.
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
requests.compactMap { request in
guard request.status == .completed else { return nil }
let id = "receiver-completed-\(request.id)"
guard !published.contains(id) else { return nil }
return PlannedNotification(
id: id, kind: .receiverCompleted,
transferName: request.transferName,
receiver: request.receiverName ?? request.receiverDeviceName
)
}
}
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
switch kind {
case .sendFailed: return "send-failed-\(transferId)"
case .receiveCompleted: return "receive-completed-\(transferId)"
case .receiveFailed: return "receive-failed-\(transferId)"
case .receiverCompleted: return "receiver-completed-\(transferId)"
}
}
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
/// or failing, a share failing, a receiver completing), so a user who left the
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
///
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
/// Each moment is terminal, so it is marked seen the first time it is observed and
/// never re-published. The first state snapshot which includes existing history
/// such as past receives only primes those ids as seen, so only new transitions
/// notify.
@MainActor
final class TransferNotificationCoordinator: ObservableObject {
private let repository: CoreGateway
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
private var published = Set<String>()
private var primedTransfers = false
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.notifications = notifications
self.visibility = visibility
self.messages = messages
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
Task { await self.syncTransfers(core.transfers) }
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged:
break
}
}
.store(in: &cancellables)
}
/// iOS suppresses notifications while the user is in the app (the convention);
/// macOS presents them even when active (also the convention the app window
/// is usually open), relying on the presenter delegate to show the banner.
private var canPublish: Bool {
guard notifications.permission == .granted else { return false }
#if os(iOS)
return !visibility.isForeground
#else
return true
#endif
}
private func syncTransfers(_ transfers: [Transfer]) async {
let planned = plannedTransferNotifications(transfers, published: published)
guard primedTransfers else {
// The first snapshot includes existing history (e.g. past receives).
// Mark those terminal transfers seen without notifying, so only new
// transitions notify.
primedTransfers = true
for plan in planned { published.insert(plan.id) }
return
}
for plan in planned { await deliver(plan) }
}
private func syncReceivers(transferId: UInt64) async {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
for plan in plannedReceiverNotifications(requests, published: published) {
await deliver(plan)
}
case .failure(let error):
messages.error(error)
}
}
/// Mark seen unconditionally (a terminal moment notifies at most once), then
/// publish only when the gate allows.
private func deliver(_ plan: PlannedNotification) async {
published.insert(plan.id)
guard canPublish else { return }
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
let notification: LocalNotification
switch plan.kind {
case .sendFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.sendFailedTitle),
body: L10n.Notifications.sendFailedBody(transferName: name))
case .receiveCompleted:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveCompletedTitle),
body: L10n.Notifications.receiveCompletedBody(transferName: name))
case .receiveFailed:
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiveFailedTitle),
body: L10n.Notifications.receiveFailedBody(transferName: name))
case .receiverCompleted:
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
notification = LocalNotification(
id: plan.id,
title: String(localized: L10n.Notifications.receiverCompletedTitle),
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
}
if case .failure(let error) = await notifications.publish(notification) {
messages.error(error)
}
}
}