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

@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState()
private let repository: CoreGateway
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
init(
repository: CoreGateway,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.preferences = preferences
self.notifications = notifications
self.visibility = visibility
self.messages = messages
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
.store(in: &cancellables)
// Recompute notifications when any input changes.
Publishers.CombineLatest4(
preferences.$preferences,
Publishers.CombineLatest3(
visibility.$isForeground,
$state,
notifications.$permission
)
.sink { [weak self] preferences, foreground, approvalState, permission in
.sink { [weak self] foreground, approvalState, permission in
guard let self else { return }
Task {
await self.synchronizeNotifications(
enabled: preferences.notificationsEnabled,
foreground: foreground,
pending: approvalState.pending,
permission: permission
@@ -137,16 +132,30 @@ final class ApprovalCoordinator: ObservableObject {
}
private func synchronizeNotifications(
enabled: Bool,
foreground: Bool,
pending: [PendingApproval],
permission: NotificationPermission
) async {
if foreground || !enabled || permission != .granted {
notifications.cancelAll()
// iOS suppresses notifications while the user is in the app (the modal shows
// instead); macOS presents them even when active (the app window is usually
// open), relying on the presenter delegate.
#if os(iOS)
let suppressed = foreground || permission != .granted
#else
let suppressed = permission != .granted
#endif
if suppressed {
// Cancel only our own approval notifications other coordinators
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
return
}
for request in pending where !publishedNotificationIds.contains(request.id) {
// Reserve the id *before* awaiting: the CombineLatest can fire several
// times near-simultaneously, and without this each pass re-adds the same
// notification identifier. macOS coalesces a repeated add of an in-flight
// id into a silent update and shows no banner.
publishedNotificationIds.insert(request.id)
let receiver = request.receiverName
?? request.receiverDeviceName
?? String(localized: L10n.Approval.nearbyDevice)
@@ -155,9 +164,9 @@ final class ApprovalCoordinator: ObservableObject {
let result = await notifications.publish(
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
)
switch result {
case .success: publishedNotificationIds.insert(request.id)
case .failure(let error): messages.error(error)
if case .failure(let error) = result {
publishedNotificationIds.remove(request.id)
messages.error(error)
}
}
}