From c9c7bdb48d89a456f8dde719f8af7e02c924c6bb Mon Sep 17 00:00:00 2001 From: litruv Date: Tue, 28 Jul 2026 02:39:41 +1000 Subject: [PATCH] Keep stacked notification images and warn when OEM AUTO_START blocks UnifiedPush. Attach image thumbnails via MessagingStyle message data so they survive multi-message history, drop useless image filenames, and surface TCL App Boot blocks in settings. --- .../java/com/paarrot/app/MatrixSyncService.kt | 124 +++++++++++++----- .../com/paarrot/app/UnifiedPushManager.kt | 38 ++++++ cinny | 2 +- .../notifications/SystemNotification.tsx | 9 ++ .../app/pages/client/ClientNonUIFeatures.tsx | 5 +- overlay/src/app/utils/backgroundSync.ts | 2 + 6 files changed, 145 insertions(+), 35 deletions(-) diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt index 8b59444..f68ca9c 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -21,6 +21,7 @@ import android.graphics.PorterDuffXfermode import android.graphics.Rect import androidx.core.app.NotificationCompat import androidx.core.app.Person +import androidx.core.content.FileProvider import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat @@ -33,6 +34,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder @@ -274,19 +277,17 @@ class MatrixSyncService : Service() { val body = when { eventType == "m.room.encrypted" -> "Encrypted message" - eventType == "m.sticker" -> { - val raw = content.optString("body") - if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker" - } + eventType == "m.sticker" -> "🖼️ Sticker" else -> { val msgtype = content.optString("msgtype") val rawBody = content.optString("body") when (msgtype) { - "m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo" + // Skip Matrix body for images/stickers — it's usually a useless filename. + "m.image" -> "📷 Photo" "m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video" "m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" "m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" - "m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker" + "m.sticker" -> "🖼️ Sticker" else -> rawBody.takeIf { it.isNotBlank() } } } @@ -836,10 +837,56 @@ class MatrixSyncService : Service() { val sender: String, val text: String, val timestamp: Long, + /** content:// URI for MessagingStyle image data, if any. */ + val imageUri: String? = null, ) private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId + private fun notifImageDir(context: Context): File = + File(context.cacheDir, "notif-images").apply { mkdirs() } + + /** Write [bitmap] to cache and return a FileProvider URI SystemUI can read. */ + private fun persistNotificationImage(context: Context, bitmap: Bitmap): Uri? { + return try { + val file = File(notifImageDir(context), "img_${System.currentTimeMillis()}.jpg") + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out) + } + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + // SystemUI reads MessagingStyle image URIs; grant explicitly on OEM builds. + runCatching { + context.grantUriPermission( + "com.android.systemui", + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + uri + } catch (e: Exception) { + Log.w(TAG, "Failed to persist notification image: ${e.message}") + null + } + } + + private fun deleteNotificationImageUri(context: Context, uriString: String?) { + if (uriString.isNullOrBlank()) return + try { + val uri = Uri.parse(uriString) + val path = uri.path ?: return + // FileProvider paths look like /my_cache_images/notif-images/... + val name = path.substringAfterLast('/') + if (name.isBlank()) return + File(notifImageDir(context), name).delete() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete notification image: ${e.message}") + } + } + private fun loadMessageHistory(context: Context, roomId: String): List { val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(historyPrefsKey(roomId), null) @@ -852,8 +899,9 @@ class MatrixSyncService : Service() { val sender = obj.optString("sender") val text = obj.optString("text") val ts = obj.optLong("ts", 0L) + val imageUri = obj.optString("imageUri").takeIf { it.isNotBlank() } if (sender.isBlank() || text.isBlank()) continue - add(PendingNotifMessage(sender, text, ts)) + add(PendingNotifMessage(sender, text, ts, imageUri)) } } } catch (e: Exception) { @@ -869,12 +917,14 @@ class MatrixSyncService : Service() { ) { val arr = JSONArray() for (msg in messages) { - arr.put( - JSONObject() - .put("sender", msg.sender) - .put("text", msg.text) - .put("ts", msg.timestamp) - ) + val obj = JSONObject() + .put("sender", msg.sender) + .put("text", msg.text) + .put("ts", msg.timestamp) + if (!msg.imageUri.isNullOrBlank()) { + obj.put("imageUri", msg.imageUri) + } + arr.put(obj) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() @@ -883,6 +933,9 @@ class MatrixSyncService : Service() { } private fun clearMessageHistory(context: Context, roomId: String) { + for (msg in loadMessageHistory(context, roomId)) { + deleteNotificationImageUri(context, msg.imageUri) + } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit() .remove(historyPrefsKey(roomId)) @@ -894,12 +947,19 @@ class MatrixSyncService : Service() { roomId: String, sender: String, text: String, + imageUri: String? = null, ): List { - val next = (loadMessageHistory(context, roomId) + PendingNotifMessage( + val previous = loadMessageHistory(context, roomId) + val next = (previous + PendingNotifMessage( sender = sender, text = text, timestamp = System.currentTimeMillis(), + imageUri = imageUri, )).takeLast(MAX_MESSAGING_HISTORY) + // Drop image files for messages that fell out of the sliding window. + for (dropped in previous - next.toSet()) { + deleteNotificationImageUri(context, dropped.imageUri) + } saveMessageHistory(context, roomId, next) return next } @@ -967,7 +1027,14 @@ class MatrixSyncService : Service() { } val senderPerson = personBuilder.build() - val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage) + val imageUri = inlineImage?.let { persistNotificationImage(context, it) }?.toString() + val history = appendMessageHistory( + context, + roomId, + resolvedSender, + resolvedMessage, + imageUri, + ) val channelId = channelIdForKindStatic(kind) val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) { @@ -1008,13 +1075,17 @@ class MatrixSyncService : Service() { .setImportant(true) .build() } - messagingStyle.addMessage( - NotificationCompat.MessagingStyle.Message( - msg.text, - msg.timestamp, - person, - ) + val line = NotificationCompat.MessagingStyle.Message( + msg.text, + msg.timestamp, + person, ) + // Attach image on the message itself so multi-message history keeps thumbnails + // (BigPictureStyle replaces MessagingStyle and culls prior lines). + if (!msg.imageUri.isNullOrBlank()) { + line.setData("image/jpeg", Uri.parse(msg.imageUri)) + } + messagingStyle.addMessage(line) } val builder = NotificationCompat.Builder(context, channelId) @@ -1044,17 +1115,6 @@ class MatrixSyncService : Service() { // Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark. if (avatar != null) builder.setLargeIcon(avatar) - // Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines. - if (inlineImage != null && history.size == 1) { - builder.setStyle( - NotificationCompat.BigPictureStyle() - .bigPicture(inlineImage) - .bigLargeIcon(avatar) - .setBigContentTitle(collapsedTitle) - .setSummaryText(collapsedText) - ) - } - nm.notify(roomNotifId, builder.build()) val summaryId = notificationIdForGroupSummary(groupId) diff --git a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt index b7afba8..8ec51aa 100644 --- a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt +++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt @@ -2,10 +2,13 @@ package com.paarrot.app import android.app.Activity import android.app.AlertDialog +import android.app.AppOpsManager import android.content.Context import android.content.pm.PackageManager +import android.os.Build import android.os.Handler import android.os.Looper +import android.os.Process import android.os.SystemClock import android.util.Log import com.getcapacitor.JSArray @@ -177,6 +180,20 @@ object UnifiedPushManager { if (!getEndpoint(context).isNullOrBlank()) return@postDelayed Log.w(TAG, "No endpoint yet after ${REGISTER_DELAY_MS}ms — retrying register") requestRegistration(context) + + // OEM "App Boot" / AUTO_START can silently drop NEW_ENDPOINT (seen on TCL). + mainHandler.postDelayed({ + if (gen != setupGeneration) return@postDelayed + if (!getEndpoint(context).isNullOrBlank()) return@postDelayed + val reason = if (isAutoStartRestricted(context)) { + "AUTO_START_BLOCKED" + } else { + "NO_ENDPOINT" + } + Log.w(TAG, "Still no endpoint after register retries — $reason") + persistLastFailure(context, reason) + dispatchRegistrationFailed(reason, INSTANCE_DEFAULT) + }, 4_000L) }, REGISTER_DELAY_MS) }, if (previous.isNotBlank() && previous != chosen) REGISTER_DELAY_MS else 150L) } @@ -246,6 +263,7 @@ object UnifiedPushManager { status.put("registered", !getEndpoint(context).isNullOrBlank()) status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault("")) status.put("lastFailure", getLastFailure(context) ?: "") + status.put("autoStartBlocked", isAutoStartRestricted(context)) val distributors = JSArray() runCatching { UnifiedPush.getDistributors(context) } .getOrDefault(emptyList()) @@ -254,6 +272,26 @@ object UnifiedPushManager { return status } + /** + * TCL / some OEMs expose an AUTO_START app-op. When it is not MODE_ALLOWED, + * [AppBootManager] drops distributor broadcasts such as NEW_ENDPOINT, so + * UnifiedPush never finishes registering even though ntfy is selected. + */ + private fun isAutoStartRestricted(context: Context): Boolean { + return try { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow("android:auto_start", Process.myUid(), context.packageName) + } else { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow("android:auto_start", Process.myUid(), context.packageName) + } + mode != AppOpsManager.MODE_ALLOWED && mode != AppOpsManager.MODE_DEFAULT + } catch (_: Exception) { + false + } + } + fun onNewEndpoint(context: Context, endpoint: String, instance: String) { Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint") clearLastFailure(context) diff --git a/cinny b/cinny index 0fb3da2..ea7642f 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 0fb3da20b955bde2fa228616dfa632db257f9130 +Subproject commit ea7642f0bbaf519a575d9bbbf6a828082ec32b3e diff --git a/overlay/src/app/features/settings/notifications/SystemNotification.tsx b/overlay/src/app/features/settings/notifications/SystemNotification.tsx index f2aeeea..ad1aa78 100644 --- a/overlay/src/app/features/settings/notifications/SystemNotification.tsx +++ b/overlay/src/app/features/settings/notifications/SystemNotification.tsx @@ -172,6 +172,7 @@ type PushStatus = { endpoint: string; distributors: string[]; lastFailure: string; + autoStartBlocked: boolean; }; /** Android-only section showing UnifiedPush registration status and controls. */ @@ -196,6 +197,7 @@ function AndroidPushNotifications() { endpoint: s.endpoint || '', distributors, lastFailure: s.lastFailure || '', + autoStartBlocked: Boolean(s.autoStartBlocked), } : undefined ); @@ -246,6 +248,13 @@ function AndroidPushNotifications() { ); } + if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) { + return ( + + Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset. + + ); + } if (status.lastFailure) { return ( diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index dc6e5f8..206a6db 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -121,7 +121,8 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined { const rawBody = typeof content.body === 'string' ? content.body : undefined; switch (content.msgtype) { case 'm.image': - return rawBody ? `📷 ${rawBody}` : '📷 Photo'; + // Matrix body is usually a filename — skip it in the shade. + return '📷 Photo'; case 'm.video': return rawBody ? `🎥 ${rawBody}` : '🎥 Video'; case 'm.audio': @@ -129,7 +130,7 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined { case 'm.file': return rawBody ? `📎 ${rawBody}` : '📎 File'; case 'm.sticker': - return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker'; + return '🖼️ Sticker'; default: return rawBody; } diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index eddb61a..e62d9c0 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -9,6 +9,8 @@ type UnifiedPushStatus = { distributor: string; distributors: string[] | string; lastFailure?: string; + /** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */ + autoStartBlocked?: boolean; }; type ClearRoomNotificationsResult = {