diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f698..20741cc 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
+
diff --git a/android/app/src/debug/kotlin/chat/pyramid/pyramid/DebugNotifyReceiver.kt b/android/app/src/debug/kotlin/chat/pyramid/pyramid/DebugNotifyReceiver.kt
new file mode 100644
index 0000000..45c0ee6
--- /dev/null
+++ b/android/app/src/debug/kotlin/chat/pyramid/pyramid/DebugNotifyReceiver.kt
@@ -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
+ }
+}
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt
index d07a45d..6750797 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/BgEngine.kt
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf()
/** 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
appContext = ctx
fallbackHandler.post {
cancelFallback(notifId)
val r = Runnable {
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)")
}
fallbacks[notifId] = r
@@ -114,16 +117,19 @@ object BgEngine {
}
// Dart finished decrypting — show the real content using the SAME
// 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" -> {
- val title = call.argument("title") ?: ""
- val body = call.argument("body") ?: "Neue Nachricht"
- val roomId = call.argument("room_id") ?: ""
- val notifId = call.argument("notif_id") ?: NotificationHelper.stableId(roomId)
+ @Suppress("UNCHECKED_CAST")
+ val args = (call.arguments as? Map) ?: emptyMap()
+ val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: ""
+ val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
+ ?: NotificationHelper.stableId(roomId)
val ctx = appContext
if (ctx != null && roomId.isNotEmpty()) {
// Beat the fallback placeholder to it — show decrypted directly.
cancelFallback(notifId)
- NotificationHelper.show(ctx, title, body, roomId, notifId)
+ NotificationHelper.showFromArgs(ctx, args)
Log.d(TAG, "showDecrypted → shown decrypted notification for $roomId")
}
result.success(null)
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
index c9ab274..200d4d9 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/MainActivity.kt
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"showNativeNotification" -> {
- val title = call.argument("title") ?: ""
- val body = call.argument("body") ?: ""
- val roomId = call.argument("roomId") ?: ""
- val notifId = call.argument("notifId") ?: NotificationHelper.stableId(roomId)
+ @Suppress("UNCHECKED_CAST")
+ val args = (call.arguments as? Map) ?: emptyMap()
+ val roomId = args["roomId"] as? String ?: ""
if (roomId.isNotEmpty()) {
- NotificationHelper.show(this, title, body, roomId, notifId)
+ NotificationHelper.showFromArgs(this, args)
result.success(null)
} else {
result.error("INVALID_ARG", "roomId missing", null)
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt
index c3ea032..09f8f97 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/NotificationHelper.kt
@@ -5,13 +5,79 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
import android.graphics.Color
+import android.net.Uri
import android.os.Build
+import android.util.Log
import androidx.core.app.NotificationCompat
+import androidx.core.app.Person
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 {
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. */
fun stableId(roomId: String): Int {
@@ -32,15 +98,226 @@ object NotificationHelper {
}
}
- fun show(context: Context, title: String, body: String, roomId: String, notifId: Int) {
+ /**
+ * 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()),
+ )
+ }
+
+ /** Strukturierter Aufruf aus Dart (`toNativeArgs` in notification_content.dart). */
+ fun showFromArgs(context: Context, args: Map) {
+ 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 mutFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
- PendingIntent.FLAG_MUTABLE else 0
+ 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)
+ }
- // Reply action — BroadcastReceiver so replying NEVER opens the app.
- // ReplyReceiver decrypts/sends via the background engine when killed.
- // (ReplyReceiver has multiple RemoteInput key fallbacks for OEM quirks.)
+ 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, 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 {
+ 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.
+ // ReplyReceiver decrypts/sends via the background engine when killed.
+ // (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)
.setLabel("Antworten…").build()
val replyIntent = Intent(context, ReplyReceiver::class.java).apply {
@@ -49,59 +326,57 @@ object NotificationHelper {
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
}
val replyPi = PendingIntent.getBroadcast(
- context, notifId,
- replyIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
+ context, notifId, replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
)
- val replyAction = NotificationCompat.Action.Builder(0, "Antworten", replyPi)
+ return NotificationCompat.Action.Builder(0, "Antworten", replyPi)
.addRemoteInput(replyInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
.build()
+ }
- // Dismiss action — also points to ReplyReceiver so notification is cancelled.
- val dismissIntent = Intent(context, ReplyReceiver::class.java).apply {
+ // „Gelesen“ — schließt die Benachrichtigung UND setzt die Lesebestätigung
+ // 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
putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId)
putExtra(ReplyReceiver.EXTRA_NOTIF_ID, notifId)
+ putExtra(ReplyReceiver.EXTRA_EVENT_ID, eventId)
}
- val dismissPi = PendingIntent.getBroadcast(
- context, notifId + 1_000_000,
- dismissIntent,
+ val pi = PendingIntent.getBroadcast(
+ context, notifId + 1_000_000, intent,
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 {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("room_id", roomId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
- val tapPi = PendingIntent.getActivity(
- context, notifId + 2_000_000,
- tapIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or mutFlag,
+ return PendingIntent.getActivity(
+ context, notifId + 2_000_000, tapIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
)
-
- 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) =
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() }
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt
index bed947c..4c63dbc 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/PushService.kt
@@ -41,23 +41,23 @@ class PushService : FirebaseMessagingService() {
val token = (prefs.getString("flutter.notif_access_token", "") ?: "")
// 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) {
val latch = CountDownLatch(1)
Thread {
- try { title = fetchTitle(hs, token, roomId, eventId) ?: "Pyramid" }
+ try { meta = fetchMeta(hs, token, roomId, eventId) ?: PushMeta() }
finally { latch.countDown() }
}.start()
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")
if (engine != null) {
// App backgrounded but alive — show placeholder immediately, then the
// 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) {
val capturedRoomId = roomId
val capturedEventId = eventId
@@ -82,7 +82,7 @@ class PushService : FirebaseMessagingService() {
// real content directly. The "Neue Nachricht" placeholder is only a
// fallback if decryption takes too long or fails.
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(
"room_id" to roomId,
"event_id" to eventId,
@@ -90,7 +90,7 @@ class PushService : FirebaseMessagingService() {
))
} else {
// 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()
}
+ /** 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 ─────────────────────────────────────────────────────────
- private fun fetchTitle(
+ private fun fetchMeta(
hs: String, token: String, roomId: String, eventId: String
- ): String? {
+ ): PushMeta? {
return try {
val encRoom = java.net.URLEncoder.encode(roomId, "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 roomName = nameState?.optString("name")
- return if (!roomName.isNullOrEmpty()) "$senderName · $roomName"
- else senderName.ifEmpty { null }
+ return PushMeta(
+ senderId = senderId,
+ senderName = senderName.ifEmpty { "Pyramid" },
+ roomName = roomName?.takeIf { it.isNotEmpty() },
+ )
} catch (e: Exception) {
Log.w(TAG, "fetchTitle failed: ${e.message}")
null
diff --git a/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt b/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt
index 8bb786d..2ad0163 100644
--- a/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt
+++ b/android/app/src/main/kotlin/chat/pyramid/pyramid/ReplyReceiver.kt
@@ -16,6 +16,7 @@ class ReplyReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "chat.pyramid.pyramid.DISMISS"
const val EXTRA_ROOM_ID = "room_id"
const val EXTRA_NOTIF_ID = "notif_id"
+ const val EXTRA_EVENT_ID = "event_id"
const val REMOTE_INPUT_KEY = "reply_text"
}
@@ -23,12 +24,13 @@ class ReplyReceiver : BroadcastReceiver() {
val roomId = intent.getStringExtra(EXTRA_ROOM_ID) ?: return
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)
.cancel(notifId)
+ NotificationHelper.clearHistory(context, roomId)
if (intent.action == ACTION_DISMISS) {
- Log.d("PYRAMID-REPLY", "dismiss action for room $roomId")
+ markRead(context, roomId, intent.getStringExtra(EXTRA_EVENT_ID))
return
}
@@ -81,4 +83,30 @@ class ReplyReceiver : BroadcastReceiver() {
try { pending.finish() } catch (_: Exception) {}
}, 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)
+ }
}
diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md
index 511a9f7..bf7f4fd 100644
--- a/docs/NOTIFICATIONS.md
+++ b/docs/NOTIFICATIONS.md
@@ -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
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 `/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)
- 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/.../BgEngine.kt` | Headless-Engine-Bootstrap, Fallback-Platzhalter |
| `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 |
| `lib/core/background_push.dart` | Dart-Hintergrund-Isolate: decrypt + reply |
| `lib/core/fcm_push_service.dart` | Pusher-Registrierung bei Sygnal |
diff --git a/lib/core/background_push.dart b/lib/core/background_push.dart
index c7d88a4..6119e3c 100644
--- a/lib/core/background_push.dart
+++ b/lib/core/background_push.dart
@@ -8,6 +8,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
import 'package:matrix/matrix.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:shared_preferences/shared_preferences.dart';
@@ -64,6 +65,11 @@ void notificationEngineMain() {
args['room_id'] as String?,
args['text'] as String?,
);
+ case 'markRead':
+ return await _bgMarkRead(
+ args['room_id'] as String?,
+ args['event_id'] as String?,
+ );
}
} catch (e) {
debugPrint('[NOTIF-BGENGINE] handler error: $e');
@@ -88,26 +94,27 @@ Future _bgDecryptAndShow(String? roomId, String? eventId, int? notifId) as
)
.timeout(const Duration(seconds: 20));
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 sender = room?.unsafeGetUserFromMemoryOrFallback(event.senderId).displayName ??
- event.senderId.split(':').first.replaceFirst('@', '');
- final title = (room?.isDirectChat ?? true)
- ? sender
- : '$sender · ${room?.getLocalizedDisplayname() ?? ''}';
+ final prefs = await SharedPreferences.getInstance();
+ final content = MessageNotificationContent.fromEvent(
+ event,
+ showPreview: prefs.getBool('notif_preview') ?? true,
+ );
+ // 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
// notification via NotificationHelper — that keeps the reliable
- // MainActivity-based reply action (the flutter_local_notifications reply
- // path doesn't fire dependably when the app is killed).
- await const MethodChannel(_kBgEngineChannel).invokeMethod('showDecrypted', {
- 'title': title,
- 'body': _buildBody(event),
- 'room_id': roomId,
- 'notif_id': notifId ?? _stableId(roomId),
- });
+ // ReplyReceiver-based reply action (the flutter_local_notifications reply
+ // path doesn't fire dependably when the app is killed). Erst der Text,
+ // dann lautlos Avatar/Bild (deliverMessageNotification).
+ await deliverMessageNotification(
+ event,
+ content,
+ notifId: notifId ?? _stableId(roomId),
+ send: (a) => const MethodChannel(_kBgEngineChannel)
+ .invokeMethod('showDecrypted', a),
+ );
return true;
} catch (e) {
debugPrint('[NOTIF-BGENGINE] decryptAndShow error: $e');
@@ -152,6 +159,27 @@ Future _bgSendReply(String? roomId, String? text) async {
}
}
+/// „Gelesen“ bei beendeter App: Lesebestätigung bis [eventId] setzen (reiner
+/// HTTP-Aufruf, keine Verschlüsselung nötig).
+Future _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
/// bootstrap the background engine. Call once at app startup.
Future registerBgEngineHandle() async {
@@ -217,20 +245,6 @@ Future _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 ───────────────────────────────────────────────
// Called in a fresh Dart isolate when the user presses Reply or Gelesen.
// Must return quickly — Android keeps the notification's "sending" spinner
diff --git a/lib/core/notification_content.dart b/lib/core/notification_content.dart
new file mode 100644
index 0000000..205a9b3
--- /dev/null
+++ b/lib/core/notification_content.dart
@@ -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 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('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('filename');
+ final body = event.content.tryGet('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 /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 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 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 _notifDir() async {
+ final dir = Directory(
+ p.join((await getTemporaryDirectory()).path, 'downloads', 'notif'),
+ );
+ await dir.create(recursive: true);
+ return dir;
+}
+
+Future _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 _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 deliverMessageNotification(
+ Event event,
+ MessageNotificationContent content, {
+ required int notifId,
+ required Future Function(Map 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,
+ ),
+ );
+}
diff --git a/lib/core/notification_service.dart b/lib/core/notification_service.dart
index 60a800a..d7bca3d 100644
--- a/lib/core/notification_service.dart
+++ b/lib/core/notification_service.dart
@@ -12,6 +12,7 @@ import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/background_push.dart';
import 'package:pyramid/core/fcm_push_service.dart';
import 'package:pyramid/core/matrix_client.dart';
+import 'package:pyramid/core/notification_content.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:windows_taskbar/windows_taskbar.dart';
@@ -197,6 +198,21 @@ Future _initAndroid(WidgetRef ref) async {
if (!share.isEmpty) {
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') {
// Called by PushService.kt after showing a "Neue Nachricht" placeholder.
// We decrypt the event here (the Matrix client has the keys) and overwrite
@@ -458,57 +474,37 @@ Future _decryptAndUpdateNotification(
final client = await ref
.read(matrixClientProvider.future)
.timeout(const Duration(seconds: 5));
- var room = client.getRoomById(roomId);
- if (room == null) {
- debugPrint('[NOTIF] decryptAndUpdate: room $roomId not found');
+ if (eventId == null) return;
+
+ // 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;
}
- // The event might not have synced yet — wait for the next sync (max 12 s).
- // We use lastEvent as a proxy: after sync it will be the event we want.
- Event? event = room.lastEvent;
- final bool alreadyHave =
- eventId == null || event?.eventId == eventId;
- if (!alreadyHave || event == null) {
- debugPrint('[NOTIF] decryptAndUpdate: waiting for sync to deliver $eventId');
- await client.onSync.stream.first.timeout(const Duration(seconds: 12));
- room = client.getRoomById(roomId);
- 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"');
+ // Same eventId → NotificationHelper ersetzt den „Neue Nachricht“-
+ // Platzhalter im Verlauf, statt eine zweite Nachricht anzuhängen.
+ await deliverMessageNotification(
+ event,
+ content,
+ notifId: notifId ?? _stableRoomId(roomId),
+ send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
+ );
+ debugPrint('[NOTIF] decryptAndUpdate: updated notification for $eventId');
} catch (e) {
debugPrint('[NOTIF] decryptAndUpdate error: $e');
}
@@ -516,27 +512,30 @@ Future _decryptAndUpdateNotification(
// ─── Show notification ────────────────────────────────────────────────────────
-Future showMessageNotification({
- required String roomId,
- String? roomName,
- required String senderName,
- required String body,
-}) async {
- final title = roomName != null ? '$senderName · $roomName' : senderName;
+Future showMessageNotification(
+ Event event,
+ MessageNotificationContent content,
+) async {
+ final roomId = content.roomId;
if (Platform.isAndroid && _androidInitialized) {
// Use native Kotlin notification so the reply PendingIntent targets
// ReplyReceiver directly — the app never opens when the user replies.
try {
- await _kInstallChannel.invokeMethod('showNativeNotification', {
- 'title': title,
- 'body': body,
- 'roomId': roomId,
- 'notifId': _stableRoomId(roomId),
- });
+ await deliverMessageNotification(
+ event,
+ content,
+ notifId: _stableRoomId(roomId),
+ send: (a) => _kInstallChannel.invokeMethod('showNativeNotification', a),
+ );
} catch (e) {
debugPrint('[NOTIF] showNativeNotification failed: $e');
}
} 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.
_windowsNotifs.remove(roomId)?.close();
@@ -624,23 +623,13 @@ void _watchEvents(Ref ref, Client client) {
if (!mentionedIds.contains(myId) && !bodyMentionsUs) return;
}
- // ── Build notification body ───────────────────────────────────────────
- final rawBody = event.body;
- final showPreview = ref.read(notifPreviewProvider);
- final notifBody = showPreview
- ? (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,
+ // ── Build notification content ────────────────────────────────────────
+ final content = MessageNotificationContent.fromEvent(
+ event,
+ showPreview: ref.read(notifPreviewProvider),
);
+ if (content == null) return;
+ showMessageNotification(event, content);
});
}
diff --git a/test/notification_content_test.dart b/test/notification_content_test.dart
new file mode 100644
index 0000000..812240b
--- /dev/null
+++ b/test/notification_content_test.dart
@@ -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 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': {},
+ }),
+ ),
+ '🎤 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);
+ });
+}