implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching

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