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 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 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 _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 finishReply({ required String chatToken, required bool sent, Future Function(String chatToken)? cleanupChat, Future> Function(String chatToken)? loadThread, Future Function(String chatToken, List messages)? renderSilent, Future 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? 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 _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 _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; final value = map[key]; return value?.toString(); } on Object { return null; } } }