mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ struct SettingsState: Equatable {
|
||||
var isValidatingFolder = false
|
||||
var supportsCustomReceiveFolders = true
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationsEnabled = false
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var deviceInfo: DeviceInfo?
|
||||
@@ -63,7 +62,7 @@ struct SettingsState: Equatable {
|
||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
||||
&& lhs.themeMode == rhs.themeMode
|
||||
&& lhs.notificationPermission == rhs.notificationPermission
|
||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||
@@ -93,7 +92,6 @@ final class SettingsModel: ObservableObject {
|
||||
private let bugReports: BugReportService
|
||||
private let diagnosticsIncluded: Bool
|
||||
|
||||
private var enableNotificationsAfterSettings = false
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -131,7 +129,6 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||
}
|
||||
@@ -171,26 +168,14 @@ final class SettingsModel: ObservableObject {
|
||||
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
||||
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
||||
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
/// Ask the OS for notification permission. This is the only time the app can
|
||||
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
|
||||
func requestNotifications() {
|
||||
Task {
|
||||
if !enabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
return
|
||||
}
|
||||
let permission = await notifications.requestPermission()
|
||||
state.notificationPermission = permission
|
||||
if permission == .granted {
|
||||
await enableNotifications()
|
||||
} else {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
let key = permission == .unsupported ? L10n.Notifications.unsupported : L10n.Notifications.permissionDenied
|
||||
messages.show(UiMessage(
|
||||
text: .resource(key),
|
||||
tone: .warning,
|
||||
actionLabel: permission == .denied ? .resource(L10n.Button.openSettings) : nil,
|
||||
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
||||
))
|
||||
if permission == .unsupported {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,34 +238,20 @@ final class SettingsModel: ObservableObject {
|
||||
|
||||
func openNotificationSettings() {
|
||||
Task {
|
||||
enableNotificationsAfterSettings = true
|
||||
let result = await notifications.openSettings()
|
||||
if case .failure = result {
|
||||
enableNotificationsAfterSettings = false
|
||||
if case .failure = await notifications.openSettings() {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the OS permission (called on appear and when returning to the
|
||||
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
|
||||
func refreshNotificationPermission() {
|
||||
Task {
|
||||
let permission = await notifications.refreshPermission()
|
||||
state.notificationPermission = permission
|
||||
if enableNotificationsAfterSettings {
|
||||
enableNotificationsAfterSettings = false
|
||||
if permission == .granted { await enableNotifications() }
|
||||
} else if permission != .granted && state.notificationsEnabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
}
|
||||
state.notificationPermission = await notifications.refreshPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private func enableNotifications() async {
|
||||
preferences.setNotificationsEnabled(true)
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.enabledMessage), tone: .success))
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// Recomputes the on-disk usage breakdown off the main actor.
|
||||
|
||||
@@ -45,16 +45,22 @@ struct NotificationSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.state.notificationsEnabled },
|
||||
set: { model.setNotificationsEnabled($0) }
|
||||
)) {
|
||||
Text(String(localized: L10n.Notifications.localTitle))
|
||||
}
|
||||
if model.state.notificationPermission == .denied {
|
||||
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
|
||||
switch model.state.notificationPermission {
|
||||
case .notDetermined:
|
||||
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
|
||||
case .granted:
|
||||
// Allowed — the OS Settings app is where you disable or fine-tune.
|
||||
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .denied:
|
||||
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .unsupported:
|
||||
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear { model.refreshNotificationPermission() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user