Files
Bernd Steckmeister 25ed765a03 wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
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
2026-07-03 05:47:18 +02:00

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);
}
```