From 3469b122c25821af21ffdb38b1b158e93c820a82 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:29:40 +0200 Subject: [PATCH] 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". --- .../Tests/AppPreferencesRepositoryTests.swift | 3 - apple/Tests/ApprovalCoordinatorTests.swift | 1 - apple/Tests/TransferNotificationTests.swift | 44 +++++ apple/VniDrop/App/AppGraph.swift | 9 +- apple/VniDrop/Core/AppPreferences.swift | 10 - .../Core/LocalNotificationService.swift | 25 ++- .../Approvals/ApprovalCoordinator.swift | 35 ++-- .../TransferNotificationCoordinator.swift | 184 ++++++++++++++++++ .../Features/Settings/SettingsModel.swift | 49 +---- .../Features/Settings/SettingsSections.swift | 20 +- localization/strings.json | 182 ++++++++++++++++- .../composeResources/values-de/strings.xml | 2 +- .../composeResources/values-es/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-it/strings.xml | 2 +- .../composeResources/values-nl/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../composeResources/values-ru/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- 20 files changed, 482 insertions(+), 98 deletions(-) create mode 100644 apple/Tests/TransferNotificationTests.swift create mode 100644 apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift index eb4ef11..b07d7fe 100644 --- a/apple/Tests/AppPreferencesRepositoryTests.swift +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase { let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.themeMode, .system) - XCTAssertFalse(repo.preferences.notificationsEnabled) } func testValuesPersistAndReload() { @@ -28,14 +27,12 @@ final class AppPreferencesRepositoryTests: XCTestCase { let repo = AppPreferencesRepository(defaults: store, fallback: fb) repo.setUsername("Bob") repo.setThemeMode(.dark) - repo.setNotificationsEnabled(true) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) // A fresh repository over the same store reflects the persisted values. let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) XCTAssertEqual(reloaded.preferences.username, "Bob") XCTAssertEqual(reloaded.preferences.themeMode, .dark) - XCTAssertTrue(reloaded.preferences.notificationsEnabled) XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) } diff --git a/apple/Tests/ApprovalCoordinatorTests.swift b/apple/Tests/ApprovalCoordinatorTests.swift index 97ac8c0..617578a 100644 --- a/apple/Tests/ApprovalCoordinatorTests.swift +++ b/apple/Tests/ApprovalCoordinatorTests.swift @@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase { private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator { ApprovalCoordinator( repository: core, - preferences: Fixtures.preferences(), notifications: LocalNotificationService(), visibility: AppVisibility(), messages: UiMessageController() diff --git a/apple/Tests/TransferNotificationTests.swift b/apple/Tests/TransferNotificationTests.swift new file mode 100644 index 0000000..e38e911 --- /dev/null +++ b/apple/Tests/TransferNotificationTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import VniDrop + +@MainActor +final class TransferNotificationTests: XCTestCase { + + func testTransferNotificationsFireForTerminalStatesOnly() { + let transfers = [ + Fixtures.transfer(id: 1, direction: .send, status: .failed), + Fixtures.transfer(id: 2, direction: .receive, status: .done), + Fixtures.transfer(id: 3, direction: .receive, status: .failed), + Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored + Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored + Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified + ] + let planned = plannedTransferNotifications(transfers, published: []) + XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed]) + XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"]) + XCTAssertEqual(planned.first?.transferName, "Photos") + } + + func testTransferNotificationsSkipAlreadyPublished() { + let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)] + XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty) + } + + func testReceiverNotificationsFireOnlyForCompletedReceivers() { + let requests = [ + Fixtures.request(id: "a", requestedAt: 1, status: .completed), + Fixtures.request(id: "b", requestedAt: 2, status: .accepted), + Fixtures.request(id: "c", requestedAt: 3, status: .requested), + ] + let planned = plannedReceiverNotifications(requests, published: []) + XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"]) + XCTAssertEqual(planned.first?.kind, .receiverCompleted) + XCTAssertEqual(planned.first?.receiver, "Peer") + XCTAssertEqual(planned.first?.transferName, "Photos") + } + + func testReceiverNotificationsSkipAlreadyPublished() { + let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)] + XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty) + } +} diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index dd029a9..d261499 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -12,6 +12,7 @@ final class AppGraph: ObservableObject { let preferencesRepository: AppPreferencesRepository let filePreviewRepository: FilePreviewRepository let approvalCoordinator: ApprovalCoordinator + let transferNotificationCoordinator: TransferNotificationCoordinator init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { self.dependencies = dependencies @@ -23,13 +24,17 @@ final class AppGraph: ObservableObject { username: dependencies.environment.defaultUsername, receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), themeMode: .system, - notificationsEnabled: false, diagnosticsEnabled: false ) ) self.approvalCoordinator = ApprovalCoordinator( repository: coreRepository, - preferences: preferencesRepository, + notifications: dependencies.notificationService, + visibility: visibility, + messages: messages + ) + self.transferNotificationCoordinator = TransferNotificationCoordinator( + repository: coreRepository, notifications: dependencies.notificationService, visibility: visibility, messages: messages diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 74049c0..1508d4d 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -25,7 +25,6 @@ struct AppPreferences: Equatable { var username: String var receiveFolder: ReceiveFolder var themeMode: ThemeMode - var notificationsEnabled: Bool var diagnosticsEnabled: Bool var diagnosticsInstallId: String } @@ -34,7 +33,6 @@ struct AppPreferencesDefaults { let username: String let receiveFolder: ReceiveFolder let themeMode: ThemeMode - var notificationsEnabled: Bool = false var diagnosticsEnabled: Bool = false } @@ -51,7 +49,6 @@ final class AppPreferencesRepository: ObservableObject { static let receiveFolderValue = "receive_folder_value" static let receiveFolderDisplayName = "receive_folder_display_name" static let themeMode = "theme_mode" - static let notificationsEnabled = "notifications_enabled" static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsInstallId = "diagnostics_install_id" } @@ -66,14 +63,12 @@ final class AppPreferencesRepository: ObservableObject { let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode - let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, - notificationsEnabled: notifications, diagnosticsEnabled: diagnostics, diagnosticsInstallId: installId ) @@ -113,11 +108,6 @@ final class AppPreferencesRepository: ObservableObject { reload() } - func setNotificationsEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: Key.notificationsEnabled) - reload() - } - func setDiagnosticsEnabled(_ enabled: Bool) { defaults.set(enabled, forKey: Key.diagnosticsEnabled) reload() diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 4f04f68..4f7f11c 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -16,12 +16,32 @@ struct LocalNotification { let body: String } +/// Presents notifications even while the app is active. Without a delegate the +/// system drops the banner when the app is frontmost — very visible on macOS, +/// where the app window is usually open when a transfer completes. +private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .sound, .list] + } +} + /// Local notification service backed by `UNUserNotificationCenter`. @MainActor final class LocalNotificationService: ObservableObject { @Published private(set) var permission: NotificationPermission = .notDetermined private let center = UNUserNotificationCenter.current() + private let presenter = NotificationPresenter() + + init() { + center.delegate = presenter + // Seed the permission immediately so gating (approval/lifecycle + // notifications) never races a not-yet-refreshed `.notDetermined`. + Task { _ = await refreshPermission() } + } func refreshPermission() async -> NotificationPermission { let settings = await center.notificationSettings() @@ -75,11 +95,6 @@ final class LocalNotificationService: ObservableObject { center.removeDeliveredNotifications(withIdentifiers: [id]) } - func cancelAll() { - center.removeAllPendingNotificationRequests() - center.removeAllDeliveredNotifications() - } - private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission { switch status { case .authorized, .provisional, .ephemeral: return .granted diff --git a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift index 6b9a844..5b2bd7d 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift @@ -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) } } } diff --git a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift new file mode 100644 index 0000000..0b166fc --- /dev/null +++ b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift @@ -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) -> [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) -> [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() + private var primedTransfers = false + private var cancellables = Set() + + 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) + } + } +} diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index ceef5b8..2ddc6b0 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -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? private var hasLocalUsernameDraft = false private var cancellables = Set() @@ -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. diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 665bcb9..509c4b6 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -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() } } } diff --git a/localization/strings.json b/localization/strings.json index dec300d..02cf6c7 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1757,15 +1757,15 @@ "notifications_description": { "context": "Settings > Notifications: explanation of what notifications are used for.", "translations": { - "en": "Get notified about new receive requests while VniDrop is in the background.", - "fr": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.", - "es": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.", - "it": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.", - "de": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.", - "pt": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.", - "pl": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.", - "nl": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.", - "ru": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне." + "en": "Get notified about transfer activity while VniDrop is in the background.", + "fr": "Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.", + "es": "Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.", + "it": "Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.", + "de": "Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.", + "pt": "Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.", + "pl": "Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.", + "nl": "Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.", + "ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне." } }, "notifications_enabled_message": { @@ -1810,6 +1810,170 @@ "ru": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках." } }, + "notifications_receive_completed_body": { + "context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” finished downloading.", + "fr": "« {transferName} » a fini de se télécharger.", + "es": "«{transferName}» terminó de descargarse.", + "it": "«{transferName}» è stato scaricato.", + "de": "„{transferName}“ wurde vollständig heruntergeladen.", + "pt": "«{transferName}» concluiu a transferência.", + "pl": "Zakończono pobieranie „{transferName}”.", + "nl": "‘{transferName}’ is volledig gedownload.", + "ru": "«{transferName}» завершил загрузку." + } + }, + "notifications_receive_completed_title": { + "context": "Notification title shown when an incoming transfer finishes downloading.", + "targets": [ + "apple" + ], + "translations": { + "en": "Download complete", + "fr": "Téléchargement terminé", + "es": "Descarga completada", + "it": "Download completato", + "de": "Download abgeschlossen", + "pt": "Transferência concluída", + "pl": "Pobieranie zakończone", + "nl": "Download voltooid", + "ru": "Загрузка завершена" + } + }, + "notifications_receive_failed_body": { + "context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” couldn’t be received.", + "fr": "« {transferName} » n’a pas pu être reçu.", + "es": "No se pudo recibir «{transferName}».", + "it": "Impossibile ricevere «{transferName}».", + "de": "„{transferName}“ konnte nicht empfangen werden.", + "pt": "Não foi possível receber «{transferName}».", + "pl": "Nie udało się odebrać „{transferName}”.", + "nl": "‘{transferName}’ kon niet worden ontvangen.", + "ru": "Не удалось получить «{transferName}»." + } + }, + "notifications_receive_failed_title": { + "context": "Notification title shown when an incoming transfer fails.", + "targets": [ + "apple" + ], + "translations": { + "en": "Download failed", + "fr": "Échec du téléchargement", + "es": "Error en la descarga", + "it": "Download non riuscito", + "de": "Download fehlgeschlagen", + "pt": "Falha na transferência", + "pl": "Pobieranie nie powiodło się", + "nl": "Download mislukt", + "ru": "Ошибка загрузки" + } + }, + "notifications_receiver_completed_body": { + "context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "receiver", + "type": "string" + }, + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "{receiver} finished receiving “{transferName}”.", + "fr": "{receiver} a fini de recevoir « {transferName} ».", + "es": "{receiver} terminó de recibir «{transferName}».", + "it": "{receiver} ha finito di ricevere «{transferName}».", + "de": "{receiver} hat „{transferName}“ vollständig empfangen.", + "pt": "{receiver} terminou de receber «{transferName}».", + "pl": "{receiver} zakończył odbieranie „{transferName}”.", + "nl": "{receiver} heeft ‘{transferName}’ volledig ontvangen.", + "ru": "{receiver} завершил получение «{transferName}»." + } + }, + "notifications_receiver_completed_title": { + "context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.", + "targets": [ + "apple" + ], + "translations": { + "en": "Transfer received", + "fr": "Transfert reçu", + "es": "Transferencia recibida", + "it": "Trasferimento ricevuto", + "de": "Übertragung empfangen", + "pt": "Transferência recebida", + "pl": "Transfer odebrany", + "nl": "Overdracht ontvangen", + "ru": "Передача получена" + } + }, + "notifications_send_failed_body": { + "context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” couldn’t be shared.", + "fr": "« {transferName} » n’a pas pu être partagé.", + "es": "No se pudo compartir «{transferName}».", + "it": "Impossibile condividere «{transferName}».", + "de": "„{transferName}“ konnte nicht geteilt werden.", + "pt": "Não foi possível partilhar «{transferName}».", + "pl": "Nie udało się udostępnić „{transferName}”.", + "nl": "‘{transferName}’ kon niet worden gedeeld.", + "ru": "Не удалось поделиться «{transferName}»." + } + }, + "notifications_send_failed_title": { + "context": "Notification title shown to the sender when a shared transfer fails.", + "targets": [ + "apple" + ], + "translations": { + "en": "Sharing failed", + "fr": "Échec du partage", + "es": "Error al compartir", + "it": "Condivisione non riuscita", + "de": "Freigabe fehlgeschlagen", + "pt": "Falha na partilha", + "pl": "Udostępnianie nie powiodło się", + "nl": "Delen mislukt", + "ru": "Не удалось поделиться" + } + }, "notifications_settings_open_failed": { "context": "Settings > Notifications: error when the OS notification settings can't be opened.", "translations": { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index f497235..da1a6ad 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -117,7 +117,7 @@ Senden Einstellungen Netzwerk - Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft. + Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft. Mitteilungen aktiviert. Mitteilungen erlauben Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index b106a93..b7ff17e 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -117,7 +117,7 @@ Enviar Ajustes Red - Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano. + Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano. Notificaciones activadas. Permitir notificaciones Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index d708fda..126bb38 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -117,7 +117,7 @@ Envoyer Réglages Réseau - Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan. + Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan. Notifications activées. Autoriser les notifications Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index c1540af..10f1aad 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -117,7 +117,7 @@ Invia Impostazioni Rete - Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background. + Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background. Notifiche attivate. Consenti le notifiche Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index f3f4f17..b281934 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -117,7 +117,7 @@ Versturen Instellingen Netwerk - Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait. + Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait. Meldingen ingeschakeld. Meldingen toestaan Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 6d7c74f..e19c520 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -117,7 +117,7 @@ Wyślij Ustawienia Sieć - Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle. + Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle. Powiadomienia włączone. Zezwól na powiadomienia Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index d40618d..1233976 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -117,7 +117,7 @@ Enviar Definições Rede - Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano. + Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano. Notificações ativadas. Permitir notificações As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 5e271cb..513bb0c 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -117,7 +117,7 @@ Отправить Настройки Сеть - Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне. + Получайте уведомления об активности передач, пока VniDrop работает в фоне. Уведомления включены. Разрешить уведомления Уведомления отключены для VniDrop. Вы можете включить их в Настройках. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7767bf6..b4da6ac 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -117,7 +117,7 @@ Send Settings Network - Get notified about new receive requests while VniDrop is in the background. + Get notified about transfer activity while VniDrop is in the background. Notifications enabled. Allow notifications Notifications are turned off for VniDrop. You can enable them in Settings.