Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Actions
725b3467d3 chore: bump version to 4.11.142 [skip ci] 2026-07-27 14:40:17 +00:00
c28b786948 Show an in-app UnifiedPush distributor picker instead of a silent OS default.
All checks were successful
Build / increment-version (push) Successful in 6s
Build / build-android (push) Successful in 4m42s
2026-07-28 00:40:09 +10:00
6 changed files with 150 additions and 38 deletions

View File

@@ -87,4 +87,22 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <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> </manifest>

View File

@@ -1,8 +1,11 @@
package com.paarrot.app package com.paarrot.app
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import org.json.JSONObject import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason import org.unifiedpush.android.connector.FailedReason
@@ -48,31 +51,73 @@ object UnifiedPushManager {
return return
} }
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> // Prefer an explicit in-app picker so the user always sees something;
if (success) { // tryUseDefaultDistributor is silent when only one distributor is installed.
requestRegistration(context) requestDistributorSetup(context, activity) { success ->
} else { if (!success) {
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
} }
} }
} }
/** /**
* Clears any saved distributor and re-opens the OS/default distributor picker * Clears any saved distributor and shows an in-app distributor picker.
* (`unifiedpush://link`), then registers again on success. * 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) { fun requestDistributorSetup(context: Context, activity: Activity, onDone: (Boolean) -> Unit) {
runCatching { UnifiedPush.removeDistributor(context) } runCatching { UnifiedPush.removeDistributor(context) }
.onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") } .onFailure { Log.w(TAG, "removeDistributor failed: ${it.message}") }
clearEndpoint(context) clearEndpoint(context)
UnifiedPush.tryUseDefaultDistributor(activity) { success -> val distributors = runCatching { UnifiedPush.getDistributors(context) }
if (success) { .getOrDefault(emptyList())
requestRegistration(context) if (distributors.isEmpty()) {
} else { Log.w(TAG, "No UnifiedPush distributors installed")
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT) dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
} onDone(false)
onDone(success) 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("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( val distributors = JSArray()
"distributors", runCatching { UnifiedPush.getDistributors(context) }
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>()) .getOrDefault(emptyList())
) .forEach { distributors.put(it) }
status.put("distributors", distributors)
return status return status
} }

2
cinny

Submodule cinny updated: 0de2fd942f...e460dbd34e

View File

@@ -170,22 +170,30 @@ type PushStatus = {
registered: boolean; registered: boolean;
distributor: string; distributor: string;
endpoint: string; endpoint: string;
distributors: string[];
}; };
/** Android-only section showing UnifiedPush registration status and controls. */ /** Android-only section showing UnifiedPush registration status and controls. */
function AndroidPushNotifications() { function AndroidPushNotifications() {
const [status, setStatus] = useState<PushStatus | undefined>(undefined); const [status, setStatus] = useState<PushStatus | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lastError, setLastError] = useState<string | undefined>(undefined);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
const s = await getBackgroundSyncStatus(); const s = await getBackgroundSyncStatus();
const distributors = Array.isArray(s?.distributors)
? s.distributors
: typeof s?.distributors === 'string' && s.distributors && s.distributors !== '[]'
? [s.distributors]
: [];
setStatus( setStatus(
s s
? { ? {
registered: s.registered, registered: s.registered,
distributor: s.distributor || '', distributor: s.distributor || '',
endpoint: s.endpoint || '', endpoint: s.endpoint || '',
distributors,
} }
: undefined : undefined
); );
@@ -198,8 +206,14 @@ function AndroidPushNotifications() {
const [resetState, reset] = useAsyncCallback( const [resetState, reset] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
await requestResetPushRegistration(); setLastError(undefined);
const result = await requestResetPushRegistration();
await refresh(); await refresh();
if (!result.success) {
setLastError(
'Could not register a push distributor. Install ntfy (with UnifiedPush enabled), then try Reset again.'
);
}
}, [refresh]) }, [refresh])
); );
@@ -214,6 +228,36 @@ function AndroidPushNotifications() {
resetState.status === AsyncStatus.Loading || resetState.status === AsyncStatus.Loading ||
pingState.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.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 ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Android Push (UnifiedPush)</Text> <Text size="L400">Android Push (UnifiedPush)</Text>
@@ -226,29 +270,20 @@ function AndroidPushNotifications() {
<SettingTile <SettingTile
title="Background Notifications" title="Background Notifications"
description={ description={
loading ? ( <>
'Loading status…' {statusDescription}
) : status === undefined ? ( {lastError ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200"> <Text as="span" style={{ color: color.Critical.Main, display: 'block' }} size="T200">
Failed to read push status. {lastError}
</Text>
) : status.registered ? (
<>
<Text as="span" size="T200">
{`Distributor: ${status.distributor || 'unknown'}`}
</Text> </Text>
</> ) : null}
) : ( </>
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
Not registered. Tap &quot;Reset&quot; to choose a distributor app.
</Text>
)
} }
after={loading ? <Spinner variant="Secondary" /> : undefined} after={loading ? <Spinner variant="Secondary" /> : undefined}
/> />
<SettingTile <SettingTile
title="Change Distributor" 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={ after={
<Button <Button
size="300" size="300"

View File

@@ -537,6 +537,7 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
* Re-opens the UnifiedPush distributor selection dialog. * Re-opens the UnifiedPush distributor selection dialog.
* Allows users to re-select or change their push notification distributor without terminal access. * Allows users to re-select or change their push notification distributor without terminal access.
* Useful when the current endpoint becomes unavailable. * 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 }> => { export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
if (!isBackgroundSyncSupported()) { if (!isBackgroundSyncSupported()) {
@@ -547,7 +548,19 @@ export const requestResetPushRegistration = async (): Promise<{ success: boolean
try { try {
const result = await MatrixBackgroundSync.requestDistributorSetup(); const result = await MatrixBackgroundSync.requestDistributorSetup();
console.log('[BackgroundSync] requestDistributorSetup completed:', result); 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) { } catch (err) {
console.error('[BackgroundSync] requestDistributorSetup failed:', err); console.error('[BackgroundSync] requestDistributorSetup failed:', err);
return { success: false }; return { success: false };

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.141", "version": "4.11.142",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"