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.
|
||||
-->
|
||||
<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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user