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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 16:27:56 +02:00
co-authored by Claude Opus 5.5
parent 460477204b
commit ef1d881967
12 changed files with 1103 additions and 170 deletions
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application>
<!-- NUR Debug: Benachrichtigungen per adb auslösen (DebugNotifyReceiver.kt). -->
<receiver
android:name=".DebugNotifyReceiver"
android:exported="true" />
</application>
</manifest>
@@ -0,0 +1,55 @@
package chat.pyramid.pyramid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import java.io.File
/**
* NUR IM DEBUG-BUILD (src/debug): löst Nachrichten-Benachrichtigungen per adb
* aus, um NotificationHelper ohne Login/Push auf dem Emulator zu prüfen.
* Ist nicht im Release-APK enthalten.
*
* adb shell am broadcast -n chat.pyramid.pyramid/.DebugNotifyReceiver \
* --es sender Uta --es body "Hallo" [--es roomName Familie] \
* [--es event e1] [--ez image true] [--ez avatar true] [--es room !r:x]
*/
class DebugNotifyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val roomId = intent.getStringExtra("room") ?: "!debug:pyramid.local"
val sender = intent.getStringExtra("sender") ?: "Testnutzer"
val roomName = intent.getStringExtra("roomName")
val eventId = intent.getStringExtra("event") ?: "\$debug${System.nanoTime()}"
val args = mapOf(
"roomId" to roomId,
"notifId" to NotificationHelper.stableId(roomId),
"eventId" to eventId,
"senderId" to "@${sender.lowercase()}:pyramid.local",
"senderName" to sender,
"roomName" to roomName,
"isGroup" to (roomName != null),
"body" to (intent.getStringExtra("body") ?: NotificationHelper.PLACEHOLDER_BODY),
"timestamp" to System.currentTimeMillis(),
"imagePath" to if (intent.getBooleanExtra("image", false)) testImage(context, "img_debug", 960, 640) else null,
"avatarPath" to if (intent.getBooleanExtra("avatar", false)) testImage(context, "avatar_$sender", 256, 256) else null,
)
NotificationHelper.showFromArgs(context, args)
}
/** Erzeugt ein buntes Testbild unter cache/downloads/notif/ (FileProvider-Pfad). */
private fun testImage(context: Context, name: String, w: Int, h: Int): String {
val dir = File(context.cacheDir, "downloads/notif").apply { mkdirs() }
val file = File(dir, name)
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val c = Canvas(bmp)
c.drawColor(Color.rgb(0x3B, 0x6D, 0x11))
val p = Paint().apply { color = Color.rgb(0xF5, 0xA6, 0x14); isAntiAlias = true }
c.drawCircle(w * 0.5f, h * 0.5f, minOf(w, h) * 0.3f, p)
file.outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) }
return file.absolutePath
}
}
@@ -35,14 +35,17 @@ object BgEngine {
private val fallbacks = mutableMapOf<Int, Runnable>()
/** 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<String>("title") ?: ""
val body = call.argument<String>("body") ?: "Neue Nachricht"
val roomId = call.argument<String>("room_id") ?: ""
val notifId = call.argument<Int>("notif_id") ?: NotificationHelper.stableId(roomId)
@Suppress("UNCHECKED_CAST")
val args = (call.arguments as? Map<String, Any?>) ?: 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)
@@ -206,12 +206,11 @@ class MainActivity : FlutterActivity() {
result.success(null)
}
"showNativeNotification" -> {
val title = call.argument<String>("title") ?: ""
val body = call.argument<String>("body") ?: ""
val roomId = call.argument<String>("roomId") ?: ""
val notifId = call.argument<Int>("notifId") ?: NotificationHelper.stableId(roomId)
@Suppress("UNCHECKED_CAST")
val args = (call.arguments as? Map<String, Any?>) ?: 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)
@@ -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<String, Any?>) {
val roomId = (args["roomId"] ?: args["room_id"]) as? String ?: return
if (roomId.isEmpty()) return
val notifId = ((args["notifId"] ?: args["notif_id"]) as? Number)?.toInt()
?: stableId(roomId)
val senderName = args["senderName"] as? String
if (senderName == null) {
// Alter Aufrufer ohne strukturierte Felder.
show(
context, args["title"] as? String ?: "",
args["body"] as? String ?: PLACEHOLDER_BODY,
roomId, notifId, args["eventId"] as? String,
)
return
}
val roomName = (args["roomName"] as? String)?.takeIf { it.isNotBlank() }
showMessage(
context, roomId, notifId, roomName,
isGroup = (args["isGroup"] as? Boolean) ?: (roomName != null),
msg = Msg(
eventId = args["eventId"] as? String,
senderKey = args["senderId"] as? String ?: senderName,
senderName = senderName,
body = args["body"] as? String ?: PLACEHOLDER_BODY,
timestamp = (args["timestamp"] as? Number)?.toLong() ?: System.currentTimeMillis(),
imagePath = args["imagePath"] as? String,
avatarPath = args["avatarPath"] as? String,
),
)
}
/** Fügt [msg] dem Verlauf des Raums hinzu (oder aktualisiert sie) und zeigt ihn. */
fun showMessage(
context: Context,
roomId: String,
notifId: Int,
roomName: String?,
isGroup: Boolean,
msg: Msg,
) {
ensureChannel(context)
val (history, isNew) = mergeIntoHistory(context, roomId, notifId, msg)
val latest = history.last()
val 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<List<Msg>, Boolean> {
val prefs = prefs(context)
val list = if (isActive(context, notifId)) load(prefs.getString(roomId, null)) else mutableListOf()
val index = msg.eventId?.let { id -> list.indexOfFirst { it.eventId == id } } ?: -1
val isNew = index < 0
if (isNew) {
list.add(msg)
} else {
val old = list[index]
// Ein später eintreffender Platzhalter überschreibt nie echten Text.
val keepOld = msg.isPlaceholder && !old.isPlaceholder
list[index] = Msg(
eventId = old.eventId,
senderKey = if (keepOld) old.senderKey else msg.senderKey,
senderName = if (keepOld) old.senderName else msg.senderName,
body = if (keepOld) old.body else msg.body,
timestamp = if (old.isPlaceholder) msg.timestamp else old.timestamp,
imagePath = msg.imagePath ?: old.imagePath,
avatarPath = msg.avatarPath ?: old.avatarPath,
)
}
list.sortBy { it.timestamp }
while (list.size > MAX_MESSAGES) list.removeAt(0)
prefs.edit().putString(roomId, JSONArray(list.map { it.toJson() }).toString()).apply()
return list to isNew
}
private fun load(json: String?): MutableList<Msg> {
if (json == null) return mutableListOf()
return try {
val arr = JSONArray(json)
MutableList(arr.length()) { Msg.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
mutableListOf()
}
}
/** Ist die Benachrichtigung noch sichtbar? (Sonst beginnt ein neuer Verlauf.) */
private fun isActive(context: Context, notifId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try {
nm(context).activeNotifications.any { it.id == notifId }
} catch (_: Exception) {
true
}
}
/**
* content://-URI für eine Datei unter cache/downloads/ (FileProvider). Die
* System-UI zeichnet die Benachrichtigung und braucht dafür Leserecht.
*/
private fun contentUri(context: Context, path: String?): Uri? {
if (path.isNullOrEmpty()) return null
val file = File(path)
if (!file.exists()) return null
return try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
context.grantUriPermission(SYSTEM_UI, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
uri
} catch (e: Exception) {
Log.w(TAG, "contentUri failed for $path: ${e.message}")
null
}
}
/** Kleines Avatar-Bitmap (≈192 px) für das große Icon bei Direktnachrichten. */
private fun avatarBitmap(path: String?): Bitmap? {
if (path.isNullOrEmpty() || !File(path).exists()) return null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= 192 && bounds.outHeight / (sample * 2) >= 192) {
sample *= 2
}
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
}
// ── Aktionen ──────────────────────────────────────────────────────────────
private fun mutableFlag() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
// Reply action — BroadcastReceiver so replying NEVER opens the app.
// 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() }
@@ -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
@@ -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)
}
}