Compare commits

...

4 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
GitHub Actions
f3d4dcb67e chore: bump version to 4.11.143 [skip ci] 2026-07-27 15:38:12 +00:00
a744e36fb1 Fix UnifiedPush register race where a late UNREGISTER wiped the new endpoint.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 5m8s
2026-07-28 01:38:04 +10:00
8 changed files with 274 additions and 51 deletions

View File

@@ -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<PendingNotifMessage> {
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()
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<PendingNotifMessage> {
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(
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)

View File

@@ -2,8 +2,14 @@ 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
import com.getcapacitor.JSObject
@@ -20,15 +26,26 @@ object UnifiedPushManager {
private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance"
private const val KEY_LAST_FAILURE = "last_failure"
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications"
private const val REGISTER_DELAY_MS = 750L
private const val IGNORE_UNREGISTER_MS = 2_500L
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile
private var plugin: SyncServicePlugin? = null
@Volatile
private var setupGeneration = 0
@Volatile
private var ignoreUnregisteredUntilElapsed = 0L
fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin
}
@@ -61,19 +78,24 @@ object UnifiedPushManager {
}
/**
* Clears any saved distributor and shows an in-app distributor picker.
* UnifiedPush's OS deeplink picker is silent when only one distributor
* (e.g. ntfy) is installed, which looked like a broken Reset button.
* Shows an in-app distributor picker, then registers after a short delay.
*
* Important: we intentionally avoid calling [UnifiedPush.removeDistributor] before the
* user picks. That sent an UNREGISTER that could arrive *after* the new REGISTER and
* wipe the fresh endpoint (looked like "found ntfy but never connects").
*/
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
runCatching { UnifiedPush.removeDistributor(context) }
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
val gen = ++setupGeneration
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
clearEndpoint(context)
clearLastFailure(context)
val distributors = runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList())
Log.i(TAG, "Distributor setup: found=${distributors.joinToString()}")
if (distributors.isEmpty()) {
Log.w(TAG, "No UnifiedPush distributors installed")
persistLastFailure(context, "NO_DISTRIBUTOR")
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
onDone(false)
return
@@ -98,10 +120,7 @@ object UnifiedPushManager {
finish(false)
return@setItems
}
runCatching { UnifiedPush.saveDistributor(context, chosen) }
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
requestRegistration(context)
finish(true)
activateDistributor(context, chosen, gen, finish)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
.setOnCancelListener { finish(false) }
@@ -109,6 +128,76 @@ object UnifiedPushManager {
}
}
/**
* Saves [chosen] as the distributor and registers after a delay so any prior
* UNREGISTER from a distributor switch can settle first.
*/
private fun activateDistributor(
context: Context,
chosen: String,
gen: Int,
finish: (Boolean) -> Unit,
) {
ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
val previous = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
Log.i(TAG, "Activating distributor=$chosen previous=$previous")
if (previous.isNotBlank() && previous != chosen) {
runCatching { UnifiedPush.removeDistributor(context) }
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
}
mainHandler.postDelayed({
if (gen != setupGeneration) {
Log.w(TAG, "Skipping stale distributor activation gen=$gen current=$setupGeneration")
finish(false)
return@postDelayed
}
runCatching { UnifiedPush.saveDistributor(context, chosen) }
.onFailure {
Log.w(TAG, "saveDistributor failed: ${it.message}")
persistLastFailure(context, "SAVE_FAILED")
finish(false)
return@postDelayed
}
val saved = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull()
if (saved != chosen) {
Log.e(TAG, "Distributor not saved after saveDistributor (got=$saved)")
persistLastFailure(context, "SAVE_NOT_PERSISTED")
finish(false)
return@postDelayed
}
requestRegistration(context)
// Report true once register was requested; JS polls for the endpoint.
finish(true)
// One retry if the distributor is slow to ack.
mainHandler.postDelayed({
if (gen != setupGeneration) return@postDelayed
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)
}
/** Human-readable label for a distributor package (falls back to package name). */
private fun distributorLabel(context: Context, packageName: String): String {
return try {
@@ -173,6 +262,8 @@ object UnifiedPushManager {
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
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())
@@ -181,7 +272,29 @@ 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)
val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply {
@@ -192,11 +305,18 @@ object UnifiedPushManager {
dispatch(EVENT_NEW_ENDPOINT, payload)
}
fun onRegistrationFailed(reason: String, instance: String) {
fun onRegistrationFailed(context: Context, reason: String, instance: String) {
Log.w(TAG, "onRegistrationFailed reason=$reason instance=$instance")
persistLastFailure(context, reason)
dispatchRegistrationFailed(reason, instance)
}
fun onUnregistered(context: Context, instance: String) {
if (SystemClock.elapsedRealtime() < ignoreUnregisteredUntilElapsed) {
Log.i(TAG, "Ignoring unregistered during setup window instance=$instance")
return
}
Log.i(TAG, "onUnregistered instance=$instance")
val previousEndpoint = getEndpoint(context)
clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance)
@@ -204,9 +324,11 @@ object UnifiedPushManager {
private fun requestRegistration(context: Context) {
runCatching {
Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT")
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
persistLastFailure(context, FailedReason.INTERNAL_ERROR.name)
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
}
}
@@ -245,6 +367,21 @@ object UnifiedPushManager {
.apply()
}
private fun persistLastFailure(context: Context, reason: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_LAST_FAILURE, reason)
.apply()
}
private fun clearLastFailure(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_LAST_FAILURE)
.apply()
}
private fun getLastFailure(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LAST_FAILURE, null)
private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)

View File

@@ -21,7 +21,7 @@ class UnifiedPushService : PushService() {
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance)
}
override fun onUnregistered(instance: String) {

2
cinny

Submodule cinny updated: e460dbd34e...ea7642f0bb

View File

@@ -171,6 +171,8 @@ type PushStatus = {
distributor: string;
endpoint: string;
distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
};
/** Android-only section showing UnifiedPush registration status and controls. */
@@ -194,6 +196,8 @@ function AndroidPushNotifications() {
distributor: s.distributor || '',
endpoint: s.endpoint || '',
distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
}
: undefined
);
@@ -211,7 +215,7 @@ function AndroidPushNotifications() {
await refresh();
if (!result.success) {
setLastError(
'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.'
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
);
}
}, [refresh])
@@ -244,6 +248,20 @@ function AndroidPushNotifications() {
</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) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) {
return (
<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;
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;
}

View File

@@ -8,6 +8,9 @@ type UnifiedPushStatus = {
registered: boolean;
distributor: string;
distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
};
type ClearRoomNotificationsResult = {
@@ -551,16 +554,20 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register().
for (let i = 0; i < 20; i += 1) {
for (let i = 0; i < 40; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) {
return { success: true };
}
if (status?.lastFailure) {
console.warn('[BackgroundSync] Registration failed while waiting:', status.lastFailure);
return { success: false };
}
}
// Picker succeeded but endpoint not yet ack'd — still treat as success.
return { success: true };
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: false };
} catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false };

View File

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