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