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

This commit is contained in:
2026-07-05 22:48:04 +02:00
parent 35e144799e
commit 483fea62ba
31 changed files with 2807 additions and 320 deletions
+210
View File
@@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:localstore/localstore.dart';
/// Maximum messages kept per chat thread — enough for a messenger-style
/// notification, small enough to stay cheap in the background isolate.
const int kChatThreadCap = 15;
/// Canonical per-chat notification tag (kept in sync with
/// `NotificationTasks.chatTag`, which delegates here).
String chatNotificationTag(String chatToken) => 'talk_$chatToken';
/// Deterministic non-negative 31-bit notification id per chat, so every
/// message of the same conversation updates ONE stacked notification instead
/// of adding a new card per push.
int stableChatNotificationId(String chatToken) {
var hash = 0;
for (final unit in chatToken.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
/// One message inside a chat's notification history.
class ThreadMessage {
final int nid;
final String sender;
final String text;
final int timestampMs;
/// Room name parsed from a group-chat subject (`<sender> in <room>`);
/// null for 1:1 chats and for history entries written before this field
/// existed (tolerated on read — they simply carry no room information).
final String? roomName;
const ThreadMessage({
required this.nid,
required this.sender,
required this.text,
required this.timestampMs,
this.roomName,
});
Map<String, dynamic> toJson() => {
'nid': nid,
'sender': sender,
'text': text,
'timestampMs': timestampMs,
'roomName': ?roomName,
};
factory ThreadMessage.fromJson(Map<String, dynamic> json) => ThreadMessage(
nid: (json['nid'] as num).toInt(),
sender: json['sender'] as String? ?? '',
text: json['text'] as String? ?? '',
timestampMs: (json['timestampMs'] as num?)?.toInt() ?? 0,
roomName: json['roomName'] as String?,
);
}
/// Appends [message] to [messages], replacing an entry with the same nid
/// (redelivered push) and capping the history at [cap] (oldest dropped).
/// Pure so the stacking behavior is unit-testable.
List<ThreadMessage> appendThreadMessage(
List<ThreadMessage> messages,
ThreadMessage message, {
int cap = kChatThreadCap,
}) {
final result = [...messages.where((m) => m.nid != message.nid), message];
return result.length > cap ? result.sublist(result.length - cap) : result;
}
/// Removes the message with [nid]; the caller cancels the notification when
/// the returned list is empty and re-renders it silently otherwise.
List<ThreadMessage> removeThreadNid(List<ThreadMessage> messages, int nid) =>
messages.where((m) => m.nid != nid).toList();
/// History for the next notification after [message] arrives, given whether
/// the chat's previous notification is still on screen ([isActive]):
///
/// - `true` → the user hasn't dismissed/read it, so [message] STACKS onto the
/// existing history.
/// - `false` → the notification is gone (swiped away or the chat was read
/// without our cleanup running), so the thread RESTARTS with only [message].
/// - `null` → the active-notification probe failed or isn't supported; keep
/// stacking defensively — degraded stacking never loses a message.
///
/// Android provides no reliable "notification dismissed" callback, so the
/// visible-state probe at append time is the substitute.
List<ThreadMessage> threadAfterIncoming(
List<ThreadMessage> existing,
ThreadMessage message,
bool? isActive, {
int cap = kChatThreadCap,
}) {
final base = isActive == false ? const <ThreadMessage>[] : existing;
return appendThreadMessage(base, message, cap: cap);
}
/// MessagingStyle header per Android convention: a 1:1 chat gets NO
/// `conversationTitle` — the system then shows the person once as the header
/// and plain texts per line; setting a title would repeat the name on every
/// row. Groups get the ROOM NAME as title (parsed from the subject) plus
/// per-line sender names; when no room name is known, >1 distinct sender
/// still marks the thread as group with the last sender as title fallback.
({String? conversationTitle, bool groupConversation}) conversationHeader(
List<ThreadMessage> messages,
) {
final roomName = messages
.lastWhere(
(m) => m.roomName?.isNotEmpty ?? false,
orElse: () =>
const ThreadMessage(nid: 0, sender: '', text: '', timestampMs: 0),
)
.roomName;
if (roomName != null && roomName.isNotEmpty) {
return (conversationTitle: roomName, groupConversation: true);
}
final distinctSenders = messages.map((m) => m.sender).toSet().length;
if (distinctSenders <= 1) {
return (conversationTitle: null, groupConversation: false);
}
return (conversationTitle: messages.last.sender, groupConversation: true);
}
/// Persists the per-chat message history via [Localstore] so both the
/// foreground and the FCM background isolate build the same stacked
/// notification. Complements [NidStore], which keeps the nid → chat mapping
/// needed to resolve delete-pushes.
class ChatThreadStore {
static const _collection = 'push_chat_threads';
final Localstore _db;
ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;
/// Localstore doc ids become file names — tokens are normally URL-safe and
/// used verbatim, anything else is base64url-encoded.
static String docIdForToken(String chatToken) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
Future<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get();
final raw = data?['messages'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(ThreadMessage.fromJson)
.toList();
}
/// Appends [message] and returns the updated (capped) history.
Future<List<ThreadMessage>> append(
String chatToken,
ThreadMessage message,
) async {
final updated = appendThreadMessage(await messages(chatToken), message);
await _write(chatToken, updated);
return updated;
}
/// Appends [message], but restarts the thread first when the chat's previous
/// notification is no longer active ([isActive] == false — dismissed/read).
/// See [threadAfterIncoming] for the tri-state semantics.
Future<List<ThreadMessage>> appendConsideringActive(
String chatToken,
ThreadMessage message,
bool? isActive,
) async {
final updated = threadAfterIncoming(
await messages(chatToken),
message,
isActive,
);
await _write(chatToken, updated);
return updated;
}
/// Removes the message with [nid] and returns the remaining history.
Future<List<ThreadMessage>> removeNid(String chatToken, int nid) async {
final remaining = removeThreadNid(await messages(chatToken), nid);
if (remaining.isEmpty) {
await clearChat(chatToken);
} else {
await _write(chatToken, remaining);
}
return remaining;
}
Future<void> clearChat(String chatToken) => _doc(chatToken).delete();
Future<void> clearAll() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return;
for (final id in docs.keys) {
// Localstore keys are full document paths (/push_chat_threads/<id>).
await _db.collection(_collection).doc(id.split('/').last).delete();
}
}
DocumentRef _doc(String chatToken) =>
_db.collection(_collection).doc(docIdForToken(chatToken));
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
_doc(chatToken).set({
'messages': [for (final m in messages) m.toJson()],
});
}
+26 -8
View File
@@ -39,17 +39,24 @@ class NextcloudPushApi {
NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client();
/// Registers (or refreshes) this device. [devicePublicKeyPem] must be the
/// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base
/// URL (with trailing slash).
/// Registers (or refreshes) a device subscription. [devicePublicKeyPem] must
/// be the 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy
/// base URL (with trailing slash).
///
/// [authorizationHeader] overrides the Basic auth (e.g. the Talk app
/// password — NC binds the subscription to the authenticated session token).
/// [userAgent] overrides the UA; a Talk-pattern UA makes NC classify the
/// subscription as apptype `talk` and route Talk pushes to it.
Future<NextcloudPushRegistration> register({
required String pushTokenHash,
required String devicePublicKeyPem,
required String proxyServer,
String? authorizationHeader,
String? userAgent,
}) async {
final response = await _client.post(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
headers: _headers(authorizationHeader, userAgent),
body: {
'pushTokenHash': pushTokenHash,
'devicePublicKey': devicePublicKeyPem,
@@ -76,13 +83,24 @@ class NextcloudPushApi {
);
}
/// Unregisters this device from Nextcloud push. Returns true when the server
/// responded 202, meaning the proxy subscription should also be removed.
Future<bool> unregister() async {
/// Unregisters one device subscription — the DELETE is per session token, so
/// each registration is removed with its own app password via
/// [authorizationHeader]. Returns true when the server responded 202,
/// meaning the proxy subscription should also be removed.
Future<bool> unregister({String? authorizationHeader}) async {
final response = await _client.delete(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
headers: _headers(authorizationHeader, null),
);
return response.statusCode == 202;
}
Map<String, String> _headers(
String? authorizationHeader,
String? userAgent,
) => {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
'User-Agent': ?userAgent,
};
}
+113 -21
View File
@@ -1,12 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_renderer.dart';
/// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them).
@@ -24,60 +29,147 @@ class PushActions {
static Future<void> handleBackgroundResponse(
NotificationResponse response,
) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever.
DartPluginRegistrant.ensureInitialized();
final chatToken = _chatTokenFrom(response.payload);
if (chatToken == null) return;
switch (response.actionId) {
case kTalkReplyActionId:
final text = response.input?.trim();
if (text != null && text.isNotEmpty) {
await sendReply(chatToken, text);
final sent = text != null && text.isNotEmpty
? await sendReply(chatToken, text)
: false;
if (sent) {
// The user has evidently seen the chat — set the read marker like
// the mark-read action does.
await markRead(chatToken);
}
await markRead(chatToken);
await finishReply(chatToken: chatToken, sent: sent);
_cleanupNidEntry(response);
break;
case kTalkMarkReadActionId:
await markRead(chatToken);
await _cancelForToken(response);
await _cleanupChat(chatToken);
_cleanupNidEntry(response);
break;
default:
break;
}
}
static Future<void> sendReply(String chatToken, String message) async {
await _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
/// Ends the reply interaction: Android keeps the RemoteInput spinner alive
/// until the notification is UPDATED or REMOVED. Success removes the whole
/// notification (history cleared + cancel — the chat is read); failure
/// re-renders the unchanged thread silently so the spinner stops (the error
/// itself is only loggable on notification level). Injectable seams so
/// tests can observe the flow without platform channels.
static Future<void> finishReply({
required String chatToken,
required bool sent,
Future<void> Function(String chatToken)? cleanupChat,
Future<List<ThreadMessage>> Function(String chatToken)? loadThread,
Future<void> Function(String chatToken, List<ThreadMessage> messages)?
renderSilent,
Future<void> Function(String chatToken)? cancelNotification,
}) async {
cleanupChat ??= _cleanupChat;
loadThread ??= (token) => ChatThreadStore().messages(token);
renderSilent ??= (token, messages) =>
PushRenderer().renderTalkThread(token, messages, alert: false);
cancelNotification ??= _cancelChatNotification;
if (sent) {
await cleanupChat(chatToken);
return;
}
final messages = await loadThread(chatToken);
if (messages.isEmpty) {
// Nothing to re-render (history cleared meanwhile) — cancel instead so
// the spinner cannot survive.
await cancelNotification(chatToken);
return;
}
await renderSilent(chatToken, messages);
}
static Future<void> markRead(String chatToken) async {
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
}
/// Sends the inline reply. Success = any 2xx (the Talk chat POST answers
/// 201 Created). Path/body match the app's working send path
/// (`SendMessage`: `v1/chat/{token}`, form field `message`).
static Future<bool> sendReply(String chatToken, String message) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<void> _ocsPost(String path, {Map<String, String>? body}) async {
static Future<bool> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
static Future<bool> _ocsPost(String path, {Map<String, String>? body}) async {
try {
await AccountData().waitForPopulation();
// Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever.
await AccountData().waitForPopulation().timeout(
const Duration(seconds: 10),
);
final response = await http.post(
NextcloudOcs.uri(path),
headers: NextcloudOcs.headers(),
body: body,
);
if (response.statusCode < 200 || response.statusCode >= 300) {
final ok = response.statusCode >= 200 && response.statusCode < 300;
if (ok) {
log('Push action $path -> HTTP ${response.statusCode}');
} else {
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
log(
'Push action $path -> HTTP ${response.statusCode} '
'body=${preview.length > 300 ? '${preview.substring(0, 300)}' : preview}',
);
}
return ok;
} on Object catch (e) {
log('Push action $path failed: $e');
return false;
}
}
static Future<void> _cancelForToken(NotificationResponse response) async {
/// Mark-read cleanup: drop the stacked history (so the next push starts
/// fresh) and cancel the chat's notification.
static Future<void> _cleanupChat(String chatToken) async {
try {
await ChatThreadStore().clearChat(chatToken);
} on Object catch (e) {
log('Push action thread cleanup failed: $e');
}
await _cancelChatNotification(chatToken);
}
static Future<void> _cancelChatNotification(String chatToken) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: stableChatNotificationId(chatToken),
tag: chatNotificationTag(chatToken),
);
} on Object catch (e) {
log('Push action cancel failed: $e');
}
}
static void _cleanupNidEntry(NotificationResponse response) {
final nid = _nidFrom(response.payload);
if (nid == null) return;
try {
await NidStore().delete(nid);
} on Object catch (e) {
log('Push action nid cleanup failed: $e');
}
unawaited(
NidStore()
.delete(nid)
.then(
(_) {},
onError: (Object e) => log('Push action nid cleanup failed: $e'),
),
);
}
static String? _chatTokenFrom(String? payload) =>
+245
View File
@@ -0,0 +1,245 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart';
/// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch
/// so the caller can re-render once (silently) when it eventually delivers.
typedef AvatarIconLookup = ({Uint8List? icon, Future<Uint8List?>? late});
/// Masks avatar [bytes] into a circular PNG on a square canvas (center-cover
/// crop). Devices do NOT reliably mask Person icons themselves, so the round
/// shape is baked into the bitmap. Runs on `dart:ui`, which is available in
/// engine-backed background isolates (the FCM handler isolate) — no
/// BuildContext involved. Returns null when decoding fails; callers fall back
/// to the raw bytes.
Future<Uint8List?> maskAvatarCircular(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final size = image.width < image.height ? image.width : image.height;
final src = ui.Rect.fromLTWH(
(image.width - size) / 2,
(image.height - size) / 2,
size.toDouble(),
size.toDouble(),
);
final dst = ui.Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble());
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.clipPath(ui.Path()..addOval(dst));
canvas.drawImageRect(image, src, dst, ui.Paint()..isAntiAlias = true);
final masked = await recorder.endRecording().toImage(size, size);
final data = await masked.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
masked.dispose();
return data?.buffer.asUint8List();
} on Object catch (e) {
log('Push avatar: circular mask failed: $e');
return null;
}
}
/// Loads conversation avatars for Talk push notifications.
///
/// Uses the same Spreed endpoint as the in-app `UserAvatar` widget
/// (`room/{token}/avatar`, no query parameters — for 1:1 chats this is the
/// other person's picture), but with its own disk cache of PRE-MASKED round
/// PNGs: the widget cache is an in-memory LRU that is empty in the FCM
/// background isolate, and masking must not be recomputed per push. Every
/// failure path returns null so a missing avatar can never delay or drop a
/// notification beyond [fetchTimeout].
class PushAvatarStore {
/// Cached files older than this are treated as stale and pruned.
static const Duration maxAge = Duration(days: 14);
static const Duration _defaultFetchTimeout = Duration(seconds: 4);
// Versioned: `push_avatars` (raw, unmasked) was used before masking landed —
// reusing it would surface square icons from old cache entries.
static const _subDirectory = 'push_avatars_masked';
/// One shared fetch per token and isolate: the bounded render-time lookup
/// and the late re-render both await the SAME future, and concurrent pushes
/// don't stampede the endpoint.
static final Map<String, Future<Uint8List?>> _inflight = {};
final Future<Directory> Function() _cacheDirProvider;
/// Fetches the raw avatar response for a chat token. Injectable so tests
/// never touch the network or the account/endpoint singletons (the default
/// builds auth headers from them).
final Future<http.Response> Function(String chatToken) _fetch;
final Duration fetchTimeout;
PushAvatarStore({
Future<Directory> Function()? cacheDirProvider,
Future<http.Response> Function(String chatToken)? fetch,
this.fetchTimeout = _defaultFetchTimeout,
}) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir,
_fetch = fetch ?? _defaultFetch;
static Future<Directory> _defaultCacheDir() async {
final base = await getApplicationCacheDirectory();
return Directory('${base.path}/$_subDirectory');
}
static Future<http.Response> _defaultFetch(String chatToken) => http.get(
NextcloudOcs.uri('apps/spreed/api/v1/room/$chatToken/avatar'),
headers: {
...NextcloudOcs.headers(),
'Accept': 'image/png,image/jpeg,image/webp',
},
);
/// File-safe cache name for a chat token. Tokens are normally URL-safe
/// already and used verbatim; anything else is base64url-encoded so exotic
/// ids can never escape the cache directory.
static String fileNameForToken(String token) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
? token
: base64Url.encode(utf8.encode(token)).replaceAll('=', '');
/// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) =>
now.difference(modified) <= maxAge;
/// Spreed answers with an SVG placeholder (initials/icon) when a chat has
/// no real picture — useless for a notification bitmap, so it is skipped.
static bool looksLikeSvg(Uint8List bytes) {
final head = utf8
.decode(
bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
allowMalformed: true,
)
.trimLeft();
return head.startsWith('<?xml') || head.startsWith('<svg');
}
/// Returns the (round-masked) conversation avatar for [chatToken]. Order:
/// fresh disk cache → shared network fetch bounded by [fetchTimeout] →
/// stale disk cache. On timeout the fetch keeps running and is returned as
/// [AvatarIconLookup.late] so the caller can re-render once it delivers.
Future<AvatarIconLookup> roomAvatarIcon(String chatToken) async {
try {
final file = await _fileFor(chatToken);
final fresh = await _readCache(file, allowStale: false);
if (fresh != null) return (icon: fresh, late: null);
final fetch = _inflight.putIfAbsent(chatToken, () {
final future = _fetchProcessAndCache(chatToken, file);
future.whenComplete(() {
if (identical(_inflight[chatToken], future)) {
_inflight.remove(chatToken);
}
});
return future;
});
try {
final fetched = await fetch.timeout(fetchTimeout);
if (fetched != null) return (icon: fetched, late: null);
// Definitive miss (no picture / error) — an outdated icon beats none.
return (icon: await _readCache(file, allowStale: true), late: null);
} on TimeoutException {
// Fetch continues in the background; hand it to the caller for the
// one-time silent re-render.
return (icon: await _readCache(file, allowStale: true), late: fetch);
}
} on Object catch (e) {
log('Push avatar $chatToken: lookup failed: $e');
return (icon: null, late: null);
}
}
/// Drops the cached avatar for [chatToken] — called when the app knows the
/// picture changed (avatar upload/removal in chat settings).
static Future<void> evict(String chatToken) async {
try {
final dir = await _defaultCacheDir();
final file = File('${dir.path}/${fileNameForToken(chatToken)}');
if (file.existsSync()) await file.delete();
} on Object {
// best effort — the 14-day max age catches it eventually
}
}
Future<File> _fileFor(String chatToken) async {
final dir = await _cacheDirProvider();
await dir.create(recursive: true);
return File('${dir.path}/${fileNameForToken(chatToken)}');
}
Future<Uint8List?> _readCache(File file, {required bool allowStale}) async {
try {
if (!file.existsSync()) return null;
if (!allowStale && !isFresh(file.lastModifiedSync(), DateTime.now())) {
return null;
}
final bytes = await file.readAsBytes();
return bytes.isEmpty ? null : bytes;
} on Object {
return null;
}
}
/// Full pipeline: fetch (with outcome diagnostics) → circular mask (raw
/// fallback when masking fails) → write processed cache. Never throws.
Future<Uint8List?> _fetchProcessAndCache(String chatToken, File file) async {
try {
final raw = await _fetchAvatar(chatToken);
if (raw == null) return null;
final icon = await maskAvatarCircular(raw) ?? raw;
await file.writeAsBytes(icon, flush: true);
unawaited(_prune(file.parent));
return icon;
} on Object catch (e) {
log('Push avatar $chatToken: fetch/process failed: $e');
return null;
}
}
Future<Uint8List?> _fetchAvatar(String chatToken) async {
final response = await _fetch(chatToken);
final contentType = response.headers['content-type']?.toLowerCase() ?? '';
final bytes = response.bodyBytes;
final svgRejected =
response.statusCode == 200 &&
bytes.isNotEmpty &&
(contentType.contains('svg') || looksLikeSvg(bytes));
// Diagnostics: makes "no icon" cases attributable (status vs. svg
// placeholder vs. empty body) without a debugger on the device.
log(
'Push avatar $chatToken: HTTP ${response.statusCode} '
'type=$contentType bytes=${bytes.length} svgRejected=$svgRejected',
);
if (response.statusCode != 200 || bytes.isEmpty || svgRejected) return null;
return bytes;
}
/// Deletes cache files past [maxAge]. Fire-and-forget after a successful
/// write — the directory stays small (one file per recently active chat).
Future<void> _prune(Directory dir) async {
try {
final now = DateTime.now();
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (!isFresh(entry.lastModifiedSync(), now)) {
await entry.delete();
}
}
} on Object {
// best effort
}
}
}
+1 -25
View File
@@ -44,7 +44,7 @@ class PushKeypair {
final FlutterSecureStorageLike _storage;
const PushKeypair({FlutterSecureStorageLike? storage})
: _storage = storage ?? const _DefaultStorage();
: _storage = storage ?? const PushSecureStorage();
/// Returns the stored keypair PEMs, generating and persisting a fresh keypair
/// on first use. Generation is offloaded to an isolate because RSA-2048 key
@@ -79,27 +79,3 @@ class PushKeypair {
return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub);
}
}
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
class _DefaultStorage implements FlutterSecureStorageLike {
const _DefaultStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+31 -11
View File
@@ -4,6 +4,7 @@ import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../notification/notification_service.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_decryptor.dart';
import 'push_keypair.dart';
@@ -34,6 +35,17 @@ PushKind classifyPush(Map<String, dynamic> data) {
return PushKind.unknown;
}
/// FCM background isolate entry point. Must be a TOP-LEVEL function with the
/// entry-point pragma: AOT builds cannot invoke static class members from
/// native code unless the class itself is annotated too (DartVM error
/// "must be annotated"), so a plain function is the reliable form.
@pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel
/// the matching tray notification via [NidStore]. Works both in the FCM
/// background isolate and the foreground.
@@ -42,25 +54,19 @@ class PushMessageHandler {
final PushRegistrationStore _registrationStore;
final PushRenderer _renderer;
final NidStore _nidStore;
final ChatThreadStore _threadStore;
PushMessageHandler({
PushKeypair? keypair,
PushRegistrationStore? registrationStore,
PushRenderer? renderer,
NidStore? nidStore,
ChatThreadStore? threadStore,
}) : _keypair = keypair ?? const PushKeypair(),
_registrationStore = registrationStore ?? const PushRegistrationStore(),
_renderer = renderer ?? PushRenderer(),
_nidStore = nidStore ?? NidStore();
/// Background isolate entry point registered with
/// `FirebaseMessaging.onBackgroundMessage`.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessage(RemoteMessage message) async {
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
}
_nidStore = nidStore ?? NidStore(),
_threadStore = threadStore ?? ChatThreadStore();
/// Processes [message]. In the foreground, pass [foreground] true and
/// [openChatToken] so a message for the currently open chat is suppressed
@@ -157,6 +163,7 @@ class PushMessageHandler {
await _cancel(entry);
}
await _nidStore.clear();
await _threadStore.clearAll();
return;
}
final nids = <int>[
@@ -165,7 +172,20 @@ class PushMessageHandler {
];
for (final nid in nids) {
final entry = await _nidStore.get(nid);
if (entry != null) await _cancel(entry);
final chatToken = entry?.chatToken;
if (chatToken != null && chatToken.isNotEmpty) {
// Stacked chat notification: drop only this message from the thread.
// Remaining messages re-render WITHOUT alerting again; the last one
// going away cancels the whole card.
final remaining = await _threadStore.removeNid(chatToken, nid);
if (remaining.isEmpty) {
await _cancel(entry!);
} else {
await _renderer.renderTalkThread(chatToken, remaining, alert: false);
}
} else if (entry != null) {
await _cancel(entry);
}
await _nidStore.delete(nid);
}
}
+185 -48
View File
@@ -15,11 +15,26 @@ import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
///
/// Every device maintains TWO Nextcloud registrations (see
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
/// subscriptions once the user has any (e.g. the official Talk app).
class PushRegistration {
/// User agents matching nextcloud/server `IRequest` Talk patterns
/// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
/// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
/// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
/// NC stores it with apptype `talk`.
static const String talkUserAgentAndroid =
'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
static const String talkUserAgentIos =
'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
final PushKeypair _keypair;
final PushRegistrationStore _store;
final NextcloudPushApi _nextcloud;
@@ -34,13 +49,18 @@ class PushRegistration {
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
/// so a beta/dev build registers against the matching proxy automatically.
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
/// Public so the push status view can compare it against the stored binding.
String get currentProxyServer =>
'${MarianumConnectEndpoint.current()}/push-proxy/';
/// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes.
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
@@ -54,28 +74,94 @@ class PushRegistration {
}
}
/// Registers this device end-to-end. No-op-safe: transport failures are
/// logged and swallowed so callers can fire-and-forget.
Future<void> register() async {
/// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return;
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
return;
}
await ensureAppPassword();
await _persistNativeAuthContext();
final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword);
} on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e');
}
}
final proxyServer = _proxyServer;
final ncBaseUrl = _ncBaseUrl;
final pems = await _keypair.ensure();
/// Registers this device end-to-end: both Nextcloud registrations (general,
/// then talk) each followed by their MarianumConnect proxy registration.
/// Partial results are persisted per type — one failing registration never
/// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
/// transport failures are logged and swallowed so callers can
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
} on Object catch (e) {
log('Push: could not obtain FCM token: $e');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
await ensureAppPassword();
await ensureTalkAppPassword();
await _persistNativeAuthContext();
final PushKeypairPems pems;
try {
pems = await _keypair.ensure();
} on Object catch (e) {
log('Push: keypair unavailable: $e');
await _recordAttempts(_shortError(e));
return false;
}
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
var allOk = true;
for (final type in PushRegistrationType.values) {
final ok = await _registerType(
type: type,
fcmToken: fcmToken,
pems: pems,
appVersion: appVersion,
);
allOk = allOk && ok;
}
return allOk;
}
Future<bool> _registerType({
required PushRegistrationType type,
required String fcmToken,
required PushKeypairPems pems,
required String? appVersion,
}) async {
try {
final proxyServer = currentProxyServer;
final ncBaseUrl = currentNcBaseUrl;
final isTalk = type == PushRegistrationType.talk;
final registration = await _nextcloud.register(
pushTokenHash: generatePushTokenHash(fcmToken),
pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader()
: null,
userAgent: isTalk ? _talkUserAgent : null,
);
await _store.save(
type: type,
deviceIdentifier: registration.deviceIdentifier,
serverPublicKeyPem: registration.publicKey,
fcmToken: fcmToken,
@@ -83,27 +169,53 @@ class PushRegistration {
ncBaseUrl: ncBaseUrl,
);
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
await PushDeviceRegister().run(
deviceIdentifier: registration.deviceIdentifier,
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
registrationType: type.wireName,
appVersion: appVersion,
);
log('Push: registered (created=${registration.created})');
log(
'Push: registered ${type.wireName} '
'(created=${registration.created})',
);
await _recordAttempt(type, null);
return true;
} on Object catch (e) {
log('Push: registration failed: $e');
log('Push: ${type.wireName} registration failed: $e');
await _recordAttempt(type, _shortError(e));
return false;
}
}
/// Persists the attempt outcome for the push status view. Storage failures
/// must never mask the actual registration result.
Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
try {
await _store.saveLastRegistrationAttempt(
type: type,
at: DateTime.now(),
error: error,
);
} on Object {
// ignore — the status view simply shows the previous attempt
}
}
Future<void> _recordAttempts(String? error) async {
for (final type in PushRegistrationType.values) {
await _recordAttempt(type, error);
}
}
static String _shortError(Object e) {
final text = e.toString();
return text.length > 300 ? '${text.substring(0, 300)}' : text;
}
/// Writes the username and Nextcloud base URL into the shared keychain so the
/// native iOS Talk action handler can authenticate OCS calls without the
/// Flutter engine. Best-effort — a failure here must not abort registration.
@@ -119,19 +231,28 @@ class PushRegistration {
}
}
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
/// Removes both subscriptions from Nextcloud and the proxy. Best-effort
/// each step is independent so one failure never blocks the rest.
Future<void> unregister() async {
final deviceIdentifier = await _store.deviceIdentifier();
try {
await _nextcloud.unregister();
} on Object catch (e) {
log('Push: NC unregister failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
for (final type in PushRegistrationType.values) {
final deviceIdentifier = await _store.deviceIdentifier(type);
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
// The DELETE removes the subscription bound to the authenticating
// session token — each registration with its own app password.
await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader()
: null,
);
} on Object catch (e) {
log('Push: proxy unregister failed: $e');
log('Push: NC unregister (${type.wireName}) failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
} on Object catch (e) {
log('Push: proxy unregister (${type.wireName}) failed: $e');
}
}
}
await _store.clear();
@@ -146,19 +267,24 @@ class PushRegistration {
required String current,
}) => registered != null && registered.isNotEmpty && registered != current;
/// True when an existing registration was made against a different
/// True when any existing registration was made against a different
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
/// configured (dev-tools endpoint switch, live/beta/custom).
Future<bool> needsEndpointReRegistration() async {
if (!await _store.isRegistered()) return false;
return endpointChanged(
registered: await _store.registeredProxyServer(),
current: _proxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(),
current: _ncBaseUrl,
);
for (final type in PushRegistrationType.values) {
if (!await _store.isRegistered(type)) continue;
final changed =
endpointChanged(
registered: await _store.registeredProxyServer(type),
current: currentProxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(type),
current: currentNcBaseUrl,
);
if (changed) return true;
}
return false;
}
/// Re-registers when the active endpoints diverge from the registered ones.
@@ -236,9 +362,10 @@ class PushRegistration {
/// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register();
/// Full teardown for logout: unregister push, revoke the app password, then
/// clear it locally. Ordered so the proxy stops pushing before credentials
/// are gone.
/// Full teardown for logout: unregister push, revoke BOTH app passwords
/// (each authenticated with itself — the endpoint revokes the credential it
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
await unregister();
try {
@@ -246,6 +373,16 @@ class PushRegistration {
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
try {
if (AccountData().hasAppPasswordTalk()) {
await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
);
}
} on Object catch (e) {
log('Push: delete talk app password failed: $e');
}
await AccountData().clearAppPassword();
await AccountData().clearAppPasswordTalk();
}
}
+101 -38
View File
@@ -1,16 +1,25 @@
import 'push_registration_type.dart';
import 'push_secure_storage.dart';
/// Persists the bookkeeping produced by a successful push registration:
/// the Nextcloud device identifier, the per-user server public key (needed to
/// verify incoming push signatures), the FCM token the registration was made
/// with (so a token refresh can be detected) and the endpoints it was bound to
/// (so an endpoint switch in the dev tools can be detected).
/// Persists the bookkeeping produced by successful push registrations —
/// per [PushRegistrationType]: the Nextcloud device identifier, the FCM token
/// the registration was made with (so a token refresh can be detected), the
/// endpoints it was bound to (so an endpoint switch in the dev tools can be
/// detected) and the last attempt outcome. The server public key and the
/// device keypair are shared between both registrations.
///
/// Key layout: the `general` type uses the pre-dual key names unchanged, so
/// existing installs are implicitly migrated — their stored registration IS
/// the general one; the missing talk registration is added by the next
/// register-on-start self-heal.
class PushRegistrationStore {
static const _deviceIdentifierKey = 'push_device_identifier';
static const _serverPublicKeyKey = 'push_server_public_key_pem';
static const _registeredTokenKey = 'push_registered_fcm_token';
static const _proxyServerKey = 'push_registered_proxy_server';
static const _ncBaseUrlKey = 'push_registered_nc_base_url';
static const _lastAttemptAtKey = 'push_last_registration_at';
static const _lastAttemptErrorKey = 'push_last_registration_error';
// Native-only context: the iOS AppDelegate answers Talk notification actions
// (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the
@@ -19,26 +28,46 @@ class PushRegistrationStore {
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
const PushRegistrationStore();
static const _perTypeKeys = [
_deviceIdentifierKey,
_registeredTokenKey,
_proxyServerKey,
_ncBaseUrlKey,
_lastAttemptAtKey,
_lastAttemptErrorKey,
];
final FlutterSecureStorageLike _storage;
const PushRegistrationStore([this._storage = const PushSecureStorage()]);
/// Type-specific key: general keeps the legacy names (implicit migration of
/// pre-dual installs), talk appends a suffix.
static String keyFor(String baseKey, PushRegistrationType type) =>
type == PushRegistrationType.general ? baseKey : '${baseKey}_talk';
Future<void> save({
required PushRegistrationType type,
required String deviceIdentifier,
required String serverPublicKeyPem,
required String fcmToken,
required String proxyServer,
required String ncBaseUrl,
}) async {
await pushSecureStorage.write(
key: _deviceIdentifierKey,
await _storage.write(
key: keyFor(_deviceIdentifierKey, type),
value: deviceIdentifier,
);
await pushSecureStorage.write(
key: _serverPublicKeyKey,
value: serverPublicKeyPem,
await _storage.write(key: _serverPublicKeyKey, value: serverPublicKeyPem);
await _storage.write(
key: keyFor(_registeredTokenKey, type),
value: fcmToken,
);
await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken);
await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer);
await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl);
await _storage.write(
key: keyFor(_proxyServerKey, type),
value: proxyServer,
);
await _storage.write(key: keyFor(_ncBaseUrlKey, type), value: ncBaseUrl);
}
/// Persists the username and Nextcloud base URL group-scoped so the native
@@ -49,39 +78,73 @@ class PushRegistrationStore {
required String username,
required String baseUrl,
}) async {
await pushSecureStorage.write(key: _usernameKey, value: username);
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
await _storage.write(key: _usernameKey, value: username);
await _storage.write(key: _baseUrlKey, value: baseUrl);
}
Future<String?> deviceIdentifier() =>
pushSecureStorage.read(key: _deviceIdentifierKey);
Future<String?> deviceIdentifier(PushRegistrationType type) =>
_storage.read(key: keyFor(_deviceIdentifierKey, type));
/// Per-user server public key — identical for both registrations.
Future<String?> serverPublicKeyPem() =>
pushSecureStorage.read(key: _serverPublicKeyKey);
_storage.read(key: _serverPublicKeyKey);
Future<String?> registeredFcmToken() =>
pushSecureStorage.read(key: _registeredTokenKey);
Future<String?> registeredFcmToken(PushRegistrationType type) =>
_storage.read(key: keyFor(_registeredTokenKey, type));
/// Proxy-server URL the current registration was made with.
Future<String?> registeredProxyServer() =>
pushSecureStorage.read(key: _proxyServerKey);
/// Proxy-server URL the registration of [type] was made with.
Future<String?> registeredProxyServer(PushRegistrationType type) =>
_storage.read(key: keyFor(_proxyServerKey, type));
/// Nextcloud base URL the current registration was made against.
Future<String?> registeredNcBaseUrl() =>
pushSecureStorage.read(key: _ncBaseUrlKey);
/// Nextcloud base URL the registration of [type] was made against.
Future<String?> registeredNcBaseUrl(PushRegistrationType type) =>
_storage.read(key: keyFor(_ncBaseUrlKey, type));
/// True when a registration has been persisted (used by the cold-start
/// self-heal to decide whether to (re-)register).
Future<bool> isRegistered() async =>
(await registeredFcmToken())?.isNotEmpty ?? false;
/// True when a registration of [type] has been persisted.
Future<bool> isRegistered(PushRegistrationType type) async =>
(await registeredFcmToken(type))?.isNotEmpty ?? false;
/// Records the outcome of the most recent registration attempt of [type] so
/// the push status view can show when it ran and why it failed. [error]
/// null = success (stored as empty string).
Future<void> saveLastRegistrationAttempt({
required PushRegistrationType type,
required DateTime at,
String? error,
}) async {
await _storage.write(
key: keyFor(_lastAttemptAtKey, type),
value: at.toIso8601String(),
);
await _storage.write(
key: keyFor(_lastAttemptErrorKey, type),
value: error ?? '',
);
}
/// Timestamp of the last registration attempt of [type], or null when none
/// ran yet.
Future<DateTime?> lastRegistrationAt(PushRegistrationType type) async {
final raw = await _storage.read(key: keyFor(_lastAttemptAtKey, type));
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
/// Error text of the last registration attempt of [type], or null when it
/// succeeded (or never ran).
Future<String?> lastRegistrationError(PushRegistrationType type) async {
final raw = await _storage.read(key: keyFor(_lastAttemptErrorKey, type));
return (raw == null || raw.isEmpty) ? null : raw;
}
Future<void> clear() async {
await pushSecureStorage.delete(key: _deviceIdentifierKey);
await pushSecureStorage.delete(key: _serverPublicKeyKey);
await pushSecureStorage.delete(key: _registeredTokenKey);
await pushSecureStorage.delete(key: _proxyServerKey);
await pushSecureStorage.delete(key: _ncBaseUrlKey);
await pushSecureStorage.delete(key: _usernameKey);
await pushSecureStorage.delete(key: _baseUrlKey);
for (final type in PushRegistrationType.values) {
for (final key in _perTypeKeys) {
await _storage.delete(key: keyFor(key, type));
}
}
await _storage.delete(key: _serverPublicKeyKey);
await _storage.delete(key: _usernameKey);
await _storage.delete(key: _baseUrlKey);
}
}
+35
View File
@@ -0,0 +1,35 @@
/// The two Nextcloud push registrations every device maintains.
///
/// Nextcloud routes Talk notifications ONLY to registrations whose session
/// user agent matched `IRequest::USER_AGENT_TALK_*` (apptype `talk`) as soon
/// as the user has at least one such device — e.g. the official Talk app.
/// A single `unknown` registration therefore stops receiving Talk pushes the
/// moment the official app is installed. The fix is one registration per
/// route, sharing the same device keypair.
enum PushRegistrationType {
/// Default registration (apptype `unknown`): files, calendar, everything
/// except Talk.
general('general'),
/// Talk registration (apptype `talk`): Talk messages and calls, ranking
/// equally beside the official Talk app.
talk('talk');
/// Value sent to MarianumConnect as `registrationType`.
final String wireName;
const PushRegistrationType(this.wireName);
}
/// Pseudo push token whose sha512 is registered with Nextcloud. The two
/// registrations MUST carry different pushTokenHashes: on registration NC's
/// `deletePushTokenByHash()` removes older subscriptions of the same user
/// with an identical hash — identical hashes would let each registration
/// delete its sibling. The backend derives the same variants from the real
/// FCM token (`registrationType` field), so the proxy hash comparison still
/// matches. Nextcloud never sees the plain token either way.
String pushTokenVariant(String fcmToken, PushRegistrationType type) =>
switch (type) {
PushRegistrationType.general => fcmToken,
PushRegistrationType.talk => '$fcmToken#talk',
};
+190 -47
View File
@@ -1,17 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../notification/notification_service.dart';
import '../notification/notification_tasks.dart';
import '../theming/light_app_theme.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_actions.dart';
import 'push_avatar.dart';
import 'push_subject.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages get a [MessagingStyleInformation] with inline
/// reply + mark-as-read actions and a per-chat tag; everything else renders in
/// a generic channel.
/// notifications. Talk messages of one chat stack into a SINGLE
/// [MessagingStyleInformation] notification (stable id/tag per chat, history
/// from [ChatThreadStore]) with inline reply + mark-as-read actions;
/// everything else renders in a generic channel.
class PushRenderer {
static const talkChannelId = 'talk_messages';
static const talkChannelName = 'Talk-Nachrichten';
@@ -20,9 +25,21 @@ class PushRenderer {
static const String iosTalkCategory = 'TALK_MESSAGE';
final NidStore _nidStore;
/// Brand accent: colors the (monochrome) small icon and action buttons in
/// the notification shade instead of the default grey.
static const _accentColor = LightAppTheme.marianumRed;
PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore();
final NidStore _nidStore;
final PushAvatarStore _avatarStore;
final ChatThreadStore _threadStore;
PushRenderer({
NidStore? nidStore,
PushAvatarStore? avatarStore,
ChatThreadStore? threadStore,
}) : _nidStore = nidStore ?? NidStore(),
_avatarStore = avatarStore ?? PushAvatarStore(),
_threadStore = threadStore ?? ChatThreadStore();
FlutterLocalNotificationsPlugin get _plugin =>
NotificationService().flutterLocalNotificationsPlugin;
@@ -60,24 +77,120 @@ class PushRenderer {
}
}
/// Tri-state probe of whether this chat's stacked notification is still in
/// the tray. `null` when the query fails or isn't supported (native code not
/// linked, older platform) — the caller then keeps stacking defensively. A
/// successful query is authoritative: absence means dismissed/read.
/// Works in the background isolate (pure plugin call, no BuildContext).
Future<bool?> _isChatNotificationActive(String chatToken) async {
final id = stableChatNotificationId(chatToken);
final tag = chatNotificationTag(chatToken);
try {
final actives = await _plugin.getActiveNotifications();
return actives.any((n) => n.id == id || n.tag == tag);
} on Object catch (e) {
log('Push: getActiveNotifications probe failed: $e');
return null;
}
}
Future<void> _renderTalk(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.id);
final chatToken = subject.id;
final tag = chatToken != null
? NotificationTasks.chatTag(chatToken)
: 'talk_$nid';
final text = subject.subject ?? 'Neue Nachricht';
final (senderName, messageText) = _splitSender(text);
final parsed = parseTalkSubject(subject.subject ?? 'Neue Nachricht');
final nid =
subject.nid ??
_fallbackId('${chatToken ?? ''}${parsed.sender}${parsed.text}');
final payload = _payload(chatToken: chatToken, nid: nid);
if (chatToken == null || chatToken.isEmpty) {
// Without a chat token there is nothing to stack under — render a
// standalone card keyed by the nid.
await _renderTalkStandalone(nid, parsed.sender, parsed.text);
return;
}
// Restart the thread when the previous notification is gone (swiped away
// or the chat was read without our cleanup running) — otherwise the new
// push would resurrect the already-seen history. A failed/unsupported
// probe keeps stacking (see threadAfterIncoming).
final isActive = await _isChatNotificationActive(chatToken);
final messages = await _threadStore.appendConsideringActive(
chatToken,
ThreadMessage(
nid: nid,
sender: parsed.sender,
text: parsed.text,
timestampMs: DateTime.now().millisecondsSinceEpoch,
roomName: parsed.roomName,
),
isActive,
);
await _nidStore.put(
NidEntry(
nid: nid,
notificationId: stableChatNotificationId(chatToken),
tag: chatNotificationTag(chatToken),
chatToken: chatToken,
),
);
await renderTalkThread(chatToken, messages);
}
/// Shows/updates the ONE stacked notification of [chatToken] from the given
/// history. Identity is stable per chat ([stableChatNotificationId] +
/// [chatNotificationTag]) so each new message updates the same card.
/// [alert] false re-renders silently — used when a delete-push removed one
/// of several messages and the remaining thread must not ping again.
Future<void> renderTalkThread(
String chatToken,
List<ThreadMessage> messages, {
bool alert = true,
}) async {
if (messages.isEmpty) return;
final tag = chatNotificationTag(chatToken);
final id = stableChatNotificationId(chatToken);
final latest = messages.last;
// Conversation avatar (the person's picture in 1:1 chats), pre-masked
// round. Bounded by the store's fetch timeout; when the icon isn't ready
// in time the notification renders without it and a still-running fetch
// triggers ONE silent re-render below once bytes arrive.
final lookup = await _avatarStore.roomAvatarIcon(chatToken);
final avatarBytes = lookup.icon;
if (alert && avatarBytes == null && lookup.late != null) {
unawaited(
lookup.late!.then((bytes) async {
if (bytes == null) return;
final fresh = await _threadStore.messages(chatToken);
if (fresh.isEmpty) return;
// Re-render hits the processed cache → icon present, no re-schedule.
await renderTalkThread(chatToken, fresh, alert: false);
}),
);
}
final senders = <String, Person>{};
Person personFor(String sender) => senders.putIfAbsent(
sender,
() => Person(
key: sender,
name: sender,
icon: avatarBytes == null ? null : ByteArrayAndroidIcon(avatarBytes),
),
);
final styleMessages = [
for (final m in messages)
Message(
m.text,
DateTime.fromMillisecondsSinceEpoch(m.timestampMs),
personFor(m.sender),
),
];
final header = conversationHeader(messages);
final messagingStyle = MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
conversationTitle: senderName,
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
conversationTitle: header.conversationTitle,
groupConversation: header.groupConversation,
messages: styleMessages,
);
final androidDetails = AndroidNotificationDetails(
@@ -86,50 +199,89 @@ class PushRenderer {
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
silent: !alert,
styleInformation: messagingStyle,
actions: const [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
],
actions: _talkActions,
);
final iosDetails = DarwinNotificationDetails(
threadIdentifier: tag,
categoryIdentifier: iosTalkCategory,
presentSound: alert ? null : false,
presentBanner: alert ? null : false,
);
await _nidStore.put(
NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken),
await _plugin.show(
id: id,
title: latest.sender,
body: latest.text,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: _payload(chatToken: chatToken, nid: latest.nid),
);
}
Future<void> _renderTalkStandalone(
int nid,
String senderName,
String messageText,
) async {
final tag = 'talk_$nid';
await _nidStore.put(NidEntry(nid: nid, notificationId: nid, tag: tag));
await _plugin.show(
id: nid,
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: iosDetails,
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag,
// Single sender ⇒ no conversationTitle (1:1 convention): the sender
// Person is the header, a title would repeat the name per line.
styleInformation: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
groupConversation: false,
messages: [
Message(messageText, DateTime.now(), Person(name: senderName)),
],
),
),
iOS: const DarwinNotificationDetails(),
),
payload: payload,
payload: _payload(chatToken: null, nid: nid),
);
}
static const _talkActions = [
AndroidNotificationAction(
kTalkReplyActionId,
'Antworten',
showsUserInterface: false,
cancelNotification: false,
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
),
AndroidNotificationAction(
kTalkMarkReadActionId,
'Gelesen',
showsUserInterface: false,
),
];
Future<void> _renderGeneric(PushSubject subject) async {
final nid = subject.nid ?? _fallbackId(subject.subject);
const androidDetails = AndroidNotificationDetails(
generalChannelId,
generalChannelName,
color: _accentColor,
);
const iosDetails = DarwinNotificationDetails();
await _nidStore.put(
@@ -160,6 +312,7 @@ class PushRenderer {
generalChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
);
await _plugin.show(
id: id,
@@ -173,16 +326,6 @@ class PushRenderer {
String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Splits a `"Sender: message"` subject into its parts, falling back to a
/// generic sender label when there's no delimiter.
(String, String) _splitSender(String subject) {
final idx = subject.indexOf(': ');
if (idx > 0 && idx < subject.length - 2) {
return (subject.substring(0, idx), subject.substring(idx + 2));
}
return ('Talk', subject);
}
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
+25
View File
@@ -25,3 +25,28 @@ const IOSOptions kPushIosOptions = IOSOptions(
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions,
);
/// Minimal storage contract so tests can inject an in-memory fake instead of
/// touching the platform keystore.
abstract class FlutterSecureStorageLike {
Future<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
/// Default [FlutterSecureStorageLike] backed by [pushSecureStorage].
class PushSecureStorage implements FlutterSecureStorageLike {
const PushSecureStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+278
View File
@@ -0,0 +1,278 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import '../model/account_data.dart';
import 'push_keypair.dart';
import 'push_registration.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
/// Tri-state result of a single push-chain check.
enum PushCheck { ok, fail, unknown }
/// State of one of the two registrations (general/talk) — Nextcloud binding,
/// proxy binding and last attempt outcome.
@immutable
class PushTypeStatus {
/// Nextcloud subscription present (device identifier stored).
final bool nextcloudRegistered;
final String? registeredNcBaseUrl;
final String? registeredProxyServer;
final DateTime? lastRegistrationAt;
/// Error text of the last registration attempt; null = success or never ran.
final String? lastRegistrationError;
const PushTypeStatus({
required this.nextcloudRegistered,
required this.registeredNcBaseUrl,
required this.registeredProxyServer,
required this.lastRegistrationAt,
required this.lastRegistrationError,
});
}
/// Snapshot of every link in the push chain, in delivery order. Pure data —
/// the display rows are derived by [buildPushStatusRows] so the mapping is
/// unit-testable without any plugin.
@immutable
class PushStatusReport {
final bool settingEnabled;
final PushCheck osPermission;
/// Backend capability `pushNotifications`; [PushCheck.unknown] while the
/// capabilities have not been loaded yet this session.
final PushCheck serverCapability;
final bool appPasswordPresent;
final bool talkAppPasswordPresent;
final bool keypairPresent;
/// State of the general (apptype unknown) registration.
final PushTypeStatus general;
/// State of the Talk-classified registration.
final PushTypeStatus talk;
final String? currentProxyServer;
const PushStatusReport({
required this.settingEnabled,
required this.osPermission,
required this.serverCapability,
required this.appPasswordPresent,
required this.talkAppPasswordPresent,
required this.keypairPresent,
required this.general,
required this.talk,
required this.currentProxyServer,
});
/// True when the stored proxy binding of [status] diverges from the active
/// endpoint.
bool proxyEndpointMismatch(PushTypeStatus status) =>
currentProxyServer != null &&
PushRegistration.endpointChanged(
registered: status.registeredProxyServer,
current: currentProxyServer!,
);
/// True when a test notification can actually be delivered. The test push
/// is a Connect direct push routed via the general registration, so a
/// healthy general chain suffices — a broken talk registration only affects
/// Talk message delivery. The OS permission must not be denied (`unknown`
/// stays permissive, mirroring [PushRegistration.isPermissionUsable]).
bool get readyForTestNotification =>
osPermission != PushCheck.fail &&
general.nextcloudRegistered &&
(general.registeredProxyServer?.isNotEmpty ?? false) &&
!proxyEndpointMismatch(general) &&
general.lastRegistrationError == null;
}
/// Collects the current push chain state. Settings/capability flags come from
/// the caller (they live in cubits); everything else is read from the secure
/// stores and the messaging plugin.
Future<PushStatusReport> collectPushStatus({
required bool settingEnabled,
required bool capabilityPush,
required bool capabilitiesLoaded,
PushRegistrationStore store = const PushRegistrationStore(),
PushKeypair keypair = const PushKeypair(),
}) async {
final registration = PushRegistration();
String? currentProxy;
try {
currentProxy = registration.currentProxyServer;
} on Object {
currentProxy = null;
}
Future<PushTypeStatus> typeStatus(PushRegistrationType type) async =>
PushTypeStatus(
nextcloudRegistered:
(await store.deviceIdentifier(type))?.isNotEmpty ?? false,
registeredNcBaseUrl: await store.registeredNcBaseUrl(type),
registeredProxyServer: await store.registeredProxyServer(type),
lastRegistrationAt: await store.lastRegistrationAt(type),
lastRegistrationError: await store.lastRegistrationError(type),
);
return PushStatusReport(
settingEnabled: settingEnabled,
osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded
? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(),
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(),
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk),
currentProxyServer: currentProxy,
);
}
Future<PushCheck> _osPermission() async {
try {
final settings = await FirebaseMessaging.instance.getNotificationSettings();
switch (settings.authorizationStatus) {
case AuthorizationStatus.authorized:
case AuthorizationStatus.provisional:
return PushCheck.ok;
case AuthorizationStatus.denied:
return PushCheck.fail;
case AuthorizationStatus.notDetermined:
return PushCheck.unknown;
}
} on Object {
return PushCheck.unknown;
}
}
/// One line in the status checklist.
@immutable
class PushStatusRow {
final String label;
final PushCheck state;
/// Short explanation shown as subtitle — set for failures (what to do) and
/// for informational details (e.g. the registered URL).
final String? detail;
const PushStatusRow({required this.label, required this.state, this.detail});
}
const _pendingDetail =
'Registrierung ausstehend — sie wird beim nächsten App-Start '
'automatisch wiederholt';
/// Derives the display checklist from a [PushStatusReport]. Pure — the order
/// mirrors the actual chain: setting → OS → server → credentials → keys →
/// Nextcloud (general/talk) → Connect (general/talk).
List<PushStatusRow> buildPushStatusRows(PushStatusReport r) => [
PushStatusRow(
label: 'Push-Benachrichtigungen aktiviert',
state: r.settingEnabled ? PushCheck.ok : PushCheck.fail,
detail: r.settingEnabled
? null
: 'In den Einstellungen deaktiviert — über den Schalter oben aktivieren',
),
PushStatusRow(
label: 'Benachrichtigungsberechtigung',
state: r.osPermission,
detail: switch (r.osPermission) {
PushCheck.ok => null,
PushCheck.fail =>
'Die Benachrichtigungsberechtigung wurde in den Systemeinstellungen '
'deaktiviert',
PushCheck.unknown => 'Noch nicht erteilt',
},
),
PushStatusRow(
label: 'Server-Unterstützung',
state: r.serverCapability,
detail: switch (r.serverCapability) {
PushCheck.ok => null,
PushCheck.fail =>
'Der Server unterstützt Push-Benachrichtigungen derzeit nicht',
PushCheck.unknown => 'Serverinformationen noch nicht geladen',
},
),
PushStatusRow(
label: 'App-Passwörter',
state: r.appPasswordPresent && r.talkAppPasswordPresent
? PushCheck.ok
: PushCheck.fail,
detail: r.appPasswordPresent && r.talkAppPasswordPresent
? null
: '${_missingPasswords(r)} — wird beim nächsten App-Start '
'automatisch angefordert',
),
PushStatusRow(
label: 'Geräteschlüssel',
state: r.keypairPresent ? PushCheck.ok : PushCheck.fail,
detail: r.keypairPresent
? null
: 'Nicht vorhanden — wird bei der nächsten Registrierung erzeugt',
),
_nextcloudRow(r.general, 'Nextcloud-Registrierung (Allgemein)'),
_nextcloudRow(r.talk, 'Nextcloud-Registrierung (Talk)'),
_connectRow(r, r.general, 'Connect-Registrierung (Allgemein)'),
_connectRow(r, r.talk, 'Connect-Registrierung (Talk)'),
];
String _missingPasswords(PushStatusReport r) {
if (!r.appPasswordPresent && !r.talkAppPasswordPresent) {
return 'Beide fehlen';
}
return r.appPasswordPresent
? 'Talk-App-Passwort fehlt'
: 'Allgemeines App-Passwort fehlt';
}
PushStatusRow _nextcloudRow(PushTypeStatus status, String label) =>
PushStatusRow(
label: label,
state: status.nextcloudRegistered ? PushCheck.ok : PushCheck.fail,
detail: status.nextcloudRegistered
? status.registeredNcBaseUrl
: _pendingDetail,
);
PushStatusRow _connectRow(
PushStatusReport r,
PushTypeStatus status,
String label,
) {
final registeredProxy = status.registeredProxyServer;
if (registeredProxy == null || registeredProxy.isEmpty) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail: _pendingDetail,
);
}
if (r.proxyEndpointMismatch(status)) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail:
'Für $registeredProxy registriert — der aktive Server ist '
'${r.currentProxyServer}. Eine erneute Registrierung ist '
'erforderlich.',
);
}
if (status.lastRegistrationError != null) {
return PushStatusRow(
label: label,
state: PushCheck.fail,
detail: 'Die letzte Registrierung ist fehlgeschlagen — Details unten',
);
}
return PushStatusRow(
label: label,
state: PushCheck.ok,
detail: registeredProxy,
);
}
+39
View File
@@ -1,3 +1,42 @@
/// Parsed parts of a Talk push subject text.
///
/// Format verified against nextcloud/spreed `Notifier::parseChatMessage`:
/// with message preview the parsed subject is `"{user}\n{message}"` for 1:1
/// chats and `'{user} in {call}' . "\n{message}"` for groups — header and
/// message are separated by a NEWLINE, and the notifications app forwards the
/// parsed subject unmodified (only shortened). The German translation of
/// `{user} in {call}` is identical (`l10n/de.json`), so the ` in ` separator
/// holds for de_DE. Legacy/other variants using `": "` are kept as fallback;
/// splitting the header at the LAST ` in ` is robust against sender names
/// containing "in" (only a display name literally containing ` in ` in a 1:1
/// chat would still mis-split — accepted heuristic limit).
({String sender, String? roomName, String text}) parseTalkSubject(String raw) {
String header;
String text;
final newline = raw.indexOf('\n');
if (newline > 0) {
header = raw.substring(0, newline).trim();
text = raw.substring(newline + 1);
} else {
final colon = raw.indexOf(': ');
if (colon > 0 && colon < raw.length - 2) {
header = raw.substring(0, colon);
text = raw.substring(colon + 2);
} else {
return (sender: 'Talk', roomName: null, text: raw);
}
}
final roomSep = header.lastIndexOf(' in ');
if (roomSep > 0 && roomSep + 4 < header.length) {
return (
sender: header.substring(0, roomSep),
roomName: header.substring(roomSep + 4),
text: text,
);
}
return (sender: header, roomName: null, text: text);
}
/// The decrypted `subject` JSON of a Nextcloud push-v2 notification.
///
/// Covers the full shape the notifications app emits, including the three