25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
292 lines
7.6 KiB
Markdown
292 lines
7.6 KiB
Markdown
# 07 — Push & Benachrichtigungen
|
|
|
|
---
|
|
|
|
## Inhaltsverzeichnis
|
|
- [Pusher registrieren (FCM)](#pusher-registrieren-fcm)
|
|
- [Pusher abrufen & verwalten](#pusher-abrufen--verwalten)
|
|
- [Push Rules](#push-rules)
|
|
- [onNotification Stream](#onnotification-stream)
|
|
- [Lokale Benachrichtigungen (Android)](#lokale-benachrichtigungen-android)
|
|
- [Benachrichtigungen: Pyramid-Architektur](#benachrichtigungen-pyramid-architektur)
|
|
- [Notification Counts](#notification-counts)
|
|
|
|
---
|
|
|
|
## Pusher registrieren (FCM)
|
|
|
|
```dart
|
|
// FCM-Token holen
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
final fcmToken = await FirebaseMessaging.instance.getToken();
|
|
|
|
// Pusher beim Homeserver registrieren
|
|
await client.postPusher(
|
|
Pusher(
|
|
pushkey: fcmToken!,
|
|
kind: 'http',
|
|
appId: 'chat.pyramid.pyramid', // Unique App-ID (muss zu Push-Gateway passen)
|
|
appDisplayName: 'Pyramid',
|
|
deviceDisplayName: 'Mein Android',
|
|
lang: 'de',
|
|
data: PusherData(
|
|
url: Uri.parse('https://push.example.org/_matrix/push/v1/notify'),
|
|
format: 'event_id_only', // Empfohlen (nur Event-ID, nicht der Inhalt)
|
|
),
|
|
),
|
|
append: true, // Nicht andere Pusher dieses Geräts löschen
|
|
);
|
|
|
|
// Token-Refresh überwachen
|
|
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
|
|
await client.postPusher(
|
|
Pusher(pushkey: newToken, /* ... */ ),
|
|
append: true,
|
|
);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Pusher abrufen & verwalten
|
|
|
|
```dart
|
|
// Alle Pusher dieses Users
|
|
final pushers = await client.getPushers() ?? [];
|
|
for (final pusher in pushers) {
|
|
print(pusher.appId);
|
|
print(pusher.pushkey);
|
|
print(pusher.data.url);
|
|
}
|
|
|
|
// Pusher entfernen
|
|
await client.deletePusher(
|
|
Pusher(
|
|
pushkey: fcmToken,
|
|
appId: 'chat.pyramid.pyramid',
|
|
kind: 'http', // Pflichtfeld beim Löschen
|
|
// ...
|
|
),
|
|
);
|
|
|
|
// Beim Logout alle Pusher entfernen
|
|
final myPushers = (await client.getPushers() ?? [])
|
|
.where((p) => p.appId == 'chat.pyramid.pyramid');
|
|
for (final p in myPushers) {
|
|
await client.deletePusher(p);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Push Rules
|
|
|
|
```dart
|
|
// Alle Push Rules abrufen
|
|
final ruleset = await client.getPushRules();
|
|
|
|
// Globale Regeln
|
|
for (final rule in ruleset.global.content ?? []) {
|
|
print(rule.ruleId);
|
|
print(rule.enabled);
|
|
print(rule.actions);
|
|
}
|
|
|
|
// Kategorien der Regeln:
|
|
// .override — höchste Priorität (z.B. DND-Modus)
|
|
// .content — Inhalt-basiert (z.B. eigener Name erwähnt)
|
|
// .room — Raum-spezifisch
|
|
// .sender — Sender-spezifisch
|
|
// .underride — niedrigste Priorität (Fallback)
|
|
|
|
// Push Rule aktivieren/deaktivieren
|
|
await client.setPushRuleEnabled(
|
|
'global',
|
|
PushRuleKind.override,
|
|
'.m.rule.master', // DND: stumm schalten
|
|
false, // false = Regel deaktivieren
|
|
);
|
|
|
|
// Eigene Push Rule erstellen (z.B. Raum stumm schalten)
|
|
await client.setPushRule(
|
|
'global',
|
|
PushRuleKind.room,
|
|
'!roomId:server', // RuleId = RoomId für Raum-Regeln
|
|
[PushRuleAction.dontNotify],
|
|
);
|
|
|
|
// Push Rule löschen
|
|
await client.deletePushRule(
|
|
'global',
|
|
PushRuleKind.room,
|
|
'!roomId:server',
|
|
);
|
|
|
|
// Standard-Rules für Erwähnungen
|
|
// Pyramid prüft lokal ob ein Event eine Mention enthält:
|
|
final myId = client.userID!;
|
|
final mentions = event.content
|
|
.tryGetMap<String, dynamic>('m.mentions')
|
|
?.tryGetList<String>('user_ids') ?? [];
|
|
final isMentioned = mentions.contains(myId) ||
|
|
event.body.contains(myId);
|
|
|
|
// Raum stumm schalten
|
|
await client.setPushRule(
|
|
'global',
|
|
PushRuleKind.override,
|
|
'!roomId:server',
|
|
[PushRuleAction.dontNotify],
|
|
conditions: [
|
|
PushCondition(kind: 'event_match', key: 'room_id', pattern: '!roomId:server'),
|
|
],
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## onNotification Stream
|
|
|
|
```dart
|
|
// Matrix-SDK emittiert Events die Benachrichtigungen auslösen sollen
|
|
// (nach Push-Rule-Matching, entschlüsselt)
|
|
client.onNotification.stream.listen((Event event) {
|
|
final roomId = event.room.id;
|
|
final room = client.getRoomById(roomId)!;
|
|
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
|
|
|
|
final title = room.isDirectChat
|
|
? sender.displayName ?? event.senderId
|
|
: '${sender.displayName} · ${room.getLocalizedDisplayname()}';
|
|
final body = event.body;
|
|
|
|
// Lokale Benachrichtigung anzeigen
|
|
showLocalNotification(title, body, roomId);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Lokale Benachrichtigungen (Android)
|
|
|
|
```dart
|
|
// Siehe auch: lib/core/notification_service.dart und PushService.kt
|
|
|
|
// flutter_local_notifications initialisieren
|
|
final plugin = FlutterLocalNotificationsPlugin();
|
|
await plugin.initialize(
|
|
const InitializationSettings(
|
|
android: AndroidInitializationSettings('@drawable/ic_notification'),
|
|
),
|
|
onDidReceiveNotificationResponse: _handleNotificationTap,
|
|
onDidReceiveBackgroundNotificationResponse: handleBackgroundResponse,
|
|
);
|
|
|
|
// Notification-Kanal erstellen (Android 8+)
|
|
await plugin.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin>()
|
|
?.createNotificationChannel(
|
|
const AndroidNotificationChannel(
|
|
'pyramid_messages',
|
|
'Nachrichten',
|
|
description: 'Neue Nachrichten',
|
|
importance: Importance.high,
|
|
),
|
|
);
|
|
|
|
// Benachrichtigung anzeigen (mit Reply-Action)
|
|
await plugin.show(
|
|
notifId,
|
|
'Absender',
|
|
'Nachrichtentext',
|
|
NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
'pyramid_messages',
|
|
'Nachrichten',
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
icon: '@drawable/ic_notification',
|
|
actions: [
|
|
AndroidNotificationAction(
|
|
'reply', 'Antworten',
|
|
inputs: [AndroidNotificationActionInput(label: 'Antworten…')],
|
|
allowGeneratedReplies: true,
|
|
showsUserInterface: false, // BroadcastReceiver-Pfad
|
|
),
|
|
AndroidNotificationAction('read', 'Gelesen',
|
|
showsUserInterface: false),
|
|
],
|
|
),
|
|
),
|
|
payload: roomId,
|
|
);
|
|
|
|
// Benachrichtigung schließen
|
|
await plugin.cancel(notifId);
|
|
```
|
|
|
|
---
|
|
|
|
## Benachrichtigungen: Pyramid-Architektur
|
|
|
|
```
|
|
FCM-Payload → PushService.kt (Kotlin, kein Dart-Overhead)
|
|
│
|
|
├─ App im Vordergrund? → Heartbeat-Check → Skip
|
|
│
|
|
├─ HTTP-Fetch: Absender + Raumname (ohne Entschlüsselung möglich)
|
|
│
|
|
├─ Native Notification anzeigen: "Neue Nachricht"
|
|
│ └─ Reply-Action → getActivity(MainActivity) → onNewIntent
|
|
│
|
|
└─ Flutter-Engine im Cache? → MethodChannel "decryptAndUpdateNotification"
|
|
└─ Dart: decryptAndUpdateNotification() → showNativeNotification() [mit echtem Text]
|
|
|
|
Reply-Flow:
|
|
User tippt Antwort → Reply-PendingIntent (getActivity)
|
|
→ MainActivity.onNewIntent → MethodChannel "replyFromNotification"
|
|
→ Dart: room.sendTextEvent(text)
|
|
→ moveTaskToBack(true)
|
|
```
|
|
|
|
```dart
|
|
// Heartbeat (App aktiv?) setzen:
|
|
void _pingHeartbeat() {
|
|
SharedPreferences.getInstance().then(
|
|
(p) => p.setInt('notif_app_heartbeat', DateTime.now().millisecondsSinceEpoch),
|
|
);
|
|
}
|
|
|
|
// Heartbeat löschen (App im Hintergrund):
|
|
void setAppForeground(bool value) {
|
|
if (!value) {
|
|
SharedPreferences.getInstance()
|
|
.then((p) => p.setInt('notif_app_heartbeat', 0));
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Notification Counts
|
|
|
|
```dart
|
|
// Pro Raum
|
|
print(room.notificationCount); // Ungelesene Nachrichten
|
|
print(room.highlightCount); // Mentions / Highlights
|
|
|
|
// Gesamt
|
|
final totalUnread = client.rooms.fold<int>(
|
|
0, (sum, r) => sum + r.notificationCount);
|
|
final totalHighlights = client.rooms.fold<int>(
|
|
0, (sum, r) => sum + r.highlightCount);
|
|
|
|
// Badge für Windows Taskbar
|
|
import 'package:windows_taskbar/windows_taskbar.dart';
|
|
if (totalUnread > 0) {
|
|
WindowsTaskbar.setProgressMode(TaskbarProgressMode.error);
|
|
} else {
|
|
WindowsTaskbar.setProgressMode(TaskbarProgressMode.noProgress);
|
|
}
|
|
```
|