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