Files
Client/lib/push/push_actions.dart
T

296 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
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;
import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_renderer.dart';
/// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them).
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.
@pragma('vm:entry-point')
static Future<void> handleBackgroundResponse(
NotificationResponse response,
) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner
// 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 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. Best effort: a failure here must not
// fail the already-delivered reply.
await markRead(chatToken);
}
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:
// 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;
}
}
/// 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,
Future<void> Function(String chatToken)? cleanupChat,
Future<List<ThreadMessage>> Function(String chatToken)? loadThread,
Future<void> Function(String chatToken, List<ThreadMessage> messages)?
renderSilent,
Future<void> Function(String chatToken)? cancelNotification,
}) async {
cleanupChat ??= _cleanupChat;
loadThread ??= (token) => ChatThreadStore().messages(token);
renderSilent ??= (token, messages) =>
PushRenderer().renderTalkThread(token, messages, alert: false);
cancelNotification ??= _cancelChatNotification;
if (sent) {
await cleanupChat(chatToken);
return;
}
final messages = await loadThread(chatToken);
if (messages.isEmpty) {
// Nothing to re-render (history cleared meanwhile) — cancel instead so
// the spinner cannot survive.
await cancelNotification(chatToken);
return;
}
await renderSilent(chatToken, messages);
}
/// 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 ok, String detail})> sendReply(
String chatToken,
String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message});
static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
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.
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(),
body: body,
);
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
_plog('Push action $path -> HTTP ${response.statusCode}');
return (ok: true, detail: 'HTTP ${response.statusCode}');
}
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) {
_plog('Push action $path failed: $e');
return (ok: false, detail: e.toString());
}
}
/// Mark-read cleanup: drop the stacked history (so the next push starts
/// fresh) and cancel the chat's notification.
static Future<void> _cleanupChat(String chatToken) async {
try {
await ChatThreadStore().clearChat(chatToken);
} on Object catch (e) {
_plog('Push action thread cleanup failed: $e');
}
await _cancelChatNotification(chatToken);
}
static Future<void> _cancelChatNotification(String chatToken) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: stableChatNotificationId(chatToken),
tag: chatNotificationTag(chatToken),
);
} on Object catch (e) {
_plog('Push action cancel failed: $e');
}
}
static void _cleanupNidEntry(NotificationResponse response) {
final nid = _nidFrom(response.payload);
if (nid == null) return;
unawaited(
NidStore()
.delete(nid)
.then(
(_) {},
onError: (Object e) => _plog('Push action nid cleanup failed: $e'),
),
);
}
static String? _chatTokenFrom(String? payload) =>
_payloadField(payload, 'chatToken');
static int? _nidFrom(String? payload) {
final raw = _payloadField(payload, 'nid');
return raw == null ? null : int.tryParse(raw);
}
static String? _payloadField(String? payload, String key) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
}