Files
Client/lib/push/push_actions.dart
T

194 lines
6.9 KiB
Dart

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<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();
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<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> sendReply(String chatToken, String message) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<bool> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
static Future<bool> _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(
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<void> _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<void> _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<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
}