From ef1d881967a41d2f6d36860b4db8a46b2cf9e691 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 16:27:56 +0200 Subject: [PATCH] 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 --- android/app/src/debug/AndroidManifest.xml | 6 + .../pyramid/pyramid/DebugNotifyReceiver.kt | 55 +++ .../kotlin/chat/pyramid/pyramid/BgEngine.kt | 20 +- .../chat/pyramid/pyramid/MainActivity.kt | 9 +- .../pyramid/pyramid/NotificationHelper.kt | 351 ++++++++++++++++-- .../chat/pyramid/pyramid/PushService.kt | 43 ++- .../chat/pyramid/pyramid/ReplyReceiver.kt | 32 +- docs/NOTIFICATIONS.md | 36 +- lib/core/background_push.dart | 74 ++-- lib/core/notification_content.dart | 293 +++++++++++++++ lib/core/notification_service.dart | 143 ++++--- test/notification_content_test.dart | 211 +++++++++++ 12 files changed, 1103 insertions(+), 170 deletions(-) create mode 100644 android/app/src/debug/kotlin/chat/pyramid/pyramid/DebugNotifyReceiver.kt create mode 100644 lib/core/notification_content.dart create mode 100644 test/notification_content_test.dart 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); + }); +}