Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f706b42
upgrade unifiedPush version and increase kotlin version to 2.2.0 and …
nucleus-ffm Oct 27, 2025
e49a572
Enhance user notification experience and troubleshooting
nucleus-ffm Nov 2, 2025
a2f0c5b
Simplify the process of switching the push distributor with a new dialog
nucleus-ffm Nov 2, 2025
97a6a8b
fix wrongly swapped coordinates, resulting in faulty bounding boxes
nucleus-ffm Nov 2, 2025
711c9d6
improve notification handling
nucleus-ffm Nov 2, 2025
4c84f90
add version number and platform to http user agent
nucleus-ffm Nov 2, 2025
a17d3d2
Only allow deleting a place if the server returns a 200 or 404. Do no…
nucleus-ffm Nov 2, 2025
26ca70e
add documentation
nucleus-ffm Nov 2, 2025
1a1f8de
read alerts from the processed alert provider instead of the alertsPr…
nucleus-ffm Nov 2, 2025
932227b
Fix the wrong method used to delete alerts from the provider, which r…
nucleus-ffm Nov 2, 2025
f485d21
remove douglas peucker simplification for now due to unknown effectiv…
nucleus-ffm Nov 2, 2025
3f25847
catch error case in which the category is null
nucleus-ffm Nov 2, 2025
70149c6
increase version number and code
nucleus-ffm Nov 2, 2025
c5ce8ad
FIX remove notification parameter
nucleus-ffm Nov 3, 2025
6b780c6
Add a result screen to the loading indicator
nucleus-ffm Nov 9, 2025
72efe68
Add a self-check to check if the notification arrived
nucleus-ffm Nov 9, 2025
fe8fc41
move onTap callback to own method and display an information if there…
nucleus-ffm Nov 9, 2025
2baa049
add a call to action to error banners to indicate that the banner is …
nucleus-ffm Nov 9, 2025
d7d1eb1
pause fetching alerts as long as re-subscription is in progress
nucleus-ffm Nov 9, 2025
1f84777
allow to wait until the shared preferences writing process is finished
nucleus-ffm Nov 9, 2025
aeb6d99
move notification background check to build method to run the check r…
nucleus-ffm Nov 9, 2025
d7dcf75
implement method to change the distributor
nucleus-ffm Nov 9, 2025
a0b16ec
display error banner even when there are not places selected
nucleus-ffm Nov 9, 2025
e648eb7
update UI after selecting a distributor
nucleus-ffm Nov 9, 2025
8aa7870
improve documentation
nucleus-ffm Nov 9, 2025
b231cb0
add and improve translations
nucleus-ffm Nov 9, 2025
c56dd15
remove unused asset
nucleus-ffm Nov 9, 2025
1e26e5d
increase version code in user preferences and add alpha0 tag to versi…
nucleus-ffm Nov 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) {

android {
namespace "de.nucleus.foss_warn"
compileSdk 35
compileSdk 36

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
Expand Down
2 changes: 1 addition & 1 deletion android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.8.0" apply false
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
id "org.jetbrains.kotlin.android" version "2.2.0" apply false
}

include ":app"
20 changes: 17 additions & 3 deletions lib/class/class_app_state.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
class AppState {
bool error = false; // true if API call and parsing was not successful
/// Flag that indicates that there was an error
bool error = false;

/// Flag to indicate that the displayed alerts are just from the cache and could
/// be out-dated. This should only happen, if the user does not have an internet connection
bool areWarningsFromCache = false;
bool isFirstFetch =
true; // used to display a text that the app is fetching new alerts if the app is freshly started

/// used to display a text that the app is fetching new alerts if the app is freshly started
bool isFirstFetch = true;

/// Flag to indicate an error with the push notification setup
bool pushNotificationSetupError = false;

/// Flag that is set to true when we are resubscribing places.
/// This is to avoid race conditions with the update loop, which could mark the
/// place as expired if it tries to fetch alerts just between we unsubscribed
/// the old subscriptions and not yet have resubscribed.
bool reSubscriptionInProgress = false;
}
20 changes: 3 additions & 17 deletions lib/class/class_area.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'package:latlong2/latlong.dart';
import 'package:flutter/material.dart';
import 'package:foss_warn/class/class_error_logger.dart';
import '../main.dart';
import 'class_douglas_peucker.dart';

class Area {
String description; // general description of the area
Expand Down Expand Up @@ -52,9 +51,9 @@ class Area {
if (data != null) {
// there a multiple entries => multiple areas
for (int i = 0; i < data.length; i++) {
Map<String, dynamic> capToGeoJson =
_convertCAPGeoInfoToGeoJson(data[i]);

Map<String, dynamic> capToGeoJson = _convertCAPGeoInfoToGeoJson(
data[i],
);
data[i].putIfAbsent("geoJson", () => jsonEncode(capToGeoJson));
result.add(Area.fromJson(data[i]));
}
Expand Down Expand Up @@ -88,7 +87,6 @@ class Area {
static Map<String, dynamic> _convertCAPGeoInfoToGeoJson(
Map<String, dynamic> data,
) {
const double polygonSimplificationTolerance = 0.001;
Map<String, dynamic> featureCollection = {};
featureCollection.putIfAbsent("type", () => "FeatureCollection");

Expand Down Expand Up @@ -138,12 +136,6 @@ class Area {
oneRowCoordinates.add(onePairOfCoordinatesList);
}

// apply douglas peucker to reduce number of polygons
oneRowCoordinates = DouglasPeucker.simplify(
coordinates: oneRowCoordinates,
tolerance: polygonSimplificationTolerance,
);

oneRowCoordinatesOuterList.clear();
oneRowCoordinatesOuterList.add(oneRowCoordinates);

Expand Down Expand Up @@ -191,12 +183,6 @@ class Area {
oneRowCoordinates.add(onePairOfCoordinatesList);
}

// apply douglas peucker to reduce number of polygons
oneRowCoordinates = DouglasPeucker.simplify(
coordinates: oneRowCoordinates,
tolerance: polygonSimplificationTolerance,
);

// add the row to the List
coordinatesList.add(oneRowCoordinates);

Expand Down
5 changes: 3 additions & 2 deletions lib/class/class_bounding_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ class BoundingBox {
/// create new warnMessage objects from saved data
return BoundingBox(
minLatLng: LatLng(
minLatLng['coordinates']![0],
minLatLng['coordinates']![1],
minLatLng['coordinates']![0],
), //@todo
maxLatLng: LatLng(
maxLatLng['coordinates']![0],
maxLatLng['coordinates']![1],
maxLatLng['coordinates']![0],
),
);
}

Map<String, dynamic> toJson() {
try {
return {
// LatLng.toJson returns [longitude, latitude]
'min_latLng': minLatLng,
'max_latLng': maxLatLng,
};
Expand Down
2 changes: 2 additions & 0 deletions lib/class/class_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ class Info {
if (json['category'] is List) {
categories =
Category.categoryListFromJson(List<String>.from(json['category']));
} else if (json["category"] == null) {
categories = [Category.other];
} else {
categories = [Category.fromString(json['category'])];
}
Expand Down
9 changes: 9 additions & 0 deletions lib/class/class_notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,13 @@ class NotificationService {
static Future<void> cancelAllNotification() async {
await _flutterLocalNotificationsPlugin.cancelAll();
}

/// Check if a notification with the given id is currently active
static Future<bool> isNotificationActive(int id) async {
List<ActiveNotification> activeNotification =
await _flutterLocalNotificationsPlugin.getActiveNotifications();
return activeNotification.any(
(notification) => notification.id != null && notification.id == id,
);
}
}
171 changes: 115 additions & 56 deletions lib/class/class_unified_push_handler.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import 'dart:convert';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:foss_warn/class/class_error_logger.dart';
import 'package:foss_warn/class/class_user_preferences.dart';
import 'package:foss_warn/class/class_warn_message.dart';
import 'package:foss_warn/services/api_handler.dart';
import 'package:foss_warn/services/list_handler.dart';
import 'package:foss_warn/services/warnings.dart';

import 'package:unifiedpush/unifiedpush.dart';
import '../main.dart';
import '../services/alert_api/fpas.dart';
import '../services/notification_handler.dart';
import '../widgets/dialogs/no_up_distributor_found_dialog.dart';
import '../widgets/dialogs/select_unified_push_distributor_dialog.dart';
import 'class_notification_service.dart';

/// Thrown when a UnifiedPush registration failed with a timeout
class UnifiedPushRegistrationTimeoutError implements Exception {}

final unifiedPushHandlerProvider = Provider(
(ref) => UnifiedPushHandler(
Expand All @@ -34,12 +36,13 @@ class UnifiedPushHandler {
final UserPreferencesService _preferencesService;
final UserPreferences _userPreferences;

/// Callback function for the UnifiedPush plugin
/// this method gets called when a new endpoint is selected
void onNewEndpoint(PushEndpoint endpoint, String instance) {
debugPrint("new Endpoint:${endpoint.url}");
if (instance != UserPreferences.unifiedPushInstance) {
return;
}
if (instance != UserPreferences.unifiedPushInstance) return;

// update preferences with the new URL and Keys
_preferencesService.setUnifiedpushEndpoint(endpoint.url);
if (endpoint.pubKeySet != null) {
_preferencesService.setWebPushPublicKey(endpoint.pubKeySet!.pubKey);
Expand All @@ -49,7 +52,11 @@ class UnifiedPushHandler {
//@TODO(Nucleus): we need to update the subscriptions and send the new endpoint to the server
}

/// Callback for the UnfiedPush plugin
/// This method gets called with the registration failed
/// For now we are just logging that error
void onRegistrationFailed(FailedReason failedReason, String instance) {
if (instance != UserPreferences.unifiedPushInstance) return;
// @todo error handling
ErrorLogger.writeErrorLog(
"class_unifiedPushHandler",
Expand All @@ -59,7 +66,13 @@ class UnifiedPushHandler {
debugPrint("Registration failed: ${failedReason.name}");
}

/// Callback for the UnifiedPush plugin
/// This method gets called when the client unregisters from the distributor
/// This updates the state in the preferences
void onUnregistered(String instance) {
debugPrint("onUnregistered called");
if (instance != UserPreferences.unifiedPushInstance) return;

debugPrint("onUnregistered called");
_preferencesService.setUnifiedpushEndpoint("");
_preferencesService.setUnifiedPushRegistered(false);
Expand All @@ -68,7 +81,8 @@ class UnifiedPushHandler {
_preferencesService.setWebPushPublicKey("");
}

/// callback function to handle notification from unifiedPush
/// callback for the UnifiedPush plugin
/// This method handles incoming notification from unifiedPush
Future<void> onMessage({
required AlertAPI alertApi,
required MyPlacesService myPlacesService,
Expand All @@ -82,60 +96,13 @@ class UnifiedPushHandler {

var payload = utf8.decode(message.content);
debugPrint("Received a notification. Message: $payload");
var userPreferences = ref.read(userPreferencesProvider);

if (userPreferences.showDebugNotification) {
NotificationService.showNotification(
id: Random().nextInt(100),
title: "DEBUG Notification",
body:
"FOSSWarn has received a push notification with content: $payload",
payload: "",
channelId: "de.nucleus.foss_warn.notifications_other",
channelName: "Debug notifications",
);
}

String? addedAlertId;

try {
var payloadAsJson = jsonDecode(payload) as Map<String, dynamic>;
addedAlertId = payloadAsJson["alert_id"];
if (addedAlertId != null) {
WarnMessage alert = await ref.read(alertApiProvider).getAlertDetail(
alertId: addedAlertId,
placeSubscriptionId: "Not used",
);
if (checkIfEventShouldBeNotified(
alert.info[0].severity,
userPreferences,
)) {
NotificationService.showNotification(
id: alert.identifier.hashCode,
title: "New alert",
body: alert.info.first.headline,
payload: "",
channelId:
"de.nucleus.foss_warn.notifications_${alert.info[0].severity.name}",
channelName: "",
);
}
}
} on FormatException catch (e) {
debugPrint("Payload is not a json: $e");
} on AlertUnavailableError catch (e) {
debugPrint("Alert is not available anymore: $e");
ErrorLogger.writeErrorLog(
"class_unified_push_handler.dart",
"onMessage",
"Alert $addedAlertId is not available anymore",
);
}
// @TODO(Nucleus): This is not the perfect solution as we can not check for already read alerts or if the alert is just an update. It would be preferable if we could fetch all alerts like we did before. Currently, this results in a "concurrent modification during iteration" error if the app is launched by a notification.
handleIncomingNotification(payload, ref);
}

/// register for push notifications and keep registration up to date
/// This method needs to called at every app startup
/// @TODO Nucleus: refactor this and use more smaller methods
Future<void> setupUnifiedPush(
BuildContext context,
WidgetRef ref,
Expand Down Expand Up @@ -216,4 +183,96 @@ class UnifiedPushHandler {
return;
}
}

/// Change the current distributor to the new selected distributor
/// This method first unregisters the old distributor and then registers
/// again with the new selected Distributor
/// The selected distributor has to be the [String] id of the distributor
///
/// Throws an [UnifiedPushRegistrationTimeoutError] when the registration failed
/// with en timeout error
Future<void> changeDistributor(
String selectedDistributor,
WidgetRef ref,
) async {
appState.reSubscriptionInProgress = true;
await unregisterDistributor(ref);
await registerDistributor(selectedDistributor, ref);
// wait until the registration is finished we have a new endpoint
await Future.doWhile(() async {
await Future.delayed(const Duration(microseconds: 1));
UserPreferences userPreferences = ref.read(userPreferencesProvider);
return !userPreferences.unifiedPushRegistered;
}).timeout(
const Duration(seconds: 20),
onTimeout: () {
debugPrint(
"Timeout waiting for unifiedPushRegistered to be set to true.",
);
throw UnifiedPushRegistrationTimeoutError();
},
);
}

/// unregisters the current distributor and sets the unifiedPush
/// registered flag in the setting to false
Future<void> unregisterDistributor(WidgetRef ref) async {
UnifiedPush.unregister(UserPreferences.unifiedPushInstance);
await _preferencesService.setUnifiedPushRegistered(false);
}

/// Register for notifications with the given distributor
Future<void> registerDistributor(
String selectedDistributor,
WidgetRef ref,
) async {
debugPrint(
"[unifiedPushHandler] register new distributor $selectedDistributor",
);
await UnifiedPush.saveDistributor(selectedDistributor);
String? tempVapidKey;
if (_userPreferences.webPushVapidKey == "") {
// if server supports WebPush, try to fetch the vapid key
if (_userPreferences.webPushVapidKey == "") {
try {
AlertAPI alertAPI = ref.read(alertApiProvider);
tempVapidKey = await alertAPI.fetchVapidKeyForWebPush();
_preferencesService.setWebPushVapidKey(tempVapidKey);
} on VapidKeyException {
ErrorLogger.writeErrorLog(
"class_unified_push_handler.dart",
"setup unifiedPush",
"Failed to fetch VAPID key for webpush",
);
}
}
}

// register your app to the distributor
try {
await UnifiedPush.register(
instance: UserPreferences.unifiedPushInstance,
vapid: tempVapidKey ?? _userPreferences.webPushVapidKey,
);
} on MissingPluginException catch (e) {
//@TODO (Nucleus) do not catch this exception here
debugPrint("error while registering UnifiedPush: $e");
return;
}
}

Future<List<Map<String, String>>> getListOfDistributors() async {
List<Map<String, String>> result = [];
List<String> distributors = await UnifiedPush.getDistributors();
for (String distributor in distributors) {
var split = distributor.split(".");
String name = split.last;
result.add({"name": name, "distributor": distributor});
}
return result;
}

Future<String?> getDistributor() async {
return await UnifiedPush.getDistributor();
}
}
Loading