Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725b3467d3 | ||
| c28b786948 | |||
|
|
19d85dbb2d | ||
| fbff6b9738 | |||
|
|
52e0fddcf1 | ||
| 2ed3b0e9c8 | |||
|
|
5f7af04a57 | ||
| 62b9c48fa1 |
@@ -87,4 +87,22 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<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>
|
||||
|
||||
@@ -31,6 +31,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
@@ -706,6 +707,8 @@ class MatrixSyncService : Service() {
|
||||
private const val GROUP_DIRECTS = "paarrot_directs"
|
||||
private const val GROUP_HOME = "paarrot_home"
|
||||
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||
private const val MAX_MESSAGING_HISTORY = 8
|
||||
private const val KEY_MSG_HISTORY_PREFIX = "notif_hist_"
|
||||
const val MODE_ONE_SHOT = "one_shot"
|
||||
|
||||
/** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
|
||||
@@ -829,13 +832,85 @@ class MatrixSyncService : Service() {
|
||||
return shortcut
|
||||
}
|
||||
|
||||
private data class PendingNotifMessage(
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
|
||||
|
||||
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(historyPrefsKey(roomId), null)
|
||||
?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.optJSONObject(i) ?: continue
|
||||
val sender = obj.optString("sender")
|
||||
val text = obj.optString("text")
|
||||
val ts = obj.optLong("ts", 0L)
|
||||
if (sender.isBlank() || text.isBlank()) continue
|
||||
add(PendingNotifMessage(sender, text, ts))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to load notification history for $roomId: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessageHistory(
|
||||
context: Context,
|
||||
roomId: String,
|
||||
messages: List<PendingNotifMessage>,
|
||||
) {
|
||||
val arr = JSONArray()
|
||||
for (msg in messages) {
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("sender", msg.sender)
|
||||
.put("text", msg.text)
|
||||
.put("ts", msg.timestamp)
|
||||
)
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(historyPrefsKey(roomId), arr.toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearMessageHistory(context: Context, roomId: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(historyPrefsKey(roomId))
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun appendMessageHistory(
|
||||
context: Context,
|
||||
roomId: String,
|
||||
sender: String,
|
||||
text: String,
|
||||
): List<PendingNotifMessage> {
|
||||
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
|
||||
sender = sender,
|
||||
text = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)).takeLast(MAX_MESSAGING_HISTORY)
|
||||
saveMessageHistory(context, roomId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a room notification (+ space/DM group summary) for both background sync
|
||||
* and JS-driven Capacitor notifications.
|
||||
*
|
||||
* Avatar goes in the circular largeIcon / conversation-shortcut slot (visible when
|
||||
* collapsed). The small status-bar icon stays the Paarrot mark (Android requirement).
|
||||
* Optional image attachments use BigPictureStyle while keeping that circular avatar.
|
||||
* Uses MessagingStyle with persisted per-room history so rapid messages accumulate
|
||||
* instead of each update wiping the previous body (stable per-room notification id).
|
||||
* Avatar goes in largeIcon + conversation shortcut (visible when collapsed).
|
||||
*/
|
||||
fun postMessageNotification(
|
||||
context: Context,
|
||||
@@ -892,6 +967,8 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
val senderPerson = personBuilder.build()
|
||||
|
||||
val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage)
|
||||
|
||||
val channelId = channelIdForKindStatic(kind)
|
||||
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
|
||||
resolvedSender
|
||||
@@ -912,6 +989,34 @@ class MatrixSyncService : Service() {
|
||||
launchIntent,
|
||||
)
|
||||
|
||||
val selfPerson = Person.Builder()
|
||||
.setName("Me")
|
||||
.setKey("self")
|
||||
.build()
|
||||
val messagingStyle = NotificationCompat.MessagingStyle(selfPerson)
|
||||
.setGroupConversation(!isDm)
|
||||
if (!isDm && !resolvedConversation.isNullOrBlank()) {
|
||||
messagingStyle.conversationTitle = resolvedConversation
|
||||
}
|
||||
for (msg in history) {
|
||||
val person = if (msg.sender == resolvedSender) {
|
||||
senderPerson
|
||||
} else {
|
||||
Person.Builder()
|
||||
.setName(msg.sender)
|
||||
.setKey("$roomId:${msg.sender}")
|
||||
.setImportant(true)
|
||||
.build()
|
||||
}
|
||||
messagingStyle.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
msg.text,
|
||||
msg.timestamp,
|
||||
person,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||
.setContentTitle(collapsedTitle)
|
||||
@@ -922,11 +1027,13 @@ class MatrixSyncService : Service() {
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setGroup(groupId)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setNumber(history.size)
|
||||
.setSubText(groupName)
|
||||
.setShortcutId(shortcut.id)
|
||||
.setShortcutInfo(shortcut)
|
||||
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
|
||||
.setAllowSystemGeneratedContextualActions(false)
|
||||
.setStyle(messagingStyle)
|
||||
|
||||
builder.extras.putString(EXTRA_ROOM_ID, roomId)
|
||||
builder.extras.putString(EXTRA_GROUP_ID, groupId)
|
||||
@@ -937,8 +1044,8 @@ class MatrixSyncService : Service() {
|
||||
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
|
||||
if (avatar != null) builder.setLargeIcon(avatar)
|
||||
|
||||
if (inlineImage != null) {
|
||||
// Expanded image attachment — keep avatar in the circular large-icon slot.
|
||||
// Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines.
|
||||
if (inlineImage != null && history.size == 1) {
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigPictureStyle()
|
||||
.bigPicture(inlineImage)
|
||||
@@ -946,15 +1053,6 @@ class MatrixSyncService : Service() {
|
||||
.setBigContentTitle(collapsedTitle)
|
||||
.setSummaryText(collapsedText)
|
||||
)
|
||||
} else {
|
||||
// BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs.
|
||||
// MessagingStyle alone often hides the avatar until the user expands.
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(collapsedText)
|
||||
.setBigContentTitle(collapsedTitle)
|
||||
.setSummaryText(groupName)
|
||||
)
|
||||
}
|
||||
|
||||
nm.notify(roomNotifId, builder.build())
|
||||
@@ -983,6 +1081,7 @@ class MatrixSyncService : Service() {
|
||||
/** Cancel the tray notification posted for [roomId], if any. */
|
||||
fun clearRoomNotifications(context: Context, roomId: String) {
|
||||
if (roomId.isBlank()) return
|
||||
clearMessageHistory(context, roomId)
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val roomNotifId = notificationIdForRoom(roomId)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||
* - `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
|
||||
* - `setNotificationGroups({ rooms })` — persist space/DM grouping for tray nesting
|
||||
* - `showNotification({ title, body, roomId, groupId, groupName, kind, largeIconBase64 })`
|
||||
@@ -131,6 +133,35 @@ class SyncServicePlugin : Plugin() {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package com.paarrot.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
|
||||
object UnifiedPushManager {
|
||||
@@ -18,6 +24,7 @@ object UnifiedPushManager {
|
||||
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
|
||||
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
|
||||
private const val DEFAULT_MESSAGE = "Paarrot notifications"
|
||||
const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||
|
||||
@Volatile
|
||||
private var plugin: SyncServicePlugin? = null
|
||||
@@ -44,15 +51,111 @@ object UnifiedPushManager {
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
|
||||
if (success) {
|
||||
requestRegistration(context)
|
||||
} else {
|
||||
// Prefer an explicit in-app picker so the user always sees something;
|
||||
// tryUseDefaultDistributor is silent when only one distributor is installed.
|
||||
requestDistributorSetup(context, activity) { success ->
|
||||
if (!success) {
|
||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
|
||||
runCatching { UnifiedPush.removeDistributor(context) }
|
||||
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
|
||||
clearEndpoint(context)
|
||||
|
||||
val distributors = runCatching { UnifiedPush.getDistributors(context) }
|
||||
.getOrDefault(emptyList())
|
||||
if (distributors.isEmpty()) {
|
||||
Log.w(TAG, "No UnifiedPush distributors installed")
|
||||
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
|
||||
}
|
||||
runCatching { UnifiedPush.saveDistributor(context, chosen) }
|
||||
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
|
||||
requestRegistration(context)
|
||||
finish(true)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
|
||||
.setOnCancelListener { finish(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
runCatching {
|
||||
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
|
||||
@@ -70,10 +173,11 @@ 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(
|
||||
"distributors",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
2
cinny
2
cinny
Submodule cinny updated: b52926f7d8...e460dbd34e
@@ -170,22 +170,30 @@ type PushStatus = {
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
endpoint: string;
|
||||
distributors: string[];
|
||||
};
|
||||
|
||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||
function AndroidPushNotifications() {
|
||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastError, setLastError] = useState<string | undefined>(undefined);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const s = await getBackgroundSyncStatus();
|
||||
const distributors = Array.isArray(s?.distributors)
|
||||
? s.distributors
|
||||
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
|
||||
? [s.distributors]
|
||||
: [];
|
||||
setStatus(
|
||||
s
|
||||
? {
|
||||
registered: s.registered,
|
||||
distributor: s.distributor || '',
|
||||
endpoint: s.endpoint || '',
|
||||
distributors,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
@@ -198,8 +206,14 @@ function AndroidPushNotifications() {
|
||||
|
||||
const [resetState, reset] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await requestResetPushRegistration();
|
||||
setLastError(undefined);
|
||||
const result = await requestResetPushRegistration();
|
||||
await refresh();
|
||||
if (!result.success) {
|
||||
setLastError(
|
||||
'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.'
|
||||
);
|
||||
}
|
||||
}, [refresh])
|
||||
);
|
||||
|
||||
@@ -214,6 +228,36 @@ function AndroidPushNotifications() {
|
||||
resetState.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.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 (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||
@@ -226,29 +270,20 @@ function AndroidPushNotifications() {
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
loading ? (
|
||||
'Loading status…'
|
||||
) : status === undefined ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Failed to read push status.
|
||||
</Text>
|
||||
) : status.registered ? (
|
||||
<>
|
||||
<Text as="span" size="T200">
|
||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||
<>
|
||||
{statusDescription}
|
||||
{lastError ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
|
||||
{lastError}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
Not registered. Tap "Reset" to choose a distributor app.
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||
/>
|
||||
<SettingTile
|
||||
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={
|
||||
<Button
|
||||
size="300"
|
||||
|
||||
@@ -52,6 +52,8 @@ interface MatrixBackgroundSyncPlugin {
|
||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||
/** Returns fetch state and current UnifiedPush registration details. */
|
||||
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. */
|
||||
clearRoomNotifications(options: { roomId: string }): Promise<ClearRoomNotificationsResult>;
|
||||
/**
|
||||
@@ -170,6 +172,15 @@ const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): 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 {
|
||||
const discoveryUrl = new URL(endpoint);
|
||||
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||
@@ -526,6 +537,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
||||
* Re-opens the UnifiedPush distributor selection dialog.
|
||||
* Allows users to re-select or change their push notification distributor without terminal access.
|
||||
* 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 }> => {
|
||||
if (!isBackgroundSyncSupported()) {
|
||||
@@ -536,7 +548,19 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
|
||||
try {
|
||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||
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 < 20; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const status = await getBackgroundSyncStatus();
|
||||
if (status?.registered && status.endpoint) {
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Picker succeeded but endpoint not yet ack'd — still treat as success.
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||
return { success: false };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.138",
|
||||
"version": "4.11.142",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user