293 lines
11 KiB
Dart
293 lines
11 KiB
Dart
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 is already gone — the action's
|
||
/// native `cancelNotification` removed it when the reply fired (a Dart-side
|
||
/// cancel is unreliable: MIUI/HyperOS ignores app cancels while an inline
|
||
/// reply is pending). Success clears the stacked history (plus a defensive
|
||
/// cancel); failure re-renders silently, a no-op when the card is really
|
||
/// gone — the real failure surface is the error card posted by the caller.
|
||
/// Injectable seams let tests 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;
|
||
}
|
||
}
|
||
}
|