Merge pull request #195 from reb1995/add-notifications-android-and-background-fixes

Fix notifications and enable background data fetch while app is not focused.
This commit is contained in:
stopflock
2026-09-04 13:24:30 -05:00
committed by GitHub
22 changed files with 766 additions and 211 deletions
+20
View File
@@ -10,6 +10,26 @@
<!-- Notification permission for proximity alerts -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Background location for proximity alerts. Android stops delivering
location to a backgrounded app within a few minutes unless it is
running a foreground service, so geolocator is given a
ForegroundNotificationConfig while proximity alerts are enabled -
that starts its GeolocatorLocationService (already declared by the
geolocator_android plugin manifest) and keeps the stream alive.
FOREGROUND_SERVICE_LOCATION is required from Android 14 onward.
Deliberately NOT declaring ACCESS_BACKGROUND_LOCATION: the foreground
service covers our case without it, and merely listing it in the
manifest makes geolocator append it to the very first permission
request - which Android 11+ silently denies, because foreground and
background location cannot be asked for in one prompt. That would
degrade first-run permissions for every user, including those who
never turn proximity alerts on. It also drags in the Play Store
background-location declaration review. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:name="${applicationName}"
android:label="DeFlock"
+11
View File
@@ -1,5 +1,6 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,6 +8,16 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Required by flutter_local_notifications: the plugin implements
// userNotificationCenter(_:willPresent:withCompletionHandler:), which is
// what allows a notification to appear while the app is in the foreground.
// iOS only calls it on the registered delegate, and the plugin does not
// register itself without this line, foreground notifications are
// silently suppressed by the system.
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+12
View File
@@ -54,6 +54,8 @@
<true/>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app optionally uses your location to center the map on your current position and provide proximity alerts for nearby surveillance devices. These features are entirely optional.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>This app optionally uses your location in the background so proximity alerts can warn you about nearby surveillance devices while the app is not on screen. This feature is entirely optional.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app optionally uses your location to show nearby cameras by centering the map on your location.</string>
<key>UIApplicationSceneManifest</key>
@@ -79,6 +81,16 @@
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Required for proximity alerts. Without the "location" background mode
iOS suspends the app the moment it leaves the screen, the CoreLocation
stream stops delivering, and no alert can ever fire while backgrounded.
Only armed at runtime: GpsController sets
allowBackgroundLocationUpdates on the stream solely while the user has
proximity alerts turned on. -->
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
+7 -8
View File
@@ -81,7 +81,6 @@
"enableOfflineFeaturesTitle": "Offline-Funktionen aktivieren",
"enableOfflineFeaturesSubtitle": "Herunterladen und Nutzen von Offline-Kartenbereichen erlauben. Wenn deaktiviert, sind Offline-Modus und heruntergeladene Bereiche nicht verfügbar."
},
"keepScreenAwake": {
"title": "Bildschirm während der App-Nutzung aktiv lassen",
"subtitle": "Verhindert, dass sich der Bildschirm während der Nutzung der App automatisch ausschaltet"
@@ -90,7 +89,6 @@
"title": "Zoom-Steuerung ausblenden",
"subtitle": "Blendet die +/- Zoom-Schaltflächen und die Zoomstufen-Anzeige aus. Sie können weiterhin mit Fingergesten zoomen."
},
"proximityAlerts": {
"getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte",
"batteryUsage": "Verbraucht zusätzlich Batterie für kontinuierliche Standortüberwachung",
@@ -101,7 +99,13 @@
"enableNotifications": "Benachrichtigungen Aktivieren",
"checkingPermissions": "Berechtigungen prüfen...",
"alertDistance": "Warnentfernung: ",
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
"rangeInfo": "Bereich: {}-{} {} (Standard: {})",
"backgroundServiceTitle": "Näherungswarnungen aktiv",
"backgroundServiceText": "Sucht nach Überwachungsgeräten in der Nähe",
"backgroundServiceChannel": "Überwachung von Näherungswarnungen",
"openSettings": "Einstellungen öffnen",
"blockedTitle": "Benachrichtigungen sind blockiert",
"blockedMessage": "Benachrichtigungen für DeFlock sind deaktiviert. Aktiviere sie in den Systemeinstellungen, um Näherungswarnungen zu erhalten."
},
"node": {
"title": "Knoten #{}",
@@ -129,9 +133,6 @@
"viewInQueue": "In Warteschlange anzeigen",
"deletedSuccessfully": "Dieser Knoten wurde erfolgreich von OpenStreetMap gelöscht."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Profil auswählen...",
@@ -281,7 +282,6 @@
"inFlightCompleteTitle": "Übermittlung Abgeschlossen",
"inFlightCompleteMessage": "Ihre Übermittlung wurde erfolgreich abgeschlossen. Ihre Einstellung wird jetzt angewendet."
},
"tileProviders": {
"title": "Kachel-Anbieter",
"noProvidersConfigured": "Keine Kachel-Anbieter konfiguriert",
@@ -433,7 +433,6 @@
"keepData": "Daten Behalten",
"deleteData": "Daten Löschen"
},
"refineTagsSheet": {
"title": "Tags Verfeinern",
"operatorProfile": "Betreiber-Profil",
+8 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Enable offline features",
"enableOfflineFeaturesSubtitle": "Allow downloading and using offline map areas. When disabled, offline mode and downloaded areas are unavailable."
},
"keepScreenAwake": {
"title": "Keep screen awake while using app",
"subtitle": "Prevents the screen from turning off automatically while using the app"
@@ -127,7 +126,6 @@
"title": "Hide zoom controls",
"subtitle": "Hide the +/- zoom buttons and zoom level indicator. You can still zoom with pinch gestures."
},
"proximityAlerts": {
"getNotified": "Get notified when approaching surveillance devices",
"batteryUsage": "Uses extra battery for continuous location monitoring",
@@ -138,7 +136,13 @@
"enableNotifications": "Enable Notifications",
"checkingPermissions": "Checking permissions...",
"alertDistance": "Alert distance: ",
"rangeInfo": "Range: {}-{} {} (default: {})"
"rangeInfo": "Range: {}-{} {} (default: {})",
"backgroundServiceTitle": "Proximity alerts active",
"backgroundServiceText": "Watching for nearby surveillance devices",
"backgroundServiceChannel": "Proximity Alert Monitoring",
"openSettings": "Open Settings",
"blockedTitle": "Notifications are blocked",
"blockedMessage": "Notifications are turned off for DeFlock. Enable them in your system settings to receive proximity alerts."
},
"node": {
"title": "Node #{}",
@@ -166,8 +170,6 @@
"viewInQueue": "View in Queue",
"deletedSuccessfully": "This node was successfully deleted from OpenStreetMap."
},
"addNode": {
"profile": "Profile",
"selectProfile": "Select a profile...",
@@ -317,7 +319,6 @@
"inFlightCompleteTitle": "Submission Complete",
"inFlightCompleteMessage": "Your submission finished successfully. Applying your setting now."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "No tile providers configured",
@@ -326,7 +327,7 @@
"needsApiKey": "Needs API key",
"editProvider": "Edit Provider",
"addProvider": "Add Provider",
"deleteProvider": "Delete Provider",
"deleteProvider": "Delete Provider",
"deleteProviderConfirm": "Are you sure you want to delete \"{}\"?",
"clearCaches": "Clear Caches",
"clearCachesConfirm": "Clear all tile caches for \"{}\"? This will free up storage space but tiles will need to be downloaded again when viewing those areas.",
@@ -469,7 +470,6 @@
"keepData": "Keep Data",
"deleteData": "Delete Data"
},
"refineTagsSheet": {
"title": "Refine Tags",
"operatorProfile": "Operator Profile",
+7 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Activar funciones sin conexión",
"enableOfflineFeaturesSubtitle": "Permitir descargar y usar áreas de mapa sin conexión. Si se desactiva, el modo sin conexión y las áreas descargadas no estarán disponibles."
},
"keepScreenAwake": {
"title": "Mantener la pantalla encendida mientras se usa la app",
"subtitle": "Evita que la pantalla se apague automáticamente mientras se usa la app"
@@ -127,7 +126,6 @@
"title": "Ocultar controles de zoom",
"subtitle": "Oculta los botones de zoom +/- y el indicador de nivel de zoom. Aún puedes hacer zoom con gestos de pellizco."
},
"proximityAlerts": {
"getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia",
"batteryUsage": "Usa batería extra para monitoreo continuo de ubicación",
@@ -138,7 +136,13 @@
"enableNotifications": "Habilitar Notificaciones",
"checkingPermissions": "Verificando permisos...",
"alertDistance": "Distancia de alerta: ",
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})",
"backgroundServiceTitle": "Alertas de proximidad activas",
"backgroundServiceText": "Buscando dispositivos de vigilancia cercanos",
"backgroundServiceChannel": "Monitoreo de alertas de proximidad",
"openSettings": "Abrir ajustes",
"blockedTitle": "Las notificaciones están bloqueadas",
"blockedMessage": "Las notificaciones están desactivadas para DeFlock. Actívalas en los ajustes del sistema para recibir alertas de proximidad."
},
"node": {
"title": "Nodo #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"deleteReasonCustom": "Personalizado..",
"deleteReasonHint": "Por favor describa la razón...",
"mustBeLoggedInToDelete": "Debes iniciar sesión con una cuenta de OpenStreetMap para contribuir con datos del mapa.",
"uploadPending": "Subida pendiente",
"uploadCreatingChangeset": "Creando changeset...",
@@ -167,8 +170,6 @@
"viewInQueue": "Ver en la Cola",
"deletedSuccessfully": "Este nodo fue eliminado con éxito de OpenStreetMap."
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Seleccionar un perfil...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Envío Completado",
"inFlightCompleteMessage": "Su envío finalizó exitosamente. Aplicando su configuración ahora."
},
"tileProviders": {
"title": "Proveedores de Tiles",
"noProvidersConfigured": "No hay proveedores de tiles configurados",
@@ -470,7 +470,6 @@
"keepData": "Conservar Datos",
"deleteData": "Eliminar Datos"
},
"refineTagsSheet": {
"title": "Refinar Etiquetas",
"operatorProfile": "Perfil de Operador",
+7 -8
View File
@@ -119,7 +119,6 @@
"enableOfflineFeaturesSubtitle": "Autoriser le téléchargement et l'utilisation de zones de carte hors ligne. Si désactivé, le mode hors ligne et les zones téléchargées ne seront pas disponibles."
},
"keepScreenAwake": {
"title": "Maintenir l'écran allumé lors de l'utilisation de l'application",
"subtitle": "Empêche l'écran de s'éteindre automatiquement pendant l'utilisation de l'application"
},
@@ -127,7 +126,6 @@
"title": "Masquer les contrôles de zoom",
"subtitle": "Masque les boutons de zoom +/- et l'indicateur de niveau de zoom. Vous pouvez toujours zoomer avec des gestes de pincement."
},
"proximityAlerts": {
"getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance",
"batteryUsage": "Utilise de la batterie supplémentaire pour la surveillance continue de la localisation",
@@ -138,7 +136,13 @@
"enableNotifications": "Activer les Notifications",
"checkingPermissions": "Vérification des autorisations...",
"alertDistance": "Distance d'alerte : ",
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
"rangeInfo": "Plage : {}-{} {} (par défaut : {})",
"backgroundServiceTitle": "Alertes de proximité actives",
"backgroundServiceText": "Recherche d'appareils de surveillance à proximité",
"backgroundServiceChannel": "Surveillance des alertes de proximité",
"openSettings": "Ouvrir les réglages",
"blockedTitle": "Les notifications sont bloquées",
"blockedMessage": "Les notifications sont désactivées pour DeFlock. Activez-les dans les réglages du système pour recevoir les alertes de proximité."
},
"node": {
"title": "Nœud #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalisme",
"deleteReasonCustom": "Personnalisé..",
"deleteReasonHint": "Veuillez décrire la raison...",
"mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.",
"uploadPending": "Téléchargement en attente",
"uploadCreatingChangeset": "Création du changeset...",
@@ -167,8 +170,6 @@
"viewInQueue": "Voir dans la File d'Attente",
"deletedSuccessfully": "Ce nœud a été supprimé avec succès d'OpenStreetMap."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Sélectionner un profil...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Soumission Terminée",
"inFlightCompleteMessage": "Votre soumission s'est terminée avec succès. Application de votre paramètre maintenant."
},
"tileProviders": {
"title": "Fournisseurs de Tuiles",
"noProvidersConfigured": "Aucun fournisseur de tuiles configuré",
@@ -470,7 +470,6 @@
"keepData": "Conserver les Données",
"deleteData": "Supprimer les Données"
},
"refineTagsSheet": {
"title": "Affiner les Étiquettes",
"operatorProfile": "Profil d'Opérateur",
+7 -8
View File
@@ -119,7 +119,6 @@
"enableOfflineFeaturesSubtitle": "Consenti il download e l'uso di aree mappa offline. Se disabilitato, la modalità offline e le aree scaricate non saranno disponibili."
},
"keepScreenAwake": {
"title": "Mantieni lo schermo attivo durante l'utilizzo dell'app",
"subtitle": "Impedisce allo schermo di spegnersi automaticamente durante l'utilizzo dell'app"
},
@@ -127,7 +126,6 @@
"title": "Nascondi i controlli dello zoom",
"subtitle": "Nasconde i pulsanti di zoom +/- e l'indicatore del livello di zoom. Puoi ancora usare i gesti di pizzico per lo zoom."
},
"proximityAlerts": {
"getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza",
"batteryUsage": "Utilizza batteria extra per il monitoraggio continuo della posizione",
@@ -138,7 +136,13 @@
"enableNotifications": "Abilita Notifiche",
"checkingPermissions": "Controllo autorizzazioni...",
"alertDistance": "Distanza di avviso: ",
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})",
"backgroundServiceTitle": "Avvisi di prossimità attivi",
"backgroundServiceText": "Ricerca di dispositivi di sorveglianza nelle vicinanze",
"backgroundServiceChannel": "Monitoraggio avvisi di prossimità",
"openSettings": "Apri impostazioni",
"blockedTitle": "Le notifiche sono bloccate",
"blockedMessage": "Le notifiche sono disattivate per DeFlock. Attivale nelle impostazioni di sistema per ricevere gli avvisi di prossimità."
},
"node": {
"title": "Nodo #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"deleteReasonCustom": "Personalizzato..",
"deleteReasonHint": "Descrivi il motivo...",
"mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.",
"uploadPending": "Caricamento in sospeso",
"uploadCreatingChangeset": "Creazione changeset...",
@@ -167,8 +170,6 @@
"viewInQueue": "Visualizza in Coda",
"deletedSuccessfully": "Questo nodo è stato eliminato con successo da OpenStreetMap."
},
"addNode": {
"profile": "Profilo",
"selectProfile": "Seleziona un profilo...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Invio Completato",
"inFlightCompleteMessage": "Il tuo invio è terminato con successo. Applicazione della tua impostazione in corso."
},
"tileProviders": {
"title": "Fornitori di Tile",
"noProvidersConfigured": "Nessun fornitore di tile configurato",
@@ -470,7 +470,6 @@
"keepData": "Conserva Dati",
"deleteData": "Elimina Dati"
},
"refineTagsSheet": {
"title": "Affina Tag",
"operatorProfile": "Profilo Operatore",
+8 -9
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Offline functies inschakelen",
"enableOfflineFeaturesSubtitle": "Sta het downloaden en gebruiken van offline kaartgebieden toe. Indien uitgeschakeld, zijn offline modus en gedownloade gebieden niet beschikbaar."
},
"keepScreenAwake": {
"title": "Houd het scherm actief tijdens het gebruik van de app",
"subtitle": "Voorkomt dat het scherm automatisch uitschakelt tijdens het gebruik van de app"
@@ -127,7 +126,6 @@
"title": "Zoomknoppen verbergen",
"subtitle": "Verbergt de +/- zoomknoppen en de zoomniveau-indicator. U kunt nog steeds zoomen met knijpgebaren."
},
"proximityAlerts": {
"getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert",
"batteryUsage": "Gebruikt extra batterij voor continue locatie monitoring",
@@ -138,7 +136,13 @@
"enableNotifications": "Schakel Meldingen In",
"checkingPermissions": "Toestemmingen controleren...",
"alertDistance": "Waarschuwingsafstand: ",
"rangeInfo": "Bereik: {}-{} {} (standaard: {})"
"rangeInfo": "Bereik: {}-{} {} (standaard: {})",
"backgroundServiceTitle": "Nabijheidswaarschuwingen actief",
"backgroundServiceText": "Zoekt naar bewakingsapparatuur in de buurt",
"backgroundServiceChannel": "Monitoring van nabijheidswaarschuwingen",
"openSettings": "Instellingen openen",
"blockedTitle": "Meldingen zijn geblokkeerd",
"blockedMessage": "Meldingen zijn uitgeschakeld voor DeFlock. Schakel ze in via je systeeminstellingen om nabijheidswaarschuwingen te ontvangen."
},
"node": {
"title": "Node #{}",
@@ -146,7 +150,7 @@
"queuedForUpload": "Node in wachtrij geplaatst voor upload",
"editQueuedForUpload": "Node bewerking in wachtrij geplaatst voor upload",
"deleteQueuedForUpload": "Node verwijdering in wachtrij geplaatst voor upload",
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteTitle": "Verwijder Node",
"confirmDeleteMessage": "Weet u zeker dat u node #{} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"deleteReasonLabel": "Reden voor Verwijdering",
"deleteReasonSelectHint": "Selecteer...",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalisme",
"deleteReasonCustom": "Aangepast..",
"deleteReasonHint": "Beschrijf de reden...",
"mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.",
"uploadPending": "Upload in behandeling",
"uploadCreatingChangeset": "Changeset maken...",
@@ -167,8 +170,6 @@
"viewInQueue": "Bekijk in Wachtrij",
"deletedSuccessfully": "Deze node is succesvol verwijderd van OpenStreetMap."
},
"addNode": {
"profile": "Profiel",
"selectProfile": "Selecteer een profiel...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Indiening Voltooid",
"inFlightCompleteMessage": "Uw indiening is succesvol afgerond. Uw instelling wordt nu toegepast."
},
"tileProviders": {
"title": "Tile Providers",
"noProvidersConfigured": "Geen tile providers geconfigureerd",
@@ -470,7 +470,6 @@
"keepData": "Gegevens Behouden",
"deleteData": "Gegevens Verwijderen"
},
"refineTagsSheet": {
"title": "Verfijn Tags",
"operatorProfile": "Operator Profiel",
+7 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Włącz funkcje offline",
"enableOfflineFeaturesSubtitle": "Zezwól na pobieranie i korzystanie z map offline. Jeśli wyłączone, tryb offline i pobrane obszary będą niedostępne."
},
"keepScreenAwake": {
"title": "Nie wygaszaj ekranu podczas korzystania z aplikacji",
"subtitle": "Zapobiega automatycznemu wygaszaniu ekranu podczas korzystania z aplikacji"
@@ -127,7 +126,6 @@
"title": "Ukryj kontrolki zoomu",
"subtitle": "Ukrywa przyciski zoomu +/- oraz wskaźnik poziomu powiększenia. Nadal możesz powiększać za pomocą gestów szczypania."
},
"proximityAlerts": {
"getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru",
"batteryUsage": "Używa dodatkowej baterii do ciągłego monitorowania lokalizacji",
@@ -138,7 +136,13 @@
"enableNotifications": "Włącz Powiadomienia",
"checkingPermissions": "Sprawdzanie uprawnień...",
"alertDistance": "Odległość alertu: ",
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})"
"rangeInfo": "Zakres: {}-{} {} (domyślnie: {})",
"backgroundServiceTitle": "Alerty o bliskości aktywne",
"backgroundServiceText": "Wyszukiwanie pobliskich urządzeń monitorujących",
"backgroundServiceChannel": "Monitorowanie alertów o bliskości",
"openSettings": "Otwórz ustawienia",
"blockedTitle": "Powiadomienia są zablokowane",
"blockedMessage": "Powiadomienia dla DeFlock są wyłączone. Włącz je w ustawieniach systemowych, aby otrzymywać alerty o bliskości."
},
"node": {
"title": "Węzeł #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/wandalizm",
"deleteReasonCustom": "Niestandardowy..",
"deleteReasonHint": "Opisz powód...",
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
"uploadPending": "Przesyłanie oczekujące",
"uploadCreatingChangeset": "Tworzenie zestawu zmian...",
@@ -167,8 +170,6 @@
"viewInQueue": "Zobacz w Kolejce",
"deletedSuccessfully": "Ten węzeł został pomyślnie usunięty z OpenStreetMap."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Wybierz profil...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Przesyłanie Zakończone",
"inFlightCompleteMessage": "Twoje przesyłanie zakończyło się pomyślnie. Stosowanie ustawienia teraz."
},
"tileProviders": {
"title": "Dostawcy Kafelków",
"noProvidersConfigured": "Brak skonfigurowanych dostawców kafelków",
@@ -470,7 +470,6 @@
"keepData": "Zachowaj Dane",
"deleteData": "Usuń Dane"
},
"refineTagsSheet": {
"title": "Doprecyzuj Tagi",
"operatorProfile": "Profil Operatora",
+7 -8
View File
@@ -119,7 +119,6 @@
"enableOfflineFeaturesSubtitle": "Permitir descarregar e usar áreas de mapa offline. Se desativado, o modo offline e as áreas descarregadas não estarão disponíveis."
},
"keepScreenAwake": {
"title": "Manter o ecrã ativo durante o uso do app",
"subtitle": "Impede que o ecrã se desligue automaticamente enquanto a app estiver em uso"
},
@@ -127,7 +126,6 @@
"title": "Ocultar controlos de zoom",
"subtitle": "Oculta os botões de zoom +/- e o indicador de nível de zoom. Ainda pode ampliar com gestos de pinça."
},
"proximityAlerts": {
"getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância",
"batteryUsage": "Usa bateria extra para monitoramento contínuo de localização",
@@ -138,7 +136,13 @@
"enableNotifications": "Habilitar Notificações",
"checkingPermissions": "Verificando permissões...",
"alertDistance": "Distância de alerta: ",
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
"rangeInfo": "Faixa: {}-{} {} (padrão: {})",
"backgroundServiceTitle": "Alertas de proximidade ativos",
"backgroundServiceText": "Procurando dispositivos de vigilância próximos",
"backgroundServiceChannel": "Monitoramento de alertas de proximidade",
"openSettings": "Abrir configurações",
"blockedTitle": "As notificações estão bloqueadas",
"blockedMessage": "As notificações estão desativadas para o DeFlock. Ative-as nas configurações do sistema para receber alertas de proximidade."
},
"node": {
"title": "Nó #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalismo",
"deleteReasonCustom": "Personalizado..",
"deleteReasonHint": "Descreva o motivo...",
"mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.",
"uploadPending": "Envio pendente",
"uploadCreatingChangeset": "Criando changeset...",
@@ -167,8 +170,6 @@
"viewInQueue": "Ver na Fila",
"deletedSuccessfully": "Este nó foi excluído com sucesso do OpenStreetMap."
},
"addNode": {
"profile": "Perfil",
"selectProfile": "Selecionar um perfil...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Envio Concluído",
"inFlightCompleteMessage": "Seu envio foi concluído com sucesso. Aplicando sua configuração agora."
},
"tileProviders": {
"title": "Provedores de Tiles",
"noProvidersConfigured": "Nenhum provedor de tiles configurado",
@@ -470,7 +470,6 @@
"keepData": "Manter Dados",
"deleteData": "Eliminar Dados"
},
"refineTagsSheet": {
"title": "Refinar Tags",
"operatorProfile": "Perfil de Operador",
+7 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Çevrimdışı özellikleri etkinleştir",
"enableOfflineFeaturesSubtitle": "Çevrimdışı harita alanlarını indirmeye ve kullanmaya izin ver. Devre dışı bırakılırsa, çevrimdışı mod ve indirilen alanlar kullanılamaz."
},
"keepScreenAwake": {
"title": "Uygulamayı kullanırken ekranı açık tut",
"subtitle": "Uygulamayı kullanırken ekranın otomatik olarak kapanmasını engeller"
@@ -127,7 +126,6 @@
"title": "Yakınlaştırma kontrollerini gizle",
"subtitle": "+/- yakınlaştırma düğmelerini ve yakınlaştırma seviyesi göstergesini gizler. Yine de kıstırma hareketleriyle yakınlaştırabilirsiniz."
},
"proximityAlerts": {
"getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al",
"batteryUsage": "Sürekli konum izleme için ekstra batarya kullanır",
@@ -138,7 +136,13 @@
"enableNotifications": "Bildirimleri Etkinleştir",
"checkingPermissions": "İzinler kontrol ediliyor...",
"alertDistance": "Uyarı mesafesi: ",
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})"
"rangeInfo": "Aralık: {}-{} {} (varsayılan: {})",
"backgroundServiceTitle": "Yakınlık uyarıları etkin",
"backgroundServiceText": "Yakındaki gözetim cihazları aranıyor",
"backgroundServiceChannel": "Yakınlık Uyarısı İzleme",
"openSettings": "Ayarları aç",
"blockedTitle": "Bildirimler engellendi",
"blockedMessage": "DeFlock için bildirimler kapalı. Yakınlık uyarıları almak için sistem ayarlarından etkinleştirin."
},
"node": {
"title": "Düğüm #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Spam/vandalizm",
"deleteReasonCustom": "Özel..",
"deleteReasonHint": "Lütfen nedeni açıklayın...",
"mustBeLoggedInToDelete": "Harita verilerine katkıda bulunmak için bir OpenStreetMap hesabıyla oturum açmış olmanız gerekir.",
"uploadPending": "Yükleme bekliyor",
"uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...",
@@ -167,8 +170,6 @@
"viewInQueue": "Sırada Görüntüle",
"deletedSuccessfully": "Bu düğüm OpenStreetMap'ten başarıyla silindi."
},
"addNode": {
"profile": "Profil",
"selectProfile": "Bir profil seçin...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Gönderim Tamamlandı",
"inFlightCompleteMessage": "Gönderiminiz başarıyla tamamlandı. Ayarınız şimdi uygulanıyor."
},
"tileProviders": {
"title": "Döşeme Sağlayıcıları",
"noProvidersConfigured": "Döşeme sağlayıcısı yapılandırılmamış",
@@ -470,7 +470,6 @@
"keepData": "Verileri Sakla",
"deleteData": "Verileri Sil"
},
"refineTagsSheet": {
"title": "Etiketleri Düzenle",
"operatorProfile": "Operatör Profili",
+7 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "Увімкнути офлайн-функції",
"enableOfflineFeaturesSubtitle": "Дозволити завантаження та використання офлайн-областей карти. Якщо вимкнено, офлайн-режим і завантажені області будуть недоступні."
},
"keepScreenAwake": {
"title": "Тримати екран активним під час використання програми",
"subtitle": "Запобігає автоматичному вимкненню екрана під час використання програми"
@@ -127,7 +126,6 @@
"title": "Приховати елементи керування масштабом",
"subtitle": "Приховує кнопки масштабування +/- та індикатор рівня масштабу. Ви все ще можете масштабувати жестами."
},
"proximityAlerts": {
"getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження",
"batteryUsage": "Використовує додаткову батарею для безперервного моніторингу місцезнаходження",
@@ -138,7 +136,13 @@
"enableNotifications": "Увімкнути Сповіщення",
"checkingPermissions": "Перевірка дозволів...",
"alertDistance": "Відстань сповіщення: ",
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})"
"rangeInfo": "Діапазон: {}-{} {} (за замовчуванням: {})",
"backgroundServiceTitle": "Сповіщення про наближення активні",
"backgroundServiceText": "Пошук пристроїв стеження поблизу",
"backgroundServiceChannel": "Моніторинг сповіщень про наближення",
"openSettings": "Відкрити налаштування",
"blockedTitle": "Сповіщення заблоковано",
"blockedMessage": "Сповіщення для DeFlock вимкнено. Увімкніть їх у налаштуваннях системи, щоб отримувати сповіщення про наближення."
},
"node": {
"title": "Вузол #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "Спам/вандалізм",
"deleteReasonCustom": "Власний..",
"deleteReasonHint": "Опишіть причину...",
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
"uploadPending": "Завантаження очікує",
"uploadCreatingChangeset": "Створення набору змін...",
@@ -167,8 +170,6 @@
"viewInQueue": "Переглянути в Черзі",
"deletedSuccessfully": "Цей вузол було успішно видалено з OpenStreetMap."
},
"addNode": {
"profile": "Профіль",
"selectProfile": "Оберіть профіль...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "Подання Завершено",
"inFlightCompleteMessage": "Ваше подання успішно завершено. Застосування вашого налаштування зараз."
},
"tileProviders": {
"title": "Постачальники Плиток",
"noProvidersConfigured": "Постачальників плиток не налаштовано",
@@ -470,7 +470,6 @@
"keepData": "Зберегти Дані",
"deleteData": "Видалити Дані"
},
"refineTagsSheet": {
"title": "Уточнити Теги",
"operatorProfile": "Профіль Оператора",
+7 -8
View File
@@ -118,7 +118,6 @@
"enableOfflineFeaturesTitle": "启用离线功能",
"enableOfflineFeaturesSubtitle": "允许下载和使用离线地图区域。禁用后,离线模式和已下载的区域将不可用。"
},
"keepScreenAwake": {
"title": "使用应用时保持屏幕常亮",
"subtitle": "防止在使用应用期间屏幕自动熄灭"
@@ -127,7 +126,6 @@
"title": "隐藏缩放控件",
"subtitle": "隐藏 +/- 缩放按钮和缩放级别指示器。您仍然可以使用捏合手势进行缩放。"
},
"proximityAlerts": {
"getNotified": "接近监控设备时接收通知",
"batteryUsage": "使用额外电量进行连续位置监控",
@@ -138,7 +136,13 @@
"enableNotifications": "启用通知",
"checkingPermissions": "检查权限中...",
"alertDistance": "警报距离:",
"rangeInfo": "范围:{}-{} {}(默认:{}"
"rangeInfo": "范围:{}-{} {}(默认:{}",
"backgroundServiceTitle": "邻近提醒已启用",
"backgroundServiceText": "正在搜索附近的监控设备",
"backgroundServiceChannel": "邻近提醒监控",
"openSettings": "打开设置",
"blockedTitle": "通知已被阻止",
"blockedMessage": "DeFlock 的通知已关闭。请在系统设置中启用,以接收邻近提醒。"
},
"node": {
"title": "节点 #{}",
@@ -156,7 +160,6 @@
"deleteReasonSpamVandalism": "垃圾信息/破坏",
"deleteReasonCustom": "自定义..",
"deleteReasonHint": "请描述原因...",
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
"uploadPending": "上传待处理",
"uploadCreatingChangeset": "正在创建变更集...",
@@ -167,8 +170,6 @@
"viewInQueue": "在队列中查看",
"deletedSuccessfully": "此节点已成功从 OpenStreetMap 删除。"
},
"addNode": {
"profile": "配置文件",
"selectProfile": "选择配置文件...",
@@ -318,7 +319,6 @@
"inFlightCompleteTitle": "提交完成",
"inFlightCompleteMessage": "您的提交已成功完成。正在应用您的设置。"
},
"tileProviders": {
"title": "瓦片提供商",
"noProvidersConfigured": "未配置瓦片提供商",
@@ -470,7 +470,6 @@
"keepData": "保留数据",
"deleteData": "删除数据"
},
"refineTagsSheet": {
"title": "细化标签",
"operatorProfile": "运营商配置文件",
@@ -18,7 +18,8 @@ class ProximityAlertsSection extends StatefulWidget {
State<ProximityAlertsSection> createState() => _ProximityAlertsSectionState();
}
class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
class _ProximityAlertsSectionState extends State<ProximityAlertsSection>
with WidgetsBindingObserver {
late final TextEditingController _distanceController;
bool _notificationsEnabled = false;
bool _checkingPermissions = false;
@@ -26,6 +27,9 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
@override
void initState() {
super.initState();
// Re-check on resume: the user may have just granted notifications in the
// system settings app and come back.
WidgetsBinding.instance.addObserver(this);
final appState = context.read<AppState>();
// Convert meters to display units for the text field
final displayValue = DistanceService.convertFromMeters(
@@ -57,19 +61,59 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
setState(() {
_checkingPermissions = true;
});
final enabled = await ProximityAlertService().requestNotificationPermissions();
if (mounted) {
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
if (!mounted) return;
setState(() {
_notificationsEnabled = enabled;
_checkingPermissions = false;
});
// Still not granted. On iOS the system prompt only ever appears once, so
// re-requesting silently does nothing — the only remaining path is the
// system settings app.
if (!enabled) {
await _promptOpenSystemSettings();
}
}
Future<void> _promptOpenSystemSettings() async {
final locService = LocalizationService.instance;
final shouldOpen = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(locService.t('proximityAlerts.blockedTitle')),
content: Text(locService.t('proximityAlerts.blockedMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(locService.t('actions.cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(locService.t('proximityAlerts.openSettings')),
),
],
),
);
if (shouldOpen == true) {
await ProximityAlertService().openSystemSettings();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkNotificationPermissions();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_distanceController.dispose();
super.dispose();
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:latlong2/latlong.dart';
/// Mean meters per degree of latitude. Constant enough at any latitude for
/// picking a node lookup box.
const double _metersPerDegreeLat = 111320.0;
/// Smallest cos(latitude) we will divide by. Past ~89.4° the true factor tends
/// to zero and the longitude span would blow up to the whole globe.
const double _minCosLatitude = 0.01;
/// A square of roughly [radiusMeters] in every direction around [center].
///
/// Used to pull cached nodes near a GPS position, as opposed to near whatever
/// the map camera is showing — the camera is frozen while the app is
/// backgrounded, so it is not a usable proxy for where the user is.
///
/// A degree of longitude shrinks by cos(latitude), so the box is widened east
/// to west to keep the real-world radius honest as you move away from the
/// equator.
LatLngBounds boundsAround(LatLng center, num radiusMeters) {
final radius = radiusMeters.toDouble().abs();
final latDelta = radius / _metersPerDegreeLat;
final cosLat = math.cos(center.latitude * math.pi / 180).abs();
final lngDelta =
radius / (_metersPerDegreeLat * math.max(cosLat, _minCosLatitude));
return LatLngBounds(
LatLng(
(center.latitude - latDelta).clamp(-90.0, 90.0),
(center.longitude - lngDelta).clamp(-180.0, 180.0),
),
LatLng(
(center.latitude + latDelta).clamp(-90.0, 90.0),
(center.longitude + lngDelta).clamp(-180.0, 180.0),
),
);
}
+162 -76
View File
@@ -12,7 +12,7 @@ import '../dev_config.dart';
class RecentAlert {
final int nodeId;
final DateTime alertTime;
RecentAlert({required this.nodeId, required this.alertTime});
}
@@ -24,125 +24,189 @@ class ProximityAlertService {
ProximityAlertService._internal();
FlutterLocalNotificationsPlugin? _notifications;
bool _isInitialized = false;
/// Whether the plugin itself is usable. Deliberately NOT derived from
/// initialize()'s return value: on iOS that reports the outcome of the
/// permission request, which we intentionally disable below, so it returns
/// false even though the plugin works fine. Gating on it made every
/// notification path dead on iOS.
bool _pluginReady = false;
/// Guards against re-initializing on every settings visit.
Future<void>? _initFuture;
// Simple in-memory tracking of recent alerts to prevent spam
final List<RecentAlert> _recentAlerts = [];
static const Duration _alertCooldown = kProximityAlertCooldown;
// Callback for showing in-app visual alerts
VoidCallback? _onVisualAlert;
/// Initialize the notification plugin and request permissions
Future<void> initialize({VoidCallback? onVisualAlert}) async {
_onVisualAlert = onVisualAlert;
/// Initialize the notification plugin. Permissions are requested separately,
/// on demand, when the user enables proximity alerts.
Future<void> initialize({VoidCallback? onVisualAlert}) {
if (onVisualAlert != null) _onVisualAlert = onVisualAlert;
return _initFuture ??= _doInitialize();
}
Future<void> _doInitialize() async {
_notifications = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
final initialized = await _notifications!.initialize(initSettings);
_isInitialized = initialized ?? false;
// Note: We don't request notification permissions here anymore.
// Permissions are requested on-demand when user enables proximity alerts.
debugPrint('[ProximityAlertService] Initialized: $_isInitialized (permissions deferred)');
await _notifications!.initialize(initSettings);
// Completing without throwing is the real signal that the plugin is up.
_pluginReady = true;
debugPrint('[ProximityAlertService] Plugin ready (permissions deferred)');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to initialize: $e');
_isInitialized = false;
_pluginReady = false;
_initFuture = null; // Allow a later retry
}
}
/// Ensure the plugin is up before touching it, so callers that arrive before
/// MapView's fire-and-forget initialize() completes still work.
Future<bool> _ensureReady() async {
if (_pluginReady) return true;
await initialize();
return _pluginReady;
}
/// Request notification permissions on both platforms
/// Request notification permissions on both platforms.
///
/// Note that on iOS the system prompt appears only ONCE, ever. After the
/// user has answered it, this call returns silently without showing
/// anything — which is why callers must check the result and fall back to
/// sending the user to system settings.
Future<void> _requestNotificationPermissions() async {
if (_notifications == null) return;
try {
// Request permissions - this will show the permission dialog on Android 13+
final result = await _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android notification permission result: $result');
// Also request for iOS (though this was already done in initialization)
await _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
final android = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (android != null) {
final result = await android.requestNotificationsPermission();
debugPrint('[ProximityAlertService] Android permission result: $result');
return;
}
final ios = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (ios != null) {
final result = await ios.requestPermissions(
alert: true,
badge: true,
sound: true,
);
debugPrint('[ProximityAlertService] iOS permission result: $result');
}
} catch (e) {
debugPrint('[ProximityAlertService] Failed to request permissions: $e');
}
}
/// Open the OS settings page for this app, so the user can grant
/// notifications after the one-shot iOS prompt has already been answered.
/// Uses Geolocator's platform channel purely as a way to open app settings;
/// it is not location-specific.
Future<bool> openSystemSettings() async {
try {
return await Geolocator.openAppSettings();
} catch (e) {
debugPrint('[ProximityAlertService] Failed to open app settings: $e');
return false;
}
}
/// Check proximity to nodes and trigger alerts if needed
/// This should be called on GPS position updates
/// Check proximity to nodes and trigger alerts if needed.
/// This should be called on GPS position updates.
Future<void> checkProximity({
required LatLng userLocation,
required List<OsmNode> nodes,
required List<NodeProfile> enabledProfiles,
required int alertDistance,
}) async {
if (!_isInitialized || nodes.isEmpty) return;
if (!_pluginReady || nodes.isEmpty) return;
// Clean up old alerts (anything older than cooldown period)
final cutoffTime = DateTime.now().subtract(_alertCooldown);
_recentAlerts.removeWhere((alert) => alert.alertTime.isBefore(cutoffTime));
// Check each node for proximity
for (final node in nodes) {
// Skip if we recently alerted for this node
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) {
continue;
}
// Calculate distance using Geolocator's distanceBetween
if (_recentAlerts.any((alert) => alert.nodeId == node.id)) continue;
final distance = Geolocator.distanceBetween(
userLocation.latitude,
userLocation.longitude,
node.coord.latitude,
node.coord.longitude,
);
// Check if within alert distance
if (distance <= alertDistance) {
// Determine node type for alert message
final nodeType = _getNodeTypeDescription(node, enabledProfiles);
// Trigger both push notification and visual alert
await _showNotification(node, nodeType, distance.round());
_showVisualAlert();
// Track this alert to prevent spam
_recentAlerts.add(RecentAlert(
nodeId: node.id,
alertTime: DateTime.now(),
));
_recentAlerts.add(RecentAlert(nodeId: node.id, alertTime: DateTime.now()));
debugPrint('[ProximityAlertService] Alert triggered for node ${node.id} ($nodeType) at ${distance.round()}m');
}
}
}
/// Notification IDs must fit in a signed 32-bit int, but OSM node IDs are
/// well past that (currently ~12 billion), so passing one through raises
/// "must fit within the size of a 32-bit integer" and the notification is
/// dropped. Fold to a stable positive 31-bit value instead.
///
/// Deterministic across runs, so re-alerting for the same node replaces the
/// existing notification rather than stacking a duplicate.
static int _notificationId(int nodeId) => nodeId.abs() % 0x7FFFFFFF;
/// Show push notification for proximity alert
Future<void> _showNotification(OsmNode node, String nodeType, int distance) async {
if (!_isInitialized || _notifications == null) return;
await _show(
id: _notificationId(node.id),
title: 'Surveillance Device Nearby',
body: '$nodeType detected ${distance}m ahead',
);
}
Future<void> _show({
required int id,
required String title,
required String body,
}) async {
if (!_pluginReady || _notifications == null) {
debugPrint('[ProximityAlertService] NOT SHOWN: plugin not ready');
return;
}
// A notification that silently no-ops because permission was never granted
// is indistinguishable from one that was never triggered. Say which.
if (!await areNotificationsEnabled()) {
debugPrint(
'[ProximityAlertService] NOT SHOWN: OS notification permission not '
'granted. Settings → Proximity Alerts → Enable Notifications.',
);
return;
}
const androidDetails = AndroidNotificationDetails(
'proximity_alerts',
'Proximity Alerts',
@@ -154,7 +218,13 @@ class ProximityAlertService {
);
const iosDetails = DarwinNotificationDetails(
// presentAlert is dead on iOS 14+: the plugin's willPresentNotification
// only consults presentBanner/presentList there and ignores presentAlert
// entirely. Setting just presentAlert produced no visible banner at all.
// Keep it for iOS 13 and below, but presentBanner is what actually works.
presentAlert: true,
presentBanner: true,
presentList: true,
presentBadge: false,
presentSound: true,
);
@@ -164,16 +234,16 @@ class ProximityAlertService {
iOS: iosDetails,
);
final title = 'Surveillance Device Nearby';
final body = '$nodeType detected ${distance}m ahead';
try {
await _notifications!.show(
node.id, // Use node ID as notification ID
id, // Use node ID as notification ID
title,
body,
notificationDetails,
);
debugPrint('[ProximityAlertService] SHOWN id=$id "$title" — $body');
} on ArgumentError catch (e) {
debugPrint('[ProximityAlertService] Rejected notification id=$id: $e');
} catch (e) {
debugPrint('[ProximityAlertService] Failed to show notification: $e');
}
@@ -226,30 +296,46 @@ class ProximityAlertService {
_recentAlerts.clear();
}
/// Check if notification permissions are granted
/// Check if notification permissions are granted.
Future<bool> areNotificationsEnabled() async {
if (!_isInitialized || _notifications == null) return false;
if (!await _ensureReady() || _notifications == null) return false;
try {
// Check Android permissions
final androidImpl = _notifications!
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidImpl != null) {
final result = await androidImpl.areNotificationsEnabled();
return result ?? false;
}
// For iOS, assume enabled if we got this far (permissions were requested during init)
return true;
// iOS: actually ask the system rather than assuming. The old code
// returned a blind `true` here, so the UI could never tell whether
// notifications were really authorized.
final iosImpl = _notifications!
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosImpl != null) {
final options = await iosImpl.checkPermissions();
if (options == null) return false;
return options.isEnabled || options.isProvisionalEnabled;
}
return false;
} catch (e) {
debugPrint('[ProximityAlertService] Failed to check notification permissions: $e');
return false;
}
}
/// Request permissions again (can be called from settings)
/// Request permissions and report whether they ended up granted.
///
/// A `false` result does NOT mean the prompt was declined just now — on iOS
/// the prompt may not have appeared at all because it was already answered
/// in a previous launch. Callers should offer [openSystemSettings] on false.
Future<bool> requestNotificationPermissions() async {
if (!await _ensureReady()) return false;
await _requestNotificationPermissions();
return await areNotificationsEnabled();
final enabled = await areNotificationsEnabled();
debugPrint('[ProximityAlertService] Permissions after request: $enabled');
return enabled;
}
}
+198 -24
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
@@ -9,9 +10,10 @@ import '../../dev_config.dart';
import '../../app_state.dart' show FollowMeMode;
import '../../services/proximity_alert_service.dart';
import '../../services/coordinate_validation.dart';
import '../../services/localization_service.dart';
import '../../models/osm_node.dart';
import '../../models/node_profile.dart';
import '../../services/geo_bounds.dart';
/// Simple GPS controller that handles precise location permissions only.
/// Key principles:
@@ -21,7 +23,11 @@ import '../../models/node_profile.dart';
class GpsController {
StreamSubscription<Position>? _positionSub;
Timer? _retryTimer;
/// Whether the live stream was started with background delivery armed, so a
/// proximity-alerts toggle knows whether it actually needs to rebuild it.
bool _streamIsBackgroundCapable = false;
// Location state
LatLng? _currentLocation;
bool _hasLocation = false;
@@ -32,11 +38,15 @@ class GpsController {
FollowMeMode Function()? _getCurrentFollowMeMode;
bool Function()? _getProximityAlertsEnabled;
int Function()? _getProximityAlertDistance;
List<OsmNode> Function()? _getNearbyNodes;
List<OsmNode> Function(LatLng userLocation, int radiusMeters)? _getNearbyNodes;
List<NodeProfile> Function()? _getEnabledProfiles;
VoidCallback? _onMapMovedProgrammatically;
bool Function()? _isUserInteracting;
LatLngBounds? _backgroundTrackingBounds;
bool _isFetchingBackgroundNodes = false;
Future<void> Function(LatLngBounds)? _onFetchBackgroundNodes;
/// Get the current GPS location (if available)
LatLng? get currentLocation => _currentLocation;
@@ -50,8 +60,9 @@ class GpsController {
required FollowMeMode Function() getCurrentFollowMeMode,
required bool Function() getProximityAlertsEnabled,
required int Function() getProximityAlertDistance,
required List<OsmNode> Function() getNearbyNodes,
required List<OsmNode> Function(LatLng userLocation, int radiusMeters) getNearbyNodes,
required List<NodeProfile> Function() getEnabledProfiles,
Future<void> Function(LatLngBounds)? onFetchBackgroundNodes,
VoidCallback? onMapMovedProgrammatically,
bool Function()? isUserInteracting,
}) async {
@@ -65,6 +76,7 @@ class GpsController {
_getProximityAlertDistance = getProximityAlertDistance;
_getNearbyNodes = getNearbyNodes;
_getEnabledProfiles = getEnabledProfiles;
_onFetchBackgroundNodes = onFetchBackgroundNodes;
_onMapMovedProgrammatically = onMapMovedProgrammatically;
_isUserInteracting = isUserInteracting;
@@ -86,6 +98,38 @@ class GpsController {
_handleInitialFollowMeAnimation(newMode, oldMode);
}
/// Force a fresh fix, e.g. when the app returns from the background.
///
/// The position stream can stay silent after a resume — and a simulated
/// location that "teleported" while the app was suspended produces no
/// movement event at all — leaving _currentLocation stale, so proximity
/// alerts would keep evaluating against the old position.
Future<void> refreshLocation() async {
debugPrint('[GpsController] Refreshing location after resume');
// Restart the stream first: iOS often stops delivering to a subscription
// that spanned suspension.
if (_positionSub != null) {
_stopLocationTracking();
_startPositionStream();
}
try {
// geolocator 10.x: getCurrentPosition takes desiredAccuracy, unlike
// getPositionStream above which takes a LocationSettings object.
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
// Match the stream's forceLocationManager: true, so the one-shot and
// the stream cannot disagree about where we are on Android.
forceAndroidLocationManager: true,
timeLimit: const Duration(seconds: 10),
);
_onPositionReceived(position);
} catch (e) {
debugPrint('[GpsController] Failed to refresh location: $e');
}
}
/// Manual retry (e.g., user pressed follow-me button)
Future<void> retryLocationInit() async {
debugPrint('[GpsController] Manual retry of location initialization');
@@ -149,24 +193,29 @@ class GpsController {
final followMeMode = _getCurrentFollowMeMode?.call() ?? FollowMeMode.off;
final distanceFilter = followMeMode == FollowMeMode.off ? 5 : 1; // 5m normal, 1m follow-me
debugPrint('[GpsController] Starting GPS position stream (${distanceFilter}m filter)');
// Both platforms stop feeding a backgrounded app by default, which is why
// alerts only ever fired with the app on screen. Keeping the stream alive
// in the background is visible to the user (persistent notification on
// Android, blue status bar on iOS), so only ask for it when proximity
// alerts are actually switched on.
final background = _getProximityAlertsEnabled?.call() ?? false;
debugPrint(
'[GpsController] Starting GPS position stream '
'(${distanceFilter}m filter, background=$background)',
);
try {
_positionSub = Geolocator.getPositionStream(
locationSettings: defaultTargetPlatform == TargetPlatform.android
? AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
forceLocationManager: true,
)
: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
),
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter,
background: background,
),
).listen(
_onPositionReceived,
onError: _onPositionError,
);
_streamIsBackgroundCapable = background;
} catch (e) {
debugPrint('[GpsController] Failed to start position stream: $e');
_hasLocation = false;
@@ -175,6 +224,79 @@ class GpsController {
}
}
/// Platform-specific location settings.
///
/// When [background] is false these are exactly the old foreground-only
/// settings, so nothing changes for users who leave proximity alerts off.
LocationSettings _buildLocationSettings({
required int distanceFilter,
required bool background,
}) {
final locService = LocalizationService.instance;
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
forceLocationManager: true,
// Supplying this config is what makes geolocator run its
// GeolocatorLocationService as a foreground service. Without it
// Android throttles a backgrounded app to a few updates per hour and
// then stops entirely, so alerts never fire.
foregroundNotificationConfig: background
? ForegroundNotificationConfig(
notificationTitle:
locService.t('proximityAlerts.backgroundServiceTitle'),
notificationText:
locService.t('proximityAlerts.backgroundServiceText'),
notificationChannelName:
locService.t('proximityAlerts.backgroundServiceChannel'),
// Without a wake lock the system sleeps and delivers the
// queued positions in one burst on wake — far too late to warn
// anyone about a device they already drove past.
enableWakeLock: true,
setOngoing: true,
)
: null,
);
}
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return AppleSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
// Pairs with UIBackgroundModes=location in Info.plist. Together they
// stop iOS from suspending the app on background, which is what killed
// the position stream (and therefore every alert) before.
allowBackgroundLocationUpdates: background,
// iOS otherwise pauses updates when it decides you have stopped moving
// and never reliably resumes them — a silent death for alerts.
pauseLocationUpdatesAutomatically: false,
// The blue status bar pill. Non-negotiable honesty: the app is reading
// location off screen and the user should be able to see that.
showBackgroundLocationIndicator: background,
activityType: ActivityType.otherNavigation,
);
}
return LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter,
);
}
/// Rebuild the stream when the proximity-alerts setting is toggled, so
/// background delivery is armed (or dropped) immediately rather than at the
/// next unrelated stream restart.
void updateProximityAlertsEnabled(bool enabled) {
if (_streamIsBackgroundCapable == enabled) return;
debugPrint('[GpsController] Proximity alerts $enabled — rebuilding stream');
_stopLocationTracking();
_startPositionStream();
}
/// Restart position stream with current follow-me settings
void _restartPositionStream() {
if (_positionSub == null) {
@@ -187,12 +309,58 @@ class GpsController {
_startPositionStream();
}
Future<void> _checkBackgroundDataRefresh(LatLng userLocation) async {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
// Debug 1: Check the early return conditions
debugPrint('[GpsController-Background] proximityEnabled: $proximityEnabled, '
'onFetchBackgroundNodes null: ${_onFetchBackgroundNodes == null}, '
'isFetchingBackgroundNodes: $_isFetchingBackgroundNodes');
if (!proximityEnabled || _onFetchBackgroundNodes == null || _isFetchingBackgroundNodes) return;
bool needsRefresh = false;
if (_backgroundTrackingBounds == null) {
debugPrint('[GpsController-Background] Bounds are null. Triggering initial refresh.');
needsRefresh = true;
} else {
final bounds = _backgroundTrackingBounds!;
final northDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, bounds.north, userLocation.longitude);
final southDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, bounds.south, userLocation.longitude);
final eastDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, userLocation.latitude, bounds.east);
final westDist = Geolocator.distanceBetween(userLocation.latitude, userLocation.longitude, userLocation.latitude, bounds.west);
final minDist = [northDist, southDist, eastDist, westDist].reduce((a, b) => a < b ? a : b);
// Debug 2: Print the closest distance to the edge
debugPrint('[GpsController-Background] Closest edge distance: ${minDist.toStringAsFixed(1)}m (Threshold: 2500m)');
if (minDist < 2500) {
debugPrint('[GpsController-Background] Threshold crossed. Triggering refresh.');
needsRefresh = true;
}
}
if (needsRefresh) {
_isFetchingBackgroundNodes = true;
try {
final newBounds = boundsAround(userLocation, 5000);
await _onFetchBackgroundNodes?.call(newBounds);
_backgroundTrackingBounds = newBounds;
debugPrint('[GpsController-Background] Successfully fetched and updated tracking bounds.');
} catch (e) {
debugPrint('[GpsController-Background] Background fetch failed: $e');
} finally {
_isFetchingBackgroundNodes = false;
}
}
}
/// Handle incoming GPS position
void _onPositionReceived(Position position) {
// Reject malformed fixes (occasionally NaN/Infinite from the platform
// location stack) before they reach map state.
if (!isValidLatitude(position.latitude) || !isValidLongitude(position.longitude)) {
debugPrint(
'[GpsController] Ignoring invalid GPS position: '
'lat=${position.latitude}, lng=${position.longitude}',
@@ -200,7 +368,6 @@ class GpsController {
return;
}
final newLocation = LatLng(position.latitude, position.longitude);
_currentLocation = newLocation;
@@ -217,13 +384,13 @@ class GpsController {
// Handle proximity alerts
_checkProximityAlerts(newLocation);
_checkBackgroundDataRefresh(newLocation);
// Handle follow-me animations
_handleFollowMeUpdate(position, newLocation);
}
/// Handle GPS stream errors
void _onPositionError(dynamic error) {
debugPrint('[GpsController] Position stream error: $error');
if (_hasLocation) {
@@ -239,13 +406,19 @@ class GpsController {
void _checkProximityAlerts(LatLng userLocation) {
final proximityEnabled = _getProximityAlertsEnabled?.call() ?? false;
if (!proximityEnabled) return;
final nearbyNodes = _getNearbyNodes?.call() ?? [];
if (nearbyNodes.isEmpty) return;
final alertDistance = _getProximityAlertDistance?.call() ?? 200;
// Look up nodes around where the user actually is, not around whatever the
// map camera happens to show. Backgrounded, the camera never moves, so the
// old viewport-based lookup went stale the moment you drove out of it —
// and even in the foreground it missed alerts whenever the map was panned
// away from your position.
final nearbyNodes = _getNearbyNodes?.call(userLocation, alertDistance) ?? [];
if (nearbyNodes.isEmpty) return;
final enabledProfiles = _getEnabledProfiles?.call() ?? [];
ProximityAlertService().checkProximity(
userLocation: userLocation,
nodes: nearbyNodes,
@@ -377,6 +550,7 @@ class GpsController {
_getProximityAlertDistance = null;
_getNearbyNodes = null;
_getEnabledProfiles = null;
_onFetchBackgroundNodes = null;
_onMapMovedProgrammatically = null;
_isUserInteracting = null;
}
+37 -9
View File
@@ -7,6 +7,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import '../app_state.dart' show AppState, FollowMeMode;
import '../services/offline_area_service.dart';
import '../services/geo_bounds.dart';
import '../models/osm_node.dart';
import '../models/suspected_location.dart';
@@ -64,7 +65,7 @@ class MapView extends StatefulWidget {
State<MapView> createState() => MapViewState();
}
class MapViewState extends State<MapView> {
class MapViewState extends State<MapView> with WidgetsBindingObserver {
late final AnimatedMapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
@@ -126,6 +127,8 @@ class MapViewState extends State<MapView> {
_dataManager = MapDataManager();
_interactionManager = MapInteractionManager();
WidgetsBinding.instance.addObserver(this);
// Initialize proximity alert service
ProximityAlertService().initialize(
onVisualAlert: () {
@@ -186,16 +189,16 @@ class MapViewState extends State<MapView> {
}
return 200;
},
getNearbyNodes: () {
getNearbyNodes: (userLocation, radiusMeters) {
if (mounted) {
try {
final LatLngBounds mapBounds;
try {
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
return [];
}
return NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds);
// Deliberately not the map camera's visibleBounds: while the app
// is backgrounded the camera is frozen wherever the user left it,
// so a viewport lookup returns nodes from a place they may be
// miles from by now. Box around the live GPS position instead.
return NodeProviderWithCache.instance.getCachedNodesForBounds(
boundsAround(userLocation, radiusMeters),
);
} catch (e) {
debugPrint('[MapView] Could not get nearby nodes: $e');
return [];
@@ -214,6 +217,16 @@ class MapViewState extends State<MapView> {
}
return [];
},
onFetchBackgroundNodes: (bounds) async {
if (!mounted) return;
final appState = context.read<AppState>();
await NodeProviderWithCache.instance.fetchAndUpdate(
bounds: bounds,
profiles: appState.enabledProfiles,
uploadMode: appState.uploadMode,
);
},
onMapMovedProgrammatically: () {
// Refresh nodes when GPS controller moves the map
_refreshNodesFromProvider();
@@ -231,8 +244,18 @@ class MapViewState extends State<MapView> {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The position stream can go quiet across suspension, so pull a fresh fix
// rather than trusting the last one we saw before backgrounding.
if (state == AppLifecycleState.resumed) {
_gpsController.refreshLocation();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_cameraDebounce.dispose();
_tileDebounce.dispose();
_mapPositionDebounce.dispose();
@@ -329,6 +352,11 @@ class MapViewState extends State<MapView> {
// Keep screen awake based on user setting
_updateWakelock(appState.keepScreenAwake);
// Arm or drop background location delivery when the proximity alert
// setting is toggled. No-op when the stream already matches, same as
// _updateWakelock above.
_gpsController.updateProximityAlertsEnabled(appState.proximityAlertsEnabled);
// Check if enabled profiles changed and refresh nodes if needed
_nodeController.checkAndHandleProfileChanges(
currentEnabledProfiles: appState.enabledProfiles,
+19 -5
View File
@@ -17,6 +17,7 @@ class NodeProviderWithCache extends ChangeNotifier {
final NodeDataManager _nodeDataManager = NodeDataManager();
Timer? _debounceTimer;
Completer<void>? _activeCompleter;
/// Get cached nodes for the given bounds, filtered by enabled profiles
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
@@ -34,14 +35,21 @@ class NodeProviderWithCache extends ChangeNotifier {
}
/// Fetch and update nodes for the given view, with debouncing for rapid map movement
void fetchAndUpdate({
Future<void> fetchAndUpdate({
required LatLngBounds bounds,
required List<NodeProfile> profiles,
UploadMode uploadMode = UploadMode.production,
}) {
// Serve cached immediately
notifyListeners();
// Prevent hanging awaits by completing the previous request if it gets cancelled
if (_activeCompleter != null && !_activeCompleter!.isCompleted) {
_activeCompleter!.complete();
}
_activeCompleter = Completer<void>();
// Debounce rapid panning/zooming
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 400), () async {
@@ -52,15 +60,21 @@ class NodeProviderWithCache extends ChangeNotifier {
uploadMode: uploadMode,
isUserInitiated: true,
);
// Notify UI of new data
notifyListeners();
} catch (e) {
debugPrint('[NodeProviderWithCache] Node fetch failed: $e');
// Cache already holds whatever is available for the view
} finally {
// Ensure we only complete if it hasn't been superseded by another call
if (_activeCompleter != null && !_activeCompleter!.isCompleted) {
_activeCompleter!.complete();
}
}
});
return _activeCompleter!.future;
}
/// Clear the cache and repopulate with pending nodes from upload queue
@@ -108,4 +122,4 @@ class NodeProviderWithCache extends ChangeNotifier {
}
return true;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:deflockapp/services/geo_bounds.dart';
/// Real-world distance from a bounds edge back to its center, so the box can be
/// checked in meters rather than in degrees.
double _metersBetween(LatLng a, LatLng b) => Geolocator.distanceBetween(
a.latitude,
a.longitude,
b.latitude,
b.longitude,
);
void main() {
group('boundsAround', () {
test('contains the center', () {
const center = LatLng(38.9, -77.0);
final bounds = boundsAround(center, 200);
expect(bounds.contains(center), isTrue);
});
test('reaches at least the requested radius north/south and east/west', () {
const center = LatLng(38.9, -77.0);
const radius = 500;
final bounds = boundsAround(center, radius);
final north = _metersBetween(center, LatLng(bounds.north, center.longitude));
final south = _metersBetween(center, LatLng(bounds.south, center.longitude));
final east = _metersBetween(center, LatLng(center.latitude, bounds.east));
final west = _metersBetween(center, LatLng(center.latitude, bounds.west));
for (final reach in [north, south, east, west]) {
// At least the radius, and not wildly oversized (the box is a square,
// so its corners legitimately overshoot, but its edges should not).
expect(reach, greaterThanOrEqualTo(radius * 0.98));
expect(reach, lessThan(radius * 1.2));
}
});
test('widens longitude at high latitude to keep the real radius', () {
const radius = 1000;
final equator = boundsAround(const LatLng(0, 0), radius);
final far = boundsAround(const LatLng(64.0, 0), radius);
final equatorLngSpan = equator.east - equator.west;
final farLngSpan = far.east - far.west;
// cos(64°) ≈ 0.438, so the degree span must be roughly 2.3x wider.
expect(farLngSpan, greaterThan(equatorLngSpan * 2));
// ...and that wider span should still be ~1000m on the ground.
final reach = _metersBetween(
const LatLng(64.0, 0),
LatLng(64.0, far.east),
);
expect(reach, greaterThanOrEqualTo(radius * 0.98));
expect(reach, lessThan(radius * 1.2));
});
test('does not blow past the poles or the antimeridian', () {
for (final center in [
const LatLng(89.99, 179.99),
const LatLng(-89.99, -179.99),
]) {
final bounds = boundsAround(center, 100000);
expect(bounds.north, lessThanOrEqualTo(90.0));
expect(bounds.south, greaterThanOrEqualTo(-90.0));
expect(bounds.east, lessThanOrEqualTo(180.0));
expect(bounds.west, greaterThanOrEqualTo(-180.0));
}
});
test('a zero radius still yields usable bounds containing the center', () {
const center = LatLng(38.9, -77.0);
final bounds = boundsAround(center, 0);
expect(bounds.contains(center), isTrue);
});
test('grows with the alert distance', () {
const center = LatLng(38.9, -77.0);
final near = boundsAround(center, 100);
final far = boundsAround(center, 1600);
expect(far.north - far.south, greaterThan(near.north - near.south));
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
/// Mirrors ProximityAlertService._notificationId. Kept in sync deliberately:
/// the real one is private, and this pins the contract the plugin enforces —
/// notification ids must fit in a signed 32-bit int.
int notificationId(int nodeId) => nodeId.abs() % 0x7FFFFFFF;
const int maxInt32 = 2147483647;
void main() {
group('notificationId', () {
test('folds real-world OSM node ids into 32-bit range', () {
// The id from the crash, plus ids around the current OSM high-water mark.
const ids = [
12741890927,
1,
2147483647,
2147483648,
9999999999,
13000000000,
];
for (final id in ids) {
final result = notificationId(id);
expect(result, greaterThanOrEqualTo(0), reason: 'id $id went negative');
expect(result, lessThanOrEqualTo(maxInt32), reason: 'id $id overflowed');
}
});
test('handles negative ids, which OSM uses for not-yet-uploaded nodes', () {
for (final id in [-1, -12741890927, -2147483648]) {
final result = notificationId(id);
expect(result, greaterThanOrEqualTo(0));
expect(result, lessThanOrEqualTo(maxInt32));
}
});
test('is deterministic, so a repeat alert replaces rather than stacks', () {
expect(notificationId(12741890927), notificationId(12741890927));
});
test('separates adjacent node ids', () {
final a = notificationId(12741890927);
final b = notificationId(12741890928);
expect(a, isNot(b));
});
});
}