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:
@@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||||
XCTAssertEqual(repo.preferences.username, "Default")
|
XCTAssertEqual(repo.preferences.username, "Default")
|
||||||
XCTAssertEqual(repo.preferences.themeMode, .system)
|
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||||
XCTAssertFalse(repo.preferences.notificationsEnabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testValuesPersistAndReload() {
|
func testValuesPersistAndReload() {
|
||||||
@@ -28,14 +27,12 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
|||||||
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
repo.setUsername("Bob")
|
repo.setUsername("Bob")
|
||||||
repo.setThemeMode(.dark)
|
repo.setThemeMode(.dark)
|
||||||
repo.setNotificationsEnabled(true)
|
|
||||||
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||||
|
|
||||||
// A fresh repository over the same store reflects the persisted values.
|
// A fresh repository over the same store reflects the persisted values.
|
||||||
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||||
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
||||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||||
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
|
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||||
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
|
|||||||
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
||||||
ApprovalCoordinator(
|
ApprovalCoordinator(
|
||||||
repository: core,
|
repository: core,
|
||||||
preferences: Fixtures.preferences(),
|
|
||||||
notifications: LocalNotificationService(),
|
notifications: LocalNotificationService(),
|
||||||
visibility: AppVisibility(),
|
visibility: AppVisibility(),
|
||||||
messages: UiMessageController()
|
messages: UiMessageController()
|
||||||
|
|||||||
44
apple/Tests/TransferNotificationTests.swift
Normal file
44
apple/Tests/TransferNotificationTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
|
|||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
@@ -23,13 +24,17 @@ final class AppGraph: ObservableObject {
|
|||||||
username: dependencies.environment.defaultUsername,
|
username: dependencies.environment.defaultUsername,
|
||||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
themeMode: .system,
|
themeMode: .system,
|
||||||
notificationsEnabled: false,
|
|
||||||
diagnosticsEnabled: false
|
diagnosticsEnabled: false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: coreRepository,
|
||||||
preferences: preferencesRepository,
|
notifications: dependencies.notificationService,
|
||||||
|
visibility: visibility,
|
||||||
|
messages: messages
|
||||||
|
)
|
||||||
|
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||||
|
repository: coreRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ struct AppPreferences: Equatable {
|
|||||||
var username: String
|
var username: String
|
||||||
var receiveFolder: ReceiveFolder
|
var receiveFolder: ReceiveFolder
|
||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var notificationsEnabled: Bool
|
|
||||||
var diagnosticsEnabled: Bool
|
var diagnosticsEnabled: Bool
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
}
|
}
|
||||||
@@ -34,7 +33,6 @@ struct AppPreferencesDefaults {
|
|||||||
let username: String
|
let username: String
|
||||||
let receiveFolder: ReceiveFolder
|
let receiveFolder: ReceiveFolder
|
||||||
let themeMode: ThemeMode
|
let themeMode: ThemeMode
|
||||||
var notificationsEnabled: Bool = false
|
|
||||||
var diagnosticsEnabled: Bool = false
|
var diagnosticsEnabled: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +49,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let receiveFolderValue = "receive_folder_value"
|
static let receiveFolderValue = "receive_folder_value"
|
||||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let notificationsEnabled = "notifications_enabled"
|
|
||||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
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 username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
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 diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
notificationsEnabled: notifications,
|
|
||||||
diagnosticsEnabled: diagnostics,
|
diagnosticsEnabled: diagnostics,
|
||||||
diagnosticsInstallId: installId
|
diagnosticsInstallId: installId
|
||||||
)
|
)
|
||||||
@@ -113,11 +108,6 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNotificationsEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||||
reload()
|
reload()
|
||||||
|
|||||||
@@ -16,12 +16,32 @@ struct LocalNotification {
|
|||||||
let body: String
|
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`.
|
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocalNotificationService: ObservableObject {
|
final class LocalNotificationService: ObservableObject {
|
||||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||||
|
|
||||||
private let center = UNUserNotificationCenter.current()
|
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 {
|
func refreshPermission() async -> NotificationPermission {
|
||||||
let settings = await center.notificationSettings()
|
let settings = await center.notificationSettings()
|
||||||
@@ -75,11 +95,6 @@ final class LocalNotificationService: ObservableObject {
|
|||||||
center.removeDeliveredNotifications(withIdentifiers: [id])
|
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelAll() {
|
|
||||||
center.removeAllPendingNotificationRequests()
|
|
||||||
center.removeAllDeliveredNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||||
switch status {
|
switch status {
|
||||||
case .authorized, .provisional, .ephemeral: return .granted
|
case .authorized, .provisional, .ephemeral: return .granted
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
@Published private(set) var state = ApprovalState()
|
@Published private(set) var state = ApprovalState()
|
||||||
|
|
||||||
private let repository: CoreGateway
|
private let repository: CoreGateway
|
||||||
private let preferences: AppPreferencesRepository
|
|
||||||
private let notifications: LocalNotificationService
|
private let notifications: LocalNotificationService
|
||||||
private let visibility: AppVisibility
|
private let visibility: AppVisibility
|
||||||
private let messages: UiMessageController
|
private let messages: UiMessageController
|
||||||
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
preferences: AppPreferencesRepository,
|
|
||||||
notifications: LocalNotificationService,
|
notifications: LocalNotificationService,
|
||||||
visibility: AppVisibility,
|
visibility: AppVisibility,
|
||||||
messages: UiMessageController
|
messages: UiMessageController
|
||||||
) {
|
) {
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.preferences = preferences
|
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Recompute notifications when any input changes.
|
// Recompute notifications when any input changes.
|
||||||
Publishers.CombineLatest4(
|
Publishers.CombineLatest3(
|
||||||
preferences.$preferences,
|
|
||||||
visibility.$isForeground,
|
visibility.$isForeground,
|
||||||
$state,
|
$state,
|
||||||
notifications.$permission
|
notifications.$permission
|
||||||
)
|
)
|
||||||
.sink { [weak self] preferences, foreground, approvalState, permission in
|
.sink { [weak self] foreground, approvalState, permission in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task {
|
Task {
|
||||||
await self.synchronizeNotifications(
|
await self.synchronizeNotifications(
|
||||||
enabled: preferences.notificationsEnabled,
|
|
||||||
foreground: foreground,
|
foreground: foreground,
|
||||||
pending: approvalState.pending,
|
pending: approvalState.pending,
|
||||||
permission: permission
|
permission: permission
|
||||||
@@ -137,16 +132,30 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func synchronizeNotifications(
|
private func synchronizeNotifications(
|
||||||
enabled: Bool,
|
|
||||||
foreground: Bool,
|
foreground: Bool,
|
||||||
pending: [PendingApproval],
|
pending: [PendingApproval],
|
||||||
permission: NotificationPermission
|
permission: NotificationPermission
|
||||||
) async {
|
) async {
|
||||||
if foreground || !enabled || permission != .granted {
|
// iOS suppresses notifications while the user is in the app (the modal shows
|
||||||
notifications.cancelAll()
|
// 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
|
return
|
||||||
}
|
}
|
||||||
for request in pending where !publishedNotificationIds.contains(request.id) {
|
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
|
let receiver = request.receiverName
|
||||||
?? request.receiverDeviceName
|
?? request.receiverDeviceName
|
||||||
?? String(localized: L10n.Approval.nearbyDevice)
|
?? String(localized: L10n.Approval.nearbyDevice)
|
||||||
@@ -155,9 +164,9 @@ final class ApprovalCoordinator: ObservableObject {
|
|||||||
let result = await notifications.publish(
|
let result = await notifications.publish(
|
||||||
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
||||||
)
|
)
|
||||||
switch result {
|
if case .failure(let error) = result {
|
||||||
case .success: publishedNotificationIds.insert(request.id)
|
publishedNotificationIds.remove(request.id)
|
||||||
case .failure(let error): messages.error(error)
|
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 isValidatingFolder = false
|
||||||
var supportsCustomReceiveFolders = true
|
var supportsCustomReceiveFolders = true
|
||||||
var themeMode: ThemeMode = .system
|
var themeMode: ThemeMode = .system
|
||||||
var notificationsEnabled = false
|
|
||||||
var notificationPermission: NotificationPermission = .notDetermined
|
var notificationPermission: NotificationPermission = .notDetermined
|
||||||
var diagnosticsEnabled = false
|
var diagnosticsEnabled = false
|
||||||
var deviceInfo: DeviceInfo?
|
var deviceInfo: DeviceInfo?
|
||||||
@@ -63,7 +62,7 @@ struct SettingsState: Equatable {
|
|||||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||||
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
&& lhs.themeMode == rhs.themeMode
|
||||||
&& lhs.notificationPermission == rhs.notificationPermission
|
&& lhs.notificationPermission == rhs.notificationPermission
|
||||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||||
@@ -93,7 +92,6 @@ final class SettingsModel: ObservableObject {
|
|||||||
private let bugReports: BugReportService
|
private let bugReports: BugReportService
|
||||||
private let diagnosticsIncluded: Bool
|
private let diagnosticsIncluded: Bool
|
||||||
|
|
||||||
private var enableNotificationsAfterSettings = false
|
|
||||||
private var usernamePersistTask: Task<Void, Never>?
|
private var usernamePersistTask: Task<Void, Never>?
|
||||||
private var hasLocalUsernameDraft = false
|
private var hasLocalUsernameDraft = false
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||||
self.state.receiveFolder = folder
|
self.state.receiveFolder = folder
|
||||||
self.state.themeMode = prefs.themeMode
|
self.state.themeMode = prefs.themeMode
|
||||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
|
||||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
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 onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
||||||
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
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 {
|
Task {
|
||||||
if !enabled {
|
|
||||||
preferences.setNotificationsEnabled(false)
|
|
||||||
notifications.cancelAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let permission = await notifications.requestPermission()
|
let permission = await notifications.requestPermission()
|
||||||
state.notificationPermission = permission
|
state.notificationPermission = permission
|
||||||
if permission == .granted {
|
if permission == .unsupported {
|
||||||
await enableNotifications()
|
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
||||||
} 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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,33 +238,19 @@ final class SettingsModel: ObservableObject {
|
|||||||
|
|
||||||
func openNotificationSettings() {
|
func openNotificationSettings() {
|
||||||
Task {
|
Task {
|
||||||
enableNotificationsAfterSettings = true
|
if case .failure = await notifications.openSettings() {
|
||||||
let result = await notifications.openSettings()
|
|
||||||
if case .failure = result {
|
|
||||||
enableNotificationsAfterSettings = false
|
|
||||||
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
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() {
|
func refreshNotificationPermission() {
|
||||||
Task {
|
Task {
|
||||||
let permission = await notifications.refreshPermission()
|
state.notificationPermission = 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func enableNotifications() async {
|
|
||||||
preferences.setNotificationsEnabled(true)
|
|
||||||
messages.show(UiMessage(text: .resource(L10n.Notifications.enabledMessage), tone: .success))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Storage
|
// MARK: - Storage
|
||||||
|
|
||||||
|
|||||||
@@ -45,16 +45,22 @@ struct NotificationSettings: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(
|
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
|
||||||
get: { model.state.notificationsEnabled },
|
switch model.state.notificationPermission {
|
||||||
set: { model.setNotificationsEnabled($0) }
|
case .notDetermined:
|
||||||
)) {
|
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
|
||||||
Text(String(localized: L10n.Notifications.localTitle))
|
case .granted:
|
||||||
}
|
// Allowed — the OS Settings app is where you disable or fine-tune.
|
||||||
if model.state.notificationPermission == .denied {
|
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
|
||||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1757,15 +1757,15 @@
|
|||||||
"notifications_description": {
|
"notifications_description": {
|
||||||
"context": "Settings > Notifications: explanation of what notifications are used for.",
|
"context": "Settings > Notifications: explanation of what notifications are used for.",
|
||||||
"translations": {
|
"translations": {
|
||||||
"en": "Get notified about new receive requests while VniDrop is in the background.",
|
"en": "Get notified about transfer activity while VniDrop is in the background.",
|
||||||
"fr": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.",
|
"fr": "Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.",
|
||||||
"es": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.",
|
"es": "Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.",
|
||||||
"it": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.",
|
"it": "Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.",
|
||||||
"de": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.",
|
"de": "Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.",
|
||||||
"pt": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.",
|
"pt": "Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.",
|
||||||
"pl": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.",
|
"pl": "Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.",
|
||||||
"nl": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.",
|
"nl": "Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.",
|
||||||
"ru": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне."
|
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications_enabled_message": {
|
"notifications_enabled_message": {
|
||||||
@@ -1810,6 +1810,170 @@
|
|||||||
"ru": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках."
|
"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": {
|
"notifications_settings_open_failed": {
|
||||||
"context": "Settings > Notifications: error when the OS notification settings can't be opened.",
|
"context": "Settings > Notifications: error when the OS notification settings can't be opened.",
|
||||||
"translations": {
|
"translations": {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Senden</string>
|
<string name="nav_send">Senden</string>
|
||||||
<string name="nav_settings">Einstellungen</string>
|
<string name="nav_settings">Einstellungen</string>
|
||||||
<string name="network_title">Netzwerk</string>
|
<string name="network_title">Netzwerk</string>
|
||||||
<string name="notifications_description">Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.</string>
|
<string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string>
|
||||||
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
||||||
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
||||||
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Enviar</string>
|
<string name="nav_send">Enviar</string>
|
||||||
<string name="nav_settings">Ajustes</string>
|
<string name="nav_settings">Ajustes</string>
|
||||||
<string name="network_title">Red</string>
|
<string name="network_title">Red</string>
|
||||||
<string name="notifications_description">Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.</string>
|
<string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string>
|
||||||
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
||||||
<string name="notifications_local_title">Permitir notificaciones</string>
|
<string name="notifications_local_title">Permitir notificaciones</string>
|
||||||
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Envoyer</string>
|
<string name="nav_send">Envoyer</string>
|
||||||
<string name="nav_settings">Réglages</string>
|
<string name="nav_settings">Réglages</string>
|
||||||
<string name="network_title">Réseau</string>
|
<string name="network_title">Réseau</string>
|
||||||
<string name="notifications_description">Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.</string>
|
<string name="notifications_description">Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.</string>
|
||||||
<string name="notifications_enabled_message">Notifications activées.</string>
|
<string name="notifications_enabled_message">Notifications activées.</string>
|
||||||
<string name="notifications_local_title">Autoriser les notifications</string>
|
<string name="notifications_local_title">Autoriser les notifications</string>
|
||||||
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Invia</string>
|
<string name="nav_send">Invia</string>
|
||||||
<string name="nav_settings">Impostazioni</string>
|
<string name="nav_settings">Impostazioni</string>
|
||||||
<string name="network_title">Rete</string>
|
<string name="network_title">Rete</string>
|
||||||
<string name="notifications_description">Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.</string>
|
<string name="notifications_description">Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.</string>
|
||||||
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
||||||
<string name="notifications_local_title">Consenti le notifiche</string>
|
<string name="notifications_local_title">Consenti le notifiche</string>
|
||||||
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Versturen</string>
|
<string name="nav_send">Versturen</string>
|
||||||
<string name="nav_settings">Instellingen</string>
|
<string name="nav_settings">Instellingen</string>
|
||||||
<string name="network_title">Netwerk</string>
|
<string name="network_title">Netwerk</string>
|
||||||
<string name="notifications_description">Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.</string>
|
<string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string>
|
||||||
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
||||||
<string name="notifications_local_title">Meldingen toestaan</string>
|
<string name="notifications_local_title">Meldingen toestaan</string>
|
||||||
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Wyślij</string>
|
<string name="nav_send">Wyślij</string>
|
||||||
<string name="nav_settings">Ustawienia</string>
|
<string name="nav_settings">Ustawienia</string>
|
||||||
<string name="network_title">Sieć</string>
|
<string name="network_title">Sieć</string>
|
||||||
<string name="notifications_description">Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.</string>
|
<string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string>
|
||||||
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
||||||
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
||||||
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Enviar</string>
|
<string name="nav_send">Enviar</string>
|
||||||
<string name="nav_settings">Definições</string>
|
<string name="nav_settings">Definições</string>
|
||||||
<string name="network_title">Rede</string>
|
<string name="network_title">Rede</string>
|
||||||
<string name="notifications_description">Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.</string>
|
<string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string>
|
||||||
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
||||||
<string name="notifications_local_title">Permitir notificações</string>
|
<string name="notifications_local_title">Permitir notificações</string>
|
||||||
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Отправить</string>
|
<string name="nav_send">Отправить</string>
|
||||||
<string name="nav_settings">Настройки</string>
|
<string name="nav_settings">Настройки</string>
|
||||||
<string name="network_title">Сеть</string>
|
<string name="network_title">Сеть</string>
|
||||||
<string name="notifications_description">Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне.</string>
|
<string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string>
|
||||||
<string name="notifications_enabled_message">Уведомления включены.</string>
|
<string name="notifications_enabled_message">Уведомления включены.</string>
|
||||||
<string name="notifications_local_title">Разрешить уведомления</string>
|
<string name="notifications_local_title">Разрешить уведомления</string>
|
||||||
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<string name="nav_send">Send</string>
|
<string name="nav_send">Send</string>
|
||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="network_title">Network</string>
|
<string name="network_title">Network</string>
|
||||||
<string name="notifications_description">Get notified about new receive requests while VniDrop is in the background.</string>
|
<string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string>
|
||||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||||
<string name="notifications_local_title">Allow notifications</string>
|
<string name="notifications_local_title">Allow notifications</string>
|
||||||
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user