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:
2026-07-23 23:29:40 +02:00
parent 0e0d43bc4d
commit 3469b122c2
20 changed files with 482 additions and 98 deletions

View File

@@ -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 lactivité 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 sullattività 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}” couldnt be received.",
"fr": "« {transferName} » na 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}” couldnt be shared.",
"fr": "« {transferName} » na 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": {