Fix UnifiedPush register race where a late UNREGISTER wiped the new endpoint.
All checks were successful
Build / increment-version (push) Successful in 4s
Build / build-android (push) Successful in 5m8s

This commit is contained in:
2026-07-28 01:38:04 +10:00
parent 725b3467d3
commit a744e36fb1
5 changed files with 129 additions and 16 deletions

View File

@@ -4,6 +4,9 @@ import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
@@ -20,15 +23,26 @@ object UnifiedPushManager {
private const val PREFS = "unifiedpush_prefs" private const val PREFS = "unifiedpush_prefs"
private const val KEY_ENDPOINT = "endpoint" private const val KEY_ENDPOINT = "endpoint"
private const val KEY_INSTANCE = "instance" 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_NEW_ENDPOINT = "unifiedPushNewEndpoint"
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered" private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed" private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
private const val DEFAULT_MESSAGE = "Paarrot notifications" 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" const val DEFAULT_MATRIX_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile @Volatile
private var plugin: SyncServicePlugin? = null private var plugin: SyncServicePlugin? = null
@Volatile
private var setupGeneration = 0
@Volatile
private var ignoreUnregisteredUntilElapsed = 0L
fun setPlugin(plugin: SyncServicePlugin) { fun setPlugin(plugin: SyncServicePlugin) {
this.plugin = plugin this.plugin = plugin
} }
@@ -61,19 +75,24 @@ object UnifiedPushManager {
} }
/** /**
* Clears any saved distributor and shows an in-app distributor picker. * Shows an in-app distributor picker, then registers after a short delay.
* UnifiedPush's OS deeplink picker is silent when only one distributor *
* (e.g. ntfy) is installed, which looked like a broken Reset button. * 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) { fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
runCatching { UnifiedPush.removeDistributor(context) } val gen = ++setupGeneration
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") } ignoreUnregisteredUntilElapsed = SystemClock.elapsedRealtime() + IGNORE_UNREGISTER_MS
clearEndpoint(context) clearEndpoint(context)
clearLastFailure(context)
val distributors = runCatching { UnifiedPush.getDistributors(context) } val distributors = runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
Log.i(TAG, "Distributor setup: found=${distributors.joinToString()}")
if (distributors.isEmpty()) { if (distributors.isEmpty()) {
Log.w(TAG, "No UnifiedPush distributors installed") Log.w(TAG, "No UnifiedPush distributors installed")
persistLastFailure(context, "NO_DISTRIBUTOR")
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
onDone(false) onDone(false)
return return
@@ -98,10 +117,7 @@ object UnifiedPushManager {
finish(false) finish(false)
return@setItems return@setItems
} }
runCatching { UnifiedPush.saveDistributor(context, chosen) } activateDistributor(context, chosen, gen, finish)
.onFailure { Log.w(TAG, "saveDistributor failed: ${it.message}") }
requestRegistration(context)
finish(true)
} }
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) } .setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
.setOnCancelListener { 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). */ /** Human-readable label for a distributor package (falls back to package name). */
private fun distributorLabel(context: Context, packageName: String): String { private fun distributorLabel(context: Context, packageName: String): String {
return try { return try {
@@ -173,6 +245,7 @@ object UnifiedPushManager {
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT) status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
status.put("registered", !getEndpoint(context).isNullOrBlank()) status.put("registered", !getEndpoint(context).isNullOrBlank())
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault("")) status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
status.put("lastFailure", getLastFailure(context) ?: "")
val distributors = JSArray() val distributors = JSArray()
runCatching { UnifiedPush.getDistributors(context) } runCatching { UnifiedPush.getDistributors(context) }
.getOrDefault(emptyList()) .getOrDefault(emptyList())
@@ -182,6 +255,8 @@ object UnifiedPushManager {
} }
fun onNewEndpoint(context: Context, endpoint: String, instance: String) { fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Log.i(TAG, "onNewEndpoint instance=$instance endpoint=$endpoint")
clearLastFailure(context)
val previousEndpoint = getEndpoint(context) val previousEndpoint = getEndpoint(context)
persistEndpoint(context, endpoint, instance) persistEndpoint(context, endpoint, instance)
val payload = JSObject().apply { val payload = JSObject().apply {
@@ -192,11 +267,18 @@ object UnifiedPushManager {
dispatch(EVENT_NEW_ENDPOINT, payload) 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) dispatchRegistrationFailed(reason, instance)
} }
fun onUnregistered(context: Context, instance: String) { 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) val previousEndpoint = getEndpoint(context)
clearEndpoint(context) clearEndpoint(context)
dispatchUnregistered(previousEndpoint, instance) dispatchUnregistered(previousEndpoint, instance)
@@ -204,9 +286,11 @@ object UnifiedPushManager {
private fun requestRegistration(context: Context) { private fun requestRegistration(context: Context) {
runCatching { runCatching {
Log.i(TAG, "Calling UnifiedPush.register instance=$INSTANCE_DEFAULT")
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null) UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
}.onFailure { }.onFailure {
Log.w(TAG, "UnifiedPush register failed: ${it.message}") Log.w(TAG, "UnifiedPush register failed: ${it.message}")
persistLastFailure(context, FailedReason.INTERNAL_ERROR.name)
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
} }
} }
@@ -245,6 +329,21 @@ object UnifiedPushManager {
.apply() .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? = private fun getEndpoint(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)

View File

@@ -21,7 +21,7 @@ class UnifiedPushService : PushService() {
override fun onRegistrationFailed(reason: FailedReason, instance: String) { override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "UnifiedPush registration failed: $reason") Log.w(TAG, "UnifiedPush registration failed: $reason")
UnifiedPushManager.onRegistrationFailed(reason.name, instance) UnifiedPushManager.onRegistrationFailed(applicationContext, reason.name, instance)
} }
override fun onUnregistered(instance: String) { override fun onUnregistered(instance: String) {

2
cinny

Submodule cinny updated: e460dbd34e...0fb3da20b9

View File

@@ -171,6 +171,7 @@ type PushStatus = {
distributor: string; distributor: string;
endpoint: string; endpoint: string;
distributors: string[]; distributors: string[];
lastFailure: string;
}; };
/** Android-only section showing UnifiedPush registration status and controls. */ /** Android-only section showing UnifiedPush registration status and controls. */
@@ -194,6 +195,7 @@ function AndroidPushNotifications() {
distributor: s.distributor || '', distributor: s.distributor || '',
endpoint: s.endpoint || '', endpoint: s.endpoint || '',
distributors, distributors,
lastFailure: s.lastFailure || '',
} }
: undefined : undefined
); );
@@ -211,7 +213,7 @@ function AndroidPushNotifications() {
await refresh(); await refresh();
if (!result.success) { if (!result.success) {
setLastError( 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]) }, [refresh])
@@ -244,6 +246,13 @@ function AndroidPushNotifications() {
</Text> </Text>
); );
} }
if (status.lastFailure) {
return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{`Registration failed (${status.lastFailure}). Tap Reset and pick ntfy again.`}
</Text>
);
}
if (status.distributors.length === 0) { if (status.distributors.length === 0) {
return ( return (
<Text as="span" style={{ color: color.Critical.Main }} size="T200"> <Text as="span" style={{ color: color.Critical.Main }} size="T200">

View File

@@ -8,6 +8,7 @@ type UnifiedPushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
distributors: string[] | string; distributors: string[] | string;
lastFailure?: string;
}; };
type ClearRoomNotificationsResult = { type ClearRoomNotificationsResult = {
@@ -551,16 +552,20 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
if (!result?.success) return { success: false }; if (!result?.success) return { success: false };
// Endpoint arrives asynchronously from the distributor after register(). // 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)); await new Promise((resolve) => setTimeout(resolve, 250));
const status = await getBackgroundSyncStatus(); const status = await getBackgroundSyncStatus();
if (status?.registered && status.endpoint) { if (status?.registered && status.endpoint) {
return { success: true }; 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. console.warn('[BackgroundSync] Distributor selected but no endpoint received in time');
return { success: true }; return { success: false };
} catch (err) { } catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err); console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false }; return { success: false };