implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching
This commit is contained in:
+113
-21
@@ -1,12 +1,17 @@
|
||||
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).
|
||||
@@ -24,60 +29,147 @@ class PushActions {
|
||||
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();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
await sendReply(chatToken, text);
|
||||
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 markRead(chatToken);
|
||||
await finishReply(chatToken: chatToken, sent: sent);
|
||||
_cleanupNidEntry(response);
|
||||
break;
|
||||
case kTalkMarkReadActionId:
|
||||
await markRead(chatToken);
|
||||
await _cancelForToken(response);
|
||||
await _cleanupChat(chatToken);
|
||||
_cleanupNidEntry(response);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendReply(String chatToken, String message) async {
|
||||
await _ocsPost(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
body: {'message': message},
|
||||
);
|
||||
/// 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);
|
||||
}
|
||||
|
||||
static Future<void> markRead(String chatToken) async {
|
||||
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||
}
|
||||
/// 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<void> _ocsPost(String path, {Map<String, String>? body}) async {
|
||||
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 {
|
||||
await AccountData().waitForPopulation();
|
||||
// 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,
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _cancelForToken(NotificationResponse response) async {
|
||||
/// 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;
|
||||
try {
|
||||
await NidStore().delete(nid);
|
||||
} on Object catch (e) {
|
||||
log('Push action nid cleanup failed: $e');
|
||||
}
|
||||
unawaited(
|
||||
NidStore()
|
||||
.delete(nid)
|
||||
.then(
|
||||
(_) {},
|
||||
onError: (Object e) => log('Push action nid cleanup failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _chatTokenFrom(String? payload) =>
|
||||
|
||||
Reference in New Issue
Block a user