simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+11 -18
View File
@@ -6,13 +6,20 @@ import 'package:localstore/localstore.dart';
/// 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).
/// Canonical per-chat notification tag.
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.
final _fileSafeToken = RegExp(r'^[A-Za-z0-9_-]+$');
/// File-/doc-safe name for a chat token: normally URL-safe and used verbatim,
/// anything else is base64url-encoded so exotic ids can't escape a directory.
String fileSafeToken(String chatToken) => _fileSafeToken.hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
int stableChatNotificationId(String chatToken) {
var hash = 0;
for (final unit in chatToken.codeUnits) {
@@ -128,12 +135,8 @@ class ChatThreadStore {
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('=', '');
/// Localstore doc ids become file names.
static String docIdForToken(String chatToken) => fileSafeToken(chatToken);
Future<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get();
@@ -145,16 +148,6 @@ class ChatThreadStore {
.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.
+4 -13
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:ui';
@@ -13,6 +12,7 @@ import '../session/session_manager.dart';
import 'chat_thread_store.dart';
import 'nid_store.dart';
import 'push_renderer.dart';
import 'push_target.dart';
/// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them).
@@ -93,8 +93,7 @@ class PushActions {
// the mark-read action does. Best effort: a failure here must not
// fail the already-delivered reply.
await markRead(chatToken);
}
if (!result.ok) {
} else {
// Never swallow the typed message: surface the failure (and the
// undelivered text) as its own notification. The nid mapping stays
// alive — the thread notification is still in the tray.
@@ -287,14 +286,6 @@ class PushActions {
return raw == null ? null : int.tryParse(raw);
}
static String? _payloadField(String? payload, String key) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
static String? _payloadField(String? payload, String key) =>
decodeNotificationPayload(payload)?[key]?.toString();
}
+2 -7
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart';
import 'chat_thread_store.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
@@ -102,13 +103,7 @@ class PushAvatarStore {
},
);
/// 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('=', '');
static String fileNameForToken(String token) => fileSafeToken(token);
/// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) =>
-2
View File
@@ -101,7 +101,6 @@ class PushMessageHandler {
case PushKind.connect:
await _handleConnect(
message,
foreground: foreground,
notificationsEnabled: notificationsEnabled,
);
break;
@@ -139,7 +138,6 @@ class PushMessageHandler {
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
required bool notificationsEnabled,
}) async {
// Connect pushes carry no silent side effects, so nothing to do when the
+1 -2
View File
@@ -258,10 +258,9 @@ class PushRegistration {
/// Flutter engine. Best-effort — a failure here must not abort registration.
Future<void> _persistNativeAuthContext() async {
try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext(
username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}',
baseUrl: currentNcBaseUrl,
);
} on Object catch (e) {
log('Push: could not persist native auth context: $e');
+26 -23
View File
@@ -203,16 +203,10 @@ class PushRenderer {
messages: styleMessages,
);
final androidDetails = AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
final androidDetails = _talkAndroidDetails(
tag: tag,
style: messagingStyle,
silent: !alert,
styleInformation: messagingStyle,
actions: _talkActions,
);
@@ -247,17 +241,11 @@ class PushRenderer {
title: senderName,
body: messageText,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
android: _talkAndroidDetails(
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(
style: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'),
groupConversation: false,
messages: [
@@ -271,6 +259,25 @@ class PushRenderer {
);
}
static AndroidNotificationDetails _talkAndroidDetails({
required String tag,
required StyleInformation style,
AndroidNotificationCategory? category = AndroidNotificationCategory.message,
bool silent = false,
List<AndroidNotificationAction>? actions,
}) => AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: category,
color: _accentColor,
tag: tag,
silent: silent,
styleInformation: style,
actions: actions,
);
// Both actions keep cancelNotification: true (the default): the plugin's
// Java receiver then removes the card NATIVELY the moment the action fires.
// The reply action must not rely on our Dart-side cancel instead — MIUI/
@@ -306,14 +313,10 @@ class PushRenderer {
title: title,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
android: _talkAndroidDetails(
tag: 'talk_error_$chatToken',
styleInformation: BigTextStyleInformation(body),
style: BigTextStyleInformation(body),
category: null,
),
iOS: const DarwinNotificationDetails(),
),
+1 -11
View File
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
@@ -25,7 +24,7 @@ class PushTapRouter {
PushActions.handleBackgroundResponse(response);
return;
}
final map = _payloadMap(response.payload);
final map = decodeNotificationPayload(response.payload);
if (map == null) return;
final target = resolvePushTarget(map);
if (target != null) pendingTarget.value = target;
@@ -50,13 +49,4 @@ class PushTapRouter {
log('Reading the notification launch details failed: $e');
}
}
static Map<String, dynamic>? _payloadMap(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
}
+13
View File
@@ -1,3 +1,16 @@
import 'dart:convert';
/// Decodes the JSON payload of a locally rendered notification; null when
/// absent or malformed.
Map<String, dynamic>? decodeNotificationPayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
/// Where a tapped notification leads.
sealed class PushTarget {
const PushTarget();