Compare commits

..

8 Commits

Author SHA1 Message Date
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
GitHub Actions
725b3467d3 chore: bump version to 4.11.142 [skip ci] 2026-07-27 14:40:17 +00:00
c28b786948 Show an in-app UnifiedPush distributor picker instead of a silent OS default.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
2026-07-28 00:40:09 +10:00
GitHub Actions
19d85dbb2d chore: bump version to 4.11.141 [skip ci] 2026-07-27 14:18:44 +00:00
fbff6b9738 Fix UnifiedPush distributor reset and native Matrix gateway discovery.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m35s
2026-07-28 00:18:37 +10:00
GitHub Actions
52e0fddcf1 chore: bump version to 4.11.140 [skip ci] 2026-07-25 10:16:53 +00:00
2ed3b0e9c8 Bump cinny: fix avatar upload for JPEG/WebP/GIF without profile color.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 6m37s
2026-07-25 20:16:46 +10:00
8 changed files with 356 additions and 31 deletions

View File

@@ -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>

View File

@@ -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.
*/ */

View File

@@ -1,12 +1,21 @@
package com.paarrot.app package com.paarrot.app
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
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 +23,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 +65,169 @@ 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)
}, 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 +245,18 @@ 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", val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>()) runCatching { UnifiedPush.getDistributors(context) }
) .getOrDefault(emptyList())
.forEach { distributors.put(it) }
status.put("distributors", distributors)
return status return status
} }
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 +267,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 +286,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 +329,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: b52926f7d8...0fb3da20b9

View File

@@ -170,22 +170,32 @@ type PushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
endpoint: string; endpoint: string;
distributors: string[];
lastFailure: string;
}; };
/** 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 || '',
} }
: undefined : undefined
); );
@@ -198,8 +208,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 +230,43 @@ 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) {
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 +279,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 &quot;Reset&quot; 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"

View File

@@ -8,6 +8,7 @@ type UnifiedPushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
distributors: string[] | string; distributors: string[] | string;
lastFailure?: string;
}; };
type ClearRoomNotificationsResult = { type ClearRoomNotificationsResult = {
@@ -52,6 +53,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 +173,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 +538,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 +549,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 };

View File

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