Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb7adafd0 | ||
| c9c7bdb48d | |||
|
|
f3d4dcb67e | ||
| a744e36fb1 | |||
|
|
725b3467d3 | ||
| c28b786948 | |||
|
|
19d85dbb2d | ||
| fbff6b9738 |
@@ -87,4 +87,22 @@
|
|||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<!-- Android 11+ package visibility for UnifiedPush distributors (ntfy, etc.) -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="org.unifiedpush.android.distributor.LINK" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="unifiedpush" />
|
||||||
|
</intent>
|
||||||
|
<package android:name="io.heckel.ntfy" />
|
||||||
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||||
* - `getStatus()` — returns current fetch and UnifiedPush state
|
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||||
|
* - `requestDistributorSetup()` — clear saved distributor and re-open OS picker
|
||||||
|
* - `resolveUnifiedPushGateway({ endpoint })` — native Matrix gateway discovery
|
||||||
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
* - `clearRoomNotifications({ roomId })` — dismiss native tray notifs for a room
|
||||||
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
||||||
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
|
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
|
||||||
@@ -131,6 +133,35 @@ class SyncServicePlugin : Plugin() {
|
|||||||
call.resolve(result)
|
call.resolve(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the saved UnifiedPush distributor and opens the OS default-distributor
|
||||||
|
* picker (`unifiedpush://link`), then re-registers on success.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun requestDistributorSetup(call: PluginCall) {
|
||||||
|
val currentActivity = activity
|
||||||
|
?: return call.reject("Activity required to select a UnifiedPush distributor")
|
||||||
|
UnifiedPushManager.requestDistributorSetup(context, currentActivity) { success ->
|
||||||
|
call.resolve(JSObject().put("success", success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Matrix push gateway for a UnifiedPush endpoint using native HTTP
|
||||||
|
* so discovery is not blocked by missing CORS headers in the WebView.
|
||||||
|
*/
|
||||||
|
@PluginMethod
|
||||||
|
fun resolveUnifiedPushGateway(call: PluginCall) {
|
||||||
|
val endpoint = call.getString("endpoint")
|
||||||
|
?: return call.reject("endpoint required")
|
||||||
|
Thread {
|
||||||
|
val gateway = UnifiedPushManager.resolveMatrixGateway(endpoint)
|
||||||
|
bridge?.executeOnMainThread {
|
||||||
|
call.resolve(JSObject().put("gatewayUrl", gateway))
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancels native tray notifications for a Matrix room after it has been marked as read.
|
* Cancels native tray notifications for a Matrix room after it has been marked as read.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
package com.paarrot.app
|
package com.paarrot.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.app.AppOpsManager
|
||||||
import android.content.Context
|
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 android.util.Log
|
||||||
|
import com.getcapacitor.JSArray
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
import org.json.JSONObject
|
||||||
import org.unifiedpush.android.connector.FailedReason
|
import org.unifiedpush.android.connector.FailedReason
|
||||||
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
|
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
|
||||||
import org.unifiedpush.android.connector.UnifiedPush
|
import org.unifiedpush.android.connector.UnifiedPush
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
|
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
|
||||||
object UnifiedPushManager {
|
object UnifiedPushManager {
|
||||||
@@ -14,14 +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"
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -44,15 +68,183 @@ object UnifiedPushManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
|
// Prefer an explicit in-app picker so the user always sees something;
|
||||||
if (success) {
|
// tryUseDefaultDistributor is silent when only one distributor is installed.
|
||||||
requestRegistration(context)
|
requestDistributorSetup(context, activity) { success ->
|
||||||
} else {
|
if (!success) {
|
||||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val labels = distributors.map { packageName ->
|
||||||
|
distributorLabel(context, packageName)
|
||||||
|
}.toTypedArray()
|
||||||
|
|
||||||
|
// Guard against cancel + dismiss double-firing the callback.
|
||||||
|
val finished = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
val finish: (Boolean) -> Unit = { success ->
|
||||||
|
if (finished.compareAndSet(false, true)) onDone(success)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.runOnUiThread {
|
||||||
|
AlertDialog.Builder(activity)
|
||||||
|
.setTitle("Choose push distributor")
|
||||||
|
.setItems(labels) { _, which ->
|
||||||
|
val chosen = distributors.getOrNull(which)
|
||||||
|
if (chosen.isNullOrBlank()) {
|
||||||
|
finish(false)
|
||||||
|
return@setItems
|
||||||
|
}
|
||||||
|
activateDistributor(context, chosen, gen, finish)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
|
||||||
|
.setOnCancelListener { finish(false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val appInfo = pm.getApplicationInfo(packageName, 0)
|
||||||
|
val label = pm.getApplicationLabel(appInfo)?.toString().orEmpty()
|
||||||
|
if (label.isNotBlank() && label != packageName) "$label ($packageName)" else packageName
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers the Matrix push gateway for a UnifiedPush endpoint via native HTTP
|
||||||
|
* (WebView `fetch` fails — ntfy responses omit CORS headers).
|
||||||
|
*/
|
||||||
|
fun resolveMatrixGateway(endpoint: String): String {
|
||||||
|
return try {
|
||||||
|
val endpointUrl = URL(endpoint)
|
||||||
|
val discoveryUrl = URL(
|
||||||
|
endpointUrl.protocol,
|
||||||
|
endpointUrl.host,
|
||||||
|
endpointUrl.port,
|
||||||
|
"/_matrix/push/v1/notify",
|
||||||
|
)
|
||||||
|
val conn = discoveryUrl.openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.connectTimeout = 4_000
|
||||||
|
conn.readTimeout = 6_000
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
if (conn.responseCode != 200) return DEFAULT_MATRIX_GATEWAY
|
||||||
|
|
||||||
|
val body = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||||
|
val gateway =
|
||||||
|
body.optString("gateway").takeIf { it.isNotBlank() }
|
||||||
|
?: body.optJSONObject("unifiedpush")?.optString("gateway")
|
||||||
|
if (gateway == "matrix") discoveryUrl.toString() else DEFAULT_MATRIX_GATEWAY
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Matrix gateway discovery failed: ${e.message}")
|
||||||
|
DEFAULT_MATRIX_GATEWAY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun unregister(context: Context) {
|
fun unregister(context: Context) {
|
||||||
runCatching {
|
runCatching {
|
||||||
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
|
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
|
||||||
@@ -70,14 +262,39 @@ 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(
|
status.put("lastFailure", getLastFailure(context) ?: "")
|
||||||
"distributors",
|
status.put("autoStartBlocked", isAutoStartRestricted(context))
|
||||||
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
|
val distributors = JSArray()
|
||||||
)
|
runCatching { UnifiedPush.getDistributors(context) }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
.forEach { distributors.put(it) }
|
||||||
|
status.put("distributors", distributors)
|
||||||
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 {
|
||||||
@@ -88,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)
|
||||||
@@ -100,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
cinny
Submodule cinny updated: 2603ca8e5c...ea7642f0bb
@@ -170,22 +170,34 @@ type PushStatus = {
|
|||||||
registered: boolean;
|
registered: boolean;
|
||||||
distributor: string;
|
distributor: string;
|
||||||
endpoint: string;
|
endpoint: 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. */
|
||||||
function AndroidPushNotifications() {
|
function AndroidPushNotifications() {
|
||||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const s = await getBackgroundSyncStatus();
|
const s = await getBackgroundSyncStatus();
|
||||||
|
const distributors = Array.isArray(s?.distributors)
|
||||||
|
? s.distributors
|
||||||
|
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
|
||||||
|
? [s.distributors]
|
||||||
|
: [];
|
||||||
setStatus(
|
setStatus(
|
||||||
s
|
s
|
||||||
? {
|
? {
|
||||||
registered: s.registered,
|
registered: s.registered,
|
||||||
distributor: s.distributor || '',
|
distributor: s.distributor || '',
|
||||||
endpoint: s.endpoint || '',
|
endpoint: s.endpoint || '',
|
||||||
|
distributors,
|
||||||
|
lastFailure: s.lastFailure || '',
|
||||||
|
autoStartBlocked: Boolean(s.autoStartBlocked),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
@@ -198,8 +210,14 @@ function AndroidPushNotifications() {
|
|||||||
|
|
||||||
const [resetState, reset] = useAsyncCallback(
|
const [resetState, reset] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
await requestResetPushRegistration();
|
setLastError(undefined);
|
||||||
|
const result = await requestResetPushRegistration();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (!result.success) {
|
||||||
|
setLastError(
|
||||||
|
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,6 +232,50 @@ function AndroidPushNotifications() {
|
|||||||
resetState.status === AsyncStatus.Loading ||
|
resetState.status === AsyncStatus.Loading ||
|
||||||
pingState.status === AsyncStatus.Loading;
|
pingState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const statusDescription = (() => {
|
||||||
|
if (loading) return 'Loading status…';
|
||||||
|
if (status === undefined) {
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
Failed to read push status.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status.registered) {
|
||||||
|
return (
|
||||||
|
<Text as="span" size="T200">
|
||||||
|
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||||
|
</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">
|
||||||
|
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||||
|
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||||
@@ -226,29 +288,20 @@ function AndroidPushNotifications() {
|
|||||||
<SettingTile
|
<SettingTile
|
||||||
title="Background Notifications"
|
title="Background Notifications"
|
||||||
description={
|
description={
|
||||||
loading ? (
|
<>
|
||||||
'Loading status…'
|
{statusDescription}
|
||||||
) : status === undefined ? (
|
{lastError ? (
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
|
||||||
Failed to read push status.
|
{lastError}
|
||||||
</Text>
|
|
||||||
) : status.registered ? (
|
|
||||||
<>
|
|
||||||
<Text as="span" size="T200">
|
|
||||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
) : null}
|
||||||
) : (
|
</>
|
||||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
|
||||||
Not registered. Tap "Reset" to choose a distributor app.
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||||
/>
|
/>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Change Distributor"
|
title="Change Distributor"
|
||||||
description="Re-open the UnifiedPush distributor selection dialog."
|
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
|
||||||
after={
|
after={
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -52,6 +55,8 @@ interface MatrixBackgroundSyncPlugin {
|
|||||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||||
/** Returns fetch state and current UnifiedPush registration details. */
|
/** Returns fetch state and current UnifiedPush registration details. */
|
||||||
getStatus(): Promise<UnifiedPushStatus>;
|
getStatus(): Promise<UnifiedPushStatus>;
|
||||||
|
/** Native Matrix gateway discovery for a UnifiedPush endpoint (avoids WebView CORS). */
|
||||||
|
resolveUnifiedPushGateway(options: { endpoint: string }): Promise<{ gatewayUrl: string }>;
|
||||||
/** Cancel native tray notifications posted for a Matrix room. */
|
/** Cancel native tray notifications posted for a Matrix room. */
|
||||||
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
|
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +175,15 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[]
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
||||||
|
// Prefer native HTTP — ntfy/Matrix gateway responses omit CORS headers, so
|
||||||
|
// WebView fetch fails with TypeError: Failed to fetch and wrongly falls back.
|
||||||
|
try {
|
||||||
|
const result = await MatrixBackgroundSync.resolveUnifiedPushGateway({ endpoint });
|
||||||
|
if (result?.gatewayUrl) return result.gatewayUrl;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[BackgroundSync] Native UnifiedPush gateway discovery failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const discoveryUrl = new URL(endpoint);
|
const discoveryUrl = new URL(endpoint);
|
||||||
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||||
@@ -526,6 +540,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
|||||||
* Re-opens the UnifiedPush distributor selection dialog.
|
* Re-opens the UnifiedPush distributor selection dialog.
|
||||||
* Allows users to re-select or change their push notification distributor without terminal access.
|
* Allows users to re-select or change their push notification distributor without terminal access.
|
||||||
* Useful when the current endpoint becomes unavailable.
|
* Useful when the current endpoint becomes unavailable.
|
||||||
|
* Waits briefly for the distributor to return an endpoint so the settings UI can refresh accurately.
|
||||||
*/
|
*/
|
||||||
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
|
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
|
||||||
if (!isBackgroundSyncSupported()) {
|
if (!isBackgroundSyncSupported()) {
|
||||||
@@ -536,7 +551,23 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
|||||||
try {
|
try {
|
||||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||||
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||||
return result ?? { success: false };
|
if (!result?.success) return { success: false };
|
||||||
|
|
||||||
|
// Endpoint arrives asynchronously from the distributor after register().
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.140",
|
"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"
|
||||||
|
|||||||
Reference in New Issue
Block a user