mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(apple): local notifications for transfer lifecycle events
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:
- a receive finished downloading (receive -> done)
- a receive failed / was interrupted (receive -> failed)
- a share you own failed (send -> failed)
- a receiver finished downloading your share (receiver status completed)
A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.
Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.
macOS delivery fixes:
- add a UNUserNotificationCenterDelegate so banners present even while the
app is active (the app window is usually open on macOS)
- present-when-active on macOS, suppress-when-foregrounded on iOS
- reserve the notification id before awaiting publish: the CombineLatest
fired several times and re-added the same identifier, which macOS
coalesces into a silent update with no banner
- LocalNotificationService seeds its permission at init so gating can't
race a not-yet-refreshed .notDetermined
Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user