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 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,8 +2,14 @@ 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.Looper
import android.os.Process
import android.os.SystemClock
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -20,15 +26,26 @@ object UnifiedPushManager {
private const val PREFS = "unifiedpush_prefs" private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint" private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance" 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_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered" private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed" private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications" 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" const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile @Volatile
private var plugin: SyncServicePlugin? = null private var plugin: SyncServicePlugin? = null
@Volatile
private var setupGeneration = 0
@Volatile
private var ignoreUnregisteredUntilElapsed = 0L
fun setPlugin(plugin: SyncServicePlugin) { fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin this.plugin = plugin
} }
@@ -61,19 +78,24 @@ object UnifiedPushManager {
} }
/** /**
* Clears any saved distributor and shows an in-app distributor picker. * Shows an in-app distributor picker, then registers after a short delay.
* UnifiedPush's OS deeplink picker is silent when only one distributor *
* (e.g. ntfy) is installed, which looked like a broken Reset button. * 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) { fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
runCatching { UnifiedPush.removeDistributor(context) } val gen = ++setupGeneration
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") } ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
clearEndpoint(context) clearEndpoint(context)
clearLastFailure(context)
val distributors = runCatching { UnifiedPush.getDistributors(context) } val distributors = runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
Log.i(TAG, "Distributor setup: found=${distributors.joinToString()}")
if (distributors.isEmpty()) { if (distributors.isEmpty()) {
Log.w(TAG, "No UnifiedPush distributors installed") Log.w(TAG, "No UnifiedPush distributors installed")
persistLastFailure(context, "NO_DISTRIBUTOR")
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
onDone(false) onDone(false)
return return
@@ -98,10 +120,7 @@ object UnifiedPushManager {
finish(false) finish(false)
return@setItems return@setItems
} }
runCatching { UnifiedPush.saveDistributor(context, chosen) } activateDistributor(context, chosen, gen, finish)
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
requestRegistration(context)
finish(true)
} }
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) } .setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
.setOnCancelListener { 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). */ /** Human-readable label for a distributor package (falls back to package name). */
private fun distributorLabel(context: Context, packageName: String): String { private fun distributorLabel(context: Context, packageName: String): String {
return try { return try {
@@ -173,6 +262,8 @@ object UnifiedPushManager {
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT) status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
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("autoStartBlocked", isAutoStartRestricted(context))
val distributors = JSArray() val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) } runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
@@ -181,7 +272,29 @@ 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")
clearLastFailure(context)
val previousEndpoint = getEndpoint(context) val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance) persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply { val payload = JSObject().apply {
@@ -192,11 +305,18 @@ object UnifiedPushManager {
dispatch(EVENT_NEW_ENDPOINT, payload) 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) dispatchRegistrationFailed(reason, instance)
} }
fun onUnregistered(context: Context, instance: String) { 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) val previousEndpoint = getEndpoint(context)
clearEndpoint(context) clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance) dispatchUnregistered(previousEndpoint, instance)
@@ -204,9 +324,11 @@ object UnifiedPushManager {
private fun requestRegistration(context: Context) { private fun requestRegistration(context: Context) {
runCatching { runCatching {
Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT")
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null) UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure { }.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}") Log.w(TAG, "UnifiedPush register failed: ${it.message}")
persistLastFailure(context, FailedReason.INTERNAL_ERROR.name)
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
} }
} }
@@ -245,6 +367,21 @@ object UnifiedPushManager {
.apply() .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? = private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null) 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) { override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason") Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance) UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance)
} }
override fun onUnregistered(instance: String) { override fun onUnregistered(instance: String) {

2
cinny

Submodule cinny updated: e460dbd34e...ea7642f0bb

View File

@@ -171,6 +171,8 @@ type PushStatus = {
distributor: string; distributor: string;
endpoint: string; endpoint: string;
distributors: string[]; distributors: string[];
lastFailure: string;
autoStartBlocked: boolean;
}; };
/** Android-only section showing UnifiedPush registration status and controls. */ /** Android-only section showing UnifiedPush registration status and controls. */
@@ -194,6 +196,8 @@ function AndroidPushNotifications() {
distributor: s.distributor || '', distributor: s.distributor || '',
endpoint: s.endpoint || '', endpoint: s.endpoint || '',
distributors, distributors,
lastFailure: s.lastFailure || '',
autoStartBlocked: Boolean(s.autoStartBlocked),
} }
: undefined : undefined
); );
@@ -211,7 +215,7 @@ function AndroidPushNotifications() {
await refresh(); await refresh();
if (!result.success) { if (!result.success) {
setLastError( 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]) }, [refresh])
@@ -244,6 +248,20 @@ 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) {
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) { if (status.distributors.length === 0) {
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

@@ -8,6 +8,9 @@ type UnifiedPushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
distributors: string[] | string; distributors: string[] | string;
lastFailure?: string;
/** OEM App Boot / AUTO_START is blocking distributor broadcasts (e.g. TCL). */
autoStartBlocked?: boolean;
}; };
type ClearRoomNotificationsResult = { type ClearRoomNotificationsResult = {
@@ -551,16 +554,20 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
if (!result?.success) return { success: false }; if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register(). // 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)); await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus(); const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) { if (status?.registered && status.endpoint) {
return { success: true }; 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. console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: true }; return { success: false };
} catch (err) { } catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err); console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false }; return { success: false };

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.142", "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"