Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d4dcb67e | ||
| a744e36fb1 | |||
|
|
725b3467d3 | ||
| c28b786948 |
@@ -87,4 +87,22 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Android 11+ package visibility for UnifiedPush distributors (ntfy, etc.) -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="org.unifiedpush.android.distributor.REGISTER" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="org.unifiedpush.android.distributor.UNREGISTER" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="org.unifiedpush.android.distributor.LINK" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="unifiedpush" />
|
||||
</intent>
|
||||
<package android:name="io.heckel.ntfy" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package com.paarrot.app
|
||||
|
||||
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
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
@@ -17,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
|
||||
}
|
||||
@@ -48,31 +65,131 @@ 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.
|
||||
* 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)
|
||||
|
||||
UnifiedPush.tryUseDefaultDistributor(activity) { success ->
|
||||
if (success) {
|
||||
requestRegistration(context)
|
||||
} else {
|
||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
activateDistributor(context, chosen, gen, finish)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(false) }
|
||||
.setOnCancelListener { finish(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
onDone(success)
|
||||
|
||||
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 {
|
||||
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,14 +245,18 @@ 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<String>())
|
||||
)
|
||||
status.put("lastFailure", getLastFailure(context) ?: "")
|
||||
val distributors = JSArray()
|
||||
runCatching { UnifiedPush.getDistributors(context) }
|
||||
.getOrDefault(emptyList())
|
||||
.forEach { distributors.put(it) }
|
||||
status.put("distributors", distributors)
|
||||
return status
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -146,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)
|
||||
@@ -158,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)
|
||||
}
|
||||
}
|
||||
@@ -199,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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
2
cinny
2
cinny
Submodule cinny updated: 0de2fd942f...0fb3da20b9
@@ -170,22 +170,32 @@ type PushStatus = {
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
endpoint: string;
|
||||
distributors: string[];
|
||||
lastFailure: string;
|
||||
};
|
||||
|
||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||
function AndroidPushNotifications() {
|
||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastError, setLastError] = useState<string | undefined>(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,
|
||||
lastFailure: s.lastFailure || '',
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
@@ -198,8 +208,14 @@ function AndroidPushNotifications() {
|
||||
|
||||
const [resetState, reset] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await requestResetPushRegistration();
|
||||
setLastError(undefined);
|
||||
const result = await requestResetPushRegistration();
|
||||
await refresh();
|
||||
if (!result.success) {
|
||||
setLastError(
|
||||
'Selected distributor but did not get a push endpoint. In ntfy: enable UnifiedPush, allow unrestricted battery, then try Reset again.'
|
||||
);
|
||||
}
|
||||
}, [refresh])
|
||||
);
|
||||
|
||||
@@ -214,6 +230,43 @@ function AndroidPushNotifications() {
|
||||
resetState.status === AsyncStatus.Loading ||
|
||||
pingState.status === AsyncStatus.Loading;
|
||||
|
||||
const statusDescription = (() => {
|
||||
if (loading) return 'Loading status…';
|
||||
if (status === undefined) {
|
||||
return (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Failed to read push status.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (status.registered) {
|
||||
return (
|
||||
<Text as="span" size="T200">
|
||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||
</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) {
|
||||
return (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
No UnifiedPush distributor found. Install ntfy from Play Store/F-Droid and enable UnifiedPush in ntfy settings.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
{`Found ${status.distributors.join(', ')} but not registered yet. Tap Reset and pick ntfy.`}
|
||||
</Text>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||
@@ -226,29 +279,20 @@ function AndroidPushNotifications() {
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
loading ? (
|
||||
'Loading status…'
|
||||
) : status === undefined ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Failed to read push status.
|
||||
</Text>
|
||||
) : status.registered ? (
|
||||
<>
|
||||
<Text as="span" size="T200">
|
||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||
<>
|
||||
{statusDescription}
|
||||
{lastError ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
|
||||
{lastError}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
Not registered. Tap "Reset" to choose a distributor app.
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Change Distributor"
|
||||
description="Re-open the UnifiedPush distributor selection dialog."
|
||||
description="Shows a list of installed UnifiedPush apps (ntfy, etc.) and registers the one you pick."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
|
||||
@@ -8,6 +8,7 @@ type UnifiedPushStatus = {
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
distributors: string[] | string;
|
||||
lastFailure?: string;
|
||||
};
|
||||
|
||||
type ClearRoomNotificationsResult = {
|
||||
@@ -537,6 +538,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
||||
* 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 +549,23 @@ 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 < 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 };
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.141",
|
||||
"version": "4.11.143",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
Reference in New Issue
Block a user