Merge pull request #198 from FoggedLens/osm-user-block-detection

Osm user block detection
This commit is contained in:
stopflock authored and GitHub committed 2026-10-04 20:57:59 -05:00
commit c317178117
29 files changed
+968 -52

No files matched your search

+141 -10
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
import 'package:url_launcher/url_launcher.dart';
import 'services/http_client.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -20,10 +21,14 @@ import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/deep_link_service.dart';
import 'services/auth_service.dart' show AccountBlockedException;
import 'services/localization_service.dart';
import 'widgets/node_provider_with_cache.dart';
import 'services/profile_service.dart';
import 'widgets/reauth_messages_dialog.dart';
import 'widgets/active_block_dialog.dart';
import 'dev_config.dart';
import 'state/account_block_state.dart';
import 'state/auth_state.dart';
import 'state/messages_state.dart';
import 'state/navigation_state.dart';
@@ -46,6 +51,7 @@ class AppState extends ChangeNotifier {
static late AppState instance;
// State modules
late final AccountBlockState _accountBlockState;
late final AuthState _authState;
late final MessagesState _messagesState;
late final NavigationState _navigationState;
@@ -72,6 +78,7 @@ class AppState extends ChangeNotifier {
AppState() {
instance = this;
_accountBlockState = AccountBlockState();
_authState = AuthState();
_messagesState = MessagesState();
_navigationState = NavigationState();
@@ -84,6 +91,7 @@ class AppState extends ChangeNotifier {
_uploadQueueState = UploadQueueState();
// Set up state change listeners
_accountBlockState.addListener(_onStateChanged);
_authState.addListener(_onStateChanged);
_messagesState.addListener(_onStateChanged);
_navigationState.addListener(_onStateChanged);
@@ -179,6 +187,9 @@ class AppState extends ChangeNotifier {
bool get hasUnreadChangesetComments => _messagesState.hasUnreadChangesetComments;
bool get hasUnreadNotifications => _messagesState.hasUnreadNotifications;
bool get isCheckingMessages => _messagesState.isChecking;
// Account block state
bool get hasActiveBlock => _accountBlockState.isBlocked;
// Tile provider state
List<TileProvider> get tileProviders => _settingsState.tileProviders;
@@ -196,6 +207,10 @@ class AppState extends ChangeNotifier {
/// queue so in-flight submissions can finish first.
bool get hasInFlightUploads => pendingUploads.any((u) => u.isActivelyProcessing);
/// True if queue processing is currently paused because an active-block
/// check found the user's account blocked.
bool get isQueuePausedDueToBlock => _uploadQueueState.isPausedDueToBlock;
// Suspected location state
SuspectedLocation? get selectedSuspectedLocation => _suspectedLocationState.selectedLocation;
@@ -245,15 +260,17 @@ class AppState extends ChangeNotifier {
await _suspectedLocationState.init(offlineMode: _settingsState.offlineMode);
await _uploadQueueState.init();
await _authState.init(_settingsState.uploadMode);
await _accountBlockState.init(_settingsState.uploadMode);
// Set up callback to repopulate pending nodes after cache clears
NodeProviderWithCache.instance.setOnCacheClearedCallback(() {
_uploadQueueState.repopulateCacheFromQueue();
});
// Check for messages on app launch if user is already logged in
// Check for messages and active blocks on app launch if user is already logged in
if (isLoggedIn) {
checkMessages();
checkActiveBlock();
}
// Note: Re-auth check will be triggered from home screen after init
@@ -298,34 +315,131 @@ class AppState extends ChangeNotifier {
}
// ---------- Auth Methods ----------
Future<void> login() async {
await _authState.login();
// Check for messages after successful login
Future<void> login({BuildContext? context}) async {
try {
await _authState.login();
} catch (e) {
await _recordLoginFailure(e);
if (context == null || !context.mounted) return;
_showLoginFailureUi(e, context);
return;
}
// Check for messages and active blocks after successful login
if (isLoggedIn) {
checkMessages();
checkActiveBlock();
}
}
Future<void> logout() async {
await _authState.logout();
// Clear message state when logging out
// Clear message and block state when logging out
clearMessages();
_accountBlockState.clear();
}
Future<void> refreshAuthState() async {
await _authState.refreshAuthState();
}
Future<void> forceLogin() async {
await _authState.forceLogin();
// Check for messages after successful login
Future<void> forceLogin({BuildContext? context}) async {
try {
await _authState.forceLogin();
} catch (e) {
await _recordLoginFailure(e);
if (context == null || !context.mounted) return;
_showLoginFailureUi(e, context);
return;
}
// Check for messages and active blocks after successful login
if (isLoggedIn) {
checkMessages();
checkActiveBlock();
}
}
Future<bool> validateToken() async {
return await _authState.validateToken();
/// Record any state changes resulting from a login/forceLogin failure.
/// Never touches the UI - safe to call before a BuildContext mounted check.
Future<void> _recordLoginFailure(Object error) async {
debugPrint('AppState: Login failed: $error');
if (error is AccountBlockedException) {
// We got a conclusive answer straight from the API during login, so
// record it like any other successful check.
await _accountBlockState.check(
accessToken: await _authState.getAccessToken(),
uploadMode: uploadMode,
);
}
}
/// Show the appropriate UI for a login/forceLogin failure. Callers must
/// check `context.mounted` immediately before calling this (no further
/// awaits in between) since it's used synchronously here.
void _showLoginFailureUi(Object error, BuildContext context) {
if (error is AccountBlockedException) {
_showActiveBlockDialog(context);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(LocalizationService.instance.t('auth.loginFailed')),
backgroundColor: Colors.red,
),
);
}
// ---------- Account Block Methods ----------
/// Silently check whether the user currently has an active OSM block.
/// Returns the conclusive result (true/false), or null if the check was
/// inconclusive (offline, error, not logged in, simulate mode).
Future<bool?> checkActiveBlock() async {
final accessToken = await _authState.getAccessToken();
final result = await _accountBlockState.check(
accessToken: accessToken,
uploadMode: uploadMode,
);
// If the block was just lifted, resume queue processing.
if (result == false && isQueuePausedDueToBlock) {
_startUploader();
}
return result;
}
/// Check for an active block and, if found, show [ActiveBlockDialog].
Future<void> checkAndPromptForActiveBlock(BuildContext context) async {
final result = await checkActiveBlock();
if (result == true) {
if (!context.mounted) return;
_showActiveBlockDialog(context);
}
}
void _showActiveBlockDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => ActiveBlockDialog(
onViewDetails: () async {
final url = Uri.parse(getBlockDetailsUrl());
await launchUrl(url, mode: LaunchMode.externalApplication);
},
onDismiss: () {
// Just dismiss - will show again on the next check that finds a block.
},
),
);
}
/// Test the OSM connection using the same endpoint as the active-block
/// check. A conclusive result (blocked or not) means the credentials are
/// valid; null means the connection/credentials failed. Also surfaces the
/// [ActiveBlockDialog] if the user turns out to be blocked.
Future<bool> testConnection(BuildContext context) async {
final result = await checkActiveBlock();
if (result == true) {
if (context.mounted) _showActiveBlockDialog(context);
}
return result != null;
}
// ---------- Messages Methods ----------
@@ -342,6 +456,18 @@ class AppState extends ChangeNotifier {
String getMessagesUrl() {
return _messagesState.getMessagesUrl(uploadMode);
}
/// URL to view the current user's block details on OSM's website, or -
/// if the username isn't known for some reason - the messages inbox,
/// since OSM also sends a message when a block is issued.
String getBlockDetailsUrl() {
if (username.isEmpty) return getMessagesUrl();
final host = switch (uploadMode) {
UploadMode.sandbox => 'https://master.apis.dev.openstreetmap.org',
UploadMode.production || UploadMode.simulate => 'https://www.openstreetmap.org',
};
return '$host/user/${Uri.encodeComponent(username)}/blocks';
}
/// URL of the specific changeset with unread comments, on OSM's website,
/// or null if there are no unread changeset comments.
@@ -783,9 +909,12 @@ class AppState extends ChangeNotifier {
// Clear and re-check messages for new mode
clearMessages();
// Load the persisted block state for the new mode, then re-check it
await _accountBlockState.init(mode);
if (isLoggedIn) {
// Don't await - let it run in background
checkMessages();
checkActiveBlock();
// Note: Re-auth check will be triggered from the settings screen after mode change
}
@@ -958,12 +1087,14 @@ class AppState extends ChangeNotifier {
pauseQueueProcessing: pauseQueueProcessing,
uploadMode: uploadMode,
getAccessToken: _authState.getAccessToken,
checkActiveBlock: checkActiveBlock,
);
}
@override
void dispose() {
_messageCheckTimer?.cancel();
_accountBlockState.removeListener(_onStateChanged);
_authState.removeListener(_onStateChanged);
_messagesState.removeListener(_onStateChanged);
_navigationState.removeListener(_onStateChanged);
+10
View File
@@ -49,6 +49,16 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
return baseTop + safeArea.top;
}
// Whether to proactively check blocks/active *before* attempting to fetch
// the username during login. Current OSM behavior: user/details returns 403
// for blocked accounts, so we check blocks/active first (it's documented as
// accessible even while blocked) to detect this reliably instead of getting
// an unexplained 403. Flip to false once OSM allows user/details to succeed
// for blocked accounts too, so login can complete normally and rely on the
// regular checkActiveBlock() triggers (launch/resume/queue/test connection)
// to surface the block warning instead.
const bool kCheckBlockBeforeUsernameFetch = true;
// Client name for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
+10 -1
View File
@@ -12,6 +12,7 @@
"edit": "Bearbeiten",
"delete": "Löschen",
"cancel": "Abbrechen",
"dismiss": "Verwerfen",
"ok": "OK",
"close": "Schließen",
"submit": "Senden",
@@ -124,6 +125,7 @@
"deleteReasonCustom": "Benutzerdefiniert..",
"deleteReasonHint": "Bitte Grund beschreiben...",
"mustBeLoggedInToDelete": "Sie müssen mit einem OpenStreetMap-Konto angemeldet sein, um Kartendaten beizutragen.",
"accountBlockedCannotDelete": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Knoten löschen.",
"uploadPending": "Ausstehender Upload",
"uploadCreatingChangeset": "Changeset wird erstellt...",
"uploadUploading": "Wird hochgeladen...",
@@ -140,6 +142,7 @@
"direction": "Richtung {}°",
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um neue Knoten zu übertragen. Bitte melden Sie sich über die Einstellungen an.",
"accountBlocked": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine neuen Knoten einreichen.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um neue Knoten zu übertragen.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um neue Knoten zu übertragen.",
"loadingAreaData": "Lade Bereichsdaten... Bitte warten Sie vor dem Übertragen.",
@@ -154,6 +157,7 @@
"profileNoDirectionInfo": "Dieses Profil benötigt keine Richtung.",
"temporarilyDisabled": "Bearbeitungen wurden vorübergehend deaktiviert, während wir einen Fehler beheben - Entschuldigung - schauen Sie bald wieder vorbei.",
"mustBeLoggedIn": "Sie müssen angemeldet sein, um Knoten zu bearbeiten. Bitte melden Sie sich über die Einstellungen an.",
"accountBlocked": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Knoten bearbeiten.",
"sandboxModeWarning": "Bearbeitungen von Produktionsknoten können nicht an die Sandbox übertragen werden. Wechseln Sie in den Produktionsmodus in den Einstellungen, um Knoten zu bearbeiten.",
"enableSubmittableProfile": "Aktivieren Sie ein übertragbares Profil in den Einstellungen, um Knoten zu bearbeiten.",
"profileViewOnlyWarning": "Dieses Profil ist nur zum Anzeigen der Karte gedacht. Bitte wählen Sie ein übertragbares Profil aus, um Knoten zu bearbeiten.",
@@ -210,6 +214,7 @@
"tapToLogout": "Zum Abmelden antippen",
"requiredToSubmit": "Erforderlich, um Kameradaten zu übertragen",
"loggedOut": "Abgemeldet",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"testConnection": "Verbindung Testen",
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
@@ -241,7 +246,10 @@
"reauthLater": "Später",
"unreadNotificationsTitle": "Sie haben ungelesene Benachrichtigungen",
"unreadNotificationsMessage": "Sie haben ungelesene OSM-Nachrichten und/oder neue Kommentare zu Ihren Changesets.",
"unreadNotificationsView": "Jetzt ansehen"
"unreadNotificationsView": "Jetzt ansehen",
"activeBlockTitle": "Ihr OSM-Konto hat eine aktive Sperre",
"activeBlockMessage": "Ihr OpenStreetMap-Konto ist derzeit gesperrt und kann keine Beiträge einreichen. Sie sollten eine Nachricht mit weiteren Details erhalten haben; bitte prüfen Sie Ihren OSM-Posteingang, um mehr zu erfahren.",
"viewBlockDetails": "Sperrdetails ansehen"
},
"queue": {
"title": "Upload-Warteschlange",
@@ -277,6 +285,7 @@
"processingPaused": "Warteschlangenverarbeitung pausiert",
"pausedDueToOffline": "Upload-Verarbeitung ist pausiert, da der Offline-Modus aktiviert ist.",
"pausedByUser": "Upload-Verarbeitung ist manuell pausiert.",
"pausedDueToBlock": "Die Upload-Verarbeitung ist pausiert, da Ihr OSM-Konto derzeit gesperrt ist.",
"inFlightWaitTitle": "Übermittlung wird abgeschlossen",
"inFlightWaitMessage": "Eine Übermittlung läuft gerade und muss abgeschlossen werden, bevor diese Einstellung angewendet werden kann. Dies sollte nur einen Moment dauern.",
"inFlightCompleteTitle": "Übermittlung Abgeschlossen",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"dismiss": "Dismiss",
"ok": "OK",
"close": "Close",
"submit": "Submit",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Custom..",
"deleteReasonHint": "Please describe the reason...",
"mustBeLoggedInToDelete": "You must be logged in with an OpenStreetMap account to contribute map data.",
"accountBlockedCannotDelete": "Your OpenStreetMap account has an active block and cannot delete nodes.",
"uploadPending": "Pending upload",
"uploadCreatingChangeset": "Creating changeset...",
"uploadUploading": "Uploading...",
@@ -177,6 +179,7 @@
"direction": "Direction {}°",
"profileNoDirectionInfo": "This profile does not require a direction.",
"mustBeLoggedIn": "You must be logged in to submit new nodes. Please log in via Settings.",
"accountBlocked": "Your OpenStreetMap account has an active block and cannot submit new nodes.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to submit new nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to submit new nodes.",
"loadingAreaData": "Loading area data... Please wait before submitting.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "This profile does not require a direction.",
"temporarilyDisabled": "Edits have been temporarily disabled while we sort out a bug - apologies - check back soon.",
"mustBeLoggedIn": "You must be logged in to edit nodes. Please log in via Settings.",
"accountBlocked": "Your OpenStreetMap account has an active block and cannot edit nodes.",
"sandboxModeWarning": "Cannot submit edits on production nodes to sandbox. Switch to Production mode in Settings to edit nodes.",
"enableSubmittableProfile": "Enable a submittable profile in Settings to edit nodes.",
"profileViewOnlyWarning": "This profile is for map viewing only. Please select a submittable profile to edit nodes.",
@@ -247,6 +251,7 @@
"tapToLogout": "Tap to logout",
"requiredToSubmit": "Required to submit camera data",
"loggedOut": "Logged out",
"loginFailed": "Login failed. Please try again.",
"testConnection": "Test Connection",
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
@@ -278,7 +283,10 @@
"reauthLater": "Later",
"unreadNotificationsTitle": "You Have Unread Notifications",
"unreadNotificationsMessage": "You have unread OSM messages and/or new comments on your changesets.",
"unreadNotificationsView": "View Now"
"unreadNotificationsView": "View Now",
"activeBlockTitle": "Your OSM Account Has an Active Block",
"activeBlockMessage": "Your OpenStreetMap account currently has an active block and is restricted from making submissions. You should have received a message with more details; please check your OSM inbox to learn more.",
"viewBlockDetails": "View Block Details"
},
"queue": {
"title": "Upload Queue",
@@ -314,6 +322,7 @@
"processingPaused": "Queue Processing Paused",
"pausedDueToOffline": "Upload processing is paused because offline mode is enabled.",
"pausedByUser": "Upload processing is manually paused.",
"pausedDueToBlock": "Upload processing is paused because your OSM account has an active block.",
"inFlightWaitTitle": "Finishing Submission",
"inFlightWaitMessage": "A submission is currently in progress and needs to finish before this setting can be applied. This should only take a moment.",
"inFlightCompleteTitle": "Submission Complete",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Editar",
"delete": "Eliminar",
"cancel": "Cancelar",
"dismiss": "Descartar",
"ok": "Aceptar",
"close": "Cerrar",
"submit": "Enviar",
@@ -161,6 +162,7 @@
"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.",
"accountBlockedCannotDelete": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede eliminar nodos.",
"uploadPending": "Subida pendiente",
"uploadCreatingChangeset": "Creando changeset...",
"uploadUploading": "Subiendo...",
@@ -177,6 +179,7 @@
"direction": "Dirección {}°",
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"mustBeLoggedIn": "Debe estar conectado para enviar nuevos nodos. Por favor, inicie sesión a través de Configuración.",
"accountBlocked": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede enviar nodos nuevos.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para enviar nuevos nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para enviar nuevos nodos.",
"loadingAreaData": "Cargando datos del área... Por favor espere antes de enviar.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Este perfil no requiere una dirección.",
"temporarilyDisabled": "Las ediciones han sido temporalmente deshabilitadas mientras solucionamos un error - disculpas - regrese pronto.",
"mustBeLoggedIn": "Debe estar conectado para editar nodos. Por favor, inicie sesión a través de Configuración.",
"accountBlocked": "Su cuenta de OpenStreetMap tiene un bloqueo activo y no puede editar nodos.",
"sandboxModeWarning": "No se pueden enviar ediciones de nodos de producción al sandbox. Cambie al modo Producción en Configuración para editar nodos.",
"enableSubmittableProfile": "Habilite un perfil envíable en Configuración para editar nodos.",
"profileViewOnlyWarning": "Este perfil es solo para visualización del mapa. Por favor, seleccione un perfil envíable para editar nodos.",
@@ -247,6 +251,7 @@
"tapToLogout": "Toque para cerrar sesión",
"requiredToSubmit": "Requerido para enviar datos de cámaras",
"loggedOut": "Sesión cerrada",
"loginFailed": "Error al iniciar sesión. Inténtelo de nuevo.",
"testConnection": "Probar Conexión",
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
@@ -278,7 +283,10 @@
"reauthLater": "Más Tarde",
"unreadNotificationsTitle": "Tienes Notificaciones Sin Leer",
"unreadNotificationsMessage": "Tienes mensajes de OSM sin leer y/o nuevos comentarios en tus changesets.",
"unreadNotificationsView": "Ver Ahora"
"unreadNotificationsView": "Ver Ahora",
"activeBlockTitle": "Su cuenta de OSM tiene un bloqueo activo",
"activeBlockMessage": "Su cuenta de OpenStreetMap tiene actualmente un bloqueo activo y no puede realizar envíos. Debería haber recibido un mensaje con más detalles; revise su bandeja de entrada de OSM para obtener más información.",
"viewBlockDetails": "Ver Detalles del Bloqueo"
},
"queue": {
"title": "Cola de Subida",
@@ -314,6 +322,7 @@
"processingPaused": "Procesamiento de Cola Pausado",
"pausedDueToOffline": "El procesamiento de subida está pausado porque el modo sin conexión está habilitado.",
"pausedByUser": "El procesamiento de subida está pausado manualmente.",
"pausedDueToBlock": "El procesamiento de subidas está pausado porque su cuenta de OSM tiene un bloqueo activo.",
"inFlightWaitTitle": "Finalizando Envío",
"inFlightWaitMessage": "Actualmente hay un envío en curso que debe finalizar antes de que se pueda aplicar esta configuración. Esto solo debería tomar un momento.",
"inFlightCompleteTitle": "Envío Completado",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Modifier",
"delete": "Supprimer",
"cancel": "Annuler",
"dismiss": "Ignorer",
"ok": "OK",
"close": "Fermer",
"submit": "Soumettre",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Personnalisé..",
"deleteReasonHint": "Veuillez décrire la raison...",
"mustBeLoggedInToDelete": "Vous devez être connecté avec un compte OpenStreetMap pour contribuer aux données cartographiques.",
"accountBlockedCannotDelete": "Votre compte OpenStreetMap a un blocage actif et ne peut pas supprimer de nœuds.",
"uploadPending": "Téléchargement en attente",
"uploadCreatingChangeset": "Création du changeset...",
"uploadUploading": "Téléchargement en cours...",
@@ -177,6 +179,7 @@
"direction": "Direction {}°",
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"mustBeLoggedIn": "Vous devez être connecté pour soumettre de nouveaux nœuds. Veuillez vous connecter via les Paramètres.",
"accountBlocked": "Votre compte OpenStreetMap a un blocage actif et ne peut pas soumettre de nouveaux nœuds.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour soumettre de nouveaux nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour soumettre de nouveaux nœuds.",
"loadingAreaData": "Chargement des données de zone... Veuillez patienter avant de soumettre.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Ce profil ne nécessite pas de direction.",
"temporarilyDisabled": "Les modifications ont été temporairement désactivées pendant que nous résolvons un bug - désolés - revenez bientôt.",
"mustBeLoggedIn": "Vous devez être connecté pour modifier les nœuds. Veuillez vous connecter via les Paramètres.",
"accountBlocked": "Votre compte OpenStreetMap a un blocage actif et ne peut pas modifier de nœuds.",
"sandboxModeWarning": "Impossible de soumettre des modifications de nœuds de production au sandbox. Passez au mode Production dans les Paramètres pour modifier les nœuds.",
"enableSubmittableProfile": "Activez un profil soumissible dans les Paramètres pour modifier les nœuds.",
"profileViewOnlyWarning": "Ce profil est uniquement pour la visualisation de la carte. Veuillez sélectionner un profil soumissible pour modifier les nœuds.",
@@ -247,6 +251,7 @@
"tapToLogout": "Appuyer pour se déconnecter",
"requiredToSubmit": "Requis pour soumettre des données de caméras",
"loggedOut": "Déconnecté",
"loginFailed": "Échec de la connexion. Veuillez réessayer.",
"testConnection": "Tester Connexion",
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
@@ -278,7 +283,10 @@
"reauthLater": "Plus Tard",
"unreadNotificationsTitle": "Vous Avez des Notifications Non Lues",
"unreadNotificationsMessage": "Vous avez des messages OSM non lus et/ou de nouveaux commentaires sur vos changesets.",
"unreadNotificationsView": "Voir Maintenant"
"unreadNotificationsView": "Voir Maintenant",
"activeBlockTitle": "Votre compte OSM a un blocage actif",
"activeBlockMessage": "Votre compte OpenStreetMap a actuellement un blocage actif et ne peut pas effectuer de soumissions. Vous devriez avoir reçu un message avec plus de détails ; veuillez consulter votre boîte de réception OSM pour en savoir plus.",
"viewBlockDetails": "Voir les Détails du Blocage"
},
"queue": {
"title": "File de Téléchargement",
@@ -314,6 +322,7 @@
"processingPaused": "Traitement de la File d'Attente Interrompu",
"pausedDueToOffline": "Le traitement des téléversements est interrompu car le mode hors ligne est activé.",
"pausedByUser": "Le traitement des téléversements est interrompu manuellement.",
"pausedDueToBlock": "Le traitement des envois est en pause car votre compte OSM a un blocage actif.",
"inFlightWaitTitle": "Finalisation de la Soumission",
"inFlightWaitMessage": "Une soumission est actuellement en cours et doit se terminer avant que ce paramètre puisse être appliqué. Cela ne devrait prendre qu'un instant.",
"inFlightCompleteTitle": "Soumission Terminée",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Modifica",
"delete": "Elimina",
"cancel": "Annulla",
"dismiss": "Ignora",
"ok": "OK",
"close": "Chiudi",
"submit": "Invia",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Personalizzato..",
"deleteReasonHint": "Descrivi il motivo...",
"mustBeLoggedInToDelete": "Devi aver effettuato l'accesso con un account OpenStreetMap per contribuire ai dati della mappa.",
"accountBlockedCannotDelete": "Il tuo account OpenStreetMap ha un blocco attivo e non può eliminare i nodi.",
"uploadPending": "Caricamento in sospeso",
"uploadCreatingChangeset": "Creazione changeset...",
"uploadUploading": "Caricamento...",
@@ -177,6 +179,7 @@
"direction": "Direzione {}°",
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"mustBeLoggedIn": "Devi essere loggato per inviare nuovi nodi. Per favore accedi tramite Impostazioni.",
"accountBlocked": "Il tuo account OpenStreetMap ha un blocco attivo e non può inviare nuovi nodi.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per inviare nuovi nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per inviare nuovi nodi.",
"loadingAreaData": "Caricamento dati area... Per favore attendi prima di inviare.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Questo profilo non richiede una direzione.",
"temporarilyDisabled": "Le modifiche sono state temporaneamente disabilitate mentre risolviamo un bug - scuse - torna presto.",
"mustBeLoggedIn": "Devi essere loggato per modificare i nodi. Per favore accedi tramite Impostazioni.",
"accountBlocked": "Il tuo account OpenStreetMap ha un blocco attivo e non può modificare i nodi.",
"sandboxModeWarning": "Impossibile inviare modifiche di nodi di produzione alla sandbox. Passa alla modalità Produzione nelle Impostazioni per modificare i nodi.",
"enableSubmittableProfile": "Abilita un profilo inviabile nelle Impostazioni per modificare i nodi.",
"profileViewOnlyWarning": "Questo profilo è solo per la visualizzazione della mappa. Per favore seleziona un profilo inviabile per modificare i nodi.",
@@ -247,6 +251,7 @@
"tapToLogout": "Tocca per disconnetterti",
"requiredToSubmit": "Richiesto per inviare dati delle telecamere",
"loggedOut": "Disconnesso",
"loginFailed": "Accesso non riuscito. Riprova.",
"testConnection": "Testa Connessione",
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
@@ -278,7 +283,10 @@
"reauthLater": "Più Tardi",
"unreadNotificationsTitle": "Hai Notifiche Non Lette",
"unreadNotificationsMessage": "Hai messaggi OSM non letti e/o nuovi commenti sui tuoi changeset.",
"unreadNotificationsView": "Visualizza Ora"
"unreadNotificationsView": "Visualizza Ora",
"activeBlockTitle": "Il tuo account OSM ha un blocco attivo",
"activeBlockMessage": "Il tuo account OpenStreetMap ha attualmente un blocco attivo e non può effettuare invii. Dovresti aver ricevuto un messaggio con maggiori dettagli; controlla la tua casella di posta OSM per saperne di più.",
"viewBlockDetails": "Visualizza Dettagli Blocco"
},
"queue": {
"title": "Coda di Upload",
@@ -314,6 +322,7 @@
"processingPaused": "Elaborazione Coda Sospesa",
"pausedDueToOffline": "L'elaborazione dei caricamenti è sospesa perché la modalità offline è abilitata.",
"pausedByUser": "L'elaborazione dei caricamenti è sospesa manualmente.",
"pausedDueToBlock": "L'elaborazione della coda di caricamento è in pausa perché il tuo account OSM ha un blocco attivo.",
"inFlightWaitTitle": "Completamento Invio",
"inFlightWaitMessage": "Un invio è attualmente in corso e deve terminare prima che questa impostazione possa essere applicata. Questo dovrebbe richiedere solo un momento.",
"inFlightCompleteTitle": "Invio Completato",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Bewerken",
"delete": "Verwijderen",
"cancel": "Annuleren",
"dismiss": "Negeren",
"ok": "OK",
"close": "Sluiten",
"submit": "Indienen",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Aangepast..",
"deleteReasonHint": "Beschrijf de reden...",
"mustBeLoggedInToDelete": "U moet ingelogd zijn met een OpenStreetMap-account om kaartgegevens bij te dragen.",
"accountBlockedCannotDelete": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen knooppunten verwijderen.",
"uploadPending": "Upload in behandeling",
"uploadCreatingChangeset": "Changeset maken...",
"uploadUploading": "Uploaden...",
@@ -177,6 +179,7 @@
"direction": "Richting {}°",
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
"mustBeLoggedIn": "U moet ingelogd zijn om nieuwe nodes in te dienen. Log in via Instellingen.",
"accountBlocked": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen nieuwe knooppunten indienen.",
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nieuwe nodes in te dienen.",
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nieuwe nodes in te dienen.",
"loadingAreaData": "Gebied gegevens laden... Wacht even voordat u indient.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Dit profiel vereist geen richting.",
"temporarilyDisabled": "Bewerkingen zijn tijdelijk uitgeschakeld terwijl we een bug oplossen - excuses - controleer binnenkort opnieuw.",
"mustBeLoggedIn": "U moet ingelogd zijn om nodes te bewerken. Log in via Instellingen.",
"accountBlocked": "Uw OpenStreetMap-account heeft een actieve blokkade en kan geen knooppunten bewerken.",
"sandboxModeWarning": "Kan geen bewerkingen op productie nodes naar sandbox versturen. Schakel naar Productie modus in Instellingen om nodes te bewerken.",
"enableSubmittableProfile": "Schakel een indienbaar profiel in via Instellingen om nodes te bewerken.",
"profileViewOnlyWarning": "Dit profiel is alleen voor kaart weergave. Selecteer een indienbaar profiel om nodes te bewerken.",
@@ -247,6 +251,7 @@
"tapToLogout": "Tik om uit te loggen",
"requiredToSubmit": "Vereist om camera gegevens in te dienen",
"loggedOut": "Uitgelogd",
"loginFailed": "Inloggen mislukt. Probeer het opnieuw.",
"testConnection": "Test Verbinding",
"testConnectionSubtitle": "Verifieer dat OSM credentials werken",
"connectionOK": "Verbinding OK - credentials zijn geldig",
@@ -278,7 +283,10 @@
"reauthLater": "Later",
"unreadNotificationsTitle": "U Heeft Ongelezen Meldingen",
"unreadNotificationsMessage": "U heeft ongelezen OSM-berichten en/of nieuwe opmerkingen op uw changesets.",
"unreadNotificationsView": "Nu Bekijken"
"unreadNotificationsView": "Nu Bekijken",
"activeBlockTitle": "Uw OSM-account heeft een actieve blokkade",
"activeBlockMessage": "Uw OpenStreetMap-account heeft momenteel een actieve blokkade en kan geen inzendingen doen. U zou een bericht met meer details moeten hebben ontvangen; controleer uw OSM-inbox voor meer informatie.",
"viewBlockDetails": "Blokkadedetails Bekijken"
},
"queue": {
"title": "Upload Wachtrij",
@@ -314,6 +322,7 @@
"processingPaused": "Wachtrij Verwerking Gepauzeerd",
"pausedDueToOffline": "Upload verwerking is gepauzeerd omdat offline modus is ingeschakeld.",
"pausedByUser": "Upload verwerking is handmatig gepauzeerd.",
"pausedDueToBlock": "Het uploadproces is gepauzeerd omdat uw OSM-account een actieve blokkade heeft.",
"inFlightWaitTitle": "Indiening Wordt Afgerond",
"inFlightWaitMessage": "Er is momenteel een indiening bezig die moet worden afgerond voordat deze instelling kan worden toegepast. Dit zou maar een moment moeten duren.",
"inFlightCompleteTitle": "Indiening Voltooid",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Edytuj",
"delete": "Usuń",
"cancel": "Anuluj",
"dismiss": "Odrzuć",
"ok": "OK",
"close": "Zamknij",
"submit": "Zgłoś",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Niestandardowy..",
"deleteReasonHint": "Opisz powód...",
"mustBeLoggedInToDelete": "Musisz być zalogowany na konto OpenStreetMap, aby dodawać dane do mapy.",
"accountBlockedCannotDelete": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może usuwać węzłów.",
"uploadPending": "Przesyłanie oczekujące",
"uploadCreatingChangeset": "Tworzenie zestawu zmian...",
"uploadUploading": "Przesyłanie...",
@@ -177,6 +179,7 @@
"direction": "Kierunek {}°",
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
"mustBeLoggedIn": "Musisz być zalogowany, aby zgłaszać nowe węzły. Zaloguj się przez Ustawienia.",
"accountBlocked": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może zgłaszać nowych węzłów.",
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby zgłaszać nowe węzły.",
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby zgłaszać nowe węzły.",
"loadingAreaData": "Ładowanie danych obszaru... Poczekaj przed zgłoszeniem.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Ten profil nie wymaga kierunku.",
"temporarilyDisabled": "Edycje zostały tymczasowo wyłączone, gdy rozwiązujemy błąd - przepraszamy - sprawdź wkrótce.",
"mustBeLoggedIn": "Musisz być zalogowany, aby edytować węzły. Zaloguj się przez Ustawienia.",
"accountBlocked": "Twoje konto OpenStreetMap ma aktywną blokadę i nie może edytować węzłów.",
"sandboxModeWarning": "Nie można przesyłać edycji węzłów produkcyjnych do piaskownicy. Przełącz na tryb Produkcyjny w Ustawieniach, aby edytować węzły.",
"enableSubmittableProfile": "Włącz profil możliwy do zgłoszenia w Ustawieniach, aby edytować węzły.",
"profileViewOnlyWarning": "Ten profil służy tylko do przeglądania mapy. Proszę wybrać profil możliwy do zgłoszenia, aby edytować węzły.",
@@ -247,6 +251,7 @@
"tapToLogout": "Stuknij aby się wylogować",
"requiredToSubmit": "Wymagane do zgłaszania danych kamer",
"loggedOut": "Wylogowany",
"loginFailed": "Logowanie nie powiodło się. Spróbuj ponownie.",
"testConnection": "Testuj Połączenie",
"testConnectionSubtitle": "Sprawdź czy dane logowania OSM działają",
"connectionOK": "Połączenie OK - dane logowania są ważne",
@@ -278,7 +283,10 @@
"reauthLater": "Później",
"unreadNotificationsTitle": "Masz Nieprzeczytane Powiadomienia",
"unreadNotificationsMessage": "Masz nieprzeczytane wiadomości OSM i/lub nowe komentarze do Twoich zestawów zmian.",
"unreadNotificationsView": "Zobacz Teraz"
"unreadNotificationsView": "Zobacz Teraz",
"activeBlockTitle": "Twoje konto OSM ma aktywną blokadę",
"activeBlockMessage": "Twoje konto OpenStreetMap ma obecnie aktywną blokadę i nie może dokonywać zgłoszeń. Powinieneś był otrzymać wiadomość z dalszymi szczegółami; sprawdź swoją skrzynkę odbiorczą OSM, aby dowiedzieć się więcej.",
"viewBlockDetails": "Zobacz Szczegóły Blokady"
},
"queue": {
"title": "Kolejka Przesyłania",
@@ -314,6 +322,7 @@
"processingPaused": "Przetwarzanie Kolejki Wstrzymane",
"pausedDueToOffline": "Przetwarzanie przesyłania jest wstrzymane, ponieważ tryb offline jest włączony.",
"pausedByUser": "Przetwarzanie przesyłania jest ręcznie wstrzymane.",
"pausedDueToBlock": "Przetwarzanie kolejki przesyłania jest wstrzymane, ponieważ Twoje konto OSM ma aktywną blokadę.",
"inFlightWaitTitle": "Kończenie Przesyłania",
"inFlightWaitMessage": "Przesyłanie jest obecnie w toku i musi zostać zakończone, zanim to ustawienie będzie mogło zostać zastosowane. To powinno zająć tylko chwilę.",
"inFlightCompleteTitle": "Przesyłanie Zakończone",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Editar",
"delete": "Excluir",
"cancel": "Cancelar",
"dismiss": "Dispensar",
"ok": "OK",
"close": "Fechar",
"submit": "Enviar",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Personalizado..",
"deleteReasonHint": "Descreva o motivo...",
"mustBeLoggedInToDelete": "Você precisa estar conectado com uma conta do OpenStreetMap para contribuir com dados do mapa.",
"accountBlockedCannotDelete": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode excluir nós.",
"uploadPending": "Envio pendente",
"uploadCreatingChangeset": "Criando changeset...",
"uploadUploading": "Enviando...",
@@ -177,6 +179,7 @@
"direction": "Direção {}°",
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"mustBeLoggedIn": "Você deve estar logado para enviar novos nós. Por favor, faça login via Configurações.",
"accountBlocked": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode enviar novos nós.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para enviar novos nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para enviar novos nós.",
"loadingAreaData": "Carregando dados da área... Por favor aguarde antes de enviar.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Este perfil não requer uma direção.",
"temporarilyDisabled": "As edições foram temporariamente desabilitadas enquanto resolvemos um bug - desculpe - volte em breve.",
"mustBeLoggedIn": "Você deve estar logado para editar nós. Por favor, faça login via Configurações.",
"accountBlocked": "Sua conta OpenStreetMap tem um bloqueio ativo e não pode editar nós.",
"sandboxModeWarning": "Não é possível enviar edições de nós de produção para o sandbox. Mude para o modo Produção nas Configurações para editar nós.",
"enableSubmittableProfile": "Ative um perfil enviável nas Configurações para editar nós.",
"profileViewOnlyWarning": "Este perfil é apenas para visualização do mapa. Por favor, selecione um perfil enviável para editar nós.",
@@ -247,6 +251,7 @@
"tapToLogout": "Toque para sair",
"requiredToSubmit": "Necessário para enviar dados de câmeras",
"loggedOut": "Deslogado",
"loginFailed": "Falha no login. Tente novamente.",
"testConnection": "Testar Conexão",
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
@@ -278,7 +283,10 @@
"reauthLater": "Mais Tarde",
"unreadNotificationsTitle": "Você Tem Notificações Não Lidas",
"unreadNotificationsMessage": "Você tem mensagens OSM não lidas e/ou novos comentários nos seus changesets.",
"unreadNotificationsView": "Ver Agora"
"unreadNotificationsView": "Ver Agora",
"activeBlockTitle": "Sua conta OSM tem um bloqueio ativo",
"activeBlockMessage": "Sua conta OpenStreetMap atualmente tem um bloqueio ativo e está impedida de fazer envios. Você deve ter recebido uma mensagem com mais detalhes; verifique sua caixa de entrada do OSM para saber mais.",
"viewBlockDetails": "Ver Detalhes do Bloqueio"
},
"queue": {
"title": "Fila de Upload",
@@ -314,6 +322,7 @@
"processingPaused": "Processamento da Fila Pausado",
"pausedDueToOffline": "O processamento de upload está pausado porque o modo offline está habilitado.",
"pausedByUser": "O processamento de upload está pausado manualmente.",
"pausedDueToBlock": "O processamento da fila de upload está pausado porque sua conta OSM tem um bloqueio ativo.",
"inFlightWaitTitle": "Finalizando Envio",
"inFlightWaitMessage": "Atualmente há um envio em andamento que precisa terminar antes que esta configuração possa ser aplicada. Isso deve levar apenas um momento.",
"inFlightCompleteTitle": "Envio Concluído",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Düzenle",
"delete": "Sil",
"cancel": "İptal",
"dismiss": "Kapat",
"ok": "Tamam",
"close": "Kapat",
"submit": "Gönder",
@@ -161,6 +162,7 @@
"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.",
"accountBlockedCannotDelete": "OpenStreetMap hesabınızda aktif bir engelleme var ve düğüm silemez.",
"uploadPending": "Yükleme bekliyor",
"uploadCreatingChangeset": "Değişiklik seti oluşturuluyor...",
"uploadUploading": "Yükleniyor...",
@@ -177,6 +179,7 @@
"direction": "Yön {}°",
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
"mustBeLoggedIn": "Yeni düğümler göndermek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
"accountBlocked": "OpenStreetMap hesabınızda aktif bir engelleme var ve yeni düğüm gönderemez.",
"enableSubmittableProfile": "Yeni düğümler göndermek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Yeni düğümler göndermek için lütfen gönderilebilir bir profil seçin.",
"loadingAreaData": "Alan verisi yükleniyor... Göndermeden önce lütfen bekleyin.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Bu profil bir yön gerektirmez.",
"temporarilyDisabled": "Bir hatayı çözmemiz sırasında düzenlemeler geçici olarak devre dışı bırakıldı - özür dileriz - yakında tekrar kontrol edin.",
"mustBeLoggedIn": "Düğümleri düzenlemek için giriş yapmalısınız. Lütfen Ayarlar üzerinden giriş yapın.",
"accountBlocked": "OpenStreetMap hesabınızda aktif bir engelleme var ve düğüm düzenleyemez.",
"sandboxModeWarning": "Üretim düğümlerinde düzenlemeleri sandbox'a gönderemezsiniz. Düğümleri düzenlemek için Ayarlar'da Üretim moduna geçin.",
"enableSubmittableProfile": "Düğümleri düzenlemek için Ayarlarda gönderilebilir bir profili etkinleştirin.",
"profileViewOnlyWarning": "Bu profil sadece harita görüntüleme içindir. Düğümleri düzenlemek için lütfen gönderilebilir bir profil seçin.",
@@ -247,6 +251,7 @@
"tapToLogout": "Çıkış yapmak için dokun",
"requiredToSubmit": "Kamera verisi göndermek için gerekli",
"loggedOut": "Çıkış yapıldı",
"loginFailed": "Giriş başarısız oldu. Lütfen tekrar deneyin.",
"testConnection": "Bağlantıyı Test Et",
"testConnectionSubtitle": "OSM kimlik bilgilerinin çalışıp çalışmadığını doğrulayın",
"connectionOK": "Bağlantı Tamam - kimlik bilgileri geçerli",
@@ -278,7 +283,10 @@
"reauthLater": "Sonra",
"unreadNotificationsTitle": "Okunmamış Bildirimleriniz Var",
"unreadNotificationsMessage": "Okunmamış OSM mesajlarınız ve/veya değişiklik kümelerinize yapılan yeni yorumlar var.",
"unreadNotificationsView": "Şimdi Görüntüle"
"unreadNotificationsView": "Şimdi Görüntüle",
"activeBlockTitle": "OSM Hesabınızda Aktif Bir Engelleme Var",
"activeBlockMessage": "OpenStreetMap hesabınızda şu anda aktif bir engelleme var ve gönderim yapmanız kısıtlanmış durumda. Daha fazla ayrıntı içeren bir mesaj almış olmalısınız; daha fazla bilgi için lütfen OSM gelen kutunuzu kontrol edin.",
"viewBlockDetails": "Engelleme Ayrıntılarını Görüntüle"
},
"queue": {
"title": "Yükleme Kuyruğu",
@@ -314,6 +322,7 @@
"processingPaused": "Kuyruk İşleme Duraklatıldı",
"pausedDueToOffline": "Çevrimdışı mod etkin olduğu için yükleme işleme duraklatıldı.",
"pausedByUser": "Yükleme işleme manuel olarak duraklatıldı.",
"pausedDueToBlock": "OSM hesabınızda aktif bir engelleme olduğu için yükleme işlemi duraklatıldı.",
"inFlightWaitTitle": "Gönderim Tamamlanıyor",
"inFlightWaitMessage": "Şu anda devam eden bir gönderim var ve bu ayar uygulanmadan önce tamamlanması gerekiyor. Bu sadece bir an sürmelidir.",
"inFlightCompleteTitle": "Gönderim Tamamlandı",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "Редагувати",
"delete": "Видалити",
"cancel": "Скасувати",
"dismiss": "Відхилити",
"ok": "ОК",
"close": "Закрити",
"submit": "Подати",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "Власний..",
"deleteReasonHint": "Опишіть причину...",
"mustBeLoggedInToDelete": "Ви повинні увійти в обліковий запис OpenStreetMap, щоб надавати дані карти.",
"accountBlockedCannotDelete": "Ваш обліковий запис OpenStreetMap має активне блокування і не може видаляти вузли.",
"uploadPending": "Завантаження очікує",
"uploadCreatingChangeset": "Створення набору змін...",
"uploadUploading": "Завантаження...",
@@ -177,6 +179,7 @@
"direction": "Напрямок {}°",
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб подавати нові вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може подавати нові вузли.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для подання нових вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для подання нових вузлів.",
"loadingAreaData": "Завантаження даних області... Будь ласка, зачекайте перед поданням.",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "Цей профіль не потребує напрямку.",
"temporarilyDisabled": "Редагування тимчасово вимкнено, поки ми розбираємося з помилкою - вибачте - перевірте пізніше.",
"mustBeLoggedIn": "Ви повинні увійти в систему, щоб редагувати вузли. Будь ласка, увійдіть через Налаштування.",
"accountBlocked": "Ваш обліковий запис OpenStreetMap має активне блокування і не може редагувати вузли.",
"sandboxModeWarning": "Не можна подавати редагування виробничих вузлів в sandbox. Перейдіть в режим Виробництва в Налаштуваннях для редагування вузлів.",
"enableSubmittableProfile": "Увімкніть профіль, який можна подавати, в Налаштуваннях для редагування вузлів.",
"profileViewOnlyWarning": "Цей профіль призначений лише для перегляду карти. Будь ласка, оберіть профіль, який можна подавати, для редагування вузлів.",
@@ -247,6 +251,7 @@
"tapToLogout": "Натисніть для виходу",
"requiredToSubmit": "Потрібно для подання даних камер",
"loggedOut": "Вихід здійснено",
"loginFailed": "Не вдалося увійти. Спробуйте ще раз.",
"testConnection": "Тестувати З'єднання",
"testConnectionSubtitle": "Перевірити, що облікові дані OSM працюють",
"connectionOK": "З'єднання в порядку - облікові дані дійсні",
@@ -278,7 +283,10 @@
"reauthLater": "Пізніше",
"unreadNotificationsTitle": "У Вас Є Непрочитані Сповіщення",
"unreadNotificationsMessage": "У вас є непрочитані повідомлення OSM та/або нові коментарі до ваших наборів змін.",
"unreadNotificationsView": "Переглянути Зараз"
"unreadNotificationsView": "Переглянути Зараз",
"activeBlockTitle": "Ваш обліковий запис OSM має активне блокування",
"activeBlockMessage": "Ваш обліковий запис OpenStreetMap наразі має активне блокування і не може надсилати дані. Ви мали отримати повідомлення з додатковими деталями; перевірте свою поштову скриньку OSM, щоб дізнатися більше.",
"viewBlockDetails": "Переглянути Деталі Блокування"
},
"queue": {
"title": "Черга Завантаження",
@@ -314,6 +322,7 @@
"processingPaused": "Обробка Черги Призупинена",
"pausedDueToOffline": "Обробка завантаження призупинена, оскільки увімкнено офлайн режим.",
"pausedByUser": "Обробка завантаження призупинена вручну.",
"pausedDueToBlock": "Обробку черги завантаження призупинено, оскільки ваш обліковий запис OSM має активне блокування.",
"inFlightWaitTitle": "Завершення Подання",
"inFlightWaitMessage": "Наразі триває подання, яке потрібно завершити перед застосуванням цього налаштування. Це має зайняти лише мить.",
"inFlightCompleteTitle": "Подання Завершено",
+10 -1
View File
@@ -49,6 +49,7 @@
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"dismiss": "关闭",
"ok": "确定",
"close": "关闭",
"submit": "提交",
@@ -161,6 +162,7 @@
"deleteReasonCustom": "自定义..",
"deleteReasonHint": "请描述原因...",
"mustBeLoggedInToDelete": "您必须使用 OpenStreetMap 账户登录才能贡献地图数据。",
"accountBlockedCannotDelete": "您的 OpenStreetMap 账户有一个有效封禁,无法删除节点。",
"uploadPending": "上传待处理",
"uploadCreatingChangeset": "正在创建变更集...",
"uploadUploading": "正在上传...",
@@ -177,6 +179,7 @@
"direction": "方向 {}°",
"profileNoDirectionInfo": "此配置文件不需要方向。",
"mustBeLoggedIn": "您必须登录才能提交新节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法提交新节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以提交新节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来提交新节点。",
"loadingAreaData": "正在加载区域数据...提交前请稍候。",
@@ -191,6 +194,7 @@
"profileNoDirectionInfo": "此配置文件不需要方向。",
"temporarilyDisabled": "编辑功能已暂时禁用,我们正在修复一个错误 - 抱歉 - 请稍后再试。",
"mustBeLoggedIn": "您必须登录才能编辑节点。请通过设置登录。",
"accountBlocked": "您的 OpenStreetMap 账户有一个有效封禁,无法编辑节点。",
"sandboxModeWarning": "无法将生产节点的编辑提交到沙盒。在设置中切换到生产模式以编辑节点。",
"enableSubmittableProfile": "在设置中启用可提交的配置文件以编辑节点。",
"profileViewOnlyWarning": "此配置文件仅用于地图查看。请选择可提交的配置文件来编辑节点。",
@@ -247,6 +251,7 @@
"tapToLogout": "点击登出",
"requiredToSubmit": "提交摄像头数据所需",
"loggedOut": "已登出",
"loginFailed": "登录失败,请重试。",
"testConnection": "测试连接",
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
@@ -278,7 +283,10 @@
"reauthLater": "稍后",
"unreadNotificationsTitle": "您有未读通知",
"unreadNotificationsMessage": "您有未读的 OSM 消息和/或对您变更集的新评论。",
"unreadNotificationsView": "立即查看"
"unreadNotificationsView": "立即查看",
"activeBlockTitle": "您的 OSM 账户有一个有效封禁",
"activeBlockMessage": "您的 OpenStreetMap 账户目前有有效封禁,无法提交内容。您应该已收到一条包含更多详情的消息;请查看您的 OSM 收件箱以了解更多信息。",
"viewBlockDetails": "查看封禁详情"
},
"queue": {
"title": "上传队列",
@@ -314,6 +322,7 @@
"processingPaused": "队列处理已暂停",
"pausedDueToOffline": "因为离线模式已启用,上传处理已暂停。",
"pausedByUser": "上传处理已手动暂停。",
"pausedDueToBlock": "由于您的 OSM 账户有一个有效封禁,上传处理已暂停。",
"inFlightWaitTitle": "正在完成提交",
"inFlightWaitMessage": "当前有一个提交正在进行中,需要先完成才能应用此设置。这应该只需要一点时间。",
"inFlightCompleteTitle": "提交完成",
+22 -2
View File
@@ -37,7 +37,7 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin, WidgetsBindingObserver {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
late final AnimatedMapController _mapController;
@@ -62,6 +62,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_mapController = AnimatedMapController(vsync: this);
_sheetCoordinator = SheetCoordinator();
_navigationCoordinator = NavigationCoordinator();
@@ -71,11 +72,24 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
DeepLinkService().onNodeDeepLink = null;
_mapController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Re-check for an active OSM block when the user returns to the app,
// in case they resolved it (or got blocked) while away.
if (state == AppLifecycleState.resumed) {
final appState = context.read<AppState>();
if (appState.isLoggedIn) {
appState.checkAndPromptForActiveBlock(context);
}
}
}
String _getFollowMeTooltip(FollowMeMode mode) {
final locService = LocalizationService.instance;
switch (mode) {
@@ -474,6 +488,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
_checkForPopup();
// Check if re-authentication is needed for message notifications
appState.checkAndPromptReauthForMessages(context);
// Check for an active OSM block on app launch
if (appState.isLoggedIn) {
appState.checkAndPromptForActiveBlock(context);
}
});
}
@@ -544,7 +562,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return IconButton(
tooltip: LocalizationService.instance.settings,
icon: Badge(
isLabelVisible: appState.hasUnreadNotifications,
isLabelVisible: appState.hasActiveBlock || appState.hasUnreadNotifications,
backgroundColor: appState.hasActiveBlock ? Colors.red : null,
label: appState.hasActiveBlock ? const Icon(Icons.block, size: 10, color: Colors.white) : null,
child: const Icon(Icons.settings),
),
onPressed: () => Navigator.pushNamed(context, '/settings'),
+35 -5
View File
@@ -17,11 +17,12 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
@override
void initState() {
super.initState();
// Check for messages when screen loads
// Check for messages and active blocks when screen loads
WidgetsBinding.instance.addPostFrameCallback((_) {
final appState = context.read<AppState>();
if (appState.isLoggedIn) {
appState.checkMessages();
appState.checkAndPromptForActiveBlock(context);
}
});
}
@@ -74,22 +75,51 @@ class _OSMAccountScreenState extends State<OSMAccountScreen> {
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin();
await appState.forceLogin(context: context);
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
// Don't show immediate feedback on success - the UI will update
// automatically when the OAuth callback completes and
// notifyListeners() is called. Failures are surfaced directly
// by forceLogin() via a snackbar/dialog.
}
},
),
if (appState.isLoggedIn) ...[
// Active block warning - shown whenever a check has
// found the user's OSM account currently blocked.
if (appState.hasActiveBlock) ...[
const Divider(),
ListTile(
leading: Badge(
isLabelVisible: true,
backgroundColor: Colors.red,
child: const Icon(Icons.block, color: Colors.red),
),
title: Text(locService.t('auth.activeBlockTitle')),
subtitle: Text(locService.t('auth.activeBlockMessage')),
trailing: const Icon(Icons.open_in_new),
onTap: () async {
final url = Uri.parse(appState.getBlockDetailsUrl());
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
),
],
const Divider(),
ListTile(
leading: const Icon(Icons.wifi_protected_setup),
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.validateToken();
final isValid = await appState.testConnection(context);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -40,10 +40,12 @@ class AuthSection extends StatelessWidget {
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin();
await appState.forceLogin(context: context);
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
// Don't show immediate feedback on success - the UI will update
// automatically when the OAuth callback completes and
// notifyListeners() is called. Failures are surfaced directly
// by forceLogin() via a snackbar/dialog.
}
},
),
@@ -53,7 +55,7 @@ class AuthSection extends StatelessWidget {
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.validateToken();
final isValid = await appState.testConnection(context);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
+3 -1
View File
@@ -118,7 +118,9 @@ class SettingsScreen extends StatelessWidget {
return ListTile(
leading: Badge(
isLabelVisible: appState.hasUnreadNotifications,
isLabelVisible: appState.hasActiveBlock || appState.hasUnreadNotifications,
backgroundColor: appState.hasActiveBlock ? Colors.red : null,
label: appState.hasActiveBlock ? const Icon(Icons.block, size: 10, color: Colors.white) : null,
child: const Icon(Icons.account_circle),
),
title: Text(locService.t('auth.osmAccountTitle')),
+6 -4
View File
@@ -111,7 +111,7 @@ class UploadQueueScreen extends StatelessWidget {
final appState = context.watch<AppState>();
// Check if queue processing is paused
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing;
final isQueuePaused = appState.offlineMode || appState.pauseQueueProcessing || appState.isQueuePausedDueToBlock;
return Scaffold(
appBar: AppBar(
@@ -151,9 +151,11 @@ class UploadQueueScreen extends StatelessWidget {
),
),
Text(
appState.offlineMode
? locService.t('queue.pausedDueToOffline')
: locService.t('queue.pausedByUser'),
appState.isQueuePausedDueToBlock
? locService.t('queue.pausedDueToBlock')
: appState.offlineMode
? locService.t('queue.pausedDueToOffline')
: locService.t('queue.pausedByUser'),
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
+107 -13
View File
@@ -2,14 +2,42 @@ import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:oauth2_client/oauth2_client.dart';
import 'package:oauth2_client/oauth2_helper.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Handles PKCE OAuth login with OpenStreetMap.
import '../keys.dart';
import '../dev_config.dart';
import '../app_state.dart' show UploadMode;
import 'http_client.dart';
import 'osm_block_service.dart';
/// Thrown when the OAuth token exchange succeeds but a subsequent
/// authenticated API call (e.g. fetching the username) fails. Carries the
/// HTTP status code so callers can distinguish e.g. a 403 (often indicating
/// an active account block) from other failures.
class AuthApiException implements Exception {
final String message;
final int? statusCode;
final String? body;
AuthApiException(this.message, {this.statusCode, this.body});
@override
String toString() => 'AuthApiException: $message (status: $statusCode)';
}
/// Thrown when the OAuth token exchange succeeds but the account turns out
/// to have an active OSM DWG block. Detected by checking the
/// `user/blocks/active` endpoint (which is accessible even while blocked)
/// immediately after obtaining the token, before attempting any other
/// authenticated call that would otherwise fail with a 403.
class AccountBlockedException implements Exception {
@override
String toString() => 'AccountBlockedException: account has an active OSM block';
}
class AuthService {
// Both client IDs from keys.dart
@@ -18,8 +46,10 @@ class AuthService {
late OAuth2Helper _helper;
String? _displayName;
UploadMode _mode = UploadMode.production;
final OSMBlockService _blockService;
AuthService({UploadMode mode = UploadMode.production}) {
AuthService({UploadMode mode = UploadMode.production, OSMBlockService? blockService})
: _blockService = blockService ?? OSMBlockService() {
setUploadMode(mode);
}
@@ -87,8 +117,12 @@ class AuthService {
try {
final token = await _helper.getToken();
if (token.accessToken == null) {
debugPrint('AuthService: OAuth error: token null or missing accessToken (status: ${token.httpStatusCode}, error: ${token.error})');
log('OAuth error: token null or missing accessToken');
return null;
throw AuthApiException(
'OAuth token exchange did not return an access token',
statusCode: token.httpStatusCode,
);
}
final tokenMap = {
'accessToken': token.accessToken,
@@ -97,8 +131,55 @@ class AuthService {
final tokenJson = jsonEncode(tokenMap);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, tokenJson); // Save token for current mode
_displayName = await _fetchUsername(token.accessToken!);
if (kCheckBlockBeforeUsernameFetch) {
// Check for an active block *before* fetching the username or
// anything else. As of writing, a blocked account's OAuth token
// exchange succeeds normally, but nearly every other authenticated
// endpoint (including user/details) returns 403. The blocks/active
// endpoint is explicitly documented as accessible even while
// blocked, so checking it first lets us detect this case reliably
// instead of getting a mysterious 403 from _fetchUsername with no
// way to tell why.
final isBlocked = await _blockService.checkActiveBlock(
accessToken: token.accessToken,
uploadMode: _mode,
);
if (isBlocked == true) {
debugPrint('AuthService: Login succeeded but account has an active block');
throw AccountBlockedException();
}
}
// Fetching the username can still fail even though the OAuth token
// exchange succeeded (e.g. if OSM ever allows this call to succeed for
// blocked accounts, kCheckBlockBeforeUsernameFetch would be false and
// we'd only find out here). Don't swallow that - the token is already
// saved, so the caller needs to know the login isn't actually usable
// rather than silently treating it as "not logged in".
try {
_displayName = await _fetchUsername(token.accessToken!);
} on AuthApiException {
// When we skipped the proactive check above, a 403 here could still
// mean the account is blocked - check blocks/active now so we can
// report that specifically instead of a generic auth failure.
if (!kCheckBlockBeforeUsernameFetch) {
final isBlocked = await _blockService.checkActiveBlock(
accessToken: token.accessToken,
uploadMode: _mode,
);
if (isBlocked == true) {
debugPrint('AuthService: user/details failed and account has an active block');
throw AccountBlockedException();
}
}
rethrow;
}
return _displayName;
} on AccountBlockedException {
rethrow;
} on AuthApiException {
rethrow;
} catch (e) {
debugPrint('AuthService: OAuth login failed: $e');
log('OAuth login failed: $e');
@@ -184,24 +265,37 @@ class AuthService {
final _client = UserAgentClient();
Future<String?> _fetchUsername(String accessToken) async {
final http.Response resp;
try {
final resp = await _client.get(
resp = await _client.get(
Uri.parse('$_apiHost/api/0.6/user/details.json'),
headers: {'Authorization': 'Bearer $accessToken'},
);
if (resp.statusCode != 200) {
log('fetchUsername response ${resp.statusCode}: ${resp.body}');
return null;
}
final userData = jsonDecode(resp.body);
final displayName = userData['user']?['display_name'];
return displayName;
} catch (e) {
// Network-level failure (no connection, timeout, etc.) - not
// indicative of a block, just a connectivity problem.
debugPrint('AuthService: Error fetching username: $e');
log('Error fetching username: $e');
return null;
rethrow;
}
if (resp.statusCode != 200) {
// A non-200 here (very commonly 403) after a successful OAuth token
// exchange is a strong signal of an active account block. Surface it
// loudly via debugPrint (visible in a normal release/profile console,
// unlike dart:developer's log()) and throw instead of silently
// returning null, so the caller can show a real error to the user.
debugPrint('AuthService: fetchUsername failed - HTTP ${resp.statusCode}: ${resp.body}');
log('fetchUsername response ${resp.statusCode}: ${resp.body}');
throw AuthApiException(
'Failed to fetch user details after login',
statusCode: resp.statusCode,
body: resp.body,
);
}
final userData = jsonDecode(resp.body);
final displayName = userData['user']?['display_name'];
return displayName;
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../state/settings_state.dart';
import 'http_client.dart';
/// Service for checking whether the current user has an active OSM DWG block.
///
/// See: https://wiki.openstreetmap.org/wiki/API_v0.6#List_active_blocks:_GET_/api/0.6/user/blocks/active
class OSMBlockService {
final http.Client _client;
OSMBlockService({http.Client? client}) : _client = client ?? UserAgentClient();
/// Returns whether the user currently has an active block.
///
/// Returns null if not logged in, in simulate mode, or on any error
/// (including no network connection) - callers should treat null as
/// "unknown, assume unchanged" rather than "not blocked".
Future<bool?> checkActiveBlock({
required String? accessToken,
required UploadMode uploadMode,
}) async {
if (uploadMode == UploadMode.simulate) return null;
if (accessToken == null || accessToken.isEmpty) return null;
try {
final apiHost = _getApiHost(uploadMode);
final response = await _client.get(
Uri.parse('$apiHost/api/0.6/user/blocks/active.json'),
headers: {'Authorization': 'Bearer $accessToken'},
);
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body);
final userBlocks = data['user_blocks'];
if (userBlocks is! List) return null;
return userBlocks.isNotEmpty;
} catch (e) {
return null;
}
}
String _getApiHost(UploadMode uploadMode) {
switch (uploadMode) {
case UploadMode.production:
return 'https://api.openstreetmap.org';
case UploadMode.sandbox:
return 'https://api06.dev.openstreetmap.org';
case UploadMode.simulate:
return 'https://api.openstreetmap.org';
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/osm_block_service.dart';
import 'settings_state.dart';
/// Tracks whether the current user has an active OSM DWG block.
///
/// Persisted per upload mode so the last known state survives app restarts
/// (e.g. launching offline) - a failed/inconclusive check leaves the
/// persisted value untouched rather than assuming "not blocked".
class AccountBlockState extends ChangeNotifier {
final OSMBlockService _blockService;
AccountBlockState({OSMBlockService? blockService})
: _blockService = blockService ?? OSMBlockService();
bool _isBlocked = false;
bool get isBlocked => _isBlocked;
/// Load the last known block state for the given upload mode.
Future<void> init(UploadMode uploadMode) async {
final prefs = await SharedPreferences.getInstance();
_isBlocked = prefs.getBool(_prefsKey(uploadMode)) ?? false;
notifyListeners();
}
/// Check for an active block. Returns the conclusive result (true/false),
/// or null if the check was inconclusive (offline, error, not logged in,
/// simulate mode). On null, the previously known state is left unchanged.
Future<bool?> check({
required String? accessToken,
required UploadMode uploadMode,
}) async {
final result = await _blockService.checkActiveBlock(
accessToken: accessToken,
uploadMode: uploadMode,
);
if (result != null && result != _isBlocked) {
_isBlocked = result;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKey(uploadMode), result);
notifyListeners();
}
return result;
}
/// Clear block state (called on logout).
void clear() {
if (_isBlocked) {
_isBlocked = false;
notifyListeners();
}
}
String _prefsKey(UploadMode uploadMode) => 'account_blocked_${uploadMode.name}';
}
+12 -2
View File
@@ -28,11 +28,16 @@ class AuthState extends ChangeNotifier {
Future<void> login() async {
try {
_username = await _auth.login();
notifyListeners();
} catch (e) {
debugPrint("AuthState: Login error: $e");
_username = null;
notifyListeners();
// Don't swallow this - the caller (AppState) needs to know login
// failed so it can tell the user, rather than silently staying
// logged out with no explanation.
rethrow;
}
notifyListeners();
}
Future<void> logout() async {
@@ -58,11 +63,16 @@ class AuthState extends ChangeNotifier {
Future<void> forceLogin() async {
try {
_username = await _auth.forceLogin();
notifyListeners();
} catch (e) {
debugPrint("AuthState: Forced login error: $e");
_username = null;
notifyListeners();
// Don't swallow this - the caller (AppState) needs to know login
// failed so it can tell the user, rather than silently staying
// logged out with no explanation.
rethrow;
}
notifyListeners();
}
Future<bool> validateToken() async {
+14
View File
@@ -20,11 +20,13 @@ class UploadQueueState extends ChangeNotifier {
final List<PendingUpload> _queue = [];
Timer? _uploadTimer;
int _activeUploadCount = 0;
bool _isPausedDueToBlock = false;
// Getters
int get pendingCount => _queue.length;
List<PendingUpload> get pendingUploads => List.unmodifiable(_queue);
int get activeUploadCount => _activeUploadCount;
bool get isPausedDueToBlock => _isPausedDueToBlock;
// Initialize by loading queue from storage and repopulate cache with pending nodes
Future<void> init() async {
@@ -326,8 +328,10 @@ class UploadQueueState extends ChangeNotifier {
required bool pauseQueueProcessing,
required UploadMode uploadMode,
required Future<String?> Function() getAccessToken,
required Future<bool?> Function() checkActiveBlock,
}) {
_uploadTimer?.cancel();
_isPausedDueToBlock = false;
// No uploads if queue is empty, offline mode is enabled, or queue processing is paused
if (_queue.isEmpty || offlineMode || pauseQueueProcessing) return;
@@ -386,6 +390,16 @@ class UploadQueueState extends ChangeNotifier {
final access = await getAccessToken();
if (access == null) return; // not logged in
// Don't start new uploads if the user has an active OSM block.
// Leave the item pending - it'll be retried next time the uploader starts.
if (await checkActiveBlock() == true) {
debugPrint('[UploadQueue] Active block detected, pausing queue processing');
_isPausedDueToBlock = true;
_uploadTimer?.cancel();
notifyListeners();
return;
}
// Start processing the next pending upload
final item = pendingItems.first;
debugPrint('[UploadQueue] Starting new upload processing for item at ${item.coord} ($_activeUploadCount/$kMaxConcurrentUploads active)');
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
/// Dialog shown whenever a check finds that the user's OSM account has an
/// active block. Unlike the unread-notifications dialog, this has no
/// "don't show again" option and is shown every time a check finds a block.
class ActiveBlockDialog extends StatelessWidget {
final VoidCallback onViewDetails;
final VoidCallback onDismiss;
const ActiveBlockDialog({
super.key,
required this.onViewDetails,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Row(
children: [
Icon(
Icons.block,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(locService.t('auth.activeBlockTitle')),
),
],
),
content: Text(locService.t('auth.activeBlockMessage')),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
onDismiss();
},
child: Text(locService.t('actions.dismiss')),
),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
onViewDetails();
},
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(locService.t('auth.viewBlockDetails')),
),
],
);
}
}
+17
View File
@@ -421,6 +421,7 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
}
final allowSubmit = appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -493,6 +494,22 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
],
),
)
else if (appState.hasActiveBlock)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.block, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('addNode.accountBlocked'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+17
View File
@@ -377,6 +377,7 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
final allowSubmit = kEnableNodeEdits &&
appState.isLoggedIn &&
!appState.hasActiveBlock &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
session.profile!.isSubmittable &&
@@ -526,6 +527,22 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
],
),
)
else if (appState.hasActiveBlock)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
const Icon(Icons.block, color: Colors.red, size: 20),
const SizedBox(width: 6),
Expanded(
child: Text(
locService.t('editNode.accountBlocked'),
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
),
)
else if (submittableProfiles.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+17
View File
@@ -184,6 +184,23 @@ class _NodeTagSheetState extends State<NodeTagSheet> {
return;
}
if (appState.hasActiveBlock) {
await showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(locService.t('node.confirmDeleteTitle')),
content: Text(locService.t('node.accountBlockedCannotDelete')),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.ok),
),
],
),
);
return;
}
final result = await showDialog<({bool confirmed, String comment})>(
context: context,
builder: (BuildContext context) => _DeleteNodeDialog(
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:deflockapp/services/osm_block_service.dart';
import 'package:deflockapp/state/settings_state.dart';
void main() {
group('OSMBlockService', () {
test('returns null in simulate mode without making a request', () async {
var requested = false;
final client = MockClient((request) async {
requested = true;
return http.Response('{"user_blocks":[]}', 200);
});
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.simulate,
);
expect(result, isNull);
expect(requested, isFalse);
});
test('returns null when access token is null', () async {
final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: null,
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
test('returns null when access token is empty', () async {
final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: '',
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
test('returns false when user_blocks is empty', () async {
final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isFalse);
});
test('returns true when user_blocks is non-empty', () async {
final client = MockClient((request) async => http.Response(
'{"user_blocks":[{"id":101,"needs_view":true}]}',
200,
));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isTrue);
});
test('sends the request to the correct production host with auth header', () async {
Uri? capturedUri;
String? capturedAuth;
final client = MockClient((request) async {
capturedUri = request.url;
capturedAuth = request.headers['Authorization'];
return http.Response('{"user_blocks":[]}', 200);
});
final service = OSMBlockService(client: client);
await service.checkActiveBlock(
accessToken: 'my-token',
uploadMode: UploadMode.production,
);
expect(capturedUri?.host, 'api.openstreetmap.org');
expect(capturedUri?.path, '/api/0.6/user/blocks/active.json');
expect(capturedAuth, 'Bearer my-token');
});
test('sends the request to the sandbox host in sandbox mode', () async {
Uri? capturedUri;
final client = MockClient((request) async {
capturedUri = request.url;
return http.Response('{"user_blocks":[]}', 200);
});
final service = OSMBlockService(client: client);
await service.checkActiveBlock(
accessToken: 'my-token',
uploadMode: UploadMode.sandbox,
);
expect(capturedUri?.host, 'api06.dev.openstreetmap.org');
});
test('returns null on non-200 response', () async {
final client = MockClient((request) async => http.Response('Unauthorized', 401));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
test('returns null on malformed JSON response', () async {
final client = MockClient((request) async => http.Response('not json', 200));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
test('returns null when user_blocks field is missing', () async {
final client = MockClient((request) async => http.Response('{}', 200));
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
test('returns null when the client throws (e.g. no network)', () async {
final client = MockClient((request) async {
throw Exception('network error');
});
final service = OSMBlockService(client: client);
final result = await service.checkActiveBlock(
accessToken: 'token',
uploadMode: UploadMode.production,
);
expect(result, isNull);
});
});
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:deflockapp/services/osm_block_service.dart';
import 'package:deflockapp/state/account_block_state.dart';
import 'package:deflockapp/state/settings_state.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('AccountBlockState', () {
test('defaults to not blocked before init()', () {
final state = AccountBlockState();
expect(state.isBlocked, isFalse);
});
test('init() loads persisted blocked state for the given upload mode', () async {
SharedPreferences.setMockInitialValues({
'account_blocked_production': true,
});
final state = AccountBlockState();
await state.init(UploadMode.production);
expect(state.isBlocked, isTrue);
});
test('init() defaults to false when nothing is persisted', () async {
final state = AccountBlockState();
await state.init(UploadMode.production);
expect(state.isBlocked, isFalse);
});
test('init() is scoped per upload mode', () async {
SharedPreferences.setMockInitialValues({
'account_blocked_production': true,
});
final state = AccountBlockState();
await state.init(UploadMode.sandbox);
expect(state.isBlocked, isFalse);
});
test('check() updates and persists state on a conclusive true result', () async {
final client = MockClient((request) async => http.Response(
'{"user_blocks":[{"id":1}]}',
200,
));
final state = AccountBlockState(blockService: OSMBlockService(client: client));
await state.init(UploadMode.production);
final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production);
expect(result, isTrue);
expect(state.isBlocked, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('account_blocked_production'), isTrue);
});
test('check() updates and persists state on a conclusive false result', () async {
SharedPreferences.setMockInitialValues({
'account_blocked_production': true,
});
final client = MockClient((request) async => http.Response('{"user_blocks":[]}', 200));
final state = AccountBlockState(blockService: OSMBlockService(client: client));
await state.init(UploadMode.production);
expect(state.isBlocked, isTrue);
final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production);
expect(result, isFalse);
expect(state.isBlocked, isFalse);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('account_blocked_production'), isFalse);
});
test('check() leaves state unchanged on an inconclusive (null) result', () async {
SharedPreferences.setMockInitialValues({
'account_blocked_production': true,
});
final client = MockClient((request) async {
throw Exception('offline');
});
final state = AccountBlockState(blockService: OSMBlockService(client: client));
await state.init(UploadMode.production);
expect(state.isBlocked, isTrue);
final result = await state.check(accessToken: 'token', uploadMode: UploadMode.production);
expect(result, isNull);
expect(state.isBlocked, isTrue); // unchanged
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('account_blocked_production'), isTrue); // unchanged
});
test('clear() resets in-memory blocked state', () async {
SharedPreferences.setMockInitialValues({
'account_blocked_production': true,
});
final state = AccountBlockState();
await state.init(UploadMode.production);
expect(state.isBlocked, isTrue);
state.clear();
expect(state.isBlocked, isFalse);
});
});
}