feat: Push als Chat-Verlauf - mehrere Nachrichten, voller Text, Bildvorschau, echtes "Gelesen"

Bernds Beta-Feedback: pro Raum wurde jede Nachricht durch die naechste ersetzt
und auf 100 Zeichen gekuerzt, ohne Bild. Jetzt:
- NotificationHelper: MessagingStyle mit den letzten 8 Nachrichten pro Raum
  (Verlauf in SharedPreferences, nur solange die Benachrichtigung sichtbar ist),
  gleiche eventId ersetzt (Platzhalter -> Klartext), nur Neues klingelt.
- notification_content.dart: Text bis 1000 Zeichen, Antwort-Zitat entfernt,
  Medien-Labels, Vorschau-Einstellung auch bei gekillter App; Bild (E2EE
  entschluesselt) + Avatar per FileProvider an die System-UI.
- "Gelesen" setzt eine echte Lesebestaetigung (Haupt-Engine bzw. BgEngine).
- Laufende App holt genau das gepushte Event statt room.lastEvent.
- Debug-only DebugNotifyReceiver zum Testen per adb ohne Login.
Emulator (API 36): Verlauf, Ersetzen, Bild, Avatar, Gelesen geprueft;
9 neue Tests. Echtes Geraet (Bernd) steht aus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 16:27:56 +02:00
co-authored by Claude Opus 5.5
parent 460477204b
commit ef1d881967
12 changed files with 1103 additions and 170 deletions
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<application>
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
<receiver
android:name=".DebugNotifyReceiver"
android:exported="true" />
</application>
</manifest> </manifest>
@@ -0,0 +1,55 @@
package chat.pyramid.pyramid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
/**
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
* Ist nicht im Release-APK enthalten.
*
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
*/
class DebugNotifyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
val roomName = intent.getStringExtra("roomName")
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
val args = mapOf(
"roomId" to roomId,
"notifId" to NotificationHelper.stableId(roomId),
"eventId" to eventId,
"senderId" to "@${sender.lowercase()}:pyramid.local",
"senderName" to sender,
"roomName" to roomName,
"isGroup" to (roomName != null),
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
"timestamp" to System.currentTimeMillis(),
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
)
NotificationHelper.showFromArgs(context, args)
}
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
val file = File(dir, name)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
return file.absolutePath
}
}
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf<Int, Runnable>() private val fallbacks = mutableMapOf<Int, Runnable>()
/** Schedule a placeholder shown after [delayMs] unless decryption beats it. */ /** Schedule a placeholder shown after [delayMs] unless decryption beats it. */
fun scheduleFallback(context: Context, title: String, roomId: String, notifId: Int, delayMs: Long) { fun scheduleFallback(
context: Context, meta: PushService.PushMeta, roomId: String, notifId: Int,
eventId: String?, delayMs: Long,
) {
val ctx = context.applicationContext val ctx = context.applicationContext
appContext = ctx appContext = ctx
fallbackHandler.post { fallbackHandler.post {
cancelFallback(notifId) cancelFallback(notifId)
val r = Runnable { val r = Runnable {
fallbacks.remove(notifId) fallbacks.remove(notifId)
NotificationHelper.show(ctx, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(ctx, roomId, notifId, eventId)
Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)") Log.d(TAG, "fallback placeholder shown for $roomId (decrypt too slow)")
} }
fallbacks[notifId] = r fallbacks[notifId] = r
@@ -114,16 +117,19 @@ object BgEngine {
} }
// Dart finished decrypting — show the real content using the SAME // Dart finished decrypting — show the real content using the SAME
// native notification (keeps the reliable MainActivity reply action). // native notification (keeps the reliable MainActivity reply action).
// May arrive twice per event: first the text, then silently
// the same event with avatar/image (NotificationHelper merges).
"showDecrypted" -> { "showDecrypted" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "Neue Nachricht" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("room_id") ?: "" val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId) val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: NotificationHelper.stableId(roomId)
val ctx = appContext val ctx = appContext
if (ctx != null && roomId.isNotEmpty()) { if (ctx != null && roomId.isNotEmpty()) {
// Beat the fallback placeholder to it — show decrypted directly. // Beat the fallback placeholder to it — show decrypted directly.
cancelFallback(notifId) cancelFallback(notifId)
NotificationHelper.show(ctx, title, body, roomId, notifId) NotificationHelper.showFromArgs(ctx, args)
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId") Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
} }
result.success(null) result.success(null)
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
result.success(null) result.success(null)
} }
"showNativeNotification" -> { "showNativeNotification" -> {
val title = call.argument<String>("title") ?: "" @Suppress("UNCHECKED_CAST")
val body = call.argument<String>("body") ?: "" val args = (call.arguments as? Map<String, Any?>) ?: emptyMap()
val roomId = call.argument<String>("roomId") ?: "" val roomId = args["roomId"] as? String ?: ""
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
if (roomId.isNotEmpty()) { if (roomId.isNotEmpty()) {
NotificationHelper.show(this, title, body, roomId, notifId) NotificationHelper.showFromArgs(this, args)
result.success(null) result.success(null)
} else { } else {
result.error("INVALID_ARG", "roomId missing", null) result.error("INVALID_ARG", "roomId missing", null)
@@ -5,13 +5,79 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.IconCompat
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Native Nachrichten-Benachrichtigungen (alle Pfade: Platzhalter aus
* PushService, Klartext aus der Haupt-Engine oder dem BgEngine-Isolate).
*
* Pro Raum gibt es EINE Benachrichtigung (notifId = stableId(roomId)), die als
* Chat-Verlauf (MessagingStyle) die letzten [MAX_MESSAGES] Nachrichten zeigt –
* wie WhatsApp/Element. Der Verlauf liegt in SharedPreferences, weil bei
* beendeter App jeder Push in einem neuen Prozess ankommt. Er wird nur
* fortgeführt, solange die Benachrichtigung noch sichtbar ist: hat der Nutzer
* sie weggewischt oder den Raum in der App geöffnet (Dart cancelt), beginnt
* der nächste Push mit einem leeren Verlauf.
*
* Gleiche eventId = Aktualisierung (Platzhalter → Klartext, Text → Text+Bild):
* die Nachricht wird ersetzt statt doppelt angehängt, und es klingelt nicht
* erneut. Nur eine wirklich neue Nachricht alarmiert.
*/
object NotificationHelper { object NotificationHelper {
const val CHANNEL_ID = "pyramid_messages" const val CHANNEL_ID = "pyramid_messages"
const val PLACEHOLDER_BODY = "Neue Nachricht"
private const val TAG = "PYRAMID-NOTIF"
private const val STORE = "pyramid_notif_history"
private const val MAX_MESSAGES = 8
private const val SYSTEM_UI = "com.android.systemui"
/** Eine Nachricht im Verlauf einer Raum-Benachrichtigung. */
data class Msg(
val eventId: String?,
val senderKey: String,
val senderName: String,
val body: String,
val timestamp: Long,
val imagePath: String? = null,
val avatarPath: String? = null,
) {
val isPlaceholder get() = body == PLACEHOLDER_BODY
fun toJson(): JSONObject = JSONObject().apply {
put("eventId", eventId ?: JSONObject.NULL)
put("senderKey", senderKey)
put("senderName", senderName)
put("body", body)
put("timestamp", timestamp)
put("imagePath", imagePath ?: JSONObject.NULL)
put("avatarPath", avatarPath ?: JSONObject.NULL)
}
companion object {
fun fromJson(o: JSONObject) = Msg(
eventId = o.optStringOrNull("eventId"),
senderKey = o.optString("senderKey"),
senderName = o.optString("senderName"),
body = o.optString("body"),
timestamp = o.optLong("timestamp"),
imagePath = o.optStringOrNull("imagePath"),
avatarPath = o.optStringOrNull("avatarPath"),
)
}
}
/** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */ /** Deterministic notification ID — same algorithm as Dart's _stableRoomId. */
fun stableId(roomId: String): Int { fun stableId(roomId: String): Int {
@@ -32,15 +98,226 @@ object NotificationHelper {
} }
} }
fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) { /**
ensureChannel(context) * Platzhalter/Alt-Aufrufer mit fertigem Titel „Absender · Raum“ (bzw. nur
* „Absender“ bei Direktnachrichten).
*/
fun show(
context: Context,
title: String,
body: String,
roomId: String,
notifId: Int,
eventId: String? = null,
) {
val parts = title.split(" · ", limit = 2)
val sender = parts[0].ifBlank { "Pyramid" }
val roomName = parts.getOrNull(1)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = Msg(eventId, sender, sender, body, System.currentTimeMillis()),
)
}
val mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) /** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
PendingIntent.FLAG_MUTABLE else 0 fun showFromArgs(context: Context, args: Map<String, Any?>) {
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
if (roomId.isEmpty()) return
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: stableId(roomId)
val senderName = args["senderName"] as? String
if (senderName == null) {
// Alter Aufrufer ohne strukturierte Felder.
show(
context, args["title"] as? String ?: "",
args["body"] as? String ?: PLACEHOLDER_BODY,
roomId, notifId, args["eventId"] as? String,
)
return
}
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName,
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
msg = Msg(
eventId = args["eventId"] as? String,
senderKey = args["senderId"] as? String ?: senderName,
senderName = senderName,
body = args["body"] as? String ?: PLACEHOLDER_BODY,
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
imagePath = args["imagePath"] as? String,
avatarPath = args["avatarPath"] as? String,
),
)
}
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
fun showMessage(
context: Context,
roomId: String,
notifId: Int,
roomName: String?,
isGroup: Boolean,
msg: Msg,
) {
ensureChannel(context)
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
val latest = history.last()
val style = NotificationCompat.MessagingStyle(
Person.Builder().setName("Du").setKey("me").build()
)
if (isGroup && roomName != null) {
style.conversationTitle = roomName
style.isGroupConversation = true
}
for (m in history) {
val person = Person.Builder()
.setName(m.senderName)
.setKey(m.senderKey)
.apply {
contentUri(context, m.avatarPath)?.let { setIcon(IconCompat.createWithContentUri(it)) }
}
.build()
val message = NotificationCompat.MessagingStyle.Message(m.body, m.timestamp, person)
contentUri(context, m.imagePath)?.let { message.setData("image/jpeg", it) }
style.addMessage(message)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
.setStyle(style)
// Fallback-Texte für Geräte/Ansichten ohne MessagingStyle.
.setContentTitle(if (isGroup && roomName != null) roomName else latest.senderName)
.setContentText(latest.body)
.setWhen(latest.timestamp)
.setShowWhen(true)
.setNumber(history.size)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Nur eine wirklich neue Nachricht klingelt; Aktualisierungen
// (Platzhalter → Klartext, Bild nachgeladen) ersetzen still.
.setOnlyAlertOnce(!isNew)
.setContentIntent(tapIntent(context, roomId, notifId))
.setAutoCancel(true)
.addAction(replyAction(context, roomId, notifId))
.addAction(markReadAction(context, roomId, notifId, latest.eventId))
.setGroup(CHANNEL_ID)
if (!isGroup) {
avatarBitmap(latest.avatarPath)?.let { builder.setLargeIcon(it) }
}
try {
nm(context).notify(notifId, builder.build())
} catch (e: Exception) {
// Sicherheitsnetz: lieber ohne Bilder/Avatare als gar nicht.
Log.w(TAG, "notify failed (${e.message}) — retrying as plain text")
builder.setLargeIcon(null as Bitmap?)
.setStyle(NotificationCompat.BigTextStyle().bigText(latest.body))
nm(context).notify(notifId, builder.build())
}
}
/** Vergisst den Verlauf eines Raums (nach Antworten/„Gelesen“). */
@Synchronized
fun clearHistory(context: Context, roomId: String) {
prefs(context).edit().remove(roomId).apply()
}
@Synchronized
private fun mergeIntoHistory(
context: Context, roomId: String, notifId: Int, msg: Msg,
): Pair<List<Msg>, Boolean> {
val prefs = prefs(context)
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
val isNew = index < 0
if (isNew) {
list.add(msg)
} else {
val old = list[index]
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
val keepOld = msg.isPlaceholder && !old.isPlaceholder
list[index] = Msg(
eventId = old.eventId,
senderKey = if (keepOld) old.senderKey else msg.senderKey,
senderName = if (keepOld) old.senderName else msg.senderName,
body = if (keepOld) old.body else msg.body,
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
imagePath = msg.imagePath ?: old.imagePath,
avatarPath = msg.avatarPath ?: old.avatarPath,
)
}
list.sortBy { it.timestamp }
while (list.size > MAX_MESSAGES) list.removeAt(0)
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
return list to isNew
}
private fun load(json: String?): MutableList<Msg> {
if (json == null) return mutableListOf()
return try {
val arr = JSONArray(json)
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
mutableListOf()
}
}
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
private fun isActive(context: Context, notifId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
nm(context).activeNotifications.any { it.id == notifId }
} catch (_: Exception) {
true
}
}
/**
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
*/
private fun contentUri(context: Context, path: String?): Uri? {
if (path.isNullOrEmpty()) return null
val file = File(path)
if (!file.exists()) return null
return try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
uri
} catch (e: Exception) {
Log.w(TAG, "contentUri failed for $path: ${e.message}")
null
}
}
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
private fun avatarBitmap(path: String?): Bitmap? {
if (path.isNullOrEmpty() || !File(path).exists()) return null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
sample *= 2
}
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
}
// ── Aktionen ──────────────────────────────────────────────────────────────
private fun mutableFlag() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
// Reply action — BroadcastReceiver so replying NEVER opens the app. // Reply action — BroadcastReceiver so replying NEVER opens the app.
// ReplyReceiver decrypts/sends via the background engine when killed. // ReplyReceiver decrypts/sends via the background engine when killed.
// (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.) // (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
private fun replyAction(context: Context, roomId: String, notifId: Int): NotificationCompat.Action {
val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY) val replyInput = RemoteInput.Builder(ReplyReceiver.REMOTE_INPUT_KEY)
.setLabel("Antworten…").build() .setLabel("Antworten…").build()
val replyIntent = Intent(context, ReplyReceiver::class.java).apply { val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
@@ -49,59 +326,57 @@ object NotificationHelper {
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
} }
val replyPi = PendingIntent.getBroadcast( val replyPi = PendingIntent.getBroadcast(
context, notifId, context, notifId, replyIntent,
replyIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
) )
val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi) return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
.addRemoteInput(replyInput) .addRemoteInput(replyInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build() .build()
}
// Dismiss action — also points to ReplyReceiver so notification is cancelled. // „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
val dismissIntent = Intent(context, ReplyReceiver::class.java).apply { // bis zur neuesten Nachricht (ReplyReceiver → Haupt-Engine bzw. BgEngine).
private fun markReadAction(
context: Context, roomId: String, notifId: Int, eventId: String?,
): NotificationCompat.Action {
val intent = Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.ACTION_DISMISS action = ReplyReceiver.ACTION_DISMISS
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId) putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId) putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
} }
val dismissPi = PendingIntent.getBroadcast( val pi = PendingIntent.getBroadcast(
context, notifId + 1_000_000, context, notifId + 1_000_000, intent,
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Action.Builder(0, "Gelesen", pi)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
// Tap action — opens MainActivity to the right room. // Tap action — opens MainActivity to the right room.
private fun tapIntent(context: Context, roomId: String, notifId: Int): PendingIntent {
val tapIntent = Intent(context, MainActivity::class.java).apply { val tapIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_MAIN action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER) addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("room_id", roomId) putExtra("room_id", roomId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
} }
val tapPi = PendingIntent.getActivity( return PendingIntent.getActivity(
context, notifId + 2_000_000, context, notifId + 2_000_000, tapIntent,
tapIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
) )
val notif = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(Color.rgb(0x7B, 0x61, 0xFF))
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Only buzz/heads-up on the first show — the decrypted update reuses
// the same notifId and replaces the text silently (no second alert).
.setOnlyAlertOnce(true)
.setContentIntent(tapPi)
.setAutoCancel(true)
.addAction(replyAction)
.addAction(NotificationCompat.Action(0, "Gelesen", dismissPi))
.setGroup(CHANNEL_ID)
.build()
nm(context).notify(notifId, notif)
} }
private fun prefs(context: Context) =
context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
private fun nm(context: Context) = private fun nm(context: Context) =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
} }
private fun JSONObject.optStringOrNull(key: String): String? =
if (!has(key) || isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
val token = (prefs.getString("flutter.notif_access_token", "") ?: "") val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
// Fetch sender + room name on a background thread (main thread forbids I/O). // Fetch sender + room name on a background thread (main thread forbids I/O).
var title = "Pyramid" var meta = PushMeta()
if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) { if (hs.isNotEmpty() && token.isNotEmpty() && eventId != null) {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
Thread { Thread {
try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" } try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
finally { latch.countDown() } finally { latch.countDown() }
}.start() }.start()
latch.await(12L, TimeUnit.SECONDS) latch.await(12L, TimeUnit.SECONDS)
} }
Log.d(TAG, "handling push: title=$title roomId=$roomId") Log.d(TAG, "handling push: sender=${meta.senderName} room=${meta.roomName} roomId=$roomId")
val engine = FlutterEngineCache.getInstance().get("main") val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) { if (engine != null) {
// App backgrounded but alive — show placeholder immediately, then the // App backgrounded but alive — show placeholder immediately, then the
// warm main client decrypts and updates it (fast, barely visible). // warm main client decrypts and updates it (fast, barely visible).
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, eventId)
if (eventId != null) { if (eventId != null) {
val capturedRoomId = roomId val capturedRoomId = roomId
val capturedEventId = eventId val capturedEventId = eventId
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
// real content directly. The "Neue Nachricht" placeholder is only a // real content directly. The "Neue Nachricht" placeholder is only a
// fallback if decryption takes too long or fails. // fallback if decryption takes too long or fails.
Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId") Log.d(TAG, "main engine gone — decrypt-first via background engine for $roomId")
BgEngine.scheduleFallback(this, title, roomId, notifId, 6_000L) BgEngine.scheduleFallback(this, meta, roomId, notifId, eventId, 6_000L)
BgEngine.run(this, "decryptAndShow", mapOf( BgEngine.run(this, "decryptAndShow", mapOf(
"room_id" to roomId, "room_id" to roomId,
"event_id" to eventId, "event_id" to eventId,
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
)) ))
} else { } else {
// No event id to decrypt — just show the placeholder. // No event id to decrypt — just show the placeholder.
NotificationHelper.show(this, title, "Neue Nachricht", roomId, notifId) meta.showPlaceholder(this, roomId, notifId, null)
} }
} }
@@ -102,11 +102,31 @@ class PushService : FirebaseMessagingService() {
.apply() .apply()
} }
/** Absender + Raumname aus unverschlüsselten Room-States (für den Platzhalter). */
data class PushMeta(
val senderId: String = "",
val senderName: String = "Pyramid",
val roomName: String? = null,
) {
fun showPlaceholder(context: android.content.Context, roomId: String, notifId: Int, eventId: String?) {
NotificationHelper.showMessage(
context, roomId, notifId, roomName, isGroup = roomName != null,
msg = NotificationHelper.Msg(
eventId = eventId,
senderKey = senderId.ifEmpty { senderName },
senderName = senderName,
body = NotificationHelper.PLACEHOLDER_BODY,
timestamp = System.currentTimeMillis(),
),
)
}
}
// ─── HTTP helpers ───────────────────────────────────────────────────────── // ─── HTTP helpers ─────────────────────────────────────────────────────────
private fun fetchTitle( private fun fetchMeta(
hs: String, token: String, roomId: String, eventId: String hs: String, token: String, roomId: String, eventId: String
): String? { ): PushMeta? {
return try { return try {
val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8") val encRoom = java.net.URLEncoder.encode(roomId, "UTF-8")
val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8") val encEvent = java.net.URLEncoder.encode(eventId, "UTF-8")
@@ -128,8 +148,11 @@ class PushService : FirebaseMessagingService() {
val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token) val nameState = httpGet("$hs/_matrix/client/v3/rooms/$encRoom/state/m.room.name", token)
val roomName = nameState?.optString("name") val roomName = nameState?.optString("name")
return if (!roomName.isNullOrEmpty()) "$senderName · $roomName" return PushMeta(
else senderName.ifEmpty { null } senderId = senderId,
senderName = senderName.ifEmpty { "Pyramid" },
roomName = roomName?.takeIf { it.isNotEmpty() },
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "fetchTitle failed: ${e.message}") Log.w(TAG, "fetchTitle failed: ${e.message}")
null null
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS" const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
const val EXTRA_ROOM_ID = "room_id" const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_NOTIF_ID = "notif_id" const val EXTRA_NOTIF_ID = "notif_id"
const val EXTRA_EVENT_ID = "event_id"
const val REMOTE_INPUT_KEY = "reply_text" const val REMOTE_INPUT_KEY = "reply_text"
} }
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0) val notifId = intent.getIntExtra(EXTRA_NOTIF_ID, 0)
// Dismiss the notification immediately. // Dismiss the notification immediately; its message history goes too.
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(notifId) .cancel(notifId)
NotificationHelper.clearHistory(context, roomId)
if (intent.action == ACTION_DISMISS) { if (intent.action == ACTION_DISMISS) {
Log.d("PYRAMID-REPLY", "dismiss action for room $roomId") markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
return return
} }
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
try { pending.finish() } catch (_: Exception) {} try { pending.finish() } catch (_: Exception) {}
}, 15_000L) }, 15_000L)
} }
/**
* „Gelesen“: Lesebestätigung bis [eventId] setzen – über die laufende
* Haupt-Engine oder (App beendet) still über die BgEngine. Ohne eventId
* (alter Platzhalter) bleibt es beim Schließen.
*/
private fun markRead(context: Context, roomId: String, eventId: String?) {
Log.d("PYRAMID-REPLY", "mark-read action for room $roomId event=$eventId")
if (eventId.isNullOrEmpty()) return
val args = mapOf("room_id" to roomId, "event_id" to eventId)
val engine = FlutterEngineCache.getInstance().get("main")
if (engine != null) {
try {
MethodChannel(engine.dartExecutor.binaryMessenger, "chat.pyramid.pyramid/install")
.invokeMethod("markReadFromNotification", args)
return
} catch (e: Exception) {
Log.w("PYRAMID-REPLY", "markRead via main engine failed: ${e.message}")
}
}
val pending = goAsync()
BgEngine.run(context, "markRead", args)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { pending.finish() } catch (_: Exception) {}
}, 10_000L)
}
} }
+35 -1
View File
@@ -70,6 +70,39 @@ Ergebnis: Die Benachrichtigung erscheint ~4 s nach der Nachricht **direkt mit
echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der echtem Text** (kein „Neue Nachricht"-Zwischenschritt). Die ~4 s sind der
Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s). Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
## Darstellung: Chat-Verlauf statt Einzelnachricht (seit 2026-10-07)
Auf Bernds Beta-Feedback („nur eine Nachricht, wenig Text, kein Bild“):
- **Pro Raum ein Chat-Verlauf** (Android `MessagingStyle`) mit den letzten 8
Nachrichten, Absendername + Avatar je Nachricht, Gruppenname als Titel.
Der Verlauf liegt in SharedPreferences (`pyramid_notif_history`), weil bei
gekillter App jeder Push in einem neuen Prozess ankommt. Er wird nur
fortgeführt, solange die Benachrichtigung noch sichtbar ist (weggewischt
oder Raum geöffnet → nächster Push beginnt neu).
- **Gleiche eventId = Aktualisierung**, nicht neue Nachricht: der
„Neue Nachricht“-Platzhalter wird durch den Klartext ersetzt, später kommen
lautlos Avatar/Bild dazu. Nur wirklich neue Nachrichten klingeln
(`setOnlyAlertOnce(!isNew)`).
- **Text:** nicht mehr auf 100 Zeichen gekürzt (Deckel 1000), Antwort-Zitat
entfernt, Medien als „📷 Foto“/„🎤 Sprachnachricht“/„📎 datei.pdf“ usw.
Logik in `lib/core/notification_content.dart` (Tests:
`test/notification_content_test.dart`). Einstellung „Nachrichtenvorschau“
gilt jetzt auch bei gekillter App.
- **Bilder/Avatare:** Dart legt sie unter `<cache>/downloads/notif/` ab
(Vorschaubild, bei E2EE entschlüsselt; Avatar aus dem MediaCache), native
reicht sie per FileProvider-URI an die System-UI weiter
(`grantUriPermission("com.android.systemui")`). Dateien älter als 2 Tage
werden aufgeräumt. Scheitert das Anzeigen mit Medien, fällt
`NotificationHelper` auf reinen Text zurück.
- **„Gelesen“** setzt jetzt eine echte Lesebestätigung bis zur neuesten
Nachricht (Haupt-Engine `markReadFromNotification` bzw. BgEngine
`markRead`), statt nur zu schließen.
- **Testen ohne Login (nur Debug-Build):** `DebugNotifyReceiver`
(`android/app/src/debug/`), z. B.
`adb shell "am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver --es roomName Familie --es sender Uta --es event e1 --ez avatar true --es body 'Hallo'"`
(`--ez image true` für ein Testbild). Nicht im Release-APK.
## Verschlüsselt antworten (gekillt, vollständig still) ## Verschlüsselt antworten (gekillt, vollständig still)
- Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`), - Die Reply-Action ist ein **BroadcastReceiver** (`getBroadcast` → `ReplyReceiver`),
@@ -103,7 +136,8 @@ Engine-Bootstrap (~1,7 s) + Client-Init/Entschlüsselung (~2,3 s).
| `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing | | `android/.../PushService.kt` | FCM-Empfang, Heartbeat-Check, decrypt-first-Routing |
| `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter | | `android/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
| `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) | | `android/.../ReplyReceiver.kt` | Stilles verschlüsseltes Antworten (Broadcast) |
| `android/.../NotificationHelper.kt` | Native Benachrichtigung (Reply/Tap/Dismiss, setOnlyAlertOnce) | | `android/.../NotificationHelper.kt` | Native Benachrichtigung: Chat-Verlauf (MessagingStyle), Bilder, Antworten/Gelesen/Tippen |
| `lib/core/notification_content.dart` | Inhalt einer Benachrichtigung (Text, Medien-Labels, Bild/Avatar-Dateien) |
| `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback | | `android/.../MainActivity.kt` | MethodChannel `install`, Cold-Start-Reply-Fallback |
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply | | `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal | | `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
+44 -30
View File
@@ -8,6 +8,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod; import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_database.dart'; import 'package:pyramid/core/app_database.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/soft_logout_guard.dart'; import 'package:pyramid/core/soft_logout_guard.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -64,6 +65,11 @@ void notificationEngineMain() {
args['room_id'] as String?, args['room_id'] as String?,
args['text'] as String?, args['text'] as String?,
); );
case 'markRead':
return await _bgMarkRead(
args['room_id'] as String?,
args['event_id'] as String?,
);
} }
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e'); debugPrint('[NOTIF-BGENGINE] handler error: $e');
@@ -88,26 +94,27 @@ Future<bool> _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
) )
.timeout(const Duration(seconds: 20)); .timeout(const Duration(seconds: 20));
if (event == null) return false; if (event == null) return false;
// Bad-encrypted body still starts with "**" — keep the native placeholder.
if (event.body.startsWith('**')) return false;
final room = client.getRoomById(roomId); final prefs = await SharedPreferences.getInstance();
final sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ?? final content = MessageNotificationContent.fromEvent(
event.senderId.split(':').first.replaceFirst('@', ''); event,
final title = (room?.isDirectChat ?? true) showPreview: prefs.getBool('notif_preview') ?? true,
? sender );
: '$sender · ${room?.getLocalizedDisplayname() ?? ''}'; // Nicht entschlüsselt / nichts Anzeigbares → nativer Platzhalter bleibt.
if (content == null) return false;
// Hand the decrypted content back to native so it re-shows the SAME // Hand the decrypted content back to native so it re-shows the SAME
// notification via NotificationHelper — that keeps the reliable // notification via NotificationHelper — that keeps the reliable
// MainActivity-based reply action (the flutter_local_notifications reply // ReplyReceiver-based reply action (the flutter_local_notifications reply
// path doesn't fire dependably when the app is killed). // path doesn't fire dependably when the app is killed). Erst der Text,
await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', { // dann lautlos Avatar/Bild (deliverMessageNotification).
'title': title, await deliverMessageNotification(
'body': _buildBody(event), event,
'room_id': roomId, content,
'notif_id': notifId ?? _stableId(roomId), notifId: notifId ?? _stableId(roomId),
}); send: (a) => const MethodChannel(_kBgEngineChannel)
.invokeMethod('showDecrypted', a),
);
return true; return true;
} catch (e) { } catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e'); debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
@@ -152,6 +159,27 @@ Future<bool> _bgSendReply(String? roomId, String? text) async {
} }
} }
/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
/// HTTP-Aufruf, keine Verschlüsselung nötig).
Future<bool> _bgMarkRead(String? roomId, String? eventId) async {
if (roomId == null || eventId == null) return false;
final client = await _buildClient();
if (client == null) return false;
try {
final room = client.getRoomById(roomId) ?? Room(id: roomId, client: client);
await room
.setReadMarker(eventId, mRead: eventId)
.timeout(const Duration(seconds: 15));
debugPrint('[NOTIF-BGENGINE] read marker set for $roomId');
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] markRead error: $e');
return false;
} finally {
await client.dispose().catchError((_) {});
}
}
/// Stores the raw callback handle of [notificationEngineMain] so native code can /// Stores the raw callback handle of [notificationEngineMain] so native code can
/// bootstrap the background engine. Call once at app startup. /// bootstrap the background engine. Call once at app startup.
Future<void> registerBgEngineHandle() async { Future<void> registerBgEngineHandle() async {
@@ -217,20 +245,6 @@ Future<Client?> _buildClient() async {
} }
} }
String _buildBody(Event event) {
final msgType = event.messageType;
final rawBody = event.body;
return switch (msgType) {
'm.text' => rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody,
'm.image' => '📷 Bild',
'm.video' => '🎥 Video',
'm.audio' => '🎵 Audio',
'm.file' => '📄 Datei',
'm.sticker' => '🏷️ Sticker',
_ => rawBody.isEmpty ? 'Neue Nachricht' : (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody),
};
}
// ── Notification action handler ─────────────────────────────────────────────── // ── Notification action handler ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen. // Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner // Must return quickly — Android keeps the notification's "sending" spinner
+293
View File
@@ -0,0 +1,293 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/core/media_cache.dart';
/// Was eine Nachrichten-Benachrichtigung zeigt – egal, ob sie aus der
/// laufenden App (`notification_service.dart`) oder dem Hintergrund-Isolate
/// bei beendeter App (`background_push.dart`) kommt. Beide Wege übergeben
/// [toNativeArgs] an `NotificationHelper.kt`, das daraus pro Raum einen
/// Chat-Verlauf (Android MessagingStyle) baut.
class MessageNotificationContent {
const MessageNotificationContent({
required this.roomId,
required this.eventId,
required this.senderId,
required this.senderName,
required this.roomName,
required this.body,
required this.timestamp,
this.isImage = false,
});
final String roomId;
final String eventId;
final String senderId;
final String senderName;
/// `null` bei Direktnachrichten – dann zeigt Android nur den Absender.
final String? roomName;
final String body;
final DateTime timestamp;
/// Bild-Nachricht: Vorschaubild wird nachgeladen ([cacheNotificationImage]).
final bool isImage;
bool get isGroup => roomName != null;
/// Einzeiliger Titel für Plattformen ohne Chat-Verlauf (Windows).
String get title => roomName == null ? senderName : '$senderName · $roomName';
/// Liest den Inhalt aus einem (entschlüsselten) Event. `null`, wenn es
/// nichts Anzeigbares ist (nicht entschlüsselt, gelöscht, kein
/// Nachrichten-Event) – dann bleibt ein evtl. Platzhalter stehen.
/// Ist [showPreview] aus (Einstellung „Vorschau“), steht nur
/// [notificationPlaceholderBody] da und es wird kein Bild geladen.
static MessageNotificationContent? fromEvent(
Event event, {
bool showPreview = true,
}) {
final body = notificationBody(event);
if (body == null) return null;
final room = event.room;
final displayName = room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.displayName
?.trim();
return MessageNotificationContent(
roomId: room.id,
eventId: event.eventId,
senderId: event.senderId,
senderName: (displayName == null || displayName.isEmpty)
? _localpart(event.senderId)
: displayName,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
body: showPreview ? body : notificationPlaceholderBody,
timestamp: event.originServerTs,
isImage: showPreview && event.messageType == MessageTypes.Image,
);
}
/// Argumente für den nativen Kanal (`showNativeNotification` bzw.
/// `showDecrypted`). `title`/`body` bleiben für ältere Native-Pfade drin.
Map<String, Object?> toNativeArgs({
required int notifId,
String? imagePath,
String? avatarPath,
}) => {
'title': title,
'body': body,
'roomId': roomId,
'notifId': notifId,
'eventId': eventId,
'senderId': senderId,
'senderName': senderName,
'roomName': roomName,
'isGroup': isGroup,
'timestamp': timestamp.millisecondsSinceEpoch,
'imagePath': imagePath,
'avatarPath': avatarPath,
};
}
/// Text, den Android anzeigt, solange der echte Inhalt fehlt.
const notificationPlaceholderBody = 'Neue Nachricht';
/// Längere Texte werden gekürzt (aufgeklappt zeigt Android ohnehin nicht mehr).
const notificationBodyMaxChars = 1000;
/// Anzeigetext einer Nachricht für die Benachrichtigung, oder `null`, wenn
/// das Event nichts Anzeigbares enthält.
String? notificationBody(Event event) {
if (event.redacted) return null;
if (event.type == EventTypes.Sticker) return '🏷️ Sticker';
if (event.type == _pollStartType) return '📊 Umfrage';
if (event.type != EventTypes.Message) return null;
final content = event.content;
final caption = _caption(event);
final String? text = switch (event.messageType) {
MessageTypes.Text || MessageTypes.Notice => _plainText(event),
MessageTypes.Emote => '* ${_plainText(event)}',
MessageTypes.Image => caption == null ? '📷 Foto' : '📷 $caption',
MessageTypes.Video => caption == null ? '🎥 Video' : '🎥 $caption',
MessageTypes.Audio =>
content.containsKey(_voiceKey) ? '🎤 Sprachnachricht' : '🎵 Audio',
MessageTypes.File =>
'📎 ${content.tryGet<String>('filename') ?? event.body}',
MessageTypes.Location => '📍 Standort',
MessageTypes.Sticker => '🏷️ Sticker',
MessageTypes.BadEncrypted => null,
_ => _plainText(event),
};
final trimmed = text?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed.length > notificationBodyMaxChars
? '${trimmed.substring(0, notificationBodyMaxChars)}…'
: trimmed;
}
const _pollStartType = 'org.matrix.msc3381.poll.start';
const _voiceKey = 'org.matrix.msc3245.voice';
/// Klartext ohne Antwort-Zitat („> <@x> …“) und ohne HTML.
String _plainText(Event event) => event.calcUnlocalizedBody(
hideReply: true,
hideEdit: true,
plaintextBody: true,
);
/// MSC2530: Steht neben `filename` ein abweichender `body`, ist das die
/// Bildunterschrift.
String? _caption(Event event) {
final filename = event.content.tryGet<String>('filename');
final body = event.content.tryGet<String>('body')?.trim();
if (filename == null || body == null || body.isEmpty || body == filename) {
return null;
}
return body;
}
String _localpart(String userId) =>
userId.split(':').first.replaceFirst('@', '');
// ── Bilder für die Benachrichtigung ─────────────────────────────────────────
// Android zeigt Bilder in Benachrichtigungen nur über eine content://-URI.
// Die Dateien liegen deshalb unter <cache>/downloads/notif/ – genau dort gibt
// der FileProvider der App frei (res/xml: cache-path "downloads/").
const _maxImageBytes = 8 * 1024 * 1024;
const _maxAvatarBytes = 2 * 1024 * 1024;
const _keepFilesFor = Duration(days: 2);
/// Lädt das Vorschaubild einer Bild-Nachricht (bei E2EE entschlüsselt) in
/// den Cache und liefert den Dateipfad – oder `null`, wenn es nicht klappt.
Future<String?> cacheNotificationImage(Event event) async {
if (event.messageType != MessageTypes.Image) return null;
try {
final file = await event
.downloadAndDecryptAttachment(getThumbnail: event.hasThumbnail)
.timeout(const Duration(seconds: 20));
return await _writeNotifFile(
'img_${_hash(event.eventId)}',
file.bytes,
maxBytes: _maxImageBytes,
);
} catch (_) {
return null;
}
}
/// Avatar des Absenders als Datei (zuerst aus dem Bild-Cache der App).
Future<String?> cacheNotificationAvatar(Event event) async {
try {
final client = event.room.client;
final mxc = event.room
.unsafeGetUserFromMemoryOrFallback(event.senderId)
.avatarUrl;
if (mxc == null) return null;
final name = 'avatar_${_hash(mxc.toString())}';
final existing = File(p.join((await _notifDir()).path, name));
if (await existing.exists()) return existing.path;
final cacheKey = 'img:$mxc';
var bytes = await MediaCache.instance.getPersistent(cacheKey);
if (bytes == null) {
final headers = {'authorization': 'Bearer ${client.accessToken}'};
try {
final thumb = await mxc.getThumbnailUri(
client,
width: 128,
height: 128,
method: ThumbnailMethod.crop,
);
final res = await client.httpClient
.get(thumb, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
} catch (_) {}
if (bytes == null) {
// Continuwuity kann keine Thumbnails → Original (Avatare sind klein).
final full = await mxc.getDownloadUri(client);
final res = await client.httpClient
.get(full, headers: headers)
.timeout(const Duration(seconds: 8));
if (res.statusCode == 200) bytes = res.bodyBytes;
}
if (bytes != null) {
await MediaCache.instance.putPersistent(cacheKey, bytes);
}
}
if (bytes == null) return null;
return await _writeNotifFile(name, bytes, maxBytes: _maxAvatarBytes);
} catch (_) {
return null;
}
}
Future<Directory> _notifDir() async {
final dir = Directory(
p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
);
await dir.create(recursive: true);
return dir;
}
Future<String?> _writeNotifFile(
String name,
Uint8List bytes, {
required int maxBytes,
}) async {
if (bytes.isEmpty || bytes.length > maxBytes) return null;
final dir = await _notifDir();
await _pruneOldFiles(dir);
final file = File(p.join(dir.path, name));
await file.writeAsBytes(bytes, flush: true);
return file.path;
}
Future<void> _pruneOldFiles(Directory dir) async {
try {
final cutoff = DateTime.now().subtract(_keepFilesFor);
await for (final entity in dir.list()) {
if (entity is File && (await entity.lastModified()).isBefore(cutoff)) {
await entity.delete();
}
}
} catch (_) {}
}
/// Dateiname aus Event-ID/MXC (enthält `$`, `:` und `/`).
String _hash(String input) =>
sha1.convert(utf8.encode(input)).toString().substring(0, 20);
/// Zeigt eine Nachricht auf Android in zwei Schritten: erst sofort den Text
/// (damit nichts wartet), dann – nur falls es etwas gibt – lautlos dasselbe
/// mit Avatar und Bild. Native erkennt die Aktualisierung an der eventId und
/// ersetzt die Nachricht im Verlauf, statt sie doppelt anzuhängen.
Future<void> deliverMessageNotification(
Event event,
MessageNotificationContent content, {
required int notifId,
required Future<void> Function(Map<String, Object?> args) send,
}) async {
await send(content.toNativeArgs(notifId: notifId));
final avatarPath = await cacheNotificationAvatar(event);
final imagePath = content.isImage
? await cacheNotificationImage(event)
: null;
if (avatarPath == null && imagePath == null) return;
await send(
content.toNativeArgs(
notifId: notifId,
avatarPath: avatarPath,
imagePath: imagePath,
),
);
}
+66 -77
View File
@@ -12,6 +12,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart'; import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart'; import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +198,21 @@ Future<void> _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) { if (!share.isEmpty) {
ref.read(pendingShareProvider.notifier).state = share; ref.read(pendingShareProvider.notifier).state = share;
} }
} else if (call.method == 'markReadFromNotification') {
// „Gelesen“ in der Benachrichtigung (ReplyReceiver, App lebt).
final roomId = (args is Map ? args['room_id'] : null) as String?;
final eventId = (args is Map ? args['event_id'] : null) as String?;
if (roomId != null && eventId != null) {
try {
final client = await ref.read(matrixClientProvider.future);
await client
.getRoomById(roomId)
?.setReadMarker(eventId, mRead: eventId);
debugPrint('[NOTIF-FG] markReadFromNotification: $roomId bis $eventId');
} catch (e) {
debugPrint('[NOTIF-FG] markReadFromNotification error: $e');
}
}
} else if (call.method == 'decryptAndUpdateNotification') { } else if (call.method == 'decryptAndUpdateNotification') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder. // Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite // We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +474,37 @@ Future<void> _decryptAndUpdateNotification(
final client = await ref final client = await ref
.read(matrixClientProvider.future) .read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
var room = client.getRoomById(roomId); if (eventId == null) return;
if (room == null) {
debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found'); // Genau das gepushte Event holen (lokal oder vom Server, entschlüsselt) –
// nicht room.lastEvent, das kann inzwischen eine Reaktion o. Ä. sein.
final event = await client
.getEventByPushNotification(
PushNotification(roomId: roomId, eventId: eventId, devices: const []),
storeInDatabase: false,
returnNullIfSeen: false,
)
.timeout(const Duration(seconds: 20));
if (event == null || event.senderId == client.userID) return;
final content = MessageNotificationContent.fromEvent(
event,
showPreview: ref.read(notifPreviewProvider),
);
if (content == null) {
debugPrint('[NOTIF] decryptAndUpdate: nichts Anzeigbares, Platzhalter bleibt');
return; return;
} }
// The event might not have synced yet — wait for the next sync (max 12 s). // Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
// We use lastEvent as a proxy: after sync it will be the event we want. // Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
Event? event = room.lastEvent; await deliverMessageNotification(
final bool alreadyHave = event,
eventId == null || event?.eventId == eventId; content,
if (!alreadyHave || event == null) { notifId: notifId ?? _stableRoomId(roomId),
debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId'); send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
await client.onSync.stream.first.timeout(const Duration(seconds: 12)); );
room = client.getRoomById(roomId); debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
if (room == null) return;
event = room.lastEvent;
}
if (event == null) {
debugPrint('[NOTIF] decryptAndUpdate: event still null after sync');
return;
}
if (event.type != EventTypes.Message) return;
if (event.senderId == client.userID) return;
final rawBody = event.body;
// If decryption failed the body starts with "** Unable to decrypt"
if (rawBody.isEmpty || rawBody.startsWith('**')) {
debugPrint('[NOTIF] decryptAndUpdate: decryption failed, keeping placeholder');
return;
}
final notifBody =
rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody;
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
final title = room.isDirectChat
? displayName
: '$displayName · ${room.getLocalizedDisplayname()}';
// Overwrite the "Neue Nachricht" placeholder — same notifId so Android
// replaces the existing notification rather than stacking a new one.
await _kInstallChannel.invokeMethod('showNativeNotification', {
'title': title,
'body': notifBody,
'roomId': roomId,
'notifId': notifId ?? _stableRoomId(roomId),
});
debugPrint('[NOTIF] decryptAndUpdate: updated notification → "$title: $notifBody"');
} catch (e) { } catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e'); debugPrint('[NOTIF] decryptAndUpdate error: $e');
} }
@@ -516,27 +512,30 @@ Future<void> _decryptAndUpdateNotification(
// ─── Show notification ──────────────────────────────────────────────────────── // ─── Show notification ────────────────────────────────────────────────────────
Future<void> showMessageNotification({ Future<void> showMessageNotification(
required String roomId, Event event,
String? roomName, MessageNotificationContent content,
required String senderName, ) async {
required String body, final roomId = content.roomId;
}) async {
final title = roomName != null ? '$senderName · $roomName' : senderName;
if (Platform.isAndroid && _androidInitialized) { if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets // Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies. // ReplyReceiver directly — the app never opens when the user replies.
try { try {
await _kInstallChannel.invokeMethod('showNativeNotification', { await deliverMessageNotification(
'title': title, event,
'body': body, content,
'roomId': roomId, notifId: _stableRoomId(roomId),
'notifId': _stableRoomId(roomId), send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
}); );
} catch (e) { } catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e'); debugPrint('[NOTIF] showNativeNotification failed: $e');
} }
} else if (Platform.isWindows) { } else if (Platform.isWindows) {
final title = content.title;
// Windows-Toasts zeigen ohnehin nur wenige Zeilen.
final body = content.body.length > 300
? '${content.body.substring(0, 300)}…'
: content.body;
// Close the previous notification for this room before showing a new one. // Close the previous notification for this room before showing a new one.
_windowsNotifs.remove(roomId)?.close(); _windowsNotifs.remove(roomId)?.close();
@@ -624,23 +623,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return; if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
} }
// ── Build notification body ─────────────────────────────────────────── // ── Build notification content ────────────────────────────────────────
final rawBody = event.body; final content = MessageNotificationContent.fromEvent(
final showPreview = ref.read(notifPreviewProvider); event,
final notifBody = showPreview showPreview: ref.read(notifPreviewProvider),
? (rawBody.length > 100 ? '${rawBody.substring(0, 100)}…' : rawBody)
: 'Neue Nachricht';
final sender = room.unsafeGetUserFromMemoryOrFallback(event.senderId);
final displayName = sender.displayName ??
event.senderId.split(':').first.replaceFirst('@', '');
showMessageNotification(
roomId: roomId,
roomName: room.isDirectChat ? null : room.getLocalizedDisplayname(),
senderName: displayName,
body: notifBody,
); );
if (content == null) return;
showMessageNotification(event, content);
}); });
} }
+211
View File
@@ -0,0 +1,211 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/notification_content.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'support/synthetic_matrix_data.dart';
/// Vertragstests für den Inhalt der Push-Benachrichtigungen (Bernds
/// Beta-Feedback 2026-10-07: „beim Aufklappen viel weniger Text“, „keine
/// Bildvorschau“). Native Darstellung (Verlauf, Bild) prüft das Gerät.
void main() {
late Client client;
late Room room;
setUp(() async {
final factory = createDatabaseFactoryFfi(
ffiInit: sqfliteFfiInit,
noIsolate: true,
);
final db = await factory.openDatabase(inMemoryDatabasePath);
client = await SyntheticMatrixData.openClient(db, name: 'PyramidNotifTest');
room = Room(id: '!raum:bench.invalid', client: client);
room.setState(
Event(
type: EventTypes.RoomName,
content: {'name': 'Familie'},
eventId: r'$name',
senderId: '@uta:bench.invalid',
stateKey: '',
originServerTs: DateTime(2026),
room: room,
),
);
room.setState(
Event(
type: EventTypes.RoomMember,
content: {'membership': 'join', 'displayname': 'Uta'},
eventId: r'$member',
senderId: '@uta:bench.invalid',
stateKey: '@uta:bench.invalid',
originServerTs: DateTime(2026),
room: room,
),
);
});
tearDown(() async {
await client.dispose(closeDatabase: true);
});
Event message(
Map<String, Object?> content, {
String type = EventTypes.Message,
String sender = '@uta:bench.invalid',
}) => Event(
type: type,
content: content,
eventId: r'$ev1',
senderId: sender,
originServerTs: DateTime(2026, 10, 7, 12, 30),
room: room,
);
test('Text wird NICHT mehr auf 100 Zeichen gekürzt', () {
final text = 'Wort ' * 60; // 300 Zeichen
final body = notificationBody(message({'msgtype': 'm.text', 'body': text}));
expect(body, text.trim());
});
test('sehr lange Texte werden bei 1000 Zeichen gekappt', () {
final body = notificationBody(
message({'msgtype': 'm.text', 'body': 'x' * 5000}),
)!;
expect(body.length, notificationBodyMaxChars + 1);
expect(body.endsWith('…'), isTrue);
});
test('Antwort-Zitat (Reply-Fallback) wird entfernt', () {
final body = notificationBody(
message({
'msgtype': 'm.text',
'body': '> <@bernd:bench.invalid> Kommst du heute?\n\nJa, um acht!',
}),
);
expect(body, 'Ja, um acht!');
});
test('Medien bekommen verständliche Bezeichnungen', () {
expect(
notificationBody(message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'})),
'📷 Foto',
);
expect(
notificationBody(
message({
'msgtype': 'm.image',
'body': 'Unser Garten',
'filename': 'IMG_2.jpg',
}),
),
'📷 Unser Garten',
reason: 'MSC2530: abweichender body neben filename = Bildunterschrift',
);
expect(
notificationBody(message({'msgtype': 'm.video', 'body': 'v.mp4'})),
'🎥 Video',
);
expect(
notificationBody(
message({
'msgtype': 'm.audio',
'body': 'Sprachnachricht.ogg',
'org.matrix.msc3245.voice': <String, Object?>{},
}),
),
'🎤 Sprachnachricht',
);
expect(
notificationBody(message({'msgtype': 'm.audio', 'body': 'lied.mp3'})),
'🎵 Audio',
);
expect(
notificationBody(
message({
'msgtype': 'm.file',
'body': 'rechnung.pdf',
'filename': 'rechnung.pdf',
}),
),
'📎 rechnung.pdf',
);
expect(
notificationBody(message({'body': 'x'}, type: EventTypes.Sticker)),
'🏷️ Sticker',
);
});
test('nicht entschlüsselte Nachricht → null (Platzhalter bleibt)', () {
expect(
notificationBody(
message({
'algorithm': 'm.megolm.v1.aes-sha2',
'ciphertext': 'abc',
}, type: EventTypes.Encrypted),
),
isNull,
);
expect(
notificationBody(
message({
'msgtype': MessageTypes.BadEncrypted,
'body': '** Unable to decrypt: …',
}),
),
isNull,
);
});
test('Markdown-Fett am Anfang ist KEIN Entschlüsselungsfehler', () {
// Früher galt jeder Text mit "**" am Anfang als nicht entschlüsselt.
expect(
notificationBody(
message({'msgtype': 'm.text', 'body': '**Wichtig** heute'}),
),
'**Wichtig** heute',
);
});
test(
'Inhalt: Absender-Anzeigename, Raumname, Bild-Flag, Native-Argumente',
() {
final content = MessageNotificationContent.fromEvent(
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
)!;
expect(content.senderName, 'Uta');
expect(content.roomName, 'Familie');
expect(content.isGroup, isTrue);
expect(content.isImage, isTrue);
expect(content.title, 'Uta · Familie');
final args = content.toNativeArgs(notifId: 42, imagePath: '/x/img');
expect(args['notifId'], 42);
expect(args['eventId'], r'$ev1');
expect(args['senderId'], '@uta:bench.invalid');
expect(args['imagePath'], '/x/img');
expect(
args['timestamp'],
DateTime(2026, 10, 7, 12, 30).millisecondsSinceEpoch,
);
},
);
test('unbekannter Absender → Localpart statt leerem Namen', () {
final content = MessageNotificationContent.fromEvent(
message({
'msgtype': 'm.text',
'body': 'Hi',
}, sender: '@jochen:bench.invalid'),
)!;
expect(content.senderName, 'jochen');
});
test('Vorschau aus → kein Inhalt, kein Bild', () {
final content = MessageNotificationContent.fromEvent(
message({'msgtype': 'm.image', 'body': 'IMG_1.jpg'}),
showPreview: false,
)!;
expect(content.body, notificationPlaceholderBody);
expect(content.isImage, isFalse);
});
}