Compare commits

...

4 Commits

Author SHA1 Message Date
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
7 changed files with 241 additions and 29 deletions

View File

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

View File

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

View File

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

Submodule cinny updated: 2603ca8e5c...e460dbd34e

View File

@@ -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 &quot;Reset&quot; 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"

View File

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

View File

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