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:
@@ -48,6 +48,17 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Fallback small icon + accent for anything FCM ever renders itself
|
||||
(e.g. plain notification messages). Launcher mipmaps are unusable
|
||||
as small icons: Android only draws their alpha silhouette, which
|
||||
shows up as a solid circle in the status bar. -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ic_stat_notification" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/notification_accent" />
|
||||
|
||||
<!-- Receiver classes live at the package root (NOT under .widgets) because
|
||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||
<receiver
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Status-bar small icon: monochrome Marianum M, ported from
|
||||
marianum_m_watermark.xml (same path + nested transforms). Android renders
|
||||
only the alpha channel of small icons, so the fill is hard white and never
|
||||
tinted at runtime. 24dp with the source's non-square viewport — the system
|
||||
fits it into the icon bounds.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="70.000168"
|
||||
android:viewportHeight="82.227348">
|
||||
|
||||
<group
|
||||
android:scaleX="0.26458333"
|
||||
android:scaleY="0.26458334"
|
||||
android:translateX="107.44411"
|
||||
android:translateY="-80.482198">
|
||||
<group
|
||||
android:translateX="-749.41293"
|
||||
android:translateY="290.52252">
|
||||
<group
|
||||
android:scaleX="0.13333333"
|
||||
android:scaleY="-0.13333333"
|
||||
android:translateX="0"
|
||||
android:translateY="632.14667">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
|
||||
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
|
||||
notification color and must stay in sync with the Dart constant. -->
|
||||
<color name="notification_accent">#993333</color>
|
||||
</resources>
|
||||
@@ -2,19 +2,24 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Revokes the current app password server-side via
|
||||
/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
|
||||
/// authenticate with the app password itself (it revokes the credential it was
|
||||
/// made with) and the result is ignored — logout clears local state regardless.
|
||||
/// Revokes an app password server-side via
|
||||
/// `DELETE /ocs/v2.php/core/apppassword`. The endpoint revokes the credential
|
||||
/// the request authenticates WITH — by default the shared OCS headers (general
|
||||
/// app password); pass [authorizationHeader] to revoke another one (the Talk
|
||||
/// app password). Best-effort: the result is ignored — logout clears local
|
||||
/// state regardless.
|
||||
class DeleteAppPassword {
|
||||
final http.Client _client;
|
||||
|
||||
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
Future<void> run() async {
|
||||
Future<void> run({String? authorizationHeader}) async {
|
||||
await _client.delete(
|
||||
NextcloudOcs.uri('core/apppassword'),
|
||||
headers: NextcloudOcs.headers(),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
'Authorization': ?authorizationHeader,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class PushDeviceRegister {
|
||||
required String userPublicKey,
|
||||
required String pushToken,
|
||||
required String platform,
|
||||
required String registrationType,
|
||||
String? appVersion,
|
||||
}) async {
|
||||
try {
|
||||
@@ -30,6 +31,9 @@ class PushDeviceRegister {
|
||||
'userPublicKey': userPublicKey,
|
||||
'pushToken': pushToken,
|
||||
'platform': platform,
|
||||
// 'general' | 'talk' — the backend derives the NC hash comparison
|
||||
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
||||
'registrationType': registrationType,
|
||||
'appVersion': ?appVersion,
|
||||
},
|
||||
);
|
||||
|
||||
+5
-5
@@ -96,7 +96,7 @@ Future<void> main() async {
|
||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage);
|
||||
FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
|
||||
|
||||
// Wire up the home-screen widget bridge before runApp so any widget render
|
||||
// triggered during startup hits initialised native storage.
|
||||
@@ -375,13 +375,13 @@ class _MainState extends State<Main> {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||
if (_appMounted)
|
||||
const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
onComplete: () => setState(
|
||||
() => _showPostLoginSplash = false,
|
||||
),
|
||||
onComplete: () =>
|
||||
setState(() => _showPostLoginSplash = false),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -11,9 +11,13 @@ import '../push/push_secure_storage.dart';
|
||||
class AccountData {
|
||||
static const _usernameField = 'username';
|
||||
static const _passwordField = 'password';
|
||||
// App password lives in the push-shared (group-scoped) keystore so the iOS
|
||||
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||
// Notification Service Extension can authenticate Nextcloud calls too.
|
||||
// The talk password authenticates the second (apptype=talk) push
|
||||
// registration — Nextcloud binds each push subscription to its session
|
||||
// token, so two registrations need two app passwords.
|
||||
static const _appPasswordField = 'nextcloud_app_password';
|
||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
||||
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||
|
||||
@@ -29,6 +33,7 @@ class AccountData {
|
||||
String? _username;
|
||||
String? _password;
|
||||
String? _appPassword;
|
||||
String? _appPasswordTalk;
|
||||
|
||||
String getUsername() {
|
||||
if (_username == null) throw Exception('Username not initialized');
|
||||
@@ -65,9 +70,11 @@ class AccountData {
|
||||
_username = null;
|
||||
_password = null;
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = null;
|
||||
await _secureStorage.delete(key: _usernameField);
|
||||
await _secureStorage.delete(key: _passwordField);
|
||||
await _clearAppPasswordStorage();
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
|
||||
/// Persists a freshly minted Nextcloud app password. After this every
|
||||
@@ -90,6 +97,28 @@ class AccountData {
|
||||
|
||||
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
||||
|
||||
/// Persists the app password backing the Talk push registration.
|
||||
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||
_appPasswordTalk = appPassword;
|
||||
try {
|
||||
await pushSecureStorage.write(
|
||||
key: _appPasswordTalkField,
|
||||
value: appPassword,
|
||||
);
|
||||
} on Object {
|
||||
// Group-scoped keystore may be unavailable — in-memory still works for
|
||||
// this session, matching setAppPassword.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAppPasswordTalk() async {
|
||||
_appPasswordTalk = null;
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
|
||||
bool hasAppPasswordTalk() =>
|
||||
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
|
||||
|
||||
Future<void> _clearAppPasswordStorage() async {
|
||||
try {
|
||||
await pushSecureStorage.delete(key: _appPasswordField);
|
||||
@@ -98,14 +127,26 @@ class AccountData {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAppPasswordTalkStorage() async {
|
||||
try {
|
||||
await pushSecureStorage.delete(key: _appPasswordTalkField);
|
||||
} on Object {
|
||||
// ignore — nothing stored or keystore unavailable
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateAndLoad() async {
|
||||
await _migrateFromLegacyStorage();
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
_appPasswordTalk = await pushSecureStorage.read(
|
||||
key: _appPasswordTalkField,
|
||||
);
|
||||
} on Object {
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = null;
|
||||
}
|
||||
if (!_populated.isCompleted) _populated.complete();
|
||||
}
|
||||
@@ -149,6 +190,22 @@ class AccountData {
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||
}
|
||||
|
||||
/// Basic-auth header using the Talk app password — authenticates the
|
||||
/// apptype=talk push registration (and its unregister). Throws when the
|
||||
/// talk password has not been minted yet; callers treat that as a failed
|
||||
/// talk registration and retry on the next start.
|
||||
String getTalkBasicAuthHeader() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
if (!hasAppPasswordTalk()) {
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
|
||||
}
|
||||
|
||||
/// Basic-auth header that always uses the real password. Needed exactly once,
|
||||
/// to mint the app password via `core/getapppassword` (an app password cannot
|
||||
/// mint another).
|
||||
|
||||
@@ -15,8 +15,11 @@ class NotificationService {
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> initializeNotifications() async {
|
||||
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
||||
// small icons because Android renders only their alpha silhouette (a
|
||||
// solid circle in the status bar).
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
'@drawable/ic_stat_notification',
|
||||
);
|
||||
|
||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../push/chat_thread_store.dart';
|
||||
import '../routing/app_routes.dart';
|
||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'notification_service.dart';
|
||||
@@ -25,9 +26,9 @@ class NotificationTasks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
|
||||
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
|
||||
static String chatTag(String chatToken) => 'talk_$chatToken';
|
||||
/// Per-chat tag scheme — canonical definition lives beside the stacked
|
||||
/// notification id in chat_thread_store.dart.
|
||||
static String chatTag(String chatToken) => chatNotificationTag(chatToken);
|
||||
|
||||
/// Removes tray notifications belonging to [chatToken]. Eraser handles
|
||||
/// iOS (where the plugin's `getActiveNotifications` returns null ids
|
||||
@@ -54,6 +55,13 @@ class NotificationTasks {
|
||||
} on Object catch (e) {
|
||||
log('Active-notification sweep failed: $e');
|
||||
}
|
||||
// Drop the stacked-notification history too — otherwise the next push
|
||||
// would resurrect all already-read messages in the new notification.
|
||||
try {
|
||||
await ChatThreadStore().clearChat(chatToken);
|
||||
} on Object catch (e) {
|
||||
log('Chat thread cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
}
|
||||
@@ -39,17 +39,24 @@ class NextcloudPushApi {
|
||||
|
||||
NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Registers (or refreshes) this device. [devicePublicKeyPem] must be the
|
||||
/// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base
|
||||
/// URL (with trailing slash).
|
||||
/// Registers (or refreshes) a device subscription. [devicePublicKeyPem] must
|
||||
/// be the 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy
|
||||
/// base URL (with trailing slash).
|
||||
///
|
||||
/// [authorizationHeader] overrides the Basic auth (e.g. the Talk app
|
||||
/// password — NC binds the subscription to the authenticated session token).
|
||||
/// [userAgent] overrides the UA; a Talk-pattern UA makes NC classify the
|
||||
/// subscription as apptype `talk` and route Talk pushes to it.
|
||||
Future<NextcloudPushRegistration> register({
|
||||
required String pushTokenHash,
|
||||
required String devicePublicKeyPem,
|
||||
required String proxyServer,
|
||||
String? authorizationHeader,
|
||||
String? userAgent,
|
||||
}) async {
|
||||
final response = await _client.post(
|
||||
NextcloudOcs.uri(_path),
|
||||
headers: NextcloudOcs.headers(),
|
||||
headers: _headers(authorizationHeader, userAgent),
|
||||
body: {
|
||||
'pushTokenHash': pushTokenHash,
|
||||
'devicePublicKey': devicePublicKeyPem,
|
||||
@@ -76,13 +83,24 @@ class NextcloudPushApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// Unregisters this device from Nextcloud push. Returns true when the server
|
||||
/// responded 202, meaning the proxy subscription should also be removed.
|
||||
Future<bool> unregister() async {
|
||||
/// Unregisters one device subscription — the DELETE is per session token, so
|
||||
/// each registration is removed with its own app password via
|
||||
/// [authorizationHeader]. Returns true when the server responded 202,
|
||||
/// meaning the proxy subscription should also be removed.
|
||||
Future<bool> unregister({String? authorizationHeader}) async {
|
||||
final response = await _client.delete(
|
||||
NextcloudOcs.uri(_path),
|
||||
headers: NextcloudOcs.headers(),
|
||||
headers: _headers(authorizationHeader, null),
|
||||
);
|
||||
return response.statusCode == 202;
|
||||
}
|
||||
|
||||
Map<String, String> _headers(
|
||||
String? authorizationHeader,
|
||||
String? userAgent,
|
||||
) => {
|
||||
...NextcloudOcs.headers(),
|
||||
'Authorization': ?authorizationHeader,
|
||||
'User-Agent': ?userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
+113
-21
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../api/marianumcloud/nextcloud_ocs.dart';
|
||||
import '../model/account_data.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_renderer.dart';
|
||||
|
||||
/// Notification action identifiers shared between the renderer (which attaches
|
||||
/// the actions) and the response handlers (which dispatch them).
|
||||
@@ -24,60 +29,147 @@ class PushActions {
|
||||
static Future<void> handleBackgroundResponse(
|
||||
NotificationResponse response,
|
||||
) async {
|
||||
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
|
||||
// isolate, plugins are not registered automatically there. Without this,
|
||||
// AccountData's secure-storage/prefs reads throw or never complete → no
|
||||
// auth header, the Talk POST never happens and the RemoteInput spinner
|
||||
// runs forever.
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
|
||||
final chatToken = _chatTokenFrom(response.payload);
|
||||
if (chatToken == null) return;
|
||||
switch (response.actionId) {
|
||||
case kTalkReplyActionId:
|
||||
final text = response.input?.trim();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
await sendReply(chatToken, text);
|
||||
final sent = text != null && text.isNotEmpty
|
||||
? await sendReply(chatToken, text)
|
||||
: false;
|
||||
if (sent) {
|
||||
// The user has evidently seen the chat — set the read marker like
|
||||
// the mark-read action does.
|
||||
await markRead(chatToken);
|
||||
}
|
||||
await markRead(chatToken);
|
||||
await finishReply(chatToken: chatToken, sent: sent);
|
||||
_cleanupNidEntry(response);
|
||||
break;
|
||||
case kTalkMarkReadActionId:
|
||||
await markRead(chatToken);
|
||||
await _cancelForToken(response);
|
||||
await _cleanupChat(chatToken);
|
||||
_cleanupNidEntry(response);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendReply(String chatToken, String message) async {
|
||||
await _ocsPost(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
body: {'message': message},
|
||||
);
|
||||
/// Ends the reply interaction: Android keeps the RemoteInput spinner alive
|
||||
/// until the notification is UPDATED or REMOVED. Success removes the whole
|
||||
/// notification (history cleared + cancel — the chat is read); failure
|
||||
/// re-renders the unchanged thread silently so the spinner stops (the error
|
||||
/// itself is only loggable on notification level). Injectable seams so
|
||||
/// tests can observe the flow without platform channels.
|
||||
static Future<void> finishReply({
|
||||
required String chatToken,
|
||||
required bool sent,
|
||||
Future<void> Function(String chatToken)? cleanupChat,
|
||||
Future<List<ThreadMessage>> Function(String chatToken)? loadThread,
|
||||
Future<void> Function(String chatToken, List<ThreadMessage> messages)?
|
||||
renderSilent,
|
||||
Future<void> Function(String chatToken)? cancelNotification,
|
||||
}) async {
|
||||
cleanupChat ??= _cleanupChat;
|
||||
loadThread ??= (token) => ChatThreadStore().messages(token);
|
||||
renderSilent ??= (token, messages) =>
|
||||
PushRenderer().renderTalkThread(token, messages, alert: false);
|
||||
cancelNotification ??= _cancelChatNotification;
|
||||
|
||||
if (sent) {
|
||||
await cleanupChat(chatToken);
|
||||
return;
|
||||
}
|
||||
final messages = await loadThread(chatToken);
|
||||
if (messages.isEmpty) {
|
||||
// Nothing to re-render (history cleared meanwhile) — cancel instead so
|
||||
// the spinner cannot survive.
|
||||
await cancelNotification(chatToken);
|
||||
return;
|
||||
}
|
||||
await renderSilent(chatToken, messages);
|
||||
}
|
||||
|
||||
static Future<void> markRead(String chatToken) async {
|
||||
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||
}
|
||||
/// Sends the inline reply. Success = any 2xx (the Talk chat POST answers
|
||||
/// 201 Created). Path/body match the app's working send path
|
||||
/// (`SendMessage`: `v1/chat/{token}`, form field `message`).
|
||||
static Future<bool> sendReply(String chatToken, String message) => _ocsPost(
|
||||
'apps/spreed/api/v1/chat/$chatToken',
|
||||
body: {'message': message},
|
||||
);
|
||||
|
||||
static Future<void> _ocsPost(String path, {Map<String, String>? body}) async {
|
||||
static Future<bool> markRead(String chatToken) =>
|
||||
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||
|
||||
static Future<bool> _ocsPost(String path, {Map<String, String>? body}) async {
|
||||
try {
|
||||
await AccountData().waitForPopulation();
|
||||
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
||||
// action instead of leaving the notification spinner running forever.
|
||||
await AccountData().waitForPopulation().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
final response = await http.post(
|
||||
NextcloudOcs.uri(path),
|
||||
headers: NextcloudOcs.headers(),
|
||||
body: body,
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
final ok = response.statusCode >= 200 && response.statusCode < 300;
|
||||
if (ok) {
|
||||
log('Push action $path -> HTTP ${response.statusCode}');
|
||||
} else {
|
||||
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
log(
|
||||
'Push action $path -> HTTP ${response.statusCode} '
|
||||
'body=${preview.length > 300 ? '${preview.substring(0, 300)}…' : preview}',
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
} on Object catch (e) {
|
||||
log('Push action $path failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _cancelForToken(NotificationResponse response) async {
|
||||
/// Mark-read cleanup: drop the stacked history (so the next push starts
|
||||
/// fresh) and cancel the chat's notification.
|
||||
static Future<void> _cleanupChat(String chatToken) async {
|
||||
try {
|
||||
await ChatThreadStore().clearChat(chatToken);
|
||||
} on Object catch (e) {
|
||||
log('Push action thread cleanup failed: $e');
|
||||
}
|
||||
await _cancelChatNotification(chatToken);
|
||||
}
|
||||
|
||||
static Future<void> _cancelChatNotification(String chatToken) async {
|
||||
try {
|
||||
await NotificationService().flutterLocalNotificationsPlugin.cancel(
|
||||
id: stableChatNotificationId(chatToken),
|
||||
tag: chatNotificationTag(chatToken),
|
||||
);
|
||||
} on Object catch (e) {
|
||||
log('Push action cancel failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static void _cleanupNidEntry(NotificationResponse response) {
|
||||
final nid = _nidFrom(response.payload);
|
||||
if (nid == null) return;
|
||||
try {
|
||||
await NidStore().delete(nid);
|
||||
} on Object catch (e) {
|
||||
log('Push action nid cleanup failed: $e');
|
||||
}
|
||||
unawaited(
|
||||
NidStore()
|
||||
.delete(nid)
|
||||
.then(
|
||||
(_) {},
|
||||
onError: (Object e) => log('Push action nid cleanup failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _chatTokenFrom(String? payload) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ class PushKeypair {
|
||||
final FlutterSecureStorageLike _storage;
|
||||
|
||||
const PushKeypair({FlutterSecureStorageLike? storage})
|
||||
: _storage = storage ?? const _DefaultStorage();
|
||||
: _storage = storage ?? const PushSecureStorage();
|
||||
|
||||
/// Returns the stored keypair PEMs, generating and persisting a fresh keypair
|
||||
/// on first use. Generation is offloaded to an isolate because RSA-2048 key
|
||||
@@ -79,27 +79,3 @@ class PushKeypair {
|
||||
return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal storage contract so tests can inject an in-memory fake instead of
|
||||
/// touching the platform keystore.
|
||||
abstract class FlutterSecureStorageLike {
|
||||
Future<String?> read({required String key});
|
||||
Future<void> write({required String key, required String? value});
|
||||
Future<void> delete({required String key});
|
||||
}
|
||||
|
||||
class _DefaultStorage implements FlutterSecureStorageLike {
|
||||
const _DefaultStorage();
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) =>
|
||||
pushSecureStorage.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) =>
|
||||
pushSecureStorage.write(key: key, value: value);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) =>
|
||||
pushSecureStorage.delete(key: key);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:crypton/crypton.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
import '../notification/notification_service.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_decryptor.dart';
|
||||
import 'push_keypair.dart';
|
||||
@@ -34,6 +35,17 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
||||
return PushKind.unknown;
|
||||
}
|
||||
|
||||
/// FCM background isolate entry point. Must be a TOP-LEVEL function with the
|
||||
/// entry-point pragma: AOT builds cannot invoke static class members from
|
||||
/// native code unless the class itself is annotated too (DartVM error
|
||||
/// "must be annotated"), so a plain function is the reliable form.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
await PushMessageHandler().handle(message);
|
||||
}
|
||||
|
||||
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
|
||||
/// the matching tray notification via [NidStore]. Works both in the FCM
|
||||
/// background isolate and the foreground.
|
||||
@@ -42,25 +54,19 @@ class PushMessageHandler {
|
||||
final PushRegistrationStore _registrationStore;
|
||||
final PushRenderer _renderer;
|
||||
final NidStore _nidStore;
|
||||
final ChatThreadStore _threadStore;
|
||||
|
||||
PushMessageHandler({
|
||||
PushKeypair? keypair,
|
||||
PushRegistrationStore? registrationStore,
|
||||
PushRenderer? renderer,
|
||||
NidStore? nidStore,
|
||||
ChatThreadStore? threadStore,
|
||||
}) : _keypair = keypair ?? const PushKeypair(),
|
||||
_registrationStore = registrationStore ?? const PushRegistrationStore(),
|
||||
_renderer = renderer ?? PushRenderer(),
|
||||
_nidStore = nidStore ?? NidStore();
|
||||
|
||||
/// Background isolate entry point registered with
|
||||
/// `FirebaseMessaging.onBackgroundMessage`.
|
||||
@pragma('vm:entry-point')
|
||||
static Future<void> onBackgroundMessage(RemoteMessage message) async {
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
await PushMessageHandler().handle(message);
|
||||
}
|
||||
_nidStore = nidStore ?? NidStore(),
|
||||
_threadStore = threadStore ?? ChatThreadStore();
|
||||
|
||||
/// Processes [message]. In the foreground, pass [foreground] true and
|
||||
/// [openChatToken] so a message for the currently open chat is suppressed
|
||||
@@ -157,6 +163,7 @@ class PushMessageHandler {
|
||||
await _cancel(entry);
|
||||
}
|
||||
await _nidStore.clear();
|
||||
await _threadStore.clearAll();
|
||||
return;
|
||||
}
|
||||
final nids = <int>[
|
||||
@@ -165,7 +172,20 @@ class PushMessageHandler {
|
||||
];
|
||||
for (final nid in nids) {
|
||||
final entry = await _nidStore.get(nid);
|
||||
if (entry != null) await _cancel(entry);
|
||||
final chatToken = entry?.chatToken;
|
||||
if (chatToken != null && chatToken.isNotEmpty) {
|
||||
// Stacked chat notification: drop only this message from the thread.
|
||||
// Remaining messages re-render WITHOUT alerting again; the last one
|
||||
// going away cancels the whole card.
|
||||
final remaining = await _threadStore.removeNid(chatToken, nid);
|
||||
if (remaining.isEmpty) {
|
||||
await _cancel(entry!);
|
||||
} else {
|
||||
await _renderer.renderTalkThread(chatToken, remaining, alert: false);
|
||||
}
|
||||
} else if (entry != null) {
|
||||
await _cancel(entry);
|
||||
}
|
||||
await _nidStore.delete(nid);
|
||||
}
|
||||
}
|
||||
|
||||
+185
-48
@@ -15,11 +15,26 @@ import '../model/endpoint_data.dart';
|
||||
import 'nextcloud_push_api.dart';
|
||||
import 'push_keypair.dart';
|
||||
import 'push_registration_store.dart';
|
||||
import 'push_registration_type.dart';
|
||||
|
||||
/// Orchestrates the full push-v2 registration lifecycle:
|
||||
/// Nextcloud device registration → MarianumConnect proxy registration, plus
|
||||
/// unregister and token-refresh handling.
|
||||
///
|
||||
/// Every device maintains TWO Nextcloud registrations (see
|
||||
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
|
||||
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
|
||||
/// subscriptions once the user has any (e.g. the official Talk app).
|
||||
class PushRegistration {
|
||||
/// User agents matching nextcloud/server `IRequest` Talk patterns
|
||||
/// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
|
||||
/// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
|
||||
/// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
|
||||
/// NC stores it with apptype `talk`.
|
||||
static const String talkUserAgentAndroid =
|
||||
'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
|
||||
static const String talkUserAgentIos =
|
||||
'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
|
||||
final PushKeypair _keypair;
|
||||
final PushRegistrationStore _store;
|
||||
final NextcloudPushApi _nextcloud;
|
||||
@@ -34,13 +49,18 @@ class PushRegistration {
|
||||
|
||||
String get _platform => Platform.isIOS ? 'ios' : 'android';
|
||||
|
||||
String get _talkUserAgent =>
|
||||
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
|
||||
|
||||
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
|
||||
/// so a beta/dev build registers against the matching proxy automatically.
|
||||
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
|
||||
/// Public so the push status view can compare it against the stored binding.
|
||||
String get currentProxyServer =>
|
||||
'${MarianumConnectEndpoint.current()}/push-proxy/';
|
||||
|
||||
/// Nextcloud origin the registration targets (full origin, no trailing
|
||||
/// slash) — persisted alongside the registration to detect endpoint changes.
|
||||
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
|
||||
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
|
||||
|
||||
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
|
||||
/// registration binds to it, so it must be obtained before registering.
|
||||
@@ -54,28 +74,94 @@ class PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers this device end-to-end. No-op-safe: transport failures are
|
||||
/// logged and swallowed so callers can fire-and-forget.
|
||||
Future<void> register() async {
|
||||
/// Ensures the second app password backing the Talk registration exists
|
||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||
Future<void> ensureTalkAppPassword() async {
|
||||
if (AccountData().hasAppPasswordTalk()) return;
|
||||
try {
|
||||
final fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
if (fcmToken == null || fcmToken.isEmpty) {
|
||||
log('Push: no FCM token, skipping registration');
|
||||
return;
|
||||
}
|
||||
await ensureAppPassword();
|
||||
await _persistNativeAuthContext();
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPasswordTalk(appPassword);
|
||||
} on Object catch (e) {
|
||||
log('Push: could not obtain talk app password (non-blocking): $e');
|
||||
}
|
||||
}
|
||||
|
||||
final proxyServer = _proxyServer;
|
||||
final ncBaseUrl = _ncBaseUrl;
|
||||
final pems = await _keypair.ensure();
|
||||
/// Registers this device end-to-end: both Nextcloud registrations (general,
|
||||
/// then talk) each followed by their MarianumConnect proxy registration.
|
||||
/// Partial results are persisted per type — one failing registration never
|
||||
/// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
|
||||
/// transport failures are logged and swallowed so callers can
|
||||
/// fire-and-forget (and simply ignore the result).
|
||||
Future<bool> register() async {
|
||||
final String? fcmToken;
|
||||
try {
|
||||
fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
} on Object catch (e) {
|
||||
log('Push: could not obtain FCM token: $e');
|
||||
await _recordAttempts('Kein FCM-Token verfügbar');
|
||||
return false;
|
||||
}
|
||||
if (fcmToken == null || fcmToken.isEmpty) {
|
||||
log('Push: no FCM token, skipping registration');
|
||||
await _recordAttempts('Kein FCM-Token verfügbar');
|
||||
return false;
|
||||
}
|
||||
|
||||
await ensureAppPassword();
|
||||
await ensureTalkAppPassword();
|
||||
await _persistNativeAuthContext();
|
||||
|
||||
final PushKeypairPems pems;
|
||||
try {
|
||||
pems = await _keypair.ensure();
|
||||
} on Object catch (e) {
|
||||
log('Push: keypair unavailable: $e');
|
||||
await _recordAttempts(_shortError(e));
|
||||
return false;
|
||||
}
|
||||
|
||||
String? appVersion;
|
||||
try {
|
||||
appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
} on Object {
|
||||
appVersion = null;
|
||||
}
|
||||
|
||||
var allOk = true;
|
||||
for (final type in PushRegistrationType.values) {
|
||||
final ok = await _registerType(
|
||||
type: type,
|
||||
fcmToken: fcmToken,
|
||||
pems: pems,
|
||||
appVersion: appVersion,
|
||||
);
|
||||
allOk = allOk && ok;
|
||||
}
|
||||
return allOk;
|
||||
}
|
||||
|
||||
Future<bool> _registerType({
|
||||
required PushRegistrationType type,
|
||||
required String fcmToken,
|
||||
required PushKeypairPems pems,
|
||||
required String? appVersion,
|
||||
}) async {
|
||||
try {
|
||||
final proxyServer = currentProxyServer;
|
||||
final ncBaseUrl = currentNcBaseUrl;
|
||||
final isTalk = type == PushRegistrationType.talk;
|
||||
final registration = await _nextcloud.register(
|
||||
pushTokenHash: generatePushTokenHash(fcmToken),
|
||||
pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
|
||||
devicePublicKeyPem: pems.publicKeyPem,
|
||||
proxyServer: proxyServer,
|
||||
authorizationHeader: isTalk
|
||||
? AccountData().getTalkBasicAuthHeader()
|
||||
: null,
|
||||
userAgent: isTalk ? _talkUserAgent : null,
|
||||
);
|
||||
|
||||
await _store.save(
|
||||
type: type,
|
||||
deviceIdentifier: registration.deviceIdentifier,
|
||||
serverPublicKeyPem: registration.publicKey,
|
||||
fcmToken: fcmToken,
|
||||
@@ -83,27 +169,53 @@ class PushRegistration {
|
||||
ncBaseUrl: ncBaseUrl,
|
||||
);
|
||||
|
||||
String? appVersion;
|
||||
try {
|
||||
appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
} on Object {
|
||||
appVersion = null;
|
||||
}
|
||||
|
||||
await PushDeviceRegister().run(
|
||||
deviceIdentifier: registration.deviceIdentifier,
|
||||
deviceIdentifierSignature: registration.signature,
|
||||
userPublicKey: registration.publicKey,
|
||||
pushToken: fcmToken,
|
||||
platform: _platform,
|
||||
registrationType: type.wireName,
|
||||
appVersion: appVersion,
|
||||
);
|
||||
log('Push: registered (created=${registration.created})');
|
||||
log(
|
||||
'Push: registered ${type.wireName} '
|
||||
'(created=${registration.created})',
|
||||
);
|
||||
await _recordAttempt(type, null);
|
||||
return true;
|
||||
} on Object catch (e) {
|
||||
log('Push: registration failed: $e');
|
||||
log('Push: ${type.wireName} registration failed: $e');
|
||||
await _recordAttempt(type, _shortError(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the attempt outcome for the push status view. Storage failures
|
||||
/// must never mask the actual registration result.
|
||||
Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
|
||||
try {
|
||||
await _store.saveLastRegistrationAttempt(
|
||||
type: type,
|
||||
at: DateTime.now(),
|
||||
error: error,
|
||||
);
|
||||
} on Object {
|
||||
// ignore — the status view simply shows the previous attempt
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _recordAttempts(String? error) async {
|
||||
for (final type in PushRegistrationType.values) {
|
||||
await _recordAttempt(type, error);
|
||||
}
|
||||
}
|
||||
|
||||
static String _shortError(Object e) {
|
||||
final text = e.toString();
|
||||
return text.length > 300 ? '${text.substring(0, 300)}…' : text;
|
||||
}
|
||||
|
||||
/// Writes the username and Nextcloud base URL into the shared keychain so the
|
||||
/// native iOS Talk action handler can authenticate OCS calls without the
|
||||
/// Flutter engine. Best-effort — a failure here must not abort registration.
|
||||
@@ -119,19 +231,28 @@ class PushRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
|
||||
/// Removes both subscriptions from Nextcloud and the proxy. Best-effort —
|
||||
/// each step is independent so one failure never blocks the rest.
|
||||
Future<void> unregister() async {
|
||||
final deviceIdentifier = await _store.deviceIdentifier();
|
||||
try {
|
||||
await _nextcloud.unregister();
|
||||
} on Object catch (e) {
|
||||
log('Push: NC unregister failed: $e');
|
||||
}
|
||||
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
|
||||
for (final type in PushRegistrationType.values) {
|
||||
final deviceIdentifier = await _store.deviceIdentifier(type);
|
||||
try {
|
||||
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
|
||||
// The DELETE removes the subscription bound to the authenticating
|
||||
// session token — each registration with its own app password.
|
||||
await _nextcloud.unregister(
|
||||
authorizationHeader: type == PushRegistrationType.talk
|
||||
? AccountData().getTalkBasicAuthHeader()
|
||||
: null,
|
||||
);
|
||||
} on Object catch (e) {
|
||||
log('Push: proxy unregister failed: $e');
|
||||
log('Push: NC unregister (${type.wireName}) failed: $e');
|
||||
}
|
||||
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
|
||||
try {
|
||||
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
|
||||
} on Object catch (e) {
|
||||
log('Push: proxy unregister (${type.wireName}) failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
await _store.clear();
|
||||
@@ -146,19 +267,24 @@ class PushRegistration {
|
||||
required String current,
|
||||
}) => registered != null && registered.isNotEmpty && registered != current;
|
||||
|
||||
/// True when an existing registration was made against a different
|
||||
/// True when any existing registration was made against a different
|
||||
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
|
||||
/// configured (dev-tools endpoint switch, live/beta/custom).
|
||||
Future<bool> needsEndpointReRegistration() async {
|
||||
if (!await _store.isRegistered()) return false;
|
||||
return endpointChanged(
|
||||
registered: await _store.registeredProxyServer(),
|
||||
current: _proxyServer,
|
||||
) ||
|
||||
endpointChanged(
|
||||
registered: await _store.registeredNcBaseUrl(),
|
||||
current: _ncBaseUrl,
|
||||
);
|
||||
for (final type in PushRegistrationType.values) {
|
||||
if (!await _store.isRegistered(type)) continue;
|
||||
final changed =
|
||||
endpointChanged(
|
||||
registered: await _store.registeredProxyServer(type),
|
||||
current: currentProxyServer,
|
||||
) ||
|
||||
endpointChanged(
|
||||
registered: await _store.registeredNcBaseUrl(type),
|
||||
current: currentNcBaseUrl,
|
||||
);
|
||||
if (changed) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Re-registers when the active endpoints diverge from the registered ones.
|
||||
@@ -236,9 +362,10 @@ class PushRegistration {
|
||||
/// first, then the proxy) with the new token.
|
||||
Future<void> onTokenRefresh() => register();
|
||||
|
||||
/// Full teardown for logout: unregister push, revoke the app password, then
|
||||
/// clear it locally. Ordered so the proxy stops pushing before credentials
|
||||
/// are gone.
|
||||
/// Full teardown for logout: unregister push, revoke BOTH app passwords
|
||||
/// (each authenticated with itself — the endpoint revokes the credential it
|
||||
/// is called with), then clear them locally. Ordered so the proxy stops
|
||||
/// pushing before credentials are gone.
|
||||
Future<void> logoutCleanup() async {
|
||||
await unregister();
|
||||
try {
|
||||
@@ -246,6 +373,16 @@ class PushRegistration {
|
||||
} on Object catch (e) {
|
||||
log('Push: delete app password failed: $e');
|
||||
}
|
||||
try {
|
||||
if (AccountData().hasAppPasswordTalk()) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
|
||||
);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
log('Push: delete talk app password failed: $e');
|
||||
}
|
||||
await AccountData().clearAppPassword();
|
||||
await AccountData().clearAppPasswordTalk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import 'push_registration_type.dart';
|
||||
import 'push_secure_storage.dart';
|
||||
|
||||
/// Persists the bookkeeping produced by a successful push registration:
|
||||
/// the Nextcloud device identifier, the per-user server public key (needed to
|
||||
/// verify incoming push signatures), the FCM token the registration was made
|
||||
/// with (so a token refresh can be detected) and the endpoints it was bound to
|
||||
/// (so an endpoint switch in the dev tools can be detected).
|
||||
/// Persists the bookkeeping produced by successful push registrations —
|
||||
/// per [PushRegistrationType]: the Nextcloud device identifier, the FCM token
|
||||
/// the registration was made with (so a token refresh can be detected), the
|
||||
/// endpoints it was bound to (so an endpoint switch in the dev tools can be
|
||||
/// detected) and the last attempt outcome. The server public key and the
|
||||
/// device keypair are shared between both registrations.
|
||||
///
|
||||
/// Key layout: the `general` type uses the pre-dual key names unchanged, so
|
||||
/// existing installs are implicitly migrated — their stored registration IS
|
||||
/// the general one; the missing talk registration is added by the next
|
||||
/// register-on-start self-heal.
|
||||
class PushRegistrationStore {
|
||||
static const _deviceIdentifierKey = 'push_device_identifier';
|
||||
static const _serverPublicKeyKey = 'push_server_public_key_pem';
|
||||
static const _registeredTokenKey = 'push_registered_fcm_token';
|
||||
static const _proxyServerKey = 'push_registered_proxy_server';
|
||||
static const _ncBaseUrlKey = 'push_registered_nc_base_url';
|
||||
static const _lastAttemptAtKey = 'push_last_registration_at';
|
||||
static const _lastAttemptErrorKey = 'push_last_registration_error';
|
||||
// Native-only context: the iOS AppDelegate answers Talk notification actions
|
||||
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
|
||||
// not guaranteed to run. It needs the Nextcloud username and base URL from the
|
||||
@@ -19,26 +28,46 @@ class PushRegistrationStore {
|
||||
static const _usernameKey = 'nextcloud_username';
|
||||
static const _baseUrlKey = 'nextcloud_base_url';
|
||||
|
||||
const PushRegistrationStore();
|
||||
static const _perTypeKeys = [
|
||||
_deviceIdentifierKey,
|
||||
_registeredTokenKey,
|
||||
_proxyServerKey,
|
||||
_ncBaseUrlKey,
|
||||
_lastAttemptAtKey,
|
||||
_lastAttemptErrorKey,
|
||||
];
|
||||
|
||||
final FlutterSecureStorageLike _storage;
|
||||
|
||||
const PushRegistrationStore([this._storage = const PushSecureStorage()]);
|
||||
|
||||
/// Type-specific key: general keeps the legacy names (implicit migration of
|
||||
/// pre-dual installs), talk appends a suffix.
|
||||
static String keyFor(String baseKey, PushRegistrationType type) =>
|
||||
type == PushRegistrationType.general ? baseKey : '${baseKey}_talk';
|
||||
|
||||
Future<void> save({
|
||||
required PushRegistrationType type,
|
||||
required String deviceIdentifier,
|
||||
required String serverPublicKeyPem,
|
||||
required String fcmToken,
|
||||
required String proxyServer,
|
||||
required String ncBaseUrl,
|
||||
}) async {
|
||||
await pushSecureStorage.write(
|
||||
key: _deviceIdentifierKey,
|
||||
await _storage.write(
|
||||
key: keyFor(_deviceIdentifierKey, type),
|
||||
value: deviceIdentifier,
|
||||
);
|
||||
await pushSecureStorage.write(
|
||||
key: _serverPublicKeyKey,
|
||||
value: serverPublicKeyPem,
|
||||
await _storage.write(key: _serverPublicKeyKey, value: serverPublicKeyPem);
|
||||
await _storage.write(
|
||||
key: keyFor(_registeredTokenKey, type),
|
||||
value: fcmToken,
|
||||
);
|
||||
await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken);
|
||||
await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer);
|
||||
await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl);
|
||||
await _storage.write(
|
||||
key: keyFor(_proxyServerKey, type),
|
||||
value: proxyServer,
|
||||
);
|
||||
await _storage.write(key: keyFor(_ncBaseUrlKey, type), value: ncBaseUrl);
|
||||
}
|
||||
|
||||
/// Persists the username and Nextcloud base URL group-scoped so the native
|
||||
@@ -49,39 +78,73 @@ class PushRegistrationStore {
|
||||
required String username,
|
||||
required String baseUrl,
|
||||
}) async {
|
||||
await pushSecureStorage.write(key: _usernameKey, value: username);
|
||||
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
|
||||
await _storage.write(key: _usernameKey, value: username);
|
||||
await _storage.write(key: _baseUrlKey, value: baseUrl);
|
||||
}
|
||||
|
||||
Future<String?> deviceIdentifier() =>
|
||||
pushSecureStorage.read(key: _deviceIdentifierKey);
|
||||
Future<String?> deviceIdentifier(PushRegistrationType type) =>
|
||||
_storage.read(key: keyFor(_deviceIdentifierKey, type));
|
||||
|
||||
/// Per-user server public key — identical for both registrations.
|
||||
Future<String?> serverPublicKeyPem() =>
|
||||
pushSecureStorage.read(key: _serverPublicKeyKey);
|
||||
_storage.read(key: _serverPublicKeyKey);
|
||||
|
||||
Future<String?> registeredFcmToken() =>
|
||||
pushSecureStorage.read(key: _registeredTokenKey);
|
||||
Future<String?> registeredFcmToken(PushRegistrationType type) =>
|
||||
_storage.read(key: keyFor(_registeredTokenKey, type));
|
||||
|
||||
/// Proxy-server URL the current registration was made with.
|
||||
Future<String?> registeredProxyServer() =>
|
||||
pushSecureStorage.read(key: _proxyServerKey);
|
||||
/// Proxy-server URL the registration of [type] was made with.
|
||||
Future<String?> registeredProxyServer(PushRegistrationType type) =>
|
||||
_storage.read(key: keyFor(_proxyServerKey, type));
|
||||
|
||||
/// Nextcloud base URL the current registration was made against.
|
||||
Future<String?> registeredNcBaseUrl() =>
|
||||
pushSecureStorage.read(key: _ncBaseUrlKey);
|
||||
/// Nextcloud base URL the registration of [type] was made against.
|
||||
Future<String?> registeredNcBaseUrl(PushRegistrationType type) =>
|
||||
_storage.read(key: keyFor(_ncBaseUrlKey, type));
|
||||
|
||||
/// True when a registration has been persisted (used by the cold-start
|
||||
/// self-heal to decide whether to (re-)register).
|
||||
Future<bool> isRegistered() async =>
|
||||
(await registeredFcmToken())?.isNotEmpty ?? false;
|
||||
/// True when a registration of [type] has been persisted.
|
||||
Future<bool> isRegistered(PushRegistrationType type) async =>
|
||||
(await registeredFcmToken(type))?.isNotEmpty ?? false;
|
||||
|
||||
/// Records the outcome of the most recent registration attempt of [type] so
|
||||
/// the push status view can show when it ran and why it failed. [error]
|
||||
/// null = success (stored as empty string).
|
||||
Future<void> saveLastRegistrationAttempt({
|
||||
required PushRegistrationType type,
|
||||
required DateTime at,
|
||||
String? error,
|
||||
}) async {
|
||||
await _storage.write(
|
||||
key: keyFor(_lastAttemptAtKey, type),
|
||||
value: at.toIso8601String(),
|
||||
);
|
||||
await _storage.write(
|
||||
key: keyFor(_lastAttemptErrorKey, type),
|
||||
value: error ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Timestamp of the last registration attempt of [type], or null when none
|
||||
/// ran yet.
|
||||
Future<DateTime?> lastRegistrationAt(PushRegistrationType type) async {
|
||||
final raw = await _storage.read(key: keyFor(_lastAttemptAtKey, type));
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return DateTime.tryParse(raw);
|
||||
}
|
||||
|
||||
/// Error text of the last registration attempt of [type], or null when it
|
||||
/// succeeded (or never ran).
|
||||
Future<String?> lastRegistrationError(PushRegistrationType type) async {
|
||||
final raw = await _storage.read(key: keyFor(_lastAttemptErrorKey, type));
|
||||
return (raw == null || raw.isEmpty) ? null : raw;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await pushSecureStorage.delete(key: _deviceIdentifierKey);
|
||||
await pushSecureStorage.delete(key: _serverPublicKeyKey);
|
||||
await pushSecureStorage.delete(key: _registeredTokenKey);
|
||||
await pushSecureStorage.delete(key: _proxyServerKey);
|
||||
await pushSecureStorage.delete(key: _ncBaseUrlKey);
|
||||
await pushSecureStorage.delete(key: _usernameKey);
|
||||
await pushSecureStorage.delete(key: _baseUrlKey);
|
||||
for (final type in PushRegistrationType.values) {
|
||||
for (final key in _perTypeKeys) {
|
||||
await _storage.delete(key: keyFor(key, type));
|
||||
}
|
||||
}
|
||||
await _storage.delete(key: _serverPublicKeyKey);
|
||||
await _storage.delete(key: _usernameKey);
|
||||
await _storage.delete(key: _baseUrlKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,17 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../notification/notification_service.dart';
|
||||
import '../notification/notification_tasks.dart';
|
||||
import '../theming/light_app_theme.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_actions.dart';
|
||||
import 'push_avatar.dart';
|
||||
import 'push_subject.dart';
|
||||
|
||||
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
||||
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
|
||||
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
|
||||
/// a generic channel.
|
||||
/// notifications. Talk messages of one chat stack into a SINGLE
|
||||
/// [MessagingStyleInformation] notification (stable id/tag per chat, history
|
||||
/// from [ChatThreadStore]) with inline reply + mark-as-read actions;
|
||||
/// everything else renders in a generic channel.
|
||||
class PushRenderer {
|
||||
static const talkChannelId = 'talk_messages';
|
||||
static const talkChannelName = 'Talk-Nachrichten';
|
||||
@@ -20,9 +25,21 @@ class PushRenderer {
|
||||
|
||||
static const String iosTalkCategory = 'TALK_MESSAGE';
|
||||
|
||||
final NidStore _nidStore;
|
||||
/// Brand accent: colors the (monochrome) small icon and action buttons in
|
||||
/// the notification shade instead of the default grey.
|
||||
static const _accentColor = LightAppTheme.marianumRed;
|
||||
|
||||
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
|
||||
final NidStore _nidStore;
|
||||
final PushAvatarStore _avatarStore;
|
||||
final ChatThreadStore _threadStore;
|
||||
|
||||
PushRenderer({
|
||||
NidStore? nidStore,
|
||||
PushAvatarStore? avatarStore,
|
||||
ChatThreadStore? threadStore,
|
||||
}) : _nidStore = nidStore ?? NidStore(),
|
||||
_avatarStore = avatarStore ?? PushAvatarStore(),
|
||||
_threadStore = threadStore ?? ChatThreadStore();
|
||||
|
||||
FlutterLocalNotificationsPlugin get _plugin =>
|
||||
NotificationService().flutterLocalNotificationsPlugin;
|
||||
@@ -60,24 +77,120 @@ class PushRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tri-state probe of whether this chat's stacked notification is still in
|
||||
/// the tray. `null` when the query fails or isn't supported (native code not
|
||||
/// linked, older platform) — the caller then keeps stacking defensively. A
|
||||
/// successful query is authoritative: absence means dismissed/read.
|
||||
/// Works in the background isolate (pure plugin call, no BuildContext).
|
||||
Future<bool?> _isChatNotificationActive(String chatToken) async {
|
||||
final id = stableChatNotificationId(chatToken);
|
||||
final tag = chatNotificationTag(chatToken);
|
||||
try {
|
||||
final actives = await _plugin.getActiveNotifications();
|
||||
return actives.any((n) => n.id == id || n.tag == tag);
|
||||
} on Object catch (e) {
|
||||
log('Push: getActiveNotifications probe failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renderTalk(PushSubject subject) async {
|
||||
final nid = subject.nid ?? _fallbackId(subject.id);
|
||||
final chatToken = subject.id;
|
||||
final tag = chatToken != null
|
||||
? NotificationTasks.chatTag(chatToken)
|
||||
: 'talk_$nid';
|
||||
final text = subject.subject ?? 'Neue Nachricht';
|
||||
final (senderName, messageText) = _splitSender(text);
|
||||
final parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
|
||||
final nid =
|
||||
subject.nid ??
|
||||
_fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');
|
||||
|
||||
final payload = _payload(chatToken: chatToken, nid: nid);
|
||||
if (chatToken == null || chatToken.isEmpty) {
|
||||
// Without a chat token there is nothing to stack under — render a
|
||||
// standalone card keyed by the nid.
|
||||
await _renderTalkStandalone(nid, parsed.sender, parsed.text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restart the thread when the previous notification is gone (swiped away
|
||||
// or the chat was read without our cleanup running) — otherwise the new
|
||||
// push would resurrect the already-seen history. A failed/unsupported
|
||||
// probe keeps stacking (see threadAfterIncoming).
|
||||
final isActive = await _isChatNotificationActive(chatToken);
|
||||
final messages = await _threadStore.appendConsideringActive(
|
||||
chatToken,
|
||||
ThreadMessage(
|
||||
nid: nid,
|
||||
sender: parsed.sender,
|
||||
text: parsed.text,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||
roomName: parsed.roomName,
|
||||
),
|
||||
isActive,
|
||||
);
|
||||
await _nidStore.put(
|
||||
NidEntry(
|
||||
nid: nid,
|
||||
notificationId: stableChatNotificationId(chatToken),
|
||||
tag: chatNotificationTag(chatToken),
|
||||
chatToken: chatToken,
|
||||
),
|
||||
);
|
||||
await renderTalkThread(chatToken, messages);
|
||||
}
|
||||
|
||||
/// Shows/updates the ONE stacked notification of [chatToken] from the given
|
||||
/// history. Identity is stable per chat ([stableChatNotificationId] +
|
||||
/// [chatNotificationTag]) so each new message updates the same card.
|
||||
/// [alert] false re-renders silently — used when a delete-push removed one
|
||||
/// of several messages and the remaining thread must not ping again.
|
||||
Future<void> renderTalkThread(
|
||||
String chatToken,
|
||||
List<ThreadMessage> messages, {
|
||||
bool alert = true,
|
||||
}) async {
|
||||
if (messages.isEmpty) return;
|
||||
final tag = chatNotificationTag(chatToken);
|
||||
final id = stableChatNotificationId(chatToken);
|
||||
final latest = messages.last;
|
||||
|
||||
// Conversation avatar (the person's picture in 1:1 chats), pre-masked
|
||||
// round. Bounded by the store's fetch timeout; when the icon isn't ready
|
||||
// in time the notification renders without it and a still-running fetch
|
||||
// triggers ONE silent re-render below once bytes arrive.
|
||||
final lookup = await _avatarStore.roomAvatarIcon(chatToken);
|
||||
final avatarBytes = lookup.icon;
|
||||
if (alert && avatarBytes == null && lookup.late != null) {
|
||||
unawaited(
|
||||
lookup.late!.then((bytes) async {
|
||||
if (bytes == null) return;
|
||||
final fresh = await _threadStore.messages(chatToken);
|
||||
if (fresh.isEmpty) return;
|
||||
// Re-render hits the processed cache → icon present, no re-schedule.
|
||||
await renderTalkThread(chatToken, fresh, alert: false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
final senders = <String, Person>{};
|
||||
Person personFor(String sender) => senders.putIfAbsent(
|
||||
sender,
|
||||
() => Person(
|
||||
key: sender,
|
||||
name: sender,
|
||||
icon: avatarBytes == null ? null : ByteArrayAndroidIcon(avatarBytes),
|
||||
),
|
||||
);
|
||||
|
||||
final styleMessages = [
|
||||
for (final m in messages)
|
||||
Message(
|
||||
m.text,
|
||||
DateTime.fromMillisecondsSinceEpoch(m.timestampMs),
|
||||
personFor(m.sender),
|
||||
),
|
||||
];
|
||||
final header = conversationHeader(messages);
|
||||
final messagingStyle = MessagingStyleInformation(
|
||||
const Person(key: 'self', name: 'Ich'),
|
||||
conversationTitle: senderName,
|
||||
groupConversation: false,
|
||||
messages: [
|
||||
Message(messageText, DateTime.now(), Person(name: senderName)),
|
||||
],
|
||||
conversationTitle: header.conversationTitle,
|
||||
groupConversation: header.groupConversation,
|
||||
messages: styleMessages,
|
||||
);
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
@@ -86,50 +199,89 @@ class PushRenderer {
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
color: _accentColor,
|
||||
tag: tag,
|
||||
silent: !alert,
|
||||
styleInformation: messagingStyle,
|
||||
actions: const [
|
||||
AndroidNotificationAction(
|
||||
kTalkReplyActionId,
|
||||
'Antworten',
|
||||
showsUserInterface: false,
|
||||
cancelNotification: false,
|
||||
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
kTalkMarkReadActionId,
|
||||
'Gelesen',
|
||||
showsUserInterface: false,
|
||||
),
|
||||
],
|
||||
actions: _talkActions,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
threadIdentifier: tag,
|
||||
categoryIdentifier: iosTalkCategory,
|
||||
presentSound: alert ? null : false,
|
||||
presentBanner: alert ? null : false,
|
||||
);
|
||||
|
||||
await _nidStore.put(
|
||||
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: latest.sender,
|
||||
body: latest.text,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
payload: _payload(chatToken: chatToken, nid: latest.nid),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _renderTalkStandalone(
|
||||
int nid,
|
||||
String senderName,
|
||||
String messageText,
|
||||
) async {
|
||||
final tag = 'talk_$nid';
|
||||
await _nidStore.put(NidEntry(nid: nid, notificationId: nid, tag: tag));
|
||||
await _plugin.show(
|
||||
id: nid,
|
||||
title: senderName,
|
||||
body: messageText,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
android: AndroidNotificationDetails(
|
||||
talkChannelId,
|
||||
talkChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
category: AndroidNotificationCategory.message,
|
||||
color: _accentColor,
|
||||
tag: tag,
|
||||
// Single sender ⇒ no conversationTitle (1:1 convention): the sender
|
||||
// Person is the header, a title would repeat the name per line.
|
||||
styleInformation: MessagingStyleInformation(
|
||||
const Person(key: 'self', name: 'Ich'),
|
||||
groupConversation: false,
|
||||
messages: [
|
||||
Message(messageText, DateTime.now(), Person(name: senderName)),
|
||||
],
|
||||
),
|
||||
),
|
||||
iOS: const DarwinNotificationDetails(),
|
||||
),
|
||||
payload: payload,
|
||||
payload: _payload(chatToken: null, nid: nid),
|
||||
);
|
||||
}
|
||||
|
||||
static const _talkActions = [
|
||||
AndroidNotificationAction(
|
||||
kTalkReplyActionId,
|
||||
'Antworten',
|
||||
showsUserInterface: false,
|
||||
cancelNotification: false,
|
||||
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
kTalkMarkReadActionId,
|
||||
'Gelesen',
|
||||
showsUserInterface: false,
|
||||
),
|
||||
];
|
||||
|
||||
Future<void> _renderGeneric(PushSubject subject) async {
|
||||
final nid = subject.nid ?? _fallbackId(subject.subject);
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
color: _accentColor,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
await _nidStore.put(
|
||||
@@ -160,6 +312,7 @@ class PushRenderer {
|
||||
generalChannelName,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
color: _accentColor,
|
||||
);
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
@@ -173,16 +326,6 @@ class PushRenderer {
|
||||
String _payload({required String? chatToken, required int nid}) =>
|
||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||
|
||||
/// Splits a `"Sender: message"` subject into its parts, falling back to a
|
||||
/// generic sender label when there's no delimiter.
|
||||
(String, String) _splitSender(String subject) {
|
||||
final idx = subject.indexOf(': ');
|
||||
if (idx > 0 && idx < subject.length - 2) {
|
||||
return (subject.substring(0, idx), subject.substring(idx + 2));
|
||||
}
|
||||
return ('Talk', subject);
|
||||
}
|
||||
|
||||
/// Deterministic non-negative 31-bit id from a string, used when the push
|
||||
/// carries no `nid`.
|
||||
int _fallbackId(String? seed) {
|
||||
|
||||
@@ -25,3 +25,28 @@ const IOSOptions kPushIosOptions = IOSOptions(
|
||||
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
|
||||
iOptions: kPushIosOptions,
|
||||
);
|
||||
|
||||
/// Minimal storage contract so tests can inject an in-memory fake instead of
|
||||
/// touching the platform keystore.
|
||||
abstract class FlutterSecureStorageLike {
|
||||
Future<String?> read({required String key});
|
||||
Future<void> write({required String key, required String? value});
|
||||
Future<void> delete({required String key});
|
||||
}
|
||||
|
||||
/// Default [FlutterSecureStorageLike] backed by [pushSecureStorage].
|
||||
class PushSecureStorage implements FlutterSecureStorageLike {
|
||||
const PushSecureStorage();
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) =>
|
||||
pushSecureStorage.read(key: key);
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) =>
|
||||
pushSecureStorage.write(key: key, value: value);
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) =>
|
||||
pushSecureStorage.delete(key: key);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,42 @@
|
||||
/// Parsed parts of a Talk push subject text.
|
||||
///
|
||||
/// Format verified against nextcloud/spreed `Notifier::parseChatMessage`:
|
||||
/// with message preview the parsed subject is `"{user}\n{message}"` for 1:1
|
||||
/// chats and `'{user} in {call}' . "\n{message}"` for groups — header and
|
||||
/// message are separated by a NEWLINE, and the notifications app forwards the
|
||||
/// parsed subject unmodified (only shortened). The German translation of
|
||||
/// `{user} in {call}` is identical (`l10n/de.json`), so the ` in ` separator
|
||||
/// holds for de_DE. Legacy/other variants using `": "` are kept as fallback;
|
||||
/// splitting the header at the LAST ` in ` is robust against sender names
|
||||
/// containing "in" (only a display name literally containing ` in ` in a 1:1
|
||||
/// chat would still mis-split — accepted heuristic limit).
|
||||
({String sender, String? roomName, String text}) parseTalkSubject(String raw) {
|
||||
String header;
|
||||
String text;
|
||||
final newline = raw.indexOf('\n');
|
||||
if (newline > 0) {
|
||||
header = raw.substring(0, newline).trim();
|
||||
text = raw.substring(newline + 1);
|
||||
} else {
|
||||
final colon = raw.indexOf(': ');
|
||||
if (colon > 0 && colon < raw.length - 2) {
|
||||
header = raw.substring(0, colon);
|
||||
text = raw.substring(colon + 2);
|
||||
} else {
|
||||
return (sender: 'Talk', roomName: null, text: raw);
|
||||
}
|
||||
}
|
||||
final roomSep = header.lastIndexOf(' in ');
|
||||
if (roomSep > 0 && roomSep + 4 < header.length) {
|
||||
return (
|
||||
sender: header.substring(0, roomSep),
|
||||
roomName: header.substring(roomSep + 4),
|
||||
text: text,
|
||||
);
|
||||
}
|
||||
return (sender: header, roomName: null, text: text);
|
||||
}
|
||||
|
||||
/// The decrypted `subject` JSON of a Nextcloud push-v2 notification.
|
||||
///
|
||||
/// Covers the full shape the notifications app emits, including the three
|
||||
|
||||
@@ -3,14 +3,12 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_registration_store.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../widgets/push_status_sheet.dart';
|
||||
|
||||
class TalkSection extends StatelessWidget {
|
||||
const TalkSection({super.key});
|
||||
@@ -56,7 +54,9 @@ class TalkSection extends StatelessWidget {
|
||||
Icon(Icons.notifications_active_outlined),
|
||||
),
|
||||
title: const Text('Push-Benachrichtigungen'),
|
||||
subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
|
||||
subtitle: const Text(
|
||||
'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
|
||||
),
|
||||
trailing: Checkbox(
|
||||
value: notificationSettings.enabled,
|
||||
onChanged: (e) {
|
||||
@@ -74,8 +74,9 @@ class TalkSection extends StatelessWidget {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Benachrichtigungen sind in den Systemeinstellungen '
|
||||
'deaktiviert — bitte dort erlauben.',
|
||||
'Die Benachrichtigungsberechtigung wurde in den '
|
||||
'Systemeinstellungen deaktiviert. Bitte aktiviere '
|
||||
'sie dort, um Push-Benachrichtigungen zu erhalten.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -87,102 +88,14 @@ class TalkSection extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (notificationSettings.enabled) const _TestNotificationTile(),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
|
||||
title: const Text('Push-Status'),
|
||||
subtitle: const Text('Registrierung und Zustellung im Detail'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => showPushStatusSheet(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Send a test notification" action, shown only while push is enabled. The
|
||||
/// button stays disabled until a registration is confirmed present, then calls
|
||||
/// the backend and reports the result via a SnackBar.
|
||||
class _TestNotificationTile extends StatefulWidget {
|
||||
const _TestNotificationTile();
|
||||
|
||||
@override
|
||||
State<_TestNotificationTile> createState() => _TestNotificationTileState();
|
||||
}
|
||||
|
||||
class _TestNotificationTileState extends State<_TestNotificationTile> {
|
||||
static const _permissionDeniedHint =
|
||||
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert';
|
||||
|
||||
bool _registered = false;
|
||||
bool _permissionDenied = false;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadState();
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final registered = await const PushRegistrationStore().isRegistered();
|
||||
final denied = await PushRegistration.isOsPermissionDenied();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_registered = registered;
|
||||
_permissionDenied = denied;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendTest() async {
|
||||
if (_sending) return;
|
||||
Haptics.selection();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// Re-check right before sending: the user may have flipped the OS
|
||||
// permission in the system settings since this tile was built.
|
||||
final denied = await PushRegistration.isOsPermissionDenied();
|
||||
if (!mounted) return;
|
||||
if (denied) {
|
||||
setState(() => _permissionDenied = true);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('$_permissionDeniedHint — bitte dort erlauben.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_permissionDenied = false;
|
||||
_sending = true;
|
||||
});
|
||||
String message;
|
||||
try {
|
||||
final devices = await PushDeviceTest().run();
|
||||
message = devices >= 1
|
||||
? 'Testbenachrichtigung an $devices Gerät(e) gesendet'
|
||||
: 'Kein Gerät registriert — Push-Registrierung prüfen';
|
||||
} on Object catch (e) {
|
||||
message = errorToUserMessage(e);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_registered) return const SizedBox.shrink();
|
||||
return ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.send_outlined)),
|
||||
title: const Text('Testbenachrichtigung senden'),
|
||||
subtitle: _permissionDenied
|
||||
? Text(
|
||||
_permissionDeniedHint,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
)
|
||||
: const Text('Prüft, ob Push auf diesem Gerät ankommt'),
|
||||
trailing: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.arrow_right),
|
||||
enabled: !_sending,
|
||||
onTap: _sending ? null : _sendTest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
|
||||
/// Opens the push status checklist: one row per link in the push chain with
|
||||
/// an explanation where it is broken, the last registration attempt (incl.
|
||||
/// verbatim error), a manual re-register action and — once the chain is
|
||||
/// operational — a test notification. Loads once on open; the refresh action
|
||||
/// re-collects on demand (no polling).
|
||||
void showPushStatusSheet(BuildContext context) {
|
||||
// Captured here: the sheet outlives this build context's element tree.
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final capabilities = context.read<CapabilitiesCubit>();
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: const ListTile(
|
||||
leading: Icon(Icons.monitor_heart_outlined),
|
||||
title: Text('Status der Push-Benachrichtigungen'),
|
||||
),
|
||||
children: (sheetCtx) => [
|
||||
_PushStatusBody(settings: settings, capabilities: capabilities),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _PushStatusBody extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
final CapabilitiesCubit capabilities;
|
||||
|
||||
const _PushStatusBody({required this.settings, required this.capabilities});
|
||||
|
||||
@override
|
||||
State<_PushStatusBody> createState() => _PushStatusBodyState();
|
||||
}
|
||||
|
||||
class _PushStatusBodyState extends State<_PushStatusBody> {
|
||||
PushStatusReport? _report;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final capabilitiesState = widget.capabilities.state;
|
||||
final report = await collectPushStatus(
|
||||
settingEnabled: widget.settings.val().notificationSettings.enabled,
|
||||
capabilityPush: capabilitiesState.pushNotifications,
|
||||
capabilitiesLoaded: capabilitiesState.loaded,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _report = report);
|
||||
}
|
||||
|
||||
Future<void> _reRegister() async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ok = await PushRegistration().register();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
await _load();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok
|
||||
? 'Registrierung erfolgreich abgeschlossen'
|
||||
: 'Registrierung fehlgeschlagen — Details in der Statusübersicht',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendTest() async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
String message;
|
||||
try {
|
||||
final devices = await PushDeviceTest().run();
|
||||
message = switch (devices) {
|
||||
0 => 'Es ist kein Gerät registriert — bitte erneut registrieren',
|
||||
1 => 'Testbenachrichtigung an 1 Gerät gesendet',
|
||||
_ => 'Testbenachrichtigung an $devices Geräte gesendet',
|
||||
};
|
||||
} on Object catch (e) {
|
||||
message = errorToUserMessage(e);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final report = _report;
|
||||
if (report == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: AppProgressIndicator.medium()),
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final rows = buildPushStatusRows(report);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...rows.map(
|
||||
(row) => ListTile(
|
||||
dense: true,
|
||||
leading: _stateIcon(row.state, theme),
|
||||
title: Text(row.label),
|
||||
subtitle: row.detail == null ? null : Text(row.detail!),
|
||||
),
|
||||
),
|
||||
if (report.general.lastRegistrationAt != null ||
|
||||
report.talk.lastRegistrationAt != null)
|
||||
const Divider(height: 1),
|
||||
..._lastAttempt(theme, 'Allgemein', report.general),
|
||||
..._lastAttempt(theme, 'Talk', report.talk),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: _actions(report),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Last-attempt line (+ verbatim error) for one registration type.
|
||||
List<Widget> _lastAttempt(
|
||||
ThemeData theme,
|
||||
String label,
|
||||
PushTypeStatus status,
|
||||
) {
|
||||
final at = status.lastRegistrationAt;
|
||||
if (at == null) return const [];
|
||||
final error = status.lastRegistrationError;
|
||||
return [
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
error == null ? Icons.history : Icons.error_outline,
|
||||
color: error == null ? null : theme.colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
'Letzte Registrierung ($label): ${at.formatDateTime()} — '
|
||||
'${error == null ? 'erfolgreich' : 'fehlgeschlagen'}',
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
error,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Action hierarchy: refresh stays a secondary icon action; the primary
|
||||
/// (filled) button is the test notification once the chain is operational,
|
||||
/// otherwise re-registering IS the primary next step and the test action is
|
||||
/// omitted (it could not succeed and the checklist explains why).
|
||||
Widget _actions(PushStatusReport report) {
|
||||
const spinner = SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
final ready = report.readyForTestNotification;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _busy ? null : _load,
|
||||
tooltip: 'Aktualisieren',
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
// OverflowBar stacks the buttons vertically on narrow screens instead
|
||||
// of overflowing the row.
|
||||
Expanded(
|
||||
child: OverflowBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
overflowAlignment: OverflowBarAlignment.end,
|
||||
spacing: 8,
|
||||
overflowSpacing: 4,
|
||||
children: [
|
||||
if (ready) ...[
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _reRegister,
|
||||
child: const Text('Erneut registrieren'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _sendTest,
|
||||
icon: _busy ? spinner : const Icon(Icons.send_outlined),
|
||||
label: const Text('Testbenachrichtigung'),
|
||||
),
|
||||
] else
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _reRegister,
|
||||
icon: _busy ? spinner : const Icon(Icons.sync),
|
||||
label: const Text('Erneut registrieren'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stateIcon(PushCheck state, ThemeData theme) {
|
||||
switch (state) {
|
||||
case PushCheck.ok:
|
||||
return const Icon(Icons.check_circle_outline, color: Colors.green);
|
||||
case PushCheck.fail:
|
||||
return Icon(Icons.cancel_outlined, color: theme.colorScheme.error);
|
||||
case PushCheck.unknown:
|
||||
return Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
@@ -8,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../model/account_data.dart';
|
||||
import '../model/endpoint_data.dart';
|
||||
import '../push/push_avatar.dart';
|
||||
|
||||
class UserAvatar extends StatefulWidget {
|
||||
final String id;
|
||||
@@ -86,6 +88,9 @@ void invalidateAvatarCache({String? id, bool? isGroup}) {
|
||||
final url = avatarUrl(id: id, isGroup: true);
|
||||
_resolvedAvatars.remove(url);
|
||||
_pendingAvatars.remove(url);
|
||||
// Keep the push-notification disk cache in sync — it serves the same
|
||||
// room avatar to the FCM background isolate.
|
||||
unawaited(PushAvatarStore.evict(id));
|
||||
} else {
|
||||
// User avatars include the rendered size in the URL — drop every variant.
|
||||
final host = EndpointData().nextcloud().full();
|
||||
@@ -242,11 +247,7 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
backgroundColor: theme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
child: content,
|
||||
),
|
||||
child: SizedBox(width: radius * 2, height: radius * 2, child: content),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user