improved reliability and error handling for background notification actions by migrating to a top-level entry point for stable AOT callback resolution; implemented failure notifications to preserve undelivered reply text and surface technical errors; resolved a race condition that could resurrect dismissed notifications during silent re-renders; updated the Android manifest with the required broadcast receiver for notification actions; and refactored internal OCS methods to return detailed success/failure records for improved logging and field debugging.

This commit is contained in:
2026-07-05 23:43:58 +02:00
parent 483fea62ba
commit dab208877f
5 changed files with 214 additions and 37 deletions
+8
View File
@@ -59,6 +59,14 @@
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
der Tap nirgends an und der Background-Handler startet nie. -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
+3 -2
View File
@@ -52,8 +52,9 @@ class NotificationService {
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
onDidReceiveBackgroundNotificationResponse:
PushActions.handleBackgroundResponse,
// Top-level function, NOT the static method: AOT callback resolution
// has failed for static class members (see pushActionBackgroundHandler).
onDidReceiveBackgroundNotificationResponse: pushActionBackgroundHandler,
);
}
}
+136 -34
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:developer';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
@@ -18,10 +19,34 @@ import 'push_renderer.dart';
const String kTalkReplyActionId = 'TALK_REPLY';
const String kTalkMarkReadActionId = 'TALK_MARK_READ';
/// Top-level FLN background entry point. A PLAIN FUNCTION with the pragma is
/// the reliable AOT form (same lesson as [pushOnBackgroundMessage]): resolving
/// static class members from a callback handle has failed in release builds
/// even with annotations present.
@pragma('vm:entry-point')
Future<void> pushActionBackgroundHandler(NotificationResponse response) =>
PushActions.handleBackgroundResponse(response);
/// Logs to the developer log AND to stdout: `dart:developer` messages are
/// invisible in release logcat, but field debugging of the notification
/// action isolates needs `adb logcat -s flutter` to show what happened.
void _plog(String message) {
log(message);
debugPrint('PushActions: $message');
}
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population.
///
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
/// on [handleBackgroundResponse]: the callback is resolved via
/// `PluginUtilities.getCallbackFromHandle`, and in AOT builds resolving a
/// static method needs its enclosing class to be an entry point too —
/// otherwise the lookup fails with "To access ... PushActions from native
/// code, it must be annotated" and no action ever reaches Dart.
@pragma('vm:entry-point')
class PushActions {
/// Background entry point for notification actions. Must be a top-level or
/// static function annotated with `vm:entry-point` so AOT keeps it alive.
@@ -36,38 +61,101 @@ class PushActions {
// runs forever.
DartPluginRegistrant.ensureInitialized();
_plog(
'action=${response.actionId} payload=${response.payload} '
'hasInput=${response.input?.isNotEmpty ?? false}',
);
final chatToken = _chatTokenFrom(response.payload);
if (chatToken == null) return;
switch (response.actionId) {
case kTalkReplyActionId:
final text = response.input?.trim();
final sent = text != null && text.isNotEmpty
? await sendReply(chatToken, text)
: false;
if (sent) {
final result = text == null || text.isEmpty
? (
ok: false,
detail: 'Keine Texteingabe empfangen (RemoteInput leer).',
)
: await sendReply(chatToken, text);
// Local cleanup FIRST, mark-read AFTER: markRead makes the server
// dismiss its notifications and emit delete-pushes. If those arrive
// in the FCM isolate while the thread history still exists here, the
// delete handler re-renders the thread and resurrects the just-
// cancelled notification (with Android re-attaching the pending
// inline reply on top).
await finishReply(chatToken: chatToken, sent: result.ok);
if (result.ok) {
_cleanupNidEntry(response);
// The user has evidently seen the chat — set the read marker like
// the mark-read action does.
// the mark-read action does. Best effort: a failure here must not
// fail the already-delivered reply.
await markRead(chatToken);
}
await finishReply(chatToken: chatToken, sent: sent);
_cleanupNidEntry(response);
if (!result.ok) {
// Never swallow the typed message: surface the failure (and the
// undelivered text) as its own notification. The nid mapping stays
// alive — the thread notification is still in the tray.
await _showActionError(
chatToken: chatToken,
title: 'Antwort nicht gesendet',
body: actionFailureBody(lostText: text, detail: result.detail),
);
}
break;
case kTalkMarkReadActionId:
await markRead(chatToken);
// Optimistic like the in-app read-marker: clean up locally first so
// the server's delete-pushes (triggered by markRead) can never race a
// still-present thread history into a resurrected notification.
await _cleanupChat(chatToken);
_cleanupNidEntry(response);
final result = await markRead(chatToken);
if (!result.ok) {
await _showActionError(
chatToken: chatToken,
title: 'Als gelesen markieren fehlgeschlagen',
body: actionFailureBody(detail: result.detail),
);
}
break;
default:
break;
}
}
/// Ends the reply interaction: Android keeps the RemoteInput spinner alive
/// until the notification is UPDATED or REMOVED. Success removes the whole
/// notification (history cleared + cancel — the chat is read); failure
/// re-renders the unchanged thread silently so the spinner stops (the error
/// itself is only loggable on notification level). Injectable seams so
/// tests can observe the flow without platform channels.
/// Body text of an action-failure notification: the undelivered reply (if
/// any) followed by the technical reason.
static String actionFailureBody({String? lostText, required String detail}) {
return [
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText',
'Grund: $detail',
].join('\n');
}
static Future<void> _showActionError({
required String chatToken,
required String title,
required String body,
}) async {
try {
await PushRenderer().renderTalkActionError(
chatToken: chatToken,
title: title,
body: body,
);
} on Object catch (e) {
_plog('Push action error notification failed: $e');
}
}
/// Ends the reply interaction. The card itself is already gone — the
/// action's native `cancelNotification` removed it the moment the reply
/// fired (a Dart-side cancel instead does NOT work everywhere: MIUI/HyperOS
/// ignores app cancels while an inline reply is pending). What remains:
/// success clears the stacked history so the next push starts fresh (plus a
/// redundant defensive cancel); failure re-renders silently, which the
/// renderer's active-probe turns into a no-op when the card is really gone
/// — the failure surface is the error card posted by the caller.
/// Injectable seams so tests can observe the flow without platform
/// channels.
static Future<void> finishReply({
required String chatToken,
required bool sent,
@@ -100,21 +188,31 @@ class PushActions {
/// Sends the inline reply. Success = any 2xx (the Talk chat POST answers
/// 201 Created). Path/body match the app's working send path
/// (`SendMessage`: `v1/chat/{token}`, form field `message`).
static Future<bool> sendReply(String chatToken, String message) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<({bool ok, String detail})> sendReply(
String chatToken,
String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message});
static Future<bool> markRead(String chatToken) =>
static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
static Future<bool> _ocsPost(String path, {Map<String, String>? body}) async {
static Future<({bool ok, String detail})> _ocsPost(
String path, {
Map<String, String>? body,
}) async {
try {
// Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever.
await AccountData().waitForPopulation().timeout(
final populated = await AccountData().waitForPopulation().timeout(
const Duration(seconds: 10),
);
if (!populated) {
_plog('Push action $path aborted: credentials unreadable in isolate');
return (
ok: false,
detail: 'Zugangsdaten im Hintergrund-Prozess nicht lesbar.',
);
}
final response = await http.post(
NextcloudOcs.uri(path),
headers: NextcloudOcs.headers(),
@@ -122,18 +220,22 @@ class PushActions {
);
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
log('Push action $path -> HTTP ${response.statusCode}');
} else {
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
log(
'Push action $path -> HTTP ${response.statusCode} '
'body=${preview.length > 300 ? '${preview.substring(0, 300)}' : preview}',
);
_plog('Push action $path -> HTTP ${response.statusCode}');
return (ok: true, detail: 'HTTP ${response.statusCode}');
}
return ok;
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final trimmed = preview.length > 200
? '${preview.substring(0, 200)}'
: preview;
_plog('Push action $path -> HTTP ${response.statusCode} body=$trimmed');
return (
ok: false,
detail:
'HTTP ${response.statusCode}${trimmed.isEmpty ? '' : ' $trimmed'}',
);
} on Object catch (e) {
log('Push action $path failed: $e');
return false;
_plog('Push action $path failed: $e');
return (ok: false, detail: e.toString());
}
}
@@ -143,7 +245,7 @@ class PushActions {
try {
await ChatThreadStore().clearChat(chatToken);
} on Object catch (e) {
log('Push action thread cleanup failed: $e');
_plog('Push action thread cleanup failed: $e');
}
await _cancelChatNotification(chatToken);
}
@@ -155,7 +257,7 @@ class PushActions {
tag: chatNotificationTag(chatToken),
);
} on Object catch (e) {
log('Push action cancel failed: $e');
_plog('Push action cancel failed: $e');
}
}
@@ -167,7 +269,7 @@ class PushActions {
.delete(nid)
.then(
(_) {},
onError: (Object e) => log('Push action nid cleanup failed: $e'),
onError: (Object e) => _plog('Push action nid cleanup failed: $e'),
),
);
}
+47 -1
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
@@ -146,6 +147,17 @@ class PushRenderer {
bool alert = true,
}) async {
if (messages.isEmpty) return;
// A silent render only ever UPDATES an existing card (delete-push shrunk
// the thread, late avatar arrived, failed reply needs its spinner
// stopped). If the card is verifiably gone meanwhile (reply/mark-read
// cleanup cancelled it, or the user swiped it away), re-posting would
// resurrect it — and Android would re-attach a pending inline reply on
// top. Probe failure (null) still renders: stopping a possible reply
// spinner outweighs a rare resurrection.
if (!alert && await _isChatNotificationActive(chatToken) == false) {
debugPrint('PushRenderer: skip silent re-render, card gone ($chatToken)');
return;
}
final tag = chatNotificationTag(chatToken);
final id = stableChatNotificationId(chatToken);
final latest = messages.last;
@@ -261,12 +273,18 @@ class PushRenderer {
);
}
// Both actions keep cancelNotification: true (the default): the plugin's
// Java receiver then removes the card NATIVELY the moment the action fires.
// The reply action must not rely on our Dart-side cancel instead — MIUI/
// HyperOS ignores an app-issued cancel while an inline reply is pending, so
// the card would stay behind showing the reply as an attached "Ich" row.
// If sending subsequently fails, the error card (with the typed text)
// replaces the lost thread card.
static const _talkActions = [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
@@ -276,6 +294,34 @@ class PushRenderer {
),
];
/// Renders a failure card for a Talk notification action (reply/mark-read).
/// Separate tag per chat, so it neither replaces the thread notification
/// nor stacks across repeated failures. Carries no payload — tapping it
/// just opens the app.
Future<void> renderTalkActionError({
required String chatToken,
required String title,
required String body,
}) async {
await _plugin.show(
id: stableChatNotificationId(chatToken),
title: title,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
tag: 'talk_error_$chatToken',
styleInformation: BigTextStyleInformation(body),
),
iOS: const DarwinNotificationDetails(),
),
);
}
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
+20
View File
@@ -3,6 +3,26 @@ import 'package:marianum_mobile/push/chat_thread_store.dart';
import 'package:marianum_mobile/push/push_actions.dart';
void main() {
group('PushActions.actionFailureBody', () {
test('includes the undelivered text and the reason', () {
expect(
PushActions.actionFailureBody(lostText: 'Hallo du', detail: 'HTTP 401'),
'Deine Nachricht: „Hallo du“\nGrund: HTTP 401',
);
});
test('omits the text line when there is none', () {
expect(
PushActions.actionFailureBody(detail: 'HTTP 404'),
'Grund: HTTP 404',
);
expect(
PushActions.actionFailureBody(lostText: '', detail: 'HTTP 404'),
'Grund: HTTP 404',
);
});
});
group('PushActions.finishReply', () {
const token = 'chat1';
final thread = [