mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user