Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Actions
9bb7adafd0 chore: bump version to 4.11.144 [skip ci] 2026-07-27 16:39:51 +00:00
c9c7bdb48d Keep stacked notification images and warn when OEM AUTO_START blocks UnifiedPush.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m35s
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.
2026-07-28 02:39:44 +10:00
7 changed files with 146 additions and 36 deletions

View File

@@ -21,6 +21,7 @@ import android.graphics.PorterDuffXfermode
import android.graphics.Rect import android.graphics.Rect
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.Person import androidx.core.app.Person
import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
@@ -33,6 +34,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
@@ -274,19 +277,17 @@ class MatrixSyncService : Service() {
val body = when { val body = when {
eventType == "m.room.encrypted" -> "Encrypted message" eventType == "m.room.encrypted" -> "Encrypted message"
eventType == "m.sticker" -> { eventType == "m.sticker" -> "🖼️ Sticker"
val raw = content.optString("body")
if (raw.isNotBlank()) "🖼️ $raw" else "🖼️ Sticker"
}
else -> { else -> {
val msgtype = content.optString("msgtype") val msgtype = content.optString("msgtype")
val rawBody = content.optString("body") val rawBody = content.optString("body")
when (msgtype) { 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.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio" "m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File" "m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker" "m.sticker" -> "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } else -> rawBody.takeIf { it.isNotBlank() }
} }
} }
@@ -836,10 +837,56 @@ class MatrixSyncService : Service() {
val sender: String, val sender: String,
val text: String, val text: String,
val timestamp: Long, 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 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<PendingNotifMessage> { private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(historyPrefsKey(roomId), null) .getString(historyPrefsKey(roomId), null)
@@ -852,8 +899,9 @@ class MatrixSyncService : Service() {
val sender = obj.optString("sender") val sender = obj.optString("sender")
val text = obj.optString("text") val text = obj.optString("text")
val ts = obj.optLong("ts", 0L) val ts = obj.optLong("ts", 0L)
val imageUri = obj.optString("imageUri").takeIf { it.isNotBlank() }
if (sender.isBlank() || text.isBlank()) continue if (sender.isBlank() || text.isBlank()) continue
add(PendingNotifMessage(sender, text, ts)) add(PendingNotifMessage(sender, text, ts, imageUri))
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -869,12 +917,14 @@ class MatrixSyncService : Service() {
) { ) {
val arr = JSONArray() val arr = JSONArray()
for (msg in messages) { for (msg in messages) {
arr.put( val obj = JSONObject()
JSONObject() .put("sender", msg.sender)
.put("sender", msg.sender) .put("text", msg.text)
.put("text", msg.text) .put("ts", msg.timestamp)
.put("ts", msg.timestamp) if (!msg.imageUri.isNullOrBlank()) {
) obj.put("imageUri", msg.imageUri)
}
arr.put(obj)
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit() .edit()
@@ -883,6 +933,9 @@ class MatrixSyncService : Service() {
} }
private fun clearMessageHistory(context: Context, roomId: String) { private fun clearMessageHistory(context: Context, roomId: String) {
for (msg in loadMessageHistory(context, roomId)) {
deleteNotificationImageUri(context, msg.imageUri)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit() .edit()
.remove(historyPrefsKey(roomId)) .remove(historyPrefsKey(roomId))
@@ -894,12 +947,19 @@ class MatrixSyncService : Service() {
roomId: String, roomId: String,
sender: String, sender: String,
text: String, text: String,
imageUri: String? = null,
): List<PendingNotifMessage> { ): List<PendingNotifMessage> {
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage( val previous = loadMessageHistory(context, roomId)
val next = (previous + PendingNotifMessage(
sender = sender, sender = sender,
text = text, text = text,
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
imageUri = imageUri,
)).takeLast(MAX_MESSAGING_HISTORY) )).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) saveMessageHistory(context, roomId, next)
return next return next
} }
@@ -967,7 +1027,14 @@ class MatrixSyncService : Service() {
} }
val senderPerson = personBuilder.build() 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 channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) { val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
@@ -1008,13 +1075,17 @@ class MatrixSyncService : Service() {
.setImportant(true) .setImportant(true)
.build() .build()
} }
messagingStyle.addMessage( val line = NotificationCompat.MessagingStyle.Message(
NotificationCompat.MessagingStyle.Message( msg.text,
msg.text, msg.timestamp,
msg.timestamp, person,
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) 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. // Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
if (avatar != null) builder.setLargeIcon(avatar) 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()) nm.notify(roomNotifId, builder.build())
val summaryId = notificationIdForGroupSummary(groupId) val summaryId = notificationIdForGroupSummary(groupId)

View File

@@ -2,10 +2,13 @@ package com.paarrot.app
import android.app.Activity import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.app.AppOpsManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Process
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
@@ -177,6 +180,20 @@ object UnifiedPushManager {
if (!getEndpoint(context).isNullOrBlank()) return@postDelayed if (!getEndpoint(context).isNullOrBlank()) return@postDelayed
Log.w(TAG, "No endpoint yet after ${REGISTER_DELAY_MS}ms — retrying register") Log.w(TAG, "No endpoint yet after ${REGISTER_DELAY_MS}ms — retrying register")
requestRegistration(context) 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) }, REGISTER_DELAY_MS)
}, if (previous.isNotBlank() && previous != chosen) REGISTER_DELAY_MS else 150L) }, if (previous.isNotBlank() && previous != chosen) REGISTER_DELAY_MS else 150L)
} }
@@ -246,6 +263,7 @@ object UnifiedPushManager {
status.put("registered", !getEndpoint(context).isNullOrBlank()) status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault("")) status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put("lastFailure", getLastFailure(context) ?: "") status.put("lastFailure", getLastFailure(context) ?: "")
status.put("autoStartBlocked", isAutoStartRestricted(context))
val distributors = JSArray() val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) } runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
@@ -254,6 +272,26 @@ object UnifiedPushManager {
return status 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) { fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint") Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint")
clearLastFailure(context) clearLastFailure(context)

2
cinny

Submodule cinny updated: 0fb3da20b9...ea7642f0bb

View File

@@ -172,6 +172,7 @@ type PushStatus = {
endpoint: string; endpoint: string;
distributors: string[]; distributors: string[];
lastFailure: string; lastFailure: string;
autoStartBlocked: boolean;
}; };
/** Android-only section showing UnifiedPush registration status and controls. */ /** Android-only section showing UnifiedPush registration status and controls. */
@@ -196,6 +197,7 @@ function AndroidPushNotifications() {
endpoint: s.endpoint || '', endpoint: s.endpoint || '',
distributors, distributors,
lastFailure: s.lastFailure || '', lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
} }
: undefined : undefined
); );
@@ -246,6 +248,13 @@ function AndroidPushNotifications() {
</Text> </Text>
); );
} }
if (status.lastFailure === 'AUTO_START_BLOCKED' || status.autoStartBlocked) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Phone is blocking auto-start for Paarrot. Open App Boot / Auto-start settings, allow Paarrot (and ntfy), then tap Reset.
</Text>
);
}
if (status.lastFailure) { if (status.lastFailure) {
return ( return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200"> <Text as="span" style={{ color: color.Critical.Main }} size="T200">

View File

@@ -121,7 +121,8 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
const rawBody = typeof content.body === 'string' ? content.body : undefined; const rawBody = typeof content.body === 'string' ? content.body : undefined;
switch (content.msgtype) { switch (content.msgtype) {
case 'm.image': case 'm.image':
return rawBody ? `📷 ${rawBody}` : '📷 Photo'; // Matrix body is usually a filename — skip it in the shade.
return '📷 Photo';
case 'm.video': case 'm.video':
return rawBody ? `🎥 ${rawBody}` : '🎥 Video'; return rawBody ? `🎥 ${rawBody}` : '🎥 Video';
case 'm.audio': case 'm.audio':
@@ -129,7 +130,7 @@ function notificationBodyFromEvent(mEvent: MatrixEvent): string | undefined {
case 'm.file': case 'm.file':
return rawBody ? `📎 ${rawBody}` : '📎 File'; return rawBody ? `📎 ${rawBody}` : '📎 File';
case 'm.sticker': case 'm.sticker':
return rawBody ? `🖼️ ${rawBody}` : '🖼️ Sticker'; return '🖼️ Sticker';
default: default:
return rawBody; return rawBody;
} }

View File

@@ -9,6 +9,8 @@ type UnifiedPushStatus = {
distributor: string; distributor: string;
distributors: string[] | string; distributors: string[] | string;
lastFailure?: string; lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
}; };
type ClearRoomNotificationsResult = { type ClearRoomNotificationsResult = {

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.143", "version": "4.11.144",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"