diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c14f862..15201b6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -48,6 +48,17 @@
android:name="flutterEmbedding"
android:value="2" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..0647008
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+
+ #993333
+
diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart
index d8d5b9b..12cbcd9 100644
--- a/lib/api/marianumcloud/app_password/delete_app_password.dart
+++ b/lib/api/marianumcloud/app_password/delete_app_password.dart
@@ -2,19 +2,24 @@ import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
-/// Revokes the current app password server-side via
-/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
-/// authenticate with the app password itself (it revokes the credential it was
-/// made with) and the result is ignored — logout clears local state regardless.
+/// Revokes an app password server-side via
+/// `DELETE /ocs/v2.php/core/apppassword`. The endpoint revokes the credential
+/// the request authenticates WITH — by default the shared OCS headers (general
+/// app password); pass [authorizationHeader] to revoke another one (the Talk
+/// app password). Best-effort: the result is ignored — logout clears local
+/// state regardless.
class DeleteAppPassword {
final http.Client _client;
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
- Future run() async {
+ Future run({String? authorizationHeader}) async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
- headers: NextcloudOcs.headers(),
+ headers: {
+ ...NextcloudOcs.headers(),
+ 'Authorization': ?authorizationHeader,
+ },
);
}
}
diff --git a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart
index 717ac2d..ec514c9 100644
--- a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart
+++ b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart
@@ -19,6 +19,7 @@ class PushDeviceRegister {
required String userPublicKey,
required String pushToken,
required String platform,
+ required String registrationType,
String? appVersion,
}) async {
try {
@@ -30,6 +31,9 @@ class PushDeviceRegister {
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
+ // 'general' | 'talk' — the backend derives the NC hash comparison
+ // value from it (general = sha512(token), talk = sha512(token+'#talk')).
+ 'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
diff --git a/lib/main.dart b/lib/main.dart
index 8c235b6..4c80079 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -96,7 +96,7 @@ Future main() async {
// decrypts and renders Nextcloud pushes while the app is not in foreground.
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
- FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage);
+ FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage);
// Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage.
@@ -375,13 +375,13 @@ class _MainState extends State {
return Stack(
fit: StackFit.expand,
children: [
- if (_appMounted) const App(key: ValueKey('app-shell')),
+ if (_appMounted)
+ const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
- onComplete: () => setState(
- () => _showPostLoginSplash = false,
- ),
+ onComplete: () =>
+ setState(() => _showPostLoginSplash = false),
),
],
);
diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart
index 787b20e..c033578 100644
--- a/lib/model/account_data.dart
+++ b/lib/model/account_data.dart
@@ -11,9 +11,13 @@ import '../push/push_secure_storage.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
- // App password lives in the push-shared (group-scoped) keystore so the iOS
+ // App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
+ // The talk password authenticates the second (apptype=talk) push
+ // registration — Nextcloud binds each push subscription to its session
+ // token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password';
+ static const _appPasswordTalkField = 'nextcloud_app_password_talk';
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
@@ -29,6 +33,7 @@ class AccountData {
String? _username;
String? _password;
String? _appPassword;
+ String? _appPasswordTalk;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
@@ -65,9 +70,11 @@ class AccountData {
_username = null;
_password = null;
_appPassword = null;
+ _appPasswordTalk = null;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _clearAppPasswordStorage();
+ await _clearAppPasswordTalkStorage();
}
/// Persists a freshly minted Nextcloud app password. After this every
@@ -90,6 +97,28 @@ class AccountData {
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
+ /// Persists the app password backing the Talk push registration.
+ Future setAppPasswordTalk(String appPassword) async {
+ _appPasswordTalk = appPassword;
+ try {
+ await pushSecureStorage.write(
+ key: _appPasswordTalkField,
+ value: appPassword,
+ );
+ } on Object {
+ // Group-scoped keystore may be unavailable — in-memory still works for
+ // this session, matching setAppPassword.
+ }
+ }
+
+ Future clearAppPasswordTalk() async {
+ _appPasswordTalk = null;
+ await _clearAppPasswordTalkStorage();
+ }
+
+ bool hasAppPasswordTalk() =>
+ _appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
+
Future _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
@@ -98,14 +127,26 @@ class AccountData {
}
}
+ Future _clearAppPasswordTalkStorage() async {
+ try {
+ await pushSecureStorage.delete(key: _appPasswordTalkField);
+ } on Object {
+ // ignore — nothing stored or keystore unavailable
+ }
+ }
+
Future _migrateAndLoad() async {
await _migrateFromLegacyStorage();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
+ _appPasswordTalk = await pushSecureStorage.read(
+ key: _appPasswordTalkField,
+ );
} on Object {
_appPassword = null;
+ _appPasswordTalk = null;
}
if (!_populated.isCompleted) _populated.complete();
}
@@ -149,6 +190,22 @@ class AccountData {
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
}
+ /// Basic-auth header using the Talk app password — authenticates the
+ /// apptype=talk push registration (and its unregister). Throws when the
+ /// talk password has not been minted yet; callers treat that as a failed
+ /// talk registration and retry on the next start.
+ String getTalkBasicAuthHeader() {
+ if (!isPopulated()) {
+ throw Exception(
+ 'AccountData (e.g. username or password) is not initialized!',
+ );
+ }
+ if (!hasAppPasswordTalk()) {
+ throw StateError('Talk app password not available yet');
+ }
+ return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
+ }
+
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart
index fa7e4ff..596ef3e 100644
--- a/lib/notification/notification_service.dart
+++ b/lib/notification/notification_service.dart
@@ -15,8 +15,11 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
Future initializeNotifications() async {
+ // Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
+ // small icons because Android renders only their alpha silhouette (a
+ // solid circle in the status bar).
const androidSettings = AndroidInitializationSettings(
- '@mipmap/ic_launcher',
+ '@drawable/ic_stat_notification',
);
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart
index d0dc98a..e3eb449 100644
--- a/lib/notification/notification_tasks.dart
+++ b/lib/notification/notification_tasks.dart
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
+import '../push/chat_thread_store.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
@@ -25,9 +26,9 @@ class NotificationTasks {
}
}
- /// Per-chat tag scheme. MUST match the Notify backend, which sets this
- /// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
- static String chatTag(String chatToken) => 'talk_$chatToken';
+ /// Per-chat tag scheme — canonical definition lives beside the stacked
+ /// notification id in chat_thread_store.dart.
+ static String chatTag(String chatToken) => chatNotificationTag(chatToken);
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
@@ -54,6 +55,13 @@ class NotificationTasks {
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
+ // Drop the stacked-notification history too — otherwise the next push
+ // would resurrect all already-read messages in the new notification.
+ try {
+ await ChatThreadStore().clearChat(chatToken);
+ } on Object catch (e) {
+ log('Chat thread cleanup failed: $e');
+ }
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
diff --git a/lib/push/chat_thread_store.dart b/lib/push/chat_thread_store.dart
new file mode 100644
index 0000000..b0fbafc
--- /dev/null
+++ b/lib/push/chat_thread_store.dart
@@ -0,0 +1,210 @@
+import 'dart:convert';
+
+import 'package:localstore/localstore.dart';
+
+/// Maximum messages kept per chat thread — enough for a messenger-style
+/// notification, small enough to stay cheap in the background isolate.
+const int kChatThreadCap = 15;
+
+/// Canonical per-chat notification tag (kept in sync with
+/// `NotificationTasks.chatTag`, which delegates here).
+String chatNotificationTag(String chatToken) => 'talk_$chatToken';
+
+/// Deterministic non-negative 31-bit notification id per chat, so every
+/// message of the same conversation updates ONE stacked notification instead
+/// of adding a new card per push.
+int stableChatNotificationId(String chatToken) {
+ var hash = 0;
+ for (final unit in chatToken.codeUnits) {
+ hash = (hash * 31 + unit) & 0x7fffffff;
+ }
+ return hash;
+}
+
+/// One message inside a chat's notification history.
+class ThreadMessage {
+ final int nid;
+ final String sender;
+ final String text;
+ final int timestampMs;
+
+ /// Room name parsed from a group-chat subject (` in `);
+ /// null for 1:1 chats and for history entries written before this field
+ /// existed (tolerated on read — they simply carry no room information).
+ final String? roomName;
+
+ const ThreadMessage({
+ required this.nid,
+ required this.sender,
+ required this.text,
+ required this.timestampMs,
+ this.roomName,
+ });
+
+ Map toJson() => {
+ 'nid': nid,
+ 'sender': sender,
+ 'text': text,
+ 'timestampMs': timestampMs,
+ 'roomName': ?roomName,
+ };
+
+ factory ThreadMessage.fromJson(Map json) => ThreadMessage(
+ nid: (json['nid'] as num).toInt(),
+ sender: json['sender'] as String? ?? '',
+ text: json['text'] as String? ?? '',
+ timestampMs: (json['timestampMs'] as num?)?.toInt() ?? 0,
+ roomName: json['roomName'] as String?,
+ );
+}
+
+/// Appends [message] to [messages], replacing an entry with the same nid
+/// (redelivered push) and capping the history at [cap] (oldest dropped).
+/// Pure so the stacking behavior is unit-testable.
+List appendThreadMessage(
+ List messages,
+ ThreadMessage message, {
+ int cap = kChatThreadCap,
+}) {
+ final result = [...messages.where((m) => m.nid != message.nid), message];
+ return result.length > cap ? result.sublist(result.length - cap) : result;
+}
+
+/// Removes the message with [nid]; the caller cancels the notification when
+/// the returned list is empty and re-renders it silently otherwise.
+List removeThreadNid(List messages, int nid) =>
+ messages.where((m) => m.nid != nid).toList();
+
+/// History for the next notification after [message] arrives, given whether
+/// the chat's previous notification is still on screen ([isActive]):
+///
+/// - `true` → the user hasn't dismissed/read it, so [message] STACKS onto the
+/// existing history.
+/// - `false` → the notification is gone (swiped away or the chat was read
+/// without our cleanup running), so the thread RESTARTS with only [message].
+/// - `null` → the active-notification probe failed or isn't supported; keep
+/// stacking defensively — degraded stacking never loses a message.
+///
+/// Android provides no reliable "notification dismissed" callback, so the
+/// visible-state probe at append time is the substitute.
+List threadAfterIncoming(
+ List existing,
+ ThreadMessage message,
+ bool? isActive, {
+ int cap = kChatThreadCap,
+}) {
+ final base = isActive == false ? const [] : existing;
+ return appendThreadMessage(base, message, cap: cap);
+}
+
+/// MessagingStyle header per Android convention: a 1:1 chat gets NO
+/// `conversationTitle` — the system then shows the person once as the header
+/// and plain texts per line; setting a title would repeat the name on every
+/// row. Groups get the ROOM NAME as title (parsed from the subject) plus
+/// per-line sender names; when no room name is known, >1 distinct sender
+/// still marks the thread as group with the last sender as title fallback.
+({String? conversationTitle, bool groupConversation}) conversationHeader(
+ List messages,
+) {
+ final roomName = messages
+ .lastWhere(
+ (m) => m.roomName?.isNotEmpty ?? false,
+ orElse: () =>
+ const ThreadMessage(nid: 0, sender: '', text: '', timestampMs: 0),
+ )
+ .roomName;
+ if (roomName != null && roomName.isNotEmpty) {
+ return (conversationTitle: roomName, groupConversation: true);
+ }
+ final distinctSenders = messages.map((m) => m.sender).toSet().length;
+ if (distinctSenders <= 1) {
+ return (conversationTitle: null, groupConversation: false);
+ }
+ return (conversationTitle: messages.last.sender, groupConversation: true);
+}
+
+/// Persists the per-chat message history via [Localstore] so both the
+/// foreground and the FCM background isolate build the same stacked
+/// notification. Complements [NidStore], which keeps the nid → chat mapping
+/// needed to resolve delete-pushes.
+class ChatThreadStore {
+ static const _collection = 'push_chat_threads';
+
+ final Localstore _db;
+
+ ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;
+
+ /// Localstore doc ids become file names — tokens are normally URL-safe and
+ /// used verbatim, anything else is base64url-encoded.
+ static String docIdForToken(String chatToken) =>
+ RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
+ ? chatToken
+ : base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
+
+ Future> messages(String chatToken) async {
+ final data = await _doc(chatToken).get();
+ final raw = data?['messages'];
+ if (raw is! List) return const [];
+ return raw
+ .whereType