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
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
# 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);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user