diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6509eb6..a4bdfc2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -87,4 +87,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
index 40670da..02119c9 100644
--- a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
+++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
@@ -1,8 +1,11 @@
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
@@ -48,31 +51,73 @@ 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 re-opens the OS/default distributor picker
- * (`unifiedpush://link`), then registers again on success.
+ * 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)
- UnifiedPush.tryUseDefaultDistributor(activity) { success ->
- if (success) {
- requestRegistration(context)
- } else {
- dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
- }
- onDone(success)
+ 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
}
}
@@ -128,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())
- )
+ val distributors = JSArray()
+ runCatching { UnifiedPush.getDistributors(context) }
+ .getOrDefault(emptyList())
+ .forEach { distributors.put(it) }
+ status.put("distributors", distributors)
return status
}
diff --git a/cinny b/cinny
index 0de2fd9..e460dbd 160000
--- a/cinny
+++ b/cinny
@@ -1 +1 @@
-Subproject commit 0de2fd942f62460e016d464529478645ab7766ff
+Subproject commit e460dbd34eed06e0d1c7e1a8dcbeab938c800527
diff --git a/overlay/src/app/features/settings/notifications/SystemNotification.tsx b/overlay/src/app/features/settings/notifications/SystemNotification.tsx
index 4b31548..eb21a4f 100644
--- a/overlay/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/overlay/src/app/features/settings/notifications/SystemNotification.tsx
@@ -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(undefined);
const [loading, setLoading] = useState(true);
+ const [lastError, setLastError] = useState(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 (
+
+ Failed to read push status.
+
+ );
+ }
+ if (status.registered) {
+ return (
+
+ {`Distributor: ${status.distributor || 'unknown'}`}
+
+ );
+ }
+ if (status.distributors.length === 0) {
+ return (
+
+ No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
+
+ );
+ }
+ return (
+
+ {`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
+
+ );
+ })();
+
return (
Android Push (UnifiedPush)
@@ -226,29 +270,20 @@ function AndroidPushNotifications() {
- Failed to read push status.
-
- ) : status.registered ? (
- <>
-
- {`Distributor: ${status.distributor || 'unknown'}`}
+ <>
+ {statusDescription}
+ {lastError ? (
+
+ {lastError}
- >
- ) : (
-
- Not registered. Tap "Reset" to choose a distributor app.
-
- )
+ ) : null}
+ >
}
after={loading ? : undefined}
/>
* 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()) {
@@ -547,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 };