Show an in-app UnifiedPush distributor picker instead of a silent OS default.
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
2
cinny
Submodule cinny updated: 0de2fd942f...e460dbd34e
@@ -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 "Reset" 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"
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user