From c28b786948a6b1689a0481fccd475a06d24cd99b Mon Sep 17 00:00:00 2001 From: litruv Date: Tue, 28 Jul 2026 00:40:08 +1000 Subject: [PATCH] Show an in-app UnifiedPush distributor picker instead of a silent OS default. --- android/app/src/main/AndroidManifest.xml | 18 +++++ .../com/paarrot/app/UnifiedPushManager.kt | 80 +++++++++++++++---- cinny | 2 +- .../notifications/SystemNotification.tsx | 71 +++++++++++----- overlay/src/app/utils/backgroundSync.ts | 15 +++- 5 files changed, 149 insertions(+), 37 deletions(-) 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 };