From a744e36fb18e09f4360e39208d8cb80d667a1d56 Mon Sep 17 00:00:00 2001 From: litruv Date: Tue, 28 Jul 2026 01:38:04 +1000 Subject: [PATCH] Fix UnifiedPush register race where a late UNREGISTER wiped the new endpoint. --- .../com/paarrot/app/UnifiedPushManager.kt | 119 ++++++++++++++++-- .../com/paarrot/app/UnifiedPushService.kt | 2 +- cinny | 2 +- .../notifications/SystemNotification.tsx | 11 +- overlay/src/app/utils/backgroundSync.ts | 11 +- 5 files changed, 129 insertions(+), 16 deletions(-) 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 02119c9..b7afba8 100644 --- a/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt +++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt @@ -4,6 +4,9 @@ import android.app.Activity import android.app.AlertDialog 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 com.getcapacitor.JSArray import com.getcapacitor.JSObject @@ -20,15 +23,26 @@ object UnifiedPushManager { private const val PREFS = "unifiedpush_prefs" private const val KEY_ENDPOINT = "endpoint" 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_UNREGISTERED = "unifiedPushUnregistered" private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed" 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 private var plugin: SyncServicePlugin? = null + @Volatile + private var setupGeneration = 0 + + @Volatile + private var ignoreUnregisteredUntilElapsed = 0L + fun setPlugin(plugin: SyncServicePlugin) { this.plugin = plugin } @@ -61,19 +75,24 @@ object UnifiedPushManager { } /** - * 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. + * 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) { - runCatching { UnifiedPush.removeDistributor(context) } - .onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") } + 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 @@ -98,10 +117,7 @@ object UnifiedPushManager { finish(false) return@setItems } - runCatching { UnifiedPush.saveDistributor(context, chosen) } - .onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") } - requestRegistration(context) - finish(true) + activateDistributor(context, chosen, gen, finish) } .setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) } .setOnCancelListener { finish(false) } @@ -109,6 +125,62 @@ object UnifiedPushManager { } } + /** + * 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 { @@ -173,6 +245,7 @@ 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("lastFailure", getLastFailure(context) ?: "") val distributors = JSArray() runCatching { UnifiedPush.getDistributors(context) } .getOrDefault(emptyList()) @@ -182,6 +255,8 @@ object UnifiedPushManager { } fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint") + clearLastFailure(context) val previousEndpoint = getEndpoint(context) persistEndpoint(context, endpoint, instance) val payload = JSObject().apply { @@ -192,11 +267,18 @@ object UnifiedPushManager { 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) } 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) clearEndpoint(context) dispatchUnregistered(previousEndpoint, instance) @@ -204,9 +286,11 @@ object UnifiedPushManager { private fun requestRegistration(context: Context) { runCatching { + Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT") UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null) }.onFailure { Log.w(TAG, "UnifiedPush register failed: ${it.message}") + persistLastFailure(context, FailedReason.INTERNAL_ERROR.name) dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT) } } @@ -245,6 +329,21 @@ object UnifiedPushManager { .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? = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null) diff --git a/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt b/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt index 7c78942..29d6260 100644 --- a/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt +++ b/android/app/src/main/java/com/paarrot/app/UnifiedPushService.kt @@ -21,7 +21,7 @@ class UnifiedPushService : PushService() { override fun onRegistrationFailed(reason: FailedReason, instance: String) { Log.w(TAG, "UnifiedPush registration failed: $reason") - UnifiedPushManager.onRegistrationFailed(reason.name, instance) + UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance) } override fun onUnregistered(instance: String) { diff --git a/cinny b/cinny index e460dbd..0fb3da2 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit e460dbd34eed06e0d1c7e1a8dcbeab938c800527 +Subproject commit 0fb3da20b955bde2fa228616dfa632db257f9130 diff --git a/overlay/src/app/features/settings/notifications/SystemNotification.tsx b/overlay/src/app/features/settings/notifications/SystemNotification.tsx index eb21a4f..f2aeeea 100644 --- a/overlay/src/app/features/settings/notifications/SystemNotification.tsx +++ b/overlay/src/app/features/settings/notifications/SystemNotification.tsx @@ -171,6 +171,7 @@ type PushStatus = { distributor: string; endpoint: string; distributors: string[]; + lastFailure: string; }; /** Android-only section showing UnifiedPush registration status and controls. */ @@ -194,6 +195,7 @@ function AndroidPushNotifications() { distributor: s.distributor || '', endpoint: s.endpoint || '', distributors, + lastFailure: s.lastFailure || '', } : undefined ); @@ -211,7 +213,7 @@ function AndroidPushNotifications() { await refresh(); if (!result.success) { setLastError( - 'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.' + 'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.' ); } }, [refresh]) @@ -244,6 +246,13 @@ function AndroidPushNotifications() { ); } + if (status.lastFailure) { + return ( + + {`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`} + + ); + } if (status.distributors.length === 0) { return ( diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index 402fc5c..eddb61a 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -8,6 +8,7 @@ type UnifiedPushStatus = { registered: boolean; distributor: string; distributors: string[] | string; + lastFailure?: string; }; type ClearRoomNotificationsResult = { @@ -551,16 +552,20 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean if (!result?.success) return { success: false }; // Endpoint arrives asynchronously from the distributor after register(). - for (let i = 0; i < 20; i += 1) { + 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 }; + } } - // Picker succeeded but endpoint not yet ack'd — still treat as success. - return { success: true }; + console.warn('[BackgroundSync] Distributor selected but no endpoint received in time'); + return { success: false }; } catch (err) { console.error('[BackgroundSync] requestDistributorSetup failed:', err); return { success: false };