diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c14f862..15201b6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,17 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0647008 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #993333 + diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart index d8d5b9b..12cbcd9 100644 --- a/lib/api/marianumcloud/app_password/delete_app_password.dart +++ b/lib/api/marianumcloud/app_password/delete_app_password.dart @@ -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 run() async { + Future run({String? authorizationHeader}) async { await _client.delete( NextcloudOcs.uri('core/apppassword'), - headers: NextcloudOcs.headers(), + headers: { + ...NextcloudOcs.headers(), + 'Authorization': ?authorizationHeader, + }, ); } } diff --git a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart index 717ac2d..ec514c9 100644 --- a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart +++ b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart @@ -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, }, ); diff --git a/lib/main.dart b/lib/main.dart index 8c235b6..4c80079 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -96,7 +96,7 @@ Future 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
{ 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), ), ], ); diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 787b20e..c033578 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -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 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 clearAppPasswordTalk() async { + _appPasswordTalk = null; + await _clearAppPasswordTalkStorage(); + } + + bool hasAppPasswordTalk() => + _appPasswordTalk != null && _appPasswordTalk!.isNotEmpty; + Future _clearAppPasswordStorage() async { try { await pushSecureStorage.delete(key: _appPasswordField); @@ -98,14 +127,26 @@ class AccountData { } } + Future _clearAppPasswordTalkStorage() async { + try { + await pushSecureStorage.delete(key: _appPasswordTalkField); + } on Object { + // ignore — nothing stored or keystore unavailable + } + } + Future _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). diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index fa7e4ff..596ef3e 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -15,8 +15,11 @@ class NotificationService { FlutterLocalNotificationsPlugin(); Future 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 diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index d0dc98a..e3eb449 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -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] — diff --git a/lib/push/chat_thread_store.dart b/lib/push/chat_thread_store.dart new file mode 100644 index 0000000..b0fbafc --- /dev/null +++ b/lib/push/chat_thread_store.dart @@ -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 (` in `); + /// 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 toJson() => { + 'nid': nid, + 'sender': sender, + 'text': text, + 'timestampMs': timestampMs, + 'roomName': ?roomName, + }; + + factory ThreadMessage.fromJson(Map 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 appendThreadMessage( + List 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 removeThreadNid(List 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 threadAfterIncoming( + List existing, + ThreadMessage message, + bool? isActive, { + int cap = kChatThreadCap, +}) { + final base = isActive == false ? const [] : 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 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> messages(String chatToken) async { + final data = await _doc(chatToken).get(); + final raw = data?['messages']; + if (raw is! List) return const []; + return raw + .whereType>() + .map(ThreadMessage.fromJson) + .toList(); + } + + /// Appends [message] and returns the updated (capped) history. + Future> 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> 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> 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 clearChat(String chatToken) => _doc(chatToken).delete(); + + Future 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/). + await _db.collection(_collection).doc(id.split('/').last).delete(); + } + } + + DocumentRef _doc(String chatToken) => + _db.collection(_collection).doc(docIdForToken(chatToken)); + + Future _write(String chatToken, List messages) => + _doc(chatToken).set({ + 'messages': [for (final m in messages) m.toJson()], + }); +} diff --git a/lib/push/nextcloud_push_api.dart b/lib/push/nextcloud_push_api.dart index dd066d0..3514a11 100644 --- a/lib/push/nextcloud_push_api.dart +++ b/lib/push/nextcloud_push_api.dart @@ -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 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 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 unregister({String? authorizationHeader}) async { final response = await _client.delete( NextcloudOcs.uri(_path), - headers: NextcloudOcs.headers(), + headers: _headers(authorizationHeader, null), ); return response.statusCode == 202; } + + Map _headers( + String? authorizationHeader, + String? userAgent, + ) => { + ...NextcloudOcs.headers(), + 'Authorization': ?authorizationHeader, + 'User-Agent': ?userAgent, + }; } diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index 0b5d15f..bd9356c 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -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 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 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 finishReply({ + required String chatToken, + required bool sent, + Future Function(String chatToken)? cleanupChat, + Future> Function(String chatToken)? loadThread, + Future Function(String chatToken, List messages)? + renderSilent, + Future Function(String chatToken)? cancelNotification, + }) async { + cleanupChat ??= _cleanupChat; + loadThread ??= (token) => ChatThreadStore().messages(token); + renderSilent ??= (token, messages) => + PushRenderer().renderTalkThread(token, messages, alert: false); + cancelNotification ??= _cancelChatNotification; + + if (sent) { + await cleanupChat(chatToken); + return; + } + final messages = await loadThread(chatToken); + if (messages.isEmpty) { + // Nothing to re-render (history cleared meanwhile) — cancel instead so + // the spinner cannot survive. + await cancelNotification(chatToken); + return; + } + await renderSilent(chatToken, messages); } - static Future 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 sendReply(String chatToken, String message) => _ocsPost( + 'apps/spreed/api/v1/chat/$chatToken', + body: {'message': message}, + ); - static Future _ocsPost(String path, {Map? body}) async { + static Future markRead(String chatToken) => + _ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); + + static Future _ocsPost(String path, {Map? 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 _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 _cleanupChat(String chatToken) async { + try { + await ChatThreadStore().clearChat(chatToken); + } on Object catch (e) { + log('Push action thread cleanup failed: $e'); + } + await _cancelChatNotification(chatToken); + } + + static Future _cancelChatNotification(String chatToken) async { + try { + await NotificationService().flutterLocalNotificationsPlugin.cancel( + id: stableChatNotificationId(chatToken), + tag: chatNotificationTag(chatToken), + ); + } on Object catch (e) { + log('Push action cancel failed: $e'); + } + } + + static void _cleanupNidEntry(NotificationResponse response) { final nid = _nidFrom(response.payload); if (nid == null) return; - 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) => diff --git a/lib/push/push_avatar.dart b/lib/push/push_avatar.dart new file mode 100644 index 0000000..5821ac0 --- /dev/null +++ b/lib/push/push_avatar.dart @@ -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? 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 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> _inflight = {}; + + final Future 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 Function(String chatToken) _fetch; + final Duration fetchTimeout; + + PushAvatarStore({ + Future Function()? cacheDirProvider, + Future Function(String chatToken)? fetch, + this.fetchTimeout = _defaultFetchTimeout, + }) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir, + _fetch = fetch ?? _defaultFetch; + + static Future _defaultCacheDir() async { + final base = await getApplicationCacheDirectory(); + return Directory('${base.path}/$_subDirectory'); + } + + static Future _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(' 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 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 _fileFor(String chatToken) async { + final dir = await _cacheDirProvider(); + await dir.create(recursive: true); + return File('${dir.path}/${fileNameForToken(chatToken)}'); + } + + Future _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 _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 _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 _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 + } + } +} diff --git a/lib/push/push_keypair.dart b/lib/push/push_keypair.dart index 8f54b0d..df0197c 100644 --- a/lib/push/push_keypair.dart +++ b/lib/push/push_keypair.dart @@ -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 read({required String key}); - Future write({required String key, required String? value}); - Future delete({required String key}); -} - -class _DefaultStorage implements FlutterSecureStorageLike { - const _DefaultStorage(); - - @override - Future read({required String key}) => - pushSecureStorage.read(key: key); - - @override - Future write({required String key, required String? value}) => - pushSecureStorage.write(key: key, value: value); - - @override - Future delete({required String key}) => - pushSecureStorage.delete(key: key); -} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 87930c8..fd1e8b5 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -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 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 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 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 = [ @@ -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); } } diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 14f8d85..db428cd 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -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 register() async { + /// Ensures the second app password backing the Talk registration exists + /// (each `getapppassword` call with the real password mints a fresh one). + Future 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 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 _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 _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 _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 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 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 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 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(); } } diff --git a/lib/push/push_registration_store.dart b/lib/push/push_registration_store.dart index 13e2c30..4ed05f0 100644 --- a/lib/push/push_registration_store.dart +++ b/lib/push/push_registration_store.dart @@ -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 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 deviceIdentifier() => - pushSecureStorage.read(key: _deviceIdentifierKey); + Future deviceIdentifier(PushRegistrationType type) => + _storage.read(key: keyFor(_deviceIdentifierKey, type)); + /// Per-user server public key — identical for both registrations. Future serverPublicKeyPem() => - pushSecureStorage.read(key: _serverPublicKeyKey); + _storage.read(key: _serverPublicKeyKey); - Future registeredFcmToken() => - pushSecureStorage.read(key: _registeredTokenKey); + Future registeredFcmToken(PushRegistrationType type) => + _storage.read(key: keyFor(_registeredTokenKey, type)); - /// Proxy-server URL the current registration was made with. - Future registeredProxyServer() => - pushSecureStorage.read(key: _proxyServerKey); + /// Proxy-server URL the registration of [type] was made with. + Future registeredProxyServer(PushRegistrationType type) => + _storage.read(key: keyFor(_proxyServerKey, type)); - /// Nextcloud base URL the current registration was made against. - Future registeredNcBaseUrl() => - pushSecureStorage.read(key: _ncBaseUrlKey); + /// Nextcloud base URL the registration of [type] was made against. + Future 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 isRegistered() async => - (await registeredFcmToken())?.isNotEmpty ?? false; + /// True when a registration of [type] has been persisted. + Future 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 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 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 lastRegistrationError(PushRegistrationType type) async { + final raw = await _storage.read(key: keyFor(_lastAttemptErrorKey, type)); + return (raw == null || raw.isEmpty) ? null : raw; + } Future 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); } } diff --git a/lib/push/push_registration_type.dart b/lib/push/push_registration_type.dart new file mode 100644 index 0000000..af0e65f --- /dev/null +++ b/lib/push/push_registration_type.dart @@ -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', + }; diff --git a/lib/push/push_renderer.dart b/lib/push/push_renderer.dart index 29ae755..9aadb0a 100644 --- a/lib/push/push_renderer.dart +++ b/lib/push/push_renderer.dart @@ -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 _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 _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 renderTalkThread( + String chatToken, + List 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 = {}; + 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 _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 _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) { diff --git a/lib/push/push_secure_storage.dart b/lib/push/push_secure_storage.dart index bc2cb62..c157f21 100644 --- a/lib/push/push_secure_storage.dart +++ b/lib/push/push_secure_storage.dart @@ -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 read({required String key}); + Future write({required String key, required String? value}); + Future delete({required String key}); +} + +/// Default [FlutterSecureStorageLike] backed by [pushSecureStorage]. +class PushSecureStorage implements FlutterSecureStorageLike { + const PushSecureStorage(); + + @override + Future read({required String key}) => + pushSecureStorage.read(key: key); + + @override + Future write({required String key, required String? value}) => + pushSecureStorage.write(key: key, value: value); + + @override + Future delete({required String key}) => + pushSecureStorage.delete(key: key); +} diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart new file mode 100644 index 0000000..413a3c0 --- /dev/null +++ b/lib/push/push_status.dart @@ -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 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 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 _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 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, + ); +} diff --git a/lib/push/push_subject.dart b/lib/push/push_subject.dart index 536eebc..64d5053 100644 --- a/lib/push/push_subject.dart +++ b/lib/push/push_subject.dart @@ -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 diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index f14e8c5..d3c51e6 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -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 _loadState() async { - final registered = await const PushRegistrationStore().isRegistered(); - final denied = await PushRegistration.isOsPermissionDenied(); - if (!mounted) return; - setState(() { - _registered = registered; - _permissionDenied = denied; - }); - } - - Future _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, - ); - } -} diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart new file mode 100644 index 0000000..31e0552 --- /dev/null +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -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(); + final capabilities = context.read(); + 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 _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 _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 _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 _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, + ); + } + } +} diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 6116420..738ad48 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -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 { 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), ), ); } diff --git a/test/push/chat_thread_store_test.dart b/test/push/chat_thread_store_test.dart new file mode 100644 index 0000000..f211847 --- /dev/null +++ b/test/push/chat_thread_store_test.dart @@ -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 = []; + 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 = []; + 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')); + }); + }); +} diff --git a/test/push/push_avatar_test.dart b/test/push/push_avatar_test.dart new file mode 100644 index 0000000..5d77149 --- /dev/null +++ b/test/push/push_avatar_test.dart @@ -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 _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 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( + '', + 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(); + 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(' '.codeUnits), + ), + isTrue, + ); + expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse); + }); + }); +} diff --git a/test/push/push_dual_registration_test.dart b/test/push/push_dual_registration_test.dart new file mode 100644 index 0000000..acc416c --- /dev/null +++ b/test/push/push_dual_registration_test.dart @@ -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 values = {}; + + @override + Future read({required String key}) async => values[key]; + + @override + Future write({required String key, required String? value}) async { + if (value == null) { + values.remove(key); + } else { + values[key] = value; + } + } + + @override + Future 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); + }); + }); +} diff --git a/test/push/push_reply_action_test.dart b/test/push/push_reply_action_test.dart new file mode 100644 index 0000000..ae2f9e3 --- /dev/null +++ b/test/push/push_reply_action_test.dart @@ -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? 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); + }, + ); + }); +} diff --git a/test/push/push_status_test.dart b/test/push/push_status_test.dart new file mode 100644 index 0000000..c61d7a6 --- /dev/null +++ b/test/push/push_status_test.dart @@ -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 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, + ); + }); + }); +} diff --git a/test/push/talk_subject_parse_test.dart b/test/push/talk_subject_parse_test.dart new file mode 100644 index 0000000..61d2ea4 --- /dev/null +++ b/test/push/talk_subject_parse_test.dart @@ -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'); + }); + }); +}