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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user