import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'dart:ui'; 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'; /// 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. 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(); 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) { // The user has evidently seen the chat — set the read marker like // the mark-read action does. await markRead(chatToken); } await finishReply(chatToken: chatToken, sent: sent); _cleanupNidEntry(response); break; case kTalkMarkReadActionId: await markRead(chatToken); await _cleanupChat(chatToken); _cleanupNidEntry(response); 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. 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 sendReply(String chatToken, String message) => _ocsPost( 'apps/spreed/api/v1/chat/$chatToken', body: {'message': message}, ); static Future markRead(String chatToken) => _ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); static Future _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. await AccountData().waitForPopulation().timeout( const Duration(seconds: 10), ); final response = await http.post( NextcloudOcs.uri(path), headers: NextcloudOcs.headers(), body: body, ); 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}', ); } return ok; } on Object catch (e) { log('Push action $path failed: $e'); return false; } } /// 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) { log('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) { log('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) => log('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; } } }