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:
2026-07-05 22:48:04 +02:00
parent 35e144799e
commit 483fea62ba
31 changed files with 2807 additions and 320 deletions
+11
View File
@@ -48,6 +48,17 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Fallback small icon + accent for anything FCM ever renders itself
(e.g. plain notification messages). Launcher mipmaps are unusable
as small icons: Android only draws their alpha silhouette, which
shows up as a solid circle in the status bar. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Status-bar small icon: monochrome Marianum M, ported from
marianum_m_watermark.xml (same path + nested transforms). Android renders
only the alpha channel of small icons, so the fill is hard white and never
tinted at runtime. 24dp with the source's non-square viewport — the system
fits it into the icon bounds.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
notification color and must stay in sync with the Dart constant. -->
<color name="notification_accent">#993333</color>
</resources>
@@ -2,19 +2,24 @@ import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
/// Revokes the current app password server-side via
/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
/// authenticate with the app password itself (it revokes the credential it was
/// made with) and the result is ignored — logout clears local state regardless.
/// Revokes an app password server-side via
/// `DELETE /ocs/v2.php/core/apppassword`. The endpoint revokes the credential
/// the request authenticates WITH — by default the shared OCS headers (general
/// app password); pass [authorizationHeader] to revoke another one (the Talk
/// app password). Best-effort: the result is ignored — logout clears local
/// state regardless.
class DeleteAppPassword {
final http.Client _client;
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
Future<void> run() async {
Future<void> run({String? authorizationHeader}) async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: NextcloudOcs.headers(),
headers: {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
},
);
}
}
@@ -19,6 +19,7 @@ class PushDeviceRegister {
required String userPublicKey,
required String pushToken,
required String platform,
required String registrationType,
String? appVersion,
}) async {
try {
@@ -30,6 +31,9 @@ class PushDeviceRegister {
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
+5 -5
View File
@@ -96,7 +96,7 @@ Future<void> main() async {
// decrypts and renders Nextcloud pushes while the app is not in foreground.
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage);
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
// Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage.
@@ -375,13 +375,13 @@ class _MainState extends State<Main> {
return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_appMounted)
const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () => setState(
() => _showPostLoginSplash = false,
),
onComplete: () =>
setState(() => _showPostLoginSplash = false),
),
],
);
+58 -1
View File
@@ -11,9 +11,13 @@ import '../push/push_secure_storage.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
// App password lives in the push-shared (group-scoped) keystore so the iOS
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
// The talk password authenticates the second (apptype=talk) push
// registration — Nextcloud binds each push subscription to its session
// token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password';
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
@@ -29,6 +33,7 @@ class AccountData {
String? _username;
String? _password;
String? _appPassword;
String? _appPasswordTalk;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
@@ -65,9 +70,11 @@ class AccountData {
_username = null;
_password = null;
_appPassword = null;
_appPasswordTalk = null;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _clearAppPasswordStorage();
await _clearAppPasswordTalkStorage();
}
/// Persists a freshly minted Nextcloud app password. After this every
@@ -90,6 +97,28 @@ class AccountData {
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
/// Persists the app password backing the Talk push registration.
Future<void> setAppPasswordTalk(String appPassword) async {
_appPasswordTalk = appPassword;
try {
await pushSecureStorage.write(
key: _appPasswordTalkField,
value: appPassword,
);
} on Object {
// Group-scoped keystore may be unavailable — in-memory still works for
// this session, matching setAppPassword.
}
}
Future<void> clearAppPasswordTalk() async {
_appPasswordTalk = null;
await _clearAppPasswordTalkStorage();
}
bool hasAppPasswordTalk() =>
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
@@ -98,14 +127,26 @@ class AccountData {
}
}
Future<void> _clearAppPasswordTalkStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordTalkField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
_appPasswordTalk = await pushSecureStorage.read(
key: _appPasswordTalkField,
);
} on Object {
_appPassword = null;
_appPasswordTalk = null;
}
if (!_populated.isCompleted) _populated.complete();
}
@@ -149,6 +190,22 @@ class AccountData {
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
}
/// Basic-auth header using the Talk app password — authenticates the
/// apptype=talk push registration (and its unregister). Throws when the
/// talk password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start.
String getTalkBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
if (!hasAppPasswordTalk()) {
throw StateError('Talk app password not available yet');
}
return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
+4 -1
View File
@@ -15,8 +15,11 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
Future<void> initializeNotifications() async {
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
// small icons because Android renders only their alpha silhouette (a
// solid circle in the status bar).
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
'@drawable/ic_stat_notification',
);
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
+11 -3
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
@@ -25,9 +26,9 @@ class NotificationTasks {
}
}
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
static String chatTag(String chatToken) => 'talk_$chatToken';
/// Per-chat tag scheme — canonical definition lives beside the stacked
/// notification id in chat_thread_store.dart.
static String chatTag(String chatToken) => chatNotificationTag(chatToken);
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
@@ -54,6 +55,13 @@ class NotificationTasks {
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
// Drop the stacked-notification history too — otherwise the next push
// would resurrect all already-read messages in the new notification.
try {
await ChatThreadStore().clearChat(chatToken);
} on Object catch (e) {
log('Chat thread cleanup failed: $e');
}
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
+210
View File
@@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:localstore/localstore.dart';
/// Maximum messages kept per chat thread — enough for a messenger-style
/// notification, small enough to stay cheap in the background isolate.
const int kChatThreadCap = 15;
/// Canonical per-chat notification tag (kept in sync with
/// `NotificationTasks.chatTag`, which delegates here).
String chatNotificationTag(String chatToken) => 'talk_$chatToken';
/// Deterministic non-negative 31-bit notification id per chat, so every
/// message of the same conversation updates ONE stacked notification instead
/// of adding a new card per push.
int stableChatNotificationId(String chatToken) {
var hash = 0;
for (final unit in chatToken.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
/// One message inside a chat's notification history.
class ThreadMessage {
final int nid;
final String sender;
final String text;
final int timestampMs;
/// Room name parsed from a group-chat subject (`<sender> in <room>`);
/// null for 1:1 chats and for history entries written before this field
/// existed (tolerated on read — they simply carry no room information).
final String? roomName;
const ThreadMessage({
required this.nid,
required this.sender,
required this.text,
required this.timestampMs,
this.roomName,
});
Map<String, dynamic> toJson() => {
'nid': nid,
'sender': sender,
'text': text,
'timestampMs': timestampMs,
'roomName': ?roomName,
};
factory ThreadMessage.fromJson(Map<String, dynamic> json) => ThreadMessage(
nid: (json['nid'] as num).toInt(),
sender: json['sender'] as String? ?? '',
text: json['text'] as String? ?? '',
timestampMs: (json['timestampMs'] as num?)?.toInt() ?? 0,
roomName: json['roomName'] as String?,
);
}
/// Appends [message] to [messages], replacing an entry with the same nid
/// (redelivered push) and capping the history at [cap] (oldest dropped).
/// Pure so the stacking behavior is unit-testable.
List<ThreadMessage> appendThreadMessage(
List<ThreadMessage> messages,
ThreadMessage message, {
int cap = kChatThreadCap,
}) {
final result = [...messages.where((m) => m.nid != message.nid), message];
return result.length > cap ? result.sublist(result.length - cap) : result;
}
/// Removes the message with [nid]; the caller cancels the notification when
/// the returned list is empty and re-renders it silently otherwise.
List<ThreadMessage> removeThreadNid(List<ThreadMessage> messages, int nid) =>
messages.where((m) => m.nid != nid).toList();
/// History for the next notification after [message] arrives, given whether
/// the chat's previous notification is still on screen ([isActive]):
///
/// - `true` → the user hasn't dismissed/read it, so [message] STACKS onto the
/// existing history.
/// - `false` → the notification is gone (swiped away or the chat was read
/// without our cleanup running), so the thread RESTARTS with only [message].
/// - `null` → the active-notification probe failed or isn't supported; keep
/// stacking defensively — degraded stacking never loses a message.
///
/// Android provides no reliable "notification dismissed" callback, so the
/// visible-state probe at append time is the substitute.
List<ThreadMessage> threadAfterIncoming(
List<ThreadMessage> existing,
ThreadMessage message,
bool? isActive, {
int cap = kChatThreadCap,
}) {
final base = isActive == false ? const <ThreadMessage>[] : existing;
return appendThreadMessage(base, message, cap: cap);
}
/// MessagingStyle header per Android convention: a 1:1 chat gets NO
/// `conversationTitle` — the system then shows the person once as the header
/// and plain texts per line; setting a title would repeat the name on every
/// row. Groups get the ROOM NAME as title (parsed from the subject) plus
/// per-line sender names; when no room name is known, >1 distinct sender
/// still marks the thread as group with the last sender as title fallback.
({String? conversationTitle, bool groupConversation}) conversationHeader(
List<ThreadMessage> messages,
) {
final roomName = messages
.lastWhere(
(m) => m.roomName?.isNotEmpty ?? false,
orElse: () =>
const ThreadMessage(nid: 0, sender: '', text: '', timestampMs: 0),
)
.roomName;
if (roomName != null && roomName.isNotEmpty) {
return (conversationTitle: roomName, groupConversation: true);
}
final distinctSenders = messages.map((m) => m.sender).toSet().length;
if (distinctSenders <= 1) {
return (conversationTitle: null, groupConversation: false);
}
return (conversationTitle: messages.last.sender, groupConversation: true);
}
/// Persists the per-chat message history via [Localstore] so both the
/// foreground and the FCM background isolate build the same stacked
/// notification. Complements [NidStore], which keeps the nid → chat mapping
/// needed to resolve delete-pushes.
class ChatThreadStore {
static const _collection = 'push_chat_threads';
final Localstore _db;
ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;
/// Localstore doc ids become file names — tokens are normally URL-safe and
/// used verbatim, anything else is base64url-encoded.
static String docIdForToken(String chatToken) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
Future<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get();
final raw = data?['messages'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(ThreadMessage.fromJson)
.toList();
}
/// Appends [message] and returns the updated (capped) history.
Future<List<ThreadMessage>> append(
String chatToken,
ThreadMessage message,
) async {
final updated = appendThreadMessage(await messages(chatToken), message);
await _write(chatToken, updated);
return updated;
}
/// Appends [message], but restarts the thread first when the chat's previous
/// notification is no longer active ([isActive] == false — dismissed/read).
/// See [threadAfterIncoming] for the tri-state semantics.
Future<List<ThreadMessage>> appendConsideringActive(
String chatToken,
ThreadMessage message,
bool? isActive,
) async {
final updated = threadAfterIncoming(
await messages(chatToken),
message,
isActive,
);
await _write(chatToken, updated);
return updated;
}
/// Removes the message with [nid] and returns the remaining history.
Future<List<ThreadMessage>> removeNid(String chatToken, int nid) async {
final remaining = removeThreadNid(await messages(chatToken), nid);
if (remaining.isEmpty) {
await clearChat(chatToken);
} else {
await _write(chatToken, remaining);
}
return remaining;
}
Future<void> clearChat(String chatToken) => _doc(chatToken).delete();
Future<void> clearAll() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return;
for (final id in docs.keys) {
// Localstore keys are full document paths (/push_chat_threads/<id>).
await _db.collection(_collection).doc(id.split('/').last).delete();
}
}
DocumentRef _doc(String chatToken) =>
_db.collection(_collection).doc(docIdForToken(chatToken));
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
_doc(chatToken).set({
'messages': [for (final m in messages) m.toJson()],
});
}
+26 -8
View File
@@ -39,17 +39,24 @@ class NextcloudPushApi {
NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client();
/// Registers (or refreshes) this device. [devicePublicKeyPem] must be the
/// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base
/// URL (with trailing slash).
/// Registers (or refreshes) a device subscription. [devicePublicKeyPem] must
/// be the 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy
/// base URL (with trailing slash).
///
/// [authorizationHeader] overrides the Basic auth (e.g. the Talk app
/// password — NC binds the subscription to the authenticated session token).
/// [userAgent] overrides the UA; a Talk-pattern UA makes NC classify the
/// subscription as apptype `talk` and route Talk pushes to it.
Future<NextcloudPushRegistration> register({
required String pushTokenHash,
required String devicePublicKeyPem,
required String proxyServer,
String? authorizationHeader,
String? userAgent,
}) async {
final response = await _client.post(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
headers: _headers(authorizationHeader, userAgent),
body: {
'pushTokenHash': pushTokenHash,
'devicePublicKey': devicePublicKeyPem,
@@ -76,13 +83,24 @@ class NextcloudPushApi {
);
}
/// Unregisters this device from Nextcloud push. Returns true when the server
/// responded 202, meaning the proxy subscription should also be removed.
Future<bool> unregister() async {
/// Unregisters one device subscription — the DELETE is per session token, so
/// each registration is removed with its own app password via
/// [authorizationHeader]. Returns true when the server responded 202,
/// meaning the proxy subscription should also be removed.
Future<bool> unregister({String? authorizationHeader}) async {
final response = await _client.delete(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
headers: _headers(authorizationHeader, null),
);
return response.statusCode == 202;
}
Map<String, String> _headers(
String? authorizationHeader,
String? userAgent,
) => {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
'User-Agent': ?userAgent,
};
}
+113 -21
View File
@@ -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) =>
+245
View File
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart';
/// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch
/// so the caller can re-render once (silently) when it eventually delivers.
typedef AvatarIconLookup = ({Uint8List? icon, Future<Uint8List?>? late});
/// Masks avatar [bytes] into a circular PNG on a square canvas (center-cover
/// crop). Devices do NOT reliably mask Person icons themselves, so the round
/// shape is baked into the bitmap. Runs on `dart:ui`, which is available in
/// engine-backed background isolates (the FCM handler isolate) — no
/// BuildContext involved. Returns null when decoding fails; callers fall back
/// to the raw bytes.
Future<Uint8List?> maskAvatarCircular(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final size = image.width < image.height ? image.width : image.height;
final src = ui.Rect.fromLTWH(
(image.width - size) / 2,
(image.height - size) / 2,
size.toDouble(),
size.toDouble(),
);
final dst = ui.Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble());
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.clipPath(ui.Path()..addOval(dst));
canvas.drawImageRect(image, src, dst, ui.Paint()..isAntiAlias = true);
final masked = await recorder.endRecording().toImage(size, size);
final data = await masked.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
masked.dispose();
return data?.buffer.asUint8List();
} on Object catch (e) {
log('Push avatar: circular mask failed: $e');
return null;
}
}
/// Loads conversation avatars for Talk push notifications.
///
/// Uses the same Spreed endpoint as the in-app `UserAvatar` widget
/// (`room/{token}/avatar`, no query parameters — for 1:1 chats this is the
/// other person's picture), but with its own disk cache of PRE-MASKED round
/// PNGs: the widget cache is an in-memory LRU that is empty in the FCM
/// background isolate, and masking must not be recomputed per push. Every
/// failure path returns null so a missing avatar can never delay or drop a
/// notification beyond [fetchTimeout].
class PushAvatarStore {
/// Cached files older than this are treated as stale and pruned.
static const Duration maxAge = Duration(days: 14);
static const Duration _defaultFetchTimeout = Duration(seconds: 4);
// Versioned: `push_avatars` (raw, unmasked) was used before masking landed —
// reusing it would surface square icons from old cache entries.
static const _subDirectory = 'push_avatars_masked';
/// One shared fetch per token and isolate: the bounded render-time lookup
/// and the late re-render both await the SAME future, and concurrent pushes
/// don't stampede the endpoint.
static final Map<String, Future<Uint8List?>> _inflight = {};
final Future<Directory> Function() _cacheDirProvider;
/// Fetches the raw avatar response for a chat token. Injectable so tests
/// never touch the network or the account/endpoint singletons (the default
/// builds auth headers from them).
final Future<http.Response> Function(String chatToken) _fetch;
final Duration fetchTimeout;
PushAvatarStore({
Future<Directory> Function()? cacheDirProvider,
Future<http.Response> Function(String chatToken)? fetch,
this.fetchTimeout = _defaultFetchTimeout,
}) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir,
_fetch = fetch ?? _defaultFetch;
static Future<Directory> _defaultCacheDir() async {
final base = await getApplicationCacheDirectory();
return Directory('${base.path}/$_subDirectory');
}
static Future<http.Response> _defaultFetch(String chatToken) => http.get(
NextcloudOcs.uri('apps/spreed/api/v1/room/$chatToken/avatar'),
headers: {
...NextcloudOcs.headers(),
'Accept': 'image/png,image/jpeg,image/webp',
},
);
/// File-safe cache name for a chat token. Tokens are normally URL-safe
/// already and used verbatim; anything else is base64url-encoded so exotic
/// ids can never escape the cache directory.
static String fileNameForToken(String token) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
? token
: base64Url.encode(utf8.encode(token)).replaceAll('=', '');
/// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) =>
now.difference(modified) <= maxAge;
/// Spreed answers with an SVG placeholder (initials/icon) when a chat has
/// no real picture — useless for a notification bitmap, so it is skipped.
static bool looksLikeSvg(Uint8List bytes) {
final head = utf8
.decode(
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
allowMalformed: true,
)
.trimLeft();
return head.startsWith('<?xml') || head.startsWith('<svg');
}
/// Returns the (round-masked) conversation avatar for [chatToken]. Order:
/// fresh disk cache → shared network fetch bounded by [fetchTimeout] →
/// stale disk cache. On timeout the fetch keeps running and is returned as
/// [AvatarIconLookup.late] so the caller can re-render once it delivers.
Future<AvatarIconLookup> roomAvatarIcon(String chatToken) async {
try {
final file = await _fileFor(chatToken);
final fresh = await _readCache(file, allowStale: false);
if (fresh != null) return (icon: fresh, late: null);
final fetch = _inflight.putIfAbsent(chatToken, () {
final future = _fetchProcessAndCache(chatToken, file);
future.whenComplete(() {
if (identical(_inflight[chatToken], future)) {
_inflight.remove(chatToken);
}
});
return future;
});
try {
final fetched = await fetch.timeout(fetchTimeout);
if (fetched != null) return (icon: fetched, late: null);
// Definitive miss (no picture / error) — an outdated icon beats none.
return (icon: await _readCache(file, allowStale: true), late: null);
} on TimeoutException {
// Fetch continues in the background; hand it to the caller for the
// one-time silent re-render.
return (icon: await _readCache(file, allowStale: true), late: fetch);
}
} on Object catch (e) {
log('Push avatar $chatToken: lookup failed: $e');
return (icon: null, late: null);
}
}
/// Drops the cached avatar for [chatToken] — called when the app knows the
/// picture changed (avatar upload/removal in chat settings).
static Future<void> evict(String chatToken) async {
try {
final dir = await _defaultCacheDir();
final file = File('${dir.path}/${fileNameForToken(chatToken)}');
if (file.existsSync()) await file.delete();
} on Object {
// best effort — the 14-day max age catches it eventually
}
}
Future<File> _fileFor(String chatToken) async {
final dir = await _cacheDirProvider();
await dir.create(recursive: true);
return File('${dir.path}/${fileNameForToken(chatToken)}');
}
Future<Uint8List?> _readCache(File file, {required bool allowStale}) async {
try {
if (!file.existsSync()) return null;
if (!allowStale && !isFresh(file.lastModifiedSync(), DateTime.now())) {
return null;
}
final bytes = await file.readAsBytes();
return bytes.isEmpty ? null : bytes;
} on Object {
return null;
}
}
/// Full pipeline: fetch (with outcome diagnostics) → circular mask (raw
/// fallback when masking fails) → write processed cache. Never throws.
Future<Uint8List?> _fetchProcessAndCache(String chatToken, File file) async {
try {
final raw = await _fetchAvatar(chatToken);
if (raw == null) return null;
final icon = await maskAvatarCircular(raw) ?? raw;
await file.writeAsBytes(icon, flush: true);
unawaited(_prune(file.parent));
return icon;
} on Object catch (e) {
log('Push avatar $chatToken: fetch/process failed: $e');
return null;
}
}
Future<Uint8List?> _fetchAvatar(String chatToken) async {
final response = await _fetch(chatToken);
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final svgRejected =
response.statusCode == 200 &&
bytes.isNotEmpty &&
(contentType.contains('svg') || looksLikeSvg(bytes));
// Diagnostics: makes "no icon" cases attributable (status vs. svg
// placeholder vs. empty body) without a debugger on the device.
log(
'Push avatar $chatToken: HTTP ${response.statusCode} '
'type=$contentType bytes=${bytes.length} svgRejected=$svgRejected',
);
if (response.statusCode != 200 || bytes.isEmpty || svgRejected) return null;
return bytes;
}
/// Deletes cache files past [maxAge]. Fire-and-forget after a successful
/// write — the directory stays small (one file per recently active chat).
Future<void> _prune(Directory dir) async {
try {
final now = DateTime.now();
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (!isFresh(entry.lastModifiedSync(), now)) {
await entry.delete();
}
}
} on Object {
// best effort
}
}
}
+1 -25
View File
@@ -44,7 +44,7 @@ class PushKeypair {
final FlutterSecureStorageLike _storage;
const PushKeypair({FlutterSecureStorageLike? storage})
: _storage = storage ?? const _DefaultStorage();
: _storage = storage ?? const PushSecureStorage();
/// Returns the stored keypair PEMs, generating and persisting a fresh keypair
/// on first use. Generation is offloaded to an isolate because RSA-2048 key
@@ -79,27 +79,3 @@ class PushKeypair {
return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub);
}
}
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
class _DefaultStorage implements FlutterSecureStorageLike {
const _DefaultStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+31 -11
View File
@@ -4,6 +4,7 @@ import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../notification/notification_service.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
import 'push_keypair.dart';
@@ -34,6 +35,17 @@ PushKind classifyPush(Map<String, dynamic> data) {
return PushKind.unknown;
}
/// FCM background isolate entry point. Must be a TOP-LEVEL function with the
/// entry-point pragma: AOT builds cannot invoke static class members from
/// native code unless the class itself is annotated too (DartVM error
/// "must be annotated"), so a plain function is the reliable form.
@pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
/// the matching tray notification via [NidStore]. Works both in the FCM
/// background isolate and the foreground.
@@ -42,25 +54,19 @@ class PushMessageHandler {
final PushRegistrationStore _registrationStore;
final PushRenderer _renderer;
final NidStore _nidStore;
final ChatThreadStore _threadStore;
PushMessageHandler({
PushKeypair? keypair,
PushRegistrationStore? registrationStore,
PushRenderer? renderer,
NidStore? nidStore,
ChatThreadStore? threadStore,
}) : _keypair = keypair ?? const PushKeypair(),
_registrationStore = registrationStore ?? const PushRegistrationStore(),
_renderer = renderer ?? PushRenderer(),
_nidStore = nidStore ?? NidStore();
/// Background isolate entry point registered with
/// `FirebaseMessaging.onBackgroundMessage`.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
_nidStore = nidStore ?? NidStore(),
_threadStore = threadStore ?? ChatThreadStore();
/// Processes [message]. In the foreground, pass [foreground] true and
/// [openChatToken] so a message for the currently open chat is suppressed
@@ -157,6 +163,7 @@ class PushMessageHandler {
await _cancel(entry);
}
await _nidStore.clear();
await _threadStore.clearAll();
return;
}
final nids = <int>[
@@ -165,7 +172,20 @@ class PushMessageHandler {
];
for (final nid in nids) {
final entry = await _nidStore.get(nid);
if (entry != null) await _cancel(entry);
final chatToken = entry?.chatToken;
if (chatToken != null && chatToken.isNotEmpty) {
// Stacked chat notification: drop only this message from the thread.
// Remaining messages re-render WITHOUT alerting again; the last one
// going away cancels the whole card.
final remaining = await _threadStore.removeNid(chatToken, nid);
if (remaining.isEmpty) {
await _cancel(entry!);
} else {
await _renderer.renderTalkThread(chatToken, remaining, alert: false);
}
} else if (entry != null) {
await _cancel(entry);
}
await _nidStore.delete(nid);
}
}
+185 -48
View File
@@ -15,11 +15,26 @@ import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
///
/// Every device maintains TWO Nextcloud registrations (see
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
/// subscriptions once the user has any (e.g. the official Talk app).
class PushRegistration {
/// User agents matching nextcloud/server `IRequest` Talk patterns
/// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
/// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
/// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
/// NC stores it with apptype `talk`.
static const String talkUserAgentAndroid =
'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
static const String talkUserAgentIos =
'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
final PushKeypair _keypair;
final PushRegistrationStore _store;
final NextcloudPushApi _nextcloud;
@@ -34,13 +49,18 @@ class PushRegistration {
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
/// so a beta/dev build registers against the matching proxy automatically.
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
/// Public so the push status view can compare it against the stored binding.
String get currentProxyServer =>
'${MarianumConnectEndpoint.current()}/push-proxy/';
/// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes.
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
@@ -54,28 +74,94 @@ class PushRegistration {
}
}
/// Registers this device end-to-end. No-op-safe: transport failures are
/// logged and swallowed so callers can fire-and-forget.
Future<void> register() async {
/// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return;
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
return;
}
await ensureAppPassword();
await _persistNativeAuthContext();
final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword);
} on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e');
}
}
final proxyServer = _proxyServer;
final ncBaseUrl = _ncBaseUrl;
final pems = await _keypair.ensure();
/// Registers this device end-to-end: both Nextcloud registrations (general,
/// then talk) each followed by their MarianumConnect proxy registration.
/// Partial results are persisted per type — one failing registration never
/// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
/// transport failures are logged and swallowed so callers can
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
} on Object catch (e) {
log('Push: could not obtain FCM token: $e');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
await ensureAppPassword();
await ensureTalkAppPassword();
await _persistNativeAuthContext();
final PushKeypairPems pems;
try {
pems = await _keypair.ensure();
} on Object catch (e) {
log('Push: keypair unavailable: $e');
await _recordAttempts(_shortError(e));
return false;
}
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
var allOk = true;
for (final type in PushRegistrationType.values) {
final ok = await _registerType(
type: type,
fcmToken: fcmToken,
pems: pems,
appVersion: appVersion,
);
allOk = allOk && ok;
}
return allOk;
}
Future<bool> _registerType({
required PushRegistrationType type,
required String fcmToken,
required PushKeypairPems pems,
required String? appVersion,
}) async {
try {
final proxyServer = currentProxyServer;
final ncBaseUrl = currentNcBaseUrl;
final isTalk = type == PushRegistrationType.talk;
final registration = await _nextcloud.register(
pushTokenHash: generatePushTokenHash(fcmToken),
pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader()
: null,
userAgent: isTalk ? _talkUserAgent : null,
);
await _store.save(
type: type,
deviceIdentifier: registration.deviceIdentifier,
serverPublicKeyPem: registration.publicKey,
fcmToken: fcmToken,
@@ -83,27 +169,53 @@ class PushRegistration {
ncBaseUrl: ncBaseUrl,
);
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
await PushDeviceRegister().run(
deviceIdentifier: registration.deviceIdentifier,
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
registrationType: type.wireName,
appVersion: appVersion,
);
log('Push: registered (created=${registration.created})');
log(
'Push: registered ${type.wireName} '
'(created=${registration.created})',
);
await _recordAttempt(type, null);
return true;
} on Object catch (e) {
log('Push: registration failed: $e');
log('Push: ${type.wireName} registration failed: $e');
await _recordAttempt(type, _shortError(e));
return false;
}
}
/// Persists the attempt outcome for the push status view. Storage failures
/// must never mask the actual registration result.
Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
try {
await _store.saveLastRegistrationAttempt(
type: type,
at: DateTime.now(),
error: error,
);
} on Object {
// ignore — the status view simply shows the previous attempt
}
}
Future<void> _recordAttempts(String? error) async {
for (final type in PushRegistrationType.values) {
await _recordAttempt(type, error);
}
}
static String _shortError(Object e) {
final text = e.toString();
return text.length > 300 ? '${text.substring(0, 300)}' : text;
}
/// Writes the username and Nextcloud base URL into the shared keychain so the
/// native iOS Talk action handler can authenticate OCS calls without the
/// Flutter engine. Best-effort — a failure here must not abort registration.
@@ -119,19 +231,28 @@ class PushRegistration {
}
}
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
/// Removes both subscriptions from Nextcloud and the proxy. Best-effort
/// each step is independent so one failure never blocks the rest.
Future<void> unregister() async {
final deviceIdentifier = await _store.deviceIdentifier();
try {
await _nextcloud.unregister();
} on Object catch (e) {
log('Push: NC unregister failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
for (final type in PushRegistrationType.values) {
final deviceIdentifier = await _store.deviceIdentifier(type);
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
// The DELETE removes the subscription bound to the authenticating
// session token — each registration with its own app password.
await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader()
: null,
);
} on Object catch (e) {
log('Push: proxy unregister failed: $e');
log('Push: NC unregister (${type.wireName}) failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
} on Object catch (e) {
log('Push: proxy unregister (${type.wireName}) failed: $e');
}
}
}
await _store.clear();
@@ -146,19 +267,24 @@ class PushRegistration {
required String current,
}) => registered != null && registered.isNotEmpty && registered != current;
/// True when an existing registration was made against a different
/// True when any existing registration was made against a different
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
/// configured (dev-tools endpoint switch, live/beta/custom).
Future<bool> needsEndpointReRegistration() async {
if (!await _store.isRegistered()) return false;
return endpointChanged(
registered: await _store.registeredProxyServer(),
current: _proxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(),
current: _ncBaseUrl,
);
for (final type in PushRegistrationType.values) {
if (!await _store.isRegistered(type)) continue;
final changed =
endpointChanged(
registered: await _store.registeredProxyServer(type),
current: currentProxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(type),
current: currentNcBaseUrl,
);
if (changed) return true;
}
return false;
}
/// Re-registers when the active endpoints diverge from the registered ones.
@@ -236,9 +362,10 @@ class PushRegistration {
/// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register();
/// Full teardown for logout: unregister push, revoke the app password, then
/// clear it locally. Ordered so the proxy stops pushing before credentials
/// are gone.
/// Full teardown for logout: unregister push, revoke BOTH app passwords
/// (each authenticated with itself — the endpoint revokes the credential it
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
await unregister();
try {
@@ -246,6 +373,16 @@ class PushRegistration {
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
try {
if (AccountData().hasAppPasswordTalk()) {
await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
);
}
} on Object catch (e) {
log('Push: delete talk app password failed: $e');
}
await AccountData().clearAppPassword();
await AccountData().clearAppPasswordTalk();
}
}
+101 -38
View File
@@ -1,16 +1,25 @@
import 'push_registration_type.dart';
import 'push_secure_storage.dart';
/// Persists the bookkeeping produced by a successful push registration:
/// the Nextcloud device identifier, the per-user server public key (needed to
/// verify incoming push signatures), the FCM token the registration was made
/// with (so a token refresh can be detected) and the endpoints it was bound to
/// (so an endpoint switch in the dev tools can be detected).
/// Persists the bookkeeping produced by successful push registrations —
/// per [PushRegistrationType]: the Nextcloud device identifier, the FCM token
/// the registration was made with (so a token refresh can be detected), the
/// endpoints it was bound to (so an endpoint switch in the dev tools can be
/// detected) and the last attempt outcome. The server public key and the
/// device keypair are shared between both registrations.
///
/// Key layout: the `general` type uses the pre-dual key names unchanged, so
/// existing installs are implicitly migrated — their stored registration IS
/// the general one; the missing talk registration is added by the next
/// register-on-start self-heal.
class PushRegistrationStore {
static const _deviceIdentifierKey = 'push_device_identifier';
static const _serverPublicKeyKey = 'push_server_public_key_pem';
static const _registeredTokenKey = 'push_registered_fcm_token';
static const _proxyServerKey = 'push_registered_proxy_server';
static const _ncBaseUrlKey = 'push_registered_nc_base_url';
static const _lastAttemptAtKey = 'push_last_registration_at';
static const _lastAttemptErrorKey = 'push_last_registration_error';
// Native-only context: the iOS AppDelegate answers Talk notification actions
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the
@@ -19,26 +28,46 @@ class PushRegistrationStore {
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
const PushRegistrationStore();
static const _perTypeKeys = [
_deviceIdentifierKey,
_registeredTokenKey,
_proxyServerKey,
_ncBaseUrlKey,
_lastAttemptAtKey,
_lastAttemptErrorKey,
];
final FlutterSecureStorageLike _storage;
const PushRegistrationStore([this._storage = const PushSecureStorage()]);
/// Type-specific key: general keeps the legacy names (implicit migration of
/// pre-dual installs), talk appends a suffix.
static String keyFor(String baseKey, PushRegistrationType type) =>
type == PushRegistrationType.general ? baseKey : '${baseKey}_talk';
Future<void> save({
required PushRegistrationType type,
required String deviceIdentifier,
required String serverPublicKeyPem,
required String fcmToken,
required String proxyServer,
required String ncBaseUrl,
}) async {
await pushSecureStorage.write(
key: _deviceIdentifierKey,
await _storage.write(
key: keyFor(_deviceIdentifierKey, type),
value: deviceIdentifier,
);
await pushSecureStorage.write(
key: _serverPublicKeyKey,
value: serverPublicKeyPem,
await _storage.write(key: _serverPublicKeyKey, value: serverPublicKeyPem);
await _storage.write(
key: keyFor(_registeredTokenKey, type),
value: fcmToken,
);
await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken);
await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer);
await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl);
await _storage.write(
key: keyFor(_proxyServerKey, type),
value: proxyServer,
);
await _storage.write(key: keyFor(_ncBaseUrlKey, type), value: ncBaseUrl);
}
/// Persists the username and Nextcloud base URL group-scoped so the native
@@ -49,39 +78,73 @@ class PushRegistrationStore {
required String username,
required String baseUrl,
}) async {
await pushSecureStorage.write(key: _usernameKey, value: username);
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
await _storage.write(key: _usernameKey, value: username);
await _storage.write(key: _baseUrlKey, value: baseUrl);
}
Future<String?> deviceIdentifier() =>
pushSecureStorage.read(key: _deviceIdentifierKey);
Future<String?> deviceIdentifier(PushRegistrationType type) =>
_storage.read(key: keyFor(_deviceIdentifierKey, type));
/// Per-user server public key — identical for both registrations.
Future<String?> serverPublicKeyPem() =>
pushSecureStorage.read(key: _serverPublicKeyKey);
_storage.read(key: _serverPublicKeyKey);
Future<String?> registeredFcmToken() =>
pushSecureStorage.read(key: _registeredTokenKey);
Future<String?> registeredFcmToken(PushRegistrationType type) =>
_storage.read(key: keyFor(_registeredTokenKey, type));
/// Proxy-server URL the current registration was made with.
Future<String?> registeredProxyServer() =>
pushSecureStorage.read(key: _proxyServerKey);
/// Proxy-server URL the registration of [type] was made with.
Future<String?> registeredProxyServer(PushRegistrationType type) =>
_storage.read(key: keyFor(_proxyServerKey, type));
/// Nextcloud base URL the current registration was made against.
Future<String?> registeredNcBaseUrl() =>
pushSecureStorage.read(key: _ncBaseUrlKey);
/// Nextcloud base URL the registration of [type] was made against.
Future<String?> registeredNcBaseUrl(PushRegistrationType type) =>
_storage.read(key: keyFor(_ncBaseUrlKey, type));
/// True when a registration has been persisted (used by the cold-start
/// self-heal to decide whether to (re-)register).
Future<bool> isRegistered() async =>
(await registeredFcmToken())?.isNotEmpty ?? false;
/// True when a registration of [type] has been persisted.
Future<bool> isRegistered(PushRegistrationType type) async =>
(await registeredFcmToken(type))?.isNotEmpty ?? false;
/// Records the outcome of the most recent registration attempt of [type] so
/// the push status view can show when it ran and why it failed. [error]
/// null = success (stored as empty string).
Future<void> saveLastRegistrationAttempt({
required PushRegistrationType type,
required DateTime at,
String? error,
}) async {
await _storage.write(
key: keyFor(_lastAttemptAtKey, type),
value: at.toIso8601String(),
);
await _storage.write(
key: keyFor(_lastAttemptErrorKey, type),
value: error ?? '',
);
}
/// Timestamp of the last registration attempt of [type], or null when none
/// ran yet.
Future<DateTime?> lastRegistrationAt(PushRegistrationType type) async {
final raw = await _storage.read(key: keyFor(_lastAttemptAtKey, type));
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
/// Error text of the last registration attempt of [type], or null when it
/// succeeded (or never ran).
Future<String?> lastRegistrationError(PushRegistrationType type) async {
final raw = await _storage.read(key: keyFor(_lastAttemptErrorKey, type));
return (raw == null || raw.isEmpty) ? null : raw;
}
Future<void> clear() async {
await pushSecureStorage.delete(key: _deviceIdentifierKey);
await pushSecureStorage.delete(key: _serverPublicKeyKey);
await pushSecureStorage.delete(key: _registeredTokenKey);
await pushSecureStorage.delete(key: _proxyServerKey);
await pushSecureStorage.delete(key: _ncBaseUrlKey);
await pushSecureStorage.delete(key: _usernameKey);
await pushSecureStorage.delete(key: _baseUrlKey);
for (final type in PushRegistrationType.values) {
for (final key in _perTypeKeys) {
await _storage.delete(key: keyFor(key, type));
}
}
await _storage.delete(key: _serverPublicKeyKey);
await _storage.delete(key: _usernameKey);
await _storage.delete(key: _baseUrlKey);
}
}
+35
View File
@@ -0,0 +1,35 @@
/// The two Nextcloud push registrations every device maintains.
///
/// Nextcloud routes Talk notifications ONLY to registrations whose session
/// user agent matched `IRequest::USER_AGENT_TALK_*` (apptype `talk`) as soon
/// as the user has at least one such device — e.g. the official Talk app.
/// A single `unknown` registration therefore stops receiving Talk pushes the
/// moment the official app is installed. The fix is one registration per
/// route, sharing the same device keypair.
enum PushRegistrationType {
/// Default registration (apptype `unknown`): files, calendar, everything
/// except Talk.
general('general'),
/// Talk registration (apptype `talk`): Talk messages and calls, ranking
/// equally beside the official Talk app.
talk('talk');
/// Value sent to MarianumConnect as `registrationType`.
final String wireName;
const PushRegistrationType(this.wireName);
}
/// Pseudo push token whose sha512 is registered with Nextcloud. The two
/// registrations MUST carry different pushTokenHashes: on registration NC's
/// `deletePushTokenByHash()` removes older subscriptions of the same user
/// with an identical hash — identical hashes would let each registration
/// delete its sibling. The backend derives the same variants from the real
/// FCM token (`registrationType` field), so the proxy hash comparison still
/// matches. Nextcloud never sees the plain token either way.
String pushTokenVariant(String fcmToken, PushRegistrationType type) =>
switch (type) {
PushRegistrationType.general => fcmToken,
PushRegistrationType.talk => '$fcmToken#talk',
};
+190 -47
View File
@@ -1,17 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
import '../notification/notification_tasks.dart';
import '../theming/light_app_theme.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_avatar.dart';
import 'push_subject.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
/// a generic channel.
/// notifications. Talk messages of one chat stack into a SINGLE
/// [MessagingStyleInformation] notification (stable id/tag per chat, history
/// from [ChatThreadStore]) with inline reply + mark-as-read actions;
/// everything else renders in a generic channel.
class PushRenderer {
static const talkChannelId = 'talk_messages';
static const talkChannelName = 'Talk-Nachrichten';
@@ -20,9 +25,21 @@ class PushRenderer {
static const String iosTalkCategory = 'TALK_MESSAGE';
final NidStore _nidStore;
/// Brand accent: colors the (monochrome) small icon and action buttons in
/// the notification shade instead of the default grey.
static const _accentColor = LightAppTheme.marianumRed;
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
final NidStore _nidStore;
final PushAvatarStore _avatarStore;
final ChatThreadStore _threadStore;
PushRenderer({
NidStore? nidStore,
PushAvatarStore? avatarStore,
ChatThreadStore? threadStore,
}) : _nidStore = nidStore ?? NidStore(),
_avatarStore = avatarStore ?? PushAvatarStore(),
_threadStore = threadStore ?? ChatThreadStore();
FlutterLocalNotificationsPlugin get _plugin =>
NotificationService().flutterLocalNotificationsPlugin;
@@ -60,24 +77,120 @@ class PushRenderer {
}
}
/// Tri-state probe of whether this chat's stacked notification is still in
/// the tray. `null` when the query fails or isn't supported (native code not
/// linked, older platform) — the caller then keeps stacking defensively. A
/// successful query is authoritative: absence means dismissed/read.
/// Works in the background isolate (pure plugin call, no BuildContext).
Future<bool?> _isChatNotificationActive(String chatToken) async {
final id = stableChatNotificationId(chatToken);
final tag = chatNotificationTag(chatToken);
try {
final actives = await _plugin.getActiveNotifications();
return actives.any((n) => n.id == id || n.tag == tag);
} on Object catch (e) {
log('Push: getActiveNotifications probe failed: $e');
return null;
}
}
Future<void> _renderTalk(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.id);
final chatToken = subject.id;
final tag = chatToken != null
? NotificationTasks.chatTag(chatToken)
: 'talk_$nid';
final text = subject.subject ?? 'Neue Nachricht';
final (senderName, messageText) = _splitSender(text);
final parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
final nid =
subject.nid ??
_fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');
final payload = _payload(chatToken: chatToken, nid: nid);
if (chatToken == null || chatToken.isEmpty) {
// Without a chat token there is nothing to stack under — render a
// standalone card keyed by the nid.
await _renderTalkStandalone(nid, parsed.sender, parsed.text);
return;
}
// Restart the thread when the previous notification is gone (swiped away
// or the chat was read without our cleanup running) — otherwise the new
// push would resurrect the already-seen history. A failed/unsupported
// probe keeps stacking (see threadAfterIncoming).
final isActive = await _isChatNotificationActive(chatToken);
final messages = await _threadStore.appendConsideringActive(
chatToken,
ThreadMessage(
nid: nid,
sender: parsed.sender,
text: parsed.text,
timestampMs: DateTime.now().millisecondsSinceEpoch,
roomName: parsed.roomName,
),
isActive,
);
await _nidStore.put(
NidEntry(
nid: nid,
notificationId: stableChatNotificationId(chatToken),
tag: chatNotificationTag(chatToken),
chatToken: chatToken,
),
);
await renderTalkThread(chatToken, messages);
}
/// Shows/updates the ONE stacked notification of [chatToken] from the given
/// history. Identity is stable per chat ([stableChatNotificationId] +
/// [chatNotificationTag]) so each new message updates the same card.
/// [alert] false re-renders silently — used when a delete-push removed one
/// of several messages and the remaining thread must not ping again.
Future<void> renderTalkThread(
String chatToken,
List<ThreadMessage> messages, {
bool alert = true,
}) async {
if (messages.isEmpty) return;
final tag = chatNotificationTag(chatToken);
final id = stableChatNotificationId(chatToken);
final latest = messages.last;
// Conversation avatar (the person's picture in 1:1 chats), pre-masked
// round. Bounded by the store's fetch timeout; when the icon isn't ready
// in time the notification renders without it and a still-running fetch
// triggers ONE silent re-render below once bytes arrive.
final lookup = await _avatarStore.roomAvatarIcon(chatToken);
final avatarBytes = lookup.icon;
if (alert && avatarBytes == null && lookup.late != null) {
unawaited(
lookup.late!.then((bytes) async {
if (bytes == null) return;
final fresh = await _threadStore.messages(chatToken);
if (fresh.isEmpty) return;
// Re-render hits the processed cache → icon present, no re-schedule.
await renderTalkThread(chatToken, fresh, alert: false);
}),
);
}
final senders = <String, Person>{};
Person personFor(String sender) => senders.putIfAbsent(
sender,
() => Person(
key: sender,
name: sender,
icon: avatarBytes == null ? null : ByteArrayAndroidIcon(avatarBytes),
),
);
final styleMessages = [
for (final m in messages)
Message(
m.text,
DateTime.fromMillisecondsSinceEpoch(m.timestampMs),
personFor(m.sender),
),
];
final header = conversationHeader(messages);
final messagingStyle = MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
conversationTitle: senderName,
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
conversationTitle: header.conversationTitle,
groupConversation: header.groupConversation,
messages: styleMessages,
);
final androidDetails = AndroidNotificationDetails(
@@ -86,50 +199,89 @@ class PushRenderer {
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
silent: !alert,
styleInformation: messagingStyle,
actions: const [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
],
actions: _talkActions,
);
final iosDetails = DarwinNotificationDetails(
threadIdentifier: tag,
categoryIdentifier: iosTalkCategory,
presentSound: alert ? null : false,
presentBanner: alert ? null : false,
);
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
await _plugin.show(
id: id,
title: latest.sender,
body: latest.text,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: _payload(chatToken: chatToken, nid: latest.nid),
);
}
Future<void> _renderTalkStandalone(
int nid,
String senderName,
String messageText,
) async {
final tag = 'talk_$nid';
await _nidStore.put(NidEntry(nid: nid, notificationId: nid, tag: tag));
await _plugin.show(
id: nid,
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
// Single sender ⇒ no conversationTitle (1:1 convention): the sender
// Person is the header, a title would repeat the name per line.
styleInformation: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
),
),
iOS: const DarwinNotificationDetails(),
),
payload: payload,
payload: _payload(chatToken: null, nid: nid),
);
}
static const _talkActions = [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
];
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
color: _accentColor,
);
const iosDetails = DarwinNotificationDetails();
await _nidStore.put(
@@ -160,6 +312,7 @@ class PushRenderer {
generalChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
);
await _plugin.show(
id: id,
@@ -173,16 +326,6 @@ class PushRenderer {
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Splits a `"Sender: message"` subject into its parts, falling back to a
/// generic sender label when there's no delimiter.
(String, String) _splitSender(String subject) {
final idx = subject.indexOf(': ');
if (idx > 0 && idx < subject.length - 2) {
return (subject.substring(0, idx), subject.substring(idx + 2));
}
return ('Talk', subject);
}
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
+25
View File
@@ -25,3 +25,28 @@ const IOSOptions kPushIosOptions = IOSOptions(
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions,
);
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
/// Default [FlutterSecureStorageLike] backed by [pushSecureStorage].
class PushSecureStorage implements FlutterSecureStorageLike {
const PushSecureStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+278
View File
@@ -0,0 +1,278 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import '../model/account_data.dart';
import 'push_keypair.dart';
import 'push_registration.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
/// Tri-state result of a single push-chain check.
enum PushCheck { ok, fail, unknown }
/// State of one of the two registrations (general/talk) — Nextcloud binding,
/// proxy binding and last attempt outcome.
@immutable
class PushTypeStatus {
/// Nextcloud subscription present (device identifier stored).
final bool nextcloudRegistered;
final String? registeredNcBaseUrl;
final String? registeredProxyServer;
final DateTime? lastRegistrationAt;
/// Error text of the last registration attempt; null = success or never ran.
final String? lastRegistrationError;
const PushTypeStatus({
required this.nextcloudRegistered,
required this.registeredNcBaseUrl,
required this.registeredProxyServer,
required this.lastRegistrationAt,
required this.lastRegistrationError,
});
}
/// Snapshot of every link in the push chain, in delivery order. Pure data —
/// the display rows are derived by [buildPushStatusRows] so the mapping is
/// unit-testable without any plugin.
@immutable
class PushStatusReport {
final bool settingEnabled;
final PushCheck osPermission;
/// Backend capability `pushNotifications`; [PushCheck.unknown] while the
/// capabilities have not been loaded yet this session.
final PushCheck serverCapability;
final bool appPasswordPresent;
final bool talkAppPasswordPresent;
final bool keypairPresent;
/// State of the general (apptype unknown) registration.
final PushTypeStatus general;
/// State of the Talk-classified registration.
final PushTypeStatus talk;
final String? currentProxyServer;
const PushStatusReport({
required this.settingEnabled,
required this.osPermission,
required this.serverCapability,
required this.appPasswordPresent,
required this.talkAppPasswordPresent,
required this.keypairPresent,
required this.general,
required this.talk,
required this.currentProxyServer,
});
/// True when the stored proxy binding of [status] diverges from the active
/// endpoint.
bool proxyEndpointMismatch(PushTypeStatus status) =>
currentProxyServer != null &&
PushRegistration.endpointChanged(
registered: status.registeredProxyServer,
current: currentProxyServer!,
);
/// True when a test notification can actually be delivered. The test push
/// is a Connect direct push routed via the general registration, so a
/// healthy general chain suffices — a broken talk registration only affects
/// Talk message delivery. The OS permission must not be denied (`unknown`
/// stays permissive, mirroring [PushRegistration.isPermissionUsable]).
bool get readyForTestNotification =>
osPermission != PushCheck.fail &&
general.nextcloudRegistered &&
(general.registeredProxyServer?.isNotEmpty ?? false) &&
!proxyEndpointMismatch(general) &&
general.lastRegistrationError == null;
}
/// Collects the current push chain state. Settings/capability flags come from
/// the caller (they live in cubits); everything else is read from the secure
/// stores and the messaging plugin.
Future<PushStatusReport> collectPushStatus({
required bool settingEnabled,
required bool capabilityPush,
required bool capabilitiesLoaded,
PushRegistrationStore store = const PushRegistrationStore(),
PushKeypair keypair = const PushKeypair(),
}) async {
final registration = PushRegistration();
String? currentProxy;
try {
currentProxy = registration.currentProxyServer;
} on Object {
currentProxy = null;
}
Future<PushTypeStatus> typeStatus(PushRegistrationType type) async =>
PushTypeStatus(
nextcloudRegistered:
(await store.deviceIdentifier(type))?.isNotEmpty ?? false,
registeredNcBaseUrl: await store.registeredNcBaseUrl(type),
registeredProxyServer: await store.registeredProxyServer(type),
lastRegistrationAt: await store.lastRegistrationAt(type),
lastRegistrationError: await store.lastRegistrationError(type),
);
return PushStatusReport(
settingEnabled: settingEnabled,
osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded
? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(),
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(),
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk),
currentProxyServer: currentProxy,
);
}
Future<PushCheck> _osPermission() async {
try {
final settings = await FirebaseMessaging.instance.getNotificationSettings();
switch (settings.authorizationStatus) {
case AuthorizationStatus.authorized:
case AuthorizationStatus.provisional:
return PushCheck.ok;
case AuthorizationStatus.denied:
return PushCheck.fail;
case AuthorizationStatus.notDetermined:
return PushCheck.unknown;
}
} on Object {
return PushCheck.unknown;
}
}
/// One line in the status checklist.
@immutable
class PushStatusRow {
final String label;
final PushCheck state;
/// Short explanation shown as subtitle — set for failures (what to do) and
/// for informational details (e.g. the registered URL).
final String? detail;
const PushStatusRow({required this.label, required this.state, this.detail});
}
const _pendingDetail =
'Registrierung ausstehend — sie wird beim nächsten App-Start '
'automatisch wiederholt';
/// Derives the display checklist from a [PushStatusReport]. Pure — the order
/// mirrors the actual chain: setting → OS → server → credentials → keys →
/// Nextcloud (general/talk) → Connect (general/talk).
List<PushStatusRow> buildPushStatusRows(PushStatusReport r) => [
PushStatusRow(
label: 'Push-Benachrichtigungen aktiviert',
state: r.settingEnabled ? PushCheck.ok : PushCheck.fail,
detail: r.settingEnabled
? null
: 'In den Einstellungen deaktiviert — über den Schalter oben aktivieren',
),
PushStatusRow(
label: 'Benachrichtigungsberechtigung',
state: r.osPermission,
detail: switch (r.osPermission) {
PushCheck.ok => null,
PushCheck.fail =>
'Die Benachrichtigungsberechtigung wurde in den Systemeinstellungen '
'deaktiviert',
PushCheck.unknown => 'Noch nicht erteilt',
},
),
PushStatusRow(
label: 'Server-Unterstützung',
state: r.serverCapability,
detail: switch (r.serverCapability) {
PushCheck.ok => null,
PushCheck.fail =>
'Der Server unterstützt Push-Benachrichtigungen derzeit nicht',
PushCheck.unknown => 'Serverinformationen noch nicht geladen',
},
),
PushStatusRow(
label: 'App-Passwörter',
state: r.appPasswordPresent && r.talkAppPasswordPresent
? PushCheck.ok
: PushCheck.fail,
detail: r.appPasswordPresent && r.talkAppPasswordPresent
? null
: '${_missingPasswords(r)} — wird beim nächsten App-Start '
'automatisch angefordert',
),
PushStatusRow(
label: 'Geräteschlüssel',
state: r.keypairPresent ? PushCheck.ok : PushCheck.fail,
detail: r.keypairPresent
? null
: 'Nicht vorhanden — wird bei der nächsten Registrierung erzeugt',
),
_nextcloudRow(r.general, 'Nextcloud-Registrierung (Allgemein)'),
_nextcloudRow(r.talk, 'Nextcloud-Registrierung (Talk)'),
_connectRow(r, r.general, 'Connect-Registrierung (Allgemein)'),
_connectRow(r, r.talk, 'Connect-Registrierung (Talk)'),
];
String _missingPasswords(PushStatusReport r) {
if (!r.appPasswordPresent && !r.talkAppPasswordPresent) {
return 'Beide fehlen';
}
return r.appPasswordPresent
? 'Talk-App-Passwort fehlt'
: 'Allgemeines App-Passwort fehlt';
}
PushStatusRow _nextcloudRow(PushTypeStatus status, String label) =>
PushStatusRow(
label: label,
state: status.nextcloudRegistered ? PushCheck.ok : PushCheck.fail,
detail: status.nextcloudRegistered
? status.registeredNcBaseUrl
: _pendingDetail,
);
PushStatusRow _connectRow(
PushStatusReport r,
PushTypeStatus status,
String label,
) {
final registeredProxy = status.registeredProxyServer;
if (registeredProxy == null || registeredProxy.isEmpty) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail: _pendingDetail,
);
}
if (r.proxyEndpointMismatch(status)) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail:
'Für $registeredProxy registriert — der aktive Server ist '
'${r.currentProxyServer}. Eine erneute Registrierung ist '
'erforderlich.',
);
}
if (status.lastRegistrationError != null) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail: 'Die letzte Registrierung ist fehlgeschlagen — Details unten',
);
}
return PushStatusRow(
label: label,
state: PushCheck.ok,
detail: registeredProxy,
);
}
+39
View File
@@ -1,3 +1,42 @@
/// Parsed parts of a Talk push subject text.
///
/// Format verified against nextcloud/spreed `Notifier::parseChatMessage`:
/// with message preview the parsed subject is `"{user}\n{message}"` for 1:1
/// chats and `'{user} in {call}' . "\n{message}"` for groups — header and
/// message are separated by a NEWLINE, and the notifications app forwards the
/// parsed subject unmodified (only shortened). The German translation of
/// `{user} in {call}` is identical (`l10n/de.json`), so the ` in ` separator
/// holds for de_DE. Legacy/other variants using `": "` are kept as fallback;
/// splitting the header at the LAST ` in ` is robust against sender names
/// containing "in" (only a display name literally containing ` in ` in a 1:1
/// chat would still mis-split — accepted heuristic limit).
({String sender, String? roomName, String text}) parseTalkSubject(String raw) {
String header;
String text;
final newline = raw.indexOf('\n');
if (newline > 0) {
header = raw.substring(0, newline).trim();
text = raw.substring(newline + 1);
} else {
final colon = raw.indexOf(': ');
if (colon > 0 && colon < raw.length - 2) {
header = raw.substring(0, colon);
text = raw.substring(colon + 2);
} else {
return (sender: 'Talk', roomName: null, text: raw);
}
}
final roomSep = header.lastIndexOf(' in ');
if (roomSep > 0 && roomSep + 4 < header.length) {
return (
sender: header.substring(0, roomSep),
roomName: header.substring(roomSep + 4),
text: text,
);
}
return (sender: header, roomName: null, text: text);
}
/// The decrypted `subject` JSON of a Nextcloud push-v2 notification.
///
/// Covers the full shape the notifications app emits, including the three
@@ -3,14 +3,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/centered_leading.dart';
import '../widgets/push_status_sheet.dart';
class TalkSection extends StatelessWidget {
const TalkSection({super.key});
@@ -56,7 +54,9 @@ class TalkSection extends StatelessWidget {
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Benachrichtigungen'),
subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
subtitle: const Text(
'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
),
trailing: Checkbox(
value: notificationSettings.enabled,
onChanged: (e) {
@@ -74,8 +74,9 @@ class TalkSection extends StatelessWidget {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Benachrichtigungen sind in den Systemeinstellungen '
'deaktiviert — bitte dort erlauben.',
'Die Benachrichtigungsberechtigung wurde in den '
'Systemeinstellungen deaktiviert. Bitte aktiviere '
'sie dort, um Push-Benachrichtigungen zu erhalten.',
),
),
);
@@ -87,102 +88,14 @@ class TalkSection extends StatelessWidget {
},
),
),
if (notificationSettings.enabled) const _TestNotificationTile(),
ListTile(
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
title: const Text('Push-Status'),
subtitle: const Text('Registrierung und Zustellung im Detail'),
trailing: const Icon(Icons.arrow_right),
onTap: () => showPushStatusSheet(context),
),
],
);
}
}
/// "Send a test notification" action, shown only while push is enabled. The
/// button stays disabled until a registration is confirmed present, then calls
/// the backend and reports the result via a SnackBar.
class _TestNotificationTile extends StatefulWidget {
const _TestNotificationTile();
@override
State<_TestNotificationTile> createState() => _TestNotificationTileState();
}
class _TestNotificationTileState extends State<_TestNotificationTile> {
static const _permissionDeniedHint =
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert';
bool _registered = false;
bool _permissionDenied = false;
bool _sending = false;
@override
void initState() {
super.initState();
_loadState();
}
Future<void> _loadState() async {
final registered = await const PushRegistrationStore().isRegistered();
final denied = await PushRegistration.isOsPermissionDenied();
if (!mounted) return;
setState(() {
_registered = registered;
_permissionDenied = denied;
});
}
Future<void> _sendTest() async {
if (_sending) return;
Haptics.selection();
final messenger = ScaffoldMessenger.of(context);
// Re-check right before sending: the user may have flipped the OS
// permission in the system settings since this tile was built.
final denied = await PushRegistration.isOsPermissionDenied();
if (!mounted) return;
if (denied) {
setState(() => _permissionDenied = true);
messenger.showSnackBar(
const SnackBar(
content: Text('$_permissionDeniedHint — bitte dort erlauben.'),
),
);
return;
}
setState(() {
_permissionDenied = false;
_sending = true;
});
String message;
try {
final devices = await PushDeviceTest().run();
message = devices >= 1
? 'Testbenachrichtigung an $devices Gerät(e) gesendet'
: 'Kein Gerät registriert — Push-Registrierung prüfen';
} on Object catch (e) {
message = errorToUserMessage(e);
}
if (!mounted) return;
setState(() => _sending = false);
messenger.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
if (!_registered) return const SizedBox.shrink();
return ListTile(
leading: const CenteredLeading(Icon(Icons.send_outlined)),
title: const Text('Testbenachrichtigung senden'),
subtitle: _permissionDenied
? Text(
_permissionDeniedHint,
style: TextStyle(color: Theme.of(context).colorScheme.error),
)
: const Text('Prüft, ob Push auf diesem Gerät ankommt'),
trailing: _sending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_right),
enabled: !_sending,
onTap: _sending ? null : _sendTest,
);
}
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
import '../../../../extensions/date_time.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_status.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/details_bottom_sheet.dart';
/// Opens the push status checklist: one row per link in the push chain with
/// an explanation where it is broken, the last registration attempt (incl.
/// verbatim error), a manual re-register action and — once the chain is
/// operational — a test notification. Loads once on open; the refresh action
/// re-collects on demand (no polling).
void showPushStatusSheet(BuildContext context) {
// Captured here: the sheet outlives this build context's element tree.
final settings = context.read<SettingsCubit>();
final capabilities = context.read<CapabilitiesCubit>();
showDetailsBottomSheet(
context,
header: const ListTile(
leading: Icon(Icons.monitor_heart_outlined),
title: Text('Status der Push-Benachrichtigungen'),
),
children: (sheetCtx) => [
_PushStatusBody(settings: settings, capabilities: capabilities),
],
);
}
class _PushStatusBody extends StatefulWidget {
final SettingsCubit settings;
final CapabilitiesCubit capabilities;
const _PushStatusBody({required this.settings, required this.capabilities});
@override
State<_PushStatusBody> createState() => _PushStatusBodyState();
}
class _PushStatusBodyState extends State<_PushStatusBody> {
PushStatusReport? _report;
bool _busy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final capabilitiesState = widget.capabilities.state;
final report = await collectPushStatus(
settingEnabled: widget.settings.val().notificationSettings.enabled,
capabilityPush: capabilitiesState.pushNotifications,
capabilitiesLoaded: capabilitiesState.loaded,
);
if (!mounted) return;
setState(() => _report = report);
}
Future<void> _reRegister() async {
if (_busy) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
final ok = await PushRegistration().register();
if (!mounted) return;
setState(() => _busy = false);
await _load();
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? 'Registrierung erfolgreich abgeschlossen'
: 'Registrierung fehlgeschlagen — Details in der Statusübersicht',
),
),
);
}
Future<void> _sendTest() async {
if (_busy) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
String message;
try {
final devices = await PushDeviceTest().run();
message = switch (devices) {
0 => 'Es ist kein Gerät registriert — bitte erneut registrieren',
1 => 'Testbenachrichtigung an 1 Gerät gesendet',
_ => 'Testbenachrichtigung an $devices Geräte gesendet',
};
} on Object catch (e) {
message = errorToUserMessage(e);
}
if (!mounted) return;
setState(() => _busy = false);
messenger.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final report = _report;
if (report == null) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: AppProgressIndicator.medium()),
);
}
final theme = Theme.of(context);
final rows = buildPushStatusRows(report);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...rows.map(
(row) => ListTile(
dense: true,
leading: _stateIcon(row.state, theme),
title: Text(row.label),
subtitle: row.detail == null ? null : Text(row.detail!),
),
),
if (report.general.lastRegistrationAt != null ||
report.talk.lastRegistrationAt != null)
const Divider(height: 1),
..._lastAttempt(theme, 'Allgemein', report.general),
..._lastAttempt(theme, 'Talk', report.talk),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _actions(report),
),
],
);
}
/// Last-attempt line (+ verbatim error) for one registration type.
List<Widget> _lastAttempt(
ThemeData theme,
String label,
PushTypeStatus status,
) {
final at = status.lastRegistrationAt;
if (at == null) return const [];
final error = status.lastRegistrationError;
return [
ListTile(
dense: true,
leading: Icon(
error == null ? Icons.history : Icons.error_outline,
color: error == null ? null : theme.colorScheme.error,
),
title: Text(
'Letzte Registrierung ($label): ${at.formatDateTime()}'
'${error == null ? 'erfolgreich' : 'fehlgeschlagen'}',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
error,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
];
}
/// Action hierarchy: refresh stays a secondary icon action; the primary
/// (filled) button is the test notification once the chain is operational,
/// otherwise re-registering IS the primary next step and the test action is
/// omitted (it could not succeed and the checklist explains why).
Widget _actions(PushStatusReport report) {
const spinner = SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
);
final ready = report.readyForTestNotification;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
onPressed: _busy ? null : _load,
tooltip: 'Aktualisieren',
icon: const Icon(Icons.refresh),
),
// OverflowBar stacks the buttons vertically on narrow screens instead
// of overflowing the row.
Expanded(
child: OverflowBar(
alignment: MainAxisAlignment.end,
overflowAlignment: OverflowBarAlignment.end,
spacing: 8,
overflowSpacing: 4,
children: [
if (ready) ...[
TextButton(
onPressed: _busy ? null : _reRegister,
child: const Text('Erneut registrieren'),
),
FilledButton.icon(
onPressed: _busy ? null : _sendTest,
icon: _busy ? spinner : const Icon(Icons.send_outlined),
label: const Text('Testbenachrichtigung'),
),
] else
FilledButton.icon(
onPressed: _busy ? null : _reRegister,
icon: _busy ? spinner : const Icon(Icons.sync),
label: const Text('Erneut registrieren'),
),
],
),
),
],
);
}
Widget _stateIcon(PushCheck state, ThemeData theme) {
switch (state) {
case PushCheck.ok:
return const Icon(Icons.check_circle_outline, color: Colors.green);
case PushCheck.fail:
return Icon(Icons.cancel_outlined, color: theme.colorScheme.error);
case PushCheck.unknown:
return Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.onSurfaceVariant,
);
}
}
}
+6 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
@@ -8,6 +9,7 @@ import 'package:http/http.dart' as http;
import '../model/account_data.dart';
import '../model/endpoint_data.dart';
import '../push/push_avatar.dart';
class UserAvatar extends StatefulWidget {
final String id;
@@ -86,6 +88,9 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
final url = avatarUrl(id: id, isGroup: true);
_resolvedAvatars.remove(url);
_pendingAvatars.remove(url);
// Keep the push-notification disk cache in sync — it serves the same
// room avatar to the FCM background isolate.
unawaited(PushAvatarStore.evict(id));
} else {
// User avatars include the rendered size in the URL — drop every variant.
final host = EndpointData().nextcloud().full();
@@ -242,11 +247,7 @@ class _UserAvatarState extends State<UserAvatar> {
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
child: ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: content,
),
child: SizedBox(width: radius * 2, height: radius * 2, child: content),
),
);
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/chat_thread_store.dart';
ThreadMessage _msg(int nid, {String sender = 'Max', String text = 'hi'}) =>
ThreadMessage(nid: nid, sender: sender, text: text, timestampMs: nid);
void main() {
group('appendThreadMessage', () {
test('appends in order', () {
var messages = <ThreadMessage>[];
messages = appendThreadMessage(messages, _msg(1));
messages = appendThreadMessage(messages, _msg(2));
expect(messages.map((m) => m.nid), [1, 2]);
});
test('caps the history, dropping the oldest', () {
var messages = <ThreadMessage>[];
for (var nid = 1; nid <= kChatThreadCap + 3; nid++) {
messages = appendThreadMessage(messages, _msg(nid));
}
expect(messages, hasLength(kChatThreadCap));
expect(messages.first.nid, 4);
expect(messages.last.nid, kChatThreadCap + 3);
});
test('a redelivered nid replaces the old entry instead of duplicating', () {
var messages = [_msg(1), _msg(2)];
messages = appendThreadMessage(messages, _msg(1, text: 'edited'));
expect(messages.map((m) => m.nid), [2, 1]);
expect(messages.last.text, 'edited');
});
});
group('threadAfterIncoming (dismiss/read reset)', () {
final existing = [_msg(1), _msg(2)];
final incoming = _msg(3);
test('active notification → new message stacks onto history', () {
final result = threadAfterIncoming(existing, incoming, true);
expect(result.map((m) => m.nid), [1, 2, 3]);
});
test('inactive notification → thread restarts with only the new one', () {
final result = threadAfterIncoming(existing, incoming, false);
expect(result.map((m) => m.nid), [3]);
});
test('unknown active state (probe failed) → stacks defensively', () {
final result = threadAfterIncoming(existing, incoming, null);
expect(result.map((m) => m.nid), [1, 2, 3]);
});
test('reset still honours the cap for a burst of messages', () {
final result = threadAfterIncoming(existing, incoming, false, cap: 1);
expect(result.map((m) => m.nid), [3]);
});
});
group('removeThreadNid', () {
test('removes only the matching message', () {
final remaining = removeThreadNid([_msg(1), _msg(2), _msg(3)], 2);
expect(remaining.map((m) => m.nid), [1, 3]);
});
test('deleting the last message empties the thread (caller cancels)', () {
final remaining = removeThreadNid([_msg(7)], 7);
expect(remaining, isEmpty);
});
test('unknown nid leaves the thread untouched', () {
final remaining = removeThreadNid([_msg(1)], 99);
expect(remaining.map((m) => m.nid), [1]);
});
});
group('conversationHeader', () {
test('1:1 chat (single sender, no room) gets no title', () {
final header = conversationHeader([
_msg(1, sender: 'Max'),
_msg(2, sender: 'Max'),
]);
expect(header.conversationTitle, isNull);
expect(header.groupConversation, isFalse);
});
test('a known room name becomes the title exactly once', () {
final header = conversationHeader([
ThreadMessage(
nid: 1,
sender: 'Max',
text: 'hi',
timestampMs: 1,
roomName: 'Projektraum',
),
_msg(2, sender: 'Max'),
]);
expect(header.conversationTitle, 'Projektraum');
expect(header.groupConversation, isTrue);
});
test('multiple senders without room name fall back to last sender', () {
final header = conversationHeader([
_msg(1, sender: 'Max'),
_msg(2, sender: 'Anna'),
]);
expect(header.conversationTitle, 'Anna');
expect(header.groupConversation, isTrue);
});
});
group('ThreadMessage roomName migration', () {
test('entries without roomName field read as null', () {
final restored = ThreadMessage.fromJson({
'nid': 1,
'sender': 'Max',
'text': 'hi',
'timestampMs': 5,
});
expect(restored.roomName, isNull);
});
test('roomName round-trips when present', () {
final restored = ThreadMessage.fromJson(
const ThreadMessage(
nid: 1,
sender: 'Max',
text: 'hi',
timestampMs: 5,
roomName: 'Raum',
).toJson(),
);
expect(restored.roomName, 'Raum');
});
});
group('stable notification identity', () {
test('same token always yields the same 31-bit id', () {
final a = stableChatNotificationId('abc123');
expect(a, stableChatNotificationId('abc123'));
expect(a, greaterThanOrEqualTo(0));
expect(a, lessThanOrEqualTo(0x7fffffff));
});
test('different tokens yield different ids and tags', () {
expect(
stableChatNotificationId('abc123'),
isNot(stableChatNotificationId('xyz789')),
);
expect(chatNotificationTag('abc123'), 'talk_abc123');
});
});
group('ThreadMessage json', () {
test('round-trips', () {
final restored = ThreadMessage.fromJson(
ThreadMessage(
nid: 5,
sender: 'Max',
text: 'Hallo',
timestampMs: 1234,
).toJson(),
);
expect(restored.nid, 5);
expect(restored.sender, 'Max');
expect(restored.text, 'Hallo');
expect(restored.timestampMs, 1234);
});
});
group('ChatThreadStore.docIdForToken', () {
test('url-safe tokens are used verbatim, others encoded', () {
expect(ChatThreadStore.docIdForToken('abc_1-2'), 'abc_1-2');
final encoded = ChatThreadStore.docIdForToken('a/b');
expect(encoded, isNot(contains('/')));
expect(encoded, ChatThreadStore.docIdForToken('a/b'));
});
});
}
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:marianum_mobile/push/push_avatar.dart';
final Uint8List _fakeBytes = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, //
]);
http.Response _imageResponse() => http.Response.bytes(
_fakeBytes,
200,
headers: {'content-type': 'image/png'},
);
/// Renders a solid 40×20 PNG via dart:ui — a real decodable source image.
Future<Uint8List> _generatePng() async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(
const ui.Rect.fromLTWH(0, 0, 40, 20),
ui.Paint()..color = const ui.Color(0xFF993333),
);
final image = await recorder.endRecording().toImage(40, 20);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
return data!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('push_avatar_test');
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
PushAvatarStore store({
required Future<http.Response> Function(String chatToken) fetch,
Duration timeout = const Duration(seconds: 4),
}) => PushAvatarStore(
cacheDirProvider: () async => tempDir,
fetch: fetch,
fetchTimeout: timeout,
);
group('maskAvatarCircular', () {
test('output is a square PNG sized to the shorter edge', () async {
final source = await _generatePng();
final masked = await maskAvatarCircular(source);
expect(masked, isNotNull);
// PNG magic bytes.
expect(masked!.sublist(0, 8), [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
]);
final codec = await ui.instantiateImageCodec(masked);
final frame = await codec.getNextFrame();
expect(frame.image.width, 20);
expect(frame.image.height, 20);
});
test('is deterministic for identical input', () async {
final source = await _generatePng();
final a = await maskAvatarCircular(source);
final b = await maskAvatarCircular(source);
expect(a, isNotNull);
expect(a, equals(b));
});
test('undecodable bytes yield null (caller falls back to raw)', () async {
expect(await maskAvatarCircular(_fakeBytes), isNull);
});
});
group('roomAvatarIcon', () {
test('fetches, caches, and serves from cache afterwards', () async {
var fetchCount = 0;
final s = store(
fetch: (_) async {
fetchCount++;
return _imageResponse();
},
);
final first = await s.roomAvatarIcon('iconToken1');
// Fake bytes are undecodable → mask falls back to the raw bytes.
expect(first.icon, _fakeBytes);
expect(first.late, isNull);
expect(fetchCount, 1);
final second = await s.roomAvatarIcon('iconToken1');
expect(second.icon, _fakeBytes);
expect(fetchCount, 1);
});
test('svg placeholder is rejected and not cached', () async {
final s = store(
fetch: (_) async => http.Response(
'<svg xmlns="http://www.w3.org/2000/svg"/>',
200,
headers: {'content-type': 'image/svg+xml'},
),
);
final lookup = await s.roomAvatarIcon('iconToken2');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
expect(File('${tempDir.path}/iconToken2').existsSync(), isFalse);
});
test('definitive fetch error yields no icon and no late future', () async {
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken3');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
test('fetch error falls back to a stale cache entry', () async {
final file = File('${tempDir.path}/iconToken4');
await file.writeAsBytes(_fakeBytes);
await file.setLastModified(
DateTime.now().subtract(const Duration(days: 20)),
);
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken4');
expect(lookup.icon, _fakeBytes);
});
test(
'timeout returns a late future that delivers exactly one result',
() async {
final completer = Completer<http.Response>();
var fetchCount = 0;
final s = store(
fetch: (_) {
fetchCount++;
return completer.future;
},
timeout: const Duration(milliseconds: 50),
);
final lookup = await s.roomAvatarIcon('iconToken5');
expect(lookup.icon, isNull);
expect(lookup.late, isNotNull);
completer.complete(_imageResponse());
final lateBytes = await lookup.late;
expect(lateBytes, _fakeBytes);
// The processed cache now serves directly — no second fetch.
final second = await s.roomAvatarIcon('iconToken5');
expect(second.icon, _fakeBytes);
expect(second.late, isNull);
expect(fetchCount, 1);
},
);
test('non-200 responses yield null', () async {
final s = store(fetch: (_) async => http.Response('gone', 404));
final lookup = await s.roomAvatarIcon('iconToken6');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
});
group('fileNameForToken / isFresh / looksLikeSvg', () {
test('url-safe tokens are used verbatim', () {
expect(PushAvatarStore.fileNameForToken('abc123_XY-z'), 'abc123_XY-z');
});
test('unsafe tokens are encoded deterministically and stay distinct', () {
final a = PushAvatarStore.fileNameForToken('a/b');
expect(a, PushAvatarStore.fileNameForToken('a/b'));
expect(a, isNot(PushAvatarStore.fileNameForToken('a.b')));
expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(a), isTrue);
});
test('isFresh boundary', () {
final now = DateTime(2026, 7, 5, 12);
expect(
PushAvatarStore.isFresh(now.subtract(const Duration(days: 13)), now),
isTrue,
);
expect(
PushAvatarStore.isFresh(
now.subtract(const Duration(days: 14, hours: 1)),
now,
),
isFalse,
);
});
test('svg detection', () {
expect(
PushAvatarStore.looksLikeSvg(
Uint8List.fromList(' <svg xmlns="..."/>'.codeUnits),
),
isTrue,
);
expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse);
});
});
}
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_registration.dart';
import 'package:marianum_mobile/push/push_registration_store.dart';
import 'package:marianum_mobile/push/push_registration_type.dart';
import 'package:marianum_mobile/push/push_secure_storage.dart';
class _MemoryStorage implements FlutterSecureStorageLike {
final Map<String, String> values = {};
@override
Future<String?> read({required String key}) async => values[key];
@override
Future<void> write({required String key, required String? value}) async {
if (value == null) {
values.remove(key);
} else {
values[key] = value;
}
}
@override
Future<void> delete({required String key}) async => values.remove(key);
}
void main() {
group('talk user agents', () {
// Quoted from nextcloud/server lib/public/IRequest.php.
final uaTalkAndroid = RegExp(
r'^Mozilla/5\.0 \(Android\) Nextcloud-Talk v([^ ]*).*$',
);
final uaTalkIos = RegExp(
r'^Mozilla/5\.0 \(iOS\) Nextcloud-Talk v([^ ]*).*$',
);
test('android UA matches USER_AGENT_TALK_ANDROID', () {
expect(
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentAndroid),
isTrue,
);
expect(
uaTalkIos.hasMatch(PushRegistration.talkUserAgentAndroid),
isFalse,
);
});
test('ios UA matches USER_AGENT_TALK_IOS', () {
expect(uaTalkIos.hasMatch(PushRegistration.talkUserAgentIos), isTrue);
expect(
uaTalkAndroid.hasMatch(PushRegistration.talkUserAgentIos),
isFalse,
);
});
});
group('pushTokenVariant', () {
test('general uses the raw token, talk appends the suffix', () {
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
expect(pushTokenVariant('tok', PushRegistrationType.talk), 'tok#talk');
});
test('variants always differ (NC would delete same-hash siblings)', () {
const token = 'any-token';
expect(
pushTokenVariant(token, PushRegistrationType.general),
isNot(pushTokenVariant(token, PushRegistrationType.talk)),
);
});
});
group('PushRegistrationStore', () {
test('pre-dual entries are read as the general registration', () async {
final storage = _MemoryStorage();
// State written by a pre-dual app version (no type suffixes).
storage.values.addAll({
'push_device_identifier': 'legacy-device',
'push_server_public_key_pem': 'legacy-key',
'push_registered_fcm_token': 'legacy-token',
'push_registered_proxy_server': 'https://old/push-proxy/',
'push_registered_nc_base_url': 'https://cloud',
'push_last_registration_at': '2026-07-01T10:00:00.000',
'push_last_registration_error': '',
});
final store = PushRegistrationStore(storage);
expect(
await store.deviceIdentifier(PushRegistrationType.general),
'legacy-device',
);
expect(await store.isRegistered(PushRegistrationType.general), isTrue);
expect(
await store.registeredProxyServer(PushRegistrationType.general),
'https://old/push-proxy/',
);
expect(
await store.lastRegistrationAt(PushRegistrationType.general),
DateTime(2026, 7, 1, 10),
);
// The talk registration is genuinely absent — the self-heal adds it.
expect(await store.deviceIdentifier(PushRegistrationType.talk), isNull);
expect(await store.isRegistered(PushRegistrationType.talk), isFalse);
});
test('per-type values stay independent', () async {
final storage = _MemoryStorage();
final store = PushRegistrationStore(storage);
await store.save(
type: PushRegistrationType.general,
deviceIdentifier: 'dev-general',
serverPublicKeyPem: 'server-key',
fcmToken: 'token',
proxyServer: 'https://a/push-proxy/',
ncBaseUrl: 'https://cloud',
);
await store.save(
type: PushRegistrationType.talk,
deviceIdentifier: 'dev-talk',
serverPublicKeyPem: 'server-key',
fcmToken: 'token',
proxyServer: 'https://a/push-proxy/',
ncBaseUrl: 'https://cloud',
);
await store.saveLastRegistrationAttempt(
type: PushRegistrationType.talk,
at: DateTime(2026, 7, 5, 12),
error: 'HTTP 404',
);
expect(
await store.deviceIdentifier(PushRegistrationType.general),
'dev-general',
);
expect(
await store.deviceIdentifier(PushRegistrationType.talk),
'dev-talk',
);
// Shared server key: last write wins, both read the same value.
expect(await store.serverPublicKeyPem(), 'server-key');
expect(
await store.lastRegistrationError(PushRegistrationType.general),
isNull,
);
expect(
await store.lastRegistrationError(PushRegistrationType.talk),
'HTTP 404',
);
});
test('clear removes both registrations and shared keys', () async {
final storage = _MemoryStorage();
final store = PushRegistrationStore(storage);
for (final type in PushRegistrationType.values) {
await store.save(
type: type,
deviceIdentifier: 'dev',
serverPublicKeyPem: 'key',
fcmToken: 'token',
proxyServer: 'https://a/',
ncBaseUrl: 'https://cloud',
);
await store.saveLastRegistrationAttempt(
type: type,
at: DateTime(2026),
error: 'x',
);
}
await store.saveNativeAuthContext(
username: 'user',
baseUrl: 'https://cloud',
);
await store.clear();
expect(storage.values, isEmpty);
});
});
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/chat_thread_store.dart';
import 'package:marianum_mobile/push/push_actions.dart';
void main() {
group('PushActions.finishReply', () {
const token = 'chat1';
final thread = [
const ThreadMessage(nid: 1, sender: 'Max', text: 'Hi', timestampMs: 1),
const ThreadMessage(nid: 2, sender: 'Max', text: 'Da?', timestampMs: 2),
];
test(
'successful reply removes the notification via chat cleanup',
() async {
var cleanups = 0;
var renders = 0;
var cancels = 0;
await PushActions.finishReply(
chatToken: token,
sent: true,
cleanupChat: (t) async {
expect(t, token);
cleanups++;
},
loadThread: (_) async => thread,
renderSilent: (_, _) async => renders++,
cancelNotification: (_) async => cancels++,
);
expect(cleanups, 1);
expect(renders, 0);
expect(cancels, 0);
},
);
test('failed reply re-renders the unchanged thread exactly once', () async {
var cleanups = 0;
var renders = 0;
List<ThreadMessage>? rendered;
await PushActions.finishReply(
chatToken: token,
sent: false,
cleanupChat: (_) async => cleanups++,
loadThread: (_) async => thread,
renderSilent: (t, messages) async {
expect(t, token);
renders++;
rendered = messages;
},
cancelNotification: (_) async => fail('must not cancel'),
);
expect(cleanups, 0);
expect(renders, 1);
// History unchanged — same messages, no self entry appended.
expect(rendered!.map((m) => m.nid), [1, 2]);
});
test(
'failed reply with empty history cancels to stop the spinner',
() async {
var cancels = 0;
await PushActions.finishReply(
chatToken: token,
sent: false,
cleanupChat: (_) async => fail('must not cleanup'),
loadThread: (_) async => const [],
renderSilent: (_, _) async => fail('nothing to render'),
cancelNotification: (_) async => cancels++,
);
expect(cancels, 1);
},
);
});
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_status.dart';
const _liveProxy = 'https://connect.marianum-fulda.de/push-proxy/';
const _betaProxy = 'https://connect-beta.marianum-fulda.de/push-proxy/';
PushTypeStatus _typeStatus({
bool nextcloudRegistered = true,
String? registeredNcBaseUrl = 'https://cloud.marianum-fulda.de',
String? registeredProxyServer = _liveProxy,
DateTime? lastRegistrationAt,
String? lastRegistrationError,
}) => PushTypeStatus(
nextcloudRegistered: nextcloudRegistered,
registeredNcBaseUrl: registeredNcBaseUrl,
registeredProxyServer: registeredProxyServer,
lastRegistrationAt: lastRegistrationAt,
lastRegistrationError: lastRegistrationError,
);
PushStatusReport _report({
bool settingEnabled = true,
PushCheck osPermission = PushCheck.ok,
PushCheck serverCapability = PushCheck.ok,
bool appPasswordPresent = true,
bool talkAppPasswordPresent = true,
bool keypairPresent = true,
PushTypeStatus? general,
PushTypeStatus? talk,
String? currentProxyServer = _liveProxy,
}) => PushStatusReport(
settingEnabled: settingEnabled,
osPermission: osPermission,
serverCapability: serverCapability,
appPasswordPresent: appPasswordPresent,
talkAppPasswordPresent: talkAppPasswordPresent,
keypairPresent: keypairPresent,
general: general ?? _typeStatus(),
talk: talk ?? _typeStatus(),
currentProxyServer: currentProxyServer,
);
PushStatusRow _row(List<PushStatusRow> rows, String label) =>
rows.singleWhere((r) => r.label == label);
void main() {
group('buildPushStatusRows', () {
test('healthy chain shows nine ok rows in chain order', () {
final rows = buildPushStatusRows(_report());
expect(rows, hasLength(9));
expect(rows.map((r) => r.label), [
'Push-Benachrichtigungen aktiviert',
'Benachrichtigungsberechtigung',
'Server-Unterstützung',
'App-Passwörter',
'Geräteschlüssel',
'Nextcloud-Registrierung (Allgemein)',
'Nextcloud-Registrierung (Talk)',
'Connect-Registrierung (Allgemein)',
'Connect-Registrierung (Talk)',
]);
expect(rows.every((r) => r.state == PushCheck.ok), isTrue);
});
test('disabled setting fails the first row with a hint', () {
final rows = buildPushStatusRows(_report(settingEnabled: false));
final row = _row(rows, 'Push-Benachrichtigungen aktiviert');
expect(row.state, PushCheck.fail);
expect(row.detail, contains('deaktiviert'));
});
test('unloaded capabilities show as unknown, not as failure', () {
final rows = buildPushStatusRows(
_report(serverCapability: PushCheck.unknown),
);
final row = _row(rows, 'Server-Unterstützung');
expect(row.state, PushCheck.unknown);
expect(row.detail, contains('noch nicht geladen'));
});
test('missing talk app password fails the password row and names it', () {
final rows = buildPushStatusRows(_report(talkAppPasswordPresent: false));
final row = _row(rows, 'App-Passwörter');
expect(row.state, PushCheck.fail);
expect(row.detail, contains('Talk-App-Passwort fehlt'));
});
test('registered rows carry their URL as detail', () {
final rows = buildPushStatusRows(_report());
expect(
_row(rows, 'Nextcloud-Registrierung (Allgemein)').detail,
'https://cloud.marianum-fulda.de',
);
expect(_row(rows, 'Connect-Registrierung (Talk)').detail, _liveProxy);
});
test('a broken talk registration fails only the talk rows', () {
final rows = buildPushStatusRows(
_report(
talk: _typeStatus(
nextcloudRegistered: false,
registeredProxyServer: null,
),
),
);
expect(
_row(rows, 'Nextcloud-Registrierung (Allgemein)').state,
PushCheck.ok,
);
expect(
_row(rows, 'Connect-Registrierung (Allgemein)').state,
PushCheck.ok,
);
expect(
_row(rows, 'Nextcloud-Registrierung (Talk)').state,
PushCheck.fail,
);
final talkConnect = _row(rows, 'Connect-Registrierung (Talk)');
expect(talkConnect.state, PushCheck.fail);
expect(talkConnect.detail, contains('ausstehend'));
});
test('proxy endpoint mismatch fails the affected row naming both URLs', () {
final report = _report(
talk: _typeStatus(registeredProxyServer: _liveProxy),
currentProxyServer: _betaProxy,
general: _typeStatus(registeredProxyServer: _betaProxy),
);
expect(report.proxyEndpointMismatch(report.talk), isTrue);
expect(report.proxyEndpointMismatch(report.general), isFalse);
final row = _row(
buildPushStatusRows(report),
'Connect-Registrierung (Talk)',
);
expect(row.state, PushCheck.fail);
expect(row.detail, contains('connect.marianum-fulda.de'));
expect(row.detail, contains('connect-beta.marianum-fulda.de'));
});
test('per-type registration errors fail only their own connect row', () {
final rows = buildPushStatusRows(
_report(talk: _typeStatus(lastRegistrationError: 'HTTP 404')),
);
expect(
_row(rows, 'Connect-Registrierung (Allgemein)').state,
PushCheck.ok,
);
final talkRow = _row(rows, 'Connect-Registrierung (Talk)');
expect(talkRow.state, PushCheck.fail);
expect(talkRow.detail, contains('fehlgeschlagen'));
});
});
group('PushStatusReport.readyForTestNotification', () {
test('test push only depends on the general chain', () {
// Test pushes are Connect direct pushes via the general registration —
// a broken talk registration must not disable the test action.
final report = _report(
talk: _typeStatus(
nextcloudRegistered: false,
registeredProxyServer: null,
lastRegistrationError: 'HTTP 404',
),
);
expect(report.readyForTestNotification, isTrue);
});
test('denied OS permission wins over a complete registration', () {
expect(
_report(osPermission: PushCheck.fail).readyForTestNotification,
isFalse,
);
});
test('undetermined OS permission stays permissive', () {
expect(
_report(osPermission: PushCheck.unknown).readyForTestNotification,
isTrue,
);
});
test('missing general registration is not ready', () {
expect(
_report(
general: _typeStatus(nextcloudRegistered: false),
).readyForTestNotification,
isFalse,
);
expect(
_report(
general: _typeStatus(registeredProxyServer: null),
).readyForTestNotification,
isFalse,
);
});
test('general endpoint mismatch or failed attempt is not ready', () {
expect(
_report(
general: _typeStatus(registeredProxyServer: _liveProxy),
talk: _typeStatus(registeredProxyServer: _betaProxy),
currentProxyServer: _betaProxy,
).readyForTestNotification,
isFalse,
);
expect(
_report(
general: _typeStatus(lastRegistrationError: 'HTTP 404'),
).readyForTestNotification,
isFalse,
);
});
});
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_subject.dart';
void main() {
group('parseTalkSubject', () {
test('1:1 chat: "{user}\\n{message}"', () {
final parsed = parseTalkSubject('Max Mustermann\nHallo zusammen');
expect(parsed.sender, 'Max Mustermann');
expect(parsed.roomName, isNull);
expect(parsed.text, 'Hallo zusammen');
});
test('group chat: "{user} in {call}\\n{message}" (de identical)', () {
final parsed = parseTalkSubject('Max in Projektraum\nHallo');
expect(parsed.sender, 'Max');
expect(parsed.roomName, 'Projektraum');
expect(parsed.text, 'Hallo');
});
test('sender containing " in " splits at the LAST separator', () {
final parsed = parseTalkSubject('Max in the Middle in Raum X\nHi');
expect(parsed.sender, 'Max in the Middle');
expect(parsed.roomName, 'Raum X');
expect(parsed.text, 'Hi');
});
test('legacy ": " separator still works as fallback', () {
final parsed = parseTalkSubject('Max: Hallo');
expect(parsed.sender, 'Max');
expect(parsed.roomName, isNull);
expect(parsed.text, 'Hallo');
});
test('legacy group form "Sender in Raum: msg"', () {
final parsed = parseTalkSubject('Max in Raum: Hallo');
expect(parsed.sender, 'Max');
expect(parsed.roomName, 'Raum');
expect(parsed.text, 'Hallo');
});
test('no separator falls back to a generic sender', () {
final parsed = parseTalkSubject(
'Max hat eine Nachricht in der Unterhaltung Raum gesendet',
);
expect(parsed.sender, 'Talk');
expect(parsed.roomName, isNull);
expect(
parsed.text,
'Max hat eine Nachricht in der Unterhaltung Raum gesendet',
);
});
test('multiline message keeps newlines after the first', () {
final parsed = parseTalkSubject('Max\nZeile 1\nZeile 2');
expect(parsed.sender, 'Max');
expect(parsed.text, 'Zeile 1\nZeile 2');
});
});
}