diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 44b676a..ed7123e 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -105,4 +105,5 @@ dependencies {
implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons
+ implementation(libs.unifiedpush.connector) // UnifiedPush / ntfy notifications
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f3f54d2..498a740 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,13 +1,14 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/push/MatrixUnifiedPushManager.kt b/app/src/main/java/ru/risdeveau/pixeldragon/push/MatrixUnifiedPushManager.kt
new file mode 100755
index 0000000..c87f427
--- /dev/null
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/push/MatrixUnifiedPushManager.kt
@@ -0,0 +1,128 @@
+/*
+ * Created by sweetbread
+ * Copyright (c) 2026. All rights reserved.
+ */
+
+package ru.risdeveau.pixeldragon.push
+
+import android.content.Context
+import android.os.Build
+import de.connect2x.trixnity.client.MatrixClient
+import de.connect2x.trixnity.clientserverapi.model.push.PusherData
+import de.connect2x.trixnity.clientserverapi.model.push.SetPushers
+import ru.risdeveau.pixeldragon.client
+import java.util.Locale
+
+const val PIXELDRAGON_UNIFIED_PUSH_APP_ID = "ru.risdeveau.pixeldragon.unifiedpush"
+
+object MatrixUnifiedPushManager {
+ suspend fun reconcileRegisteredPusher(context: Context): Result {
+ val state = UnifiedPushSettingsStore.read(context)
+ if (!state.enabled || state.endpoint.isNullOrBlank()) {
+ return Result.success(false)
+ }
+ return registerMatrixPusher(context)
+ }
+
+ suspend fun registerMatrixPusher(context: Context): Result {
+ return runCatching {
+ val state = UnifiedPushSettingsStore.read(context)
+ val endpoint = state.endpoint?.takeIf { it.isNotBlank() }
+ ?: error("UnifiedPush endpoint is not available yet")
+ val gatewayUrl = state.gatewayUrl?.takeIf { it.isNotBlank() }
+ ?: error("ntfy Matrix gateway URL is not configured")
+ val matrixClient = client ?: error("Matrix client is not ready")
+
+ matrixClient.registerNtfyPusher(
+ endpoint = endpoint,
+ gatewayUrl = gatewayUrl,
+ )
+ UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = true)
+ true
+ }.onFailure { throwable ->
+ UnifiedPushSettingsStore.setMatrixPusherStatus(
+ context = context,
+ registered = false,
+ error = throwable.toPushError(),
+ )
+ }
+ }
+
+ suspend fun unregisterMatrixPusher(context: Context): Result {
+ return runCatching {
+ val endpoint = UnifiedPushSettingsStore.read(context).endpoint
+ ?: return@runCatching false
+ val matrixClient = client ?: error("Matrix client is not ready")
+
+ matrixClient.api.push.setPushers(
+ SetPushers.Request.Remove(
+ appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
+ pushkey = endpoint,
+ )
+ ).getOrThrow()
+ UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = false)
+ true
+ }.onFailure { throwable ->
+ UnifiedPushSettingsStore.setMatrixPusherStatus(
+ context = context,
+ registered = false,
+ error = throwable.toPushError(),
+ )
+ }
+ }
+
+ suspend fun refreshMatrixPusherStatus(context: Context): Result {
+ return runCatching {
+ val endpoint = UnifiedPushSettingsStore.read(context).endpoint
+ ?: return@runCatching false
+ val matrixClient = client ?: error("Matrix client is not ready")
+ val isRegistered = matrixClient.api.push.getPushers()
+ .getOrThrow()
+ .devices
+ .any { pusher ->
+ pusher.appId == PIXELDRAGON_UNIFIED_PUSH_APP_ID && pusher.pushkey == endpoint
+ }
+ UnifiedPushSettingsStore.setMatrixPusherStatus(context, registered = isRegistered)
+ isRegistered
+ }.onFailure { throwable ->
+ UnifiedPushSettingsStore.setMatrixPusherStatus(
+ context = context,
+ registered = false,
+ error = throwable.toPushError(),
+ )
+ }
+ }
+}
+
+private suspend fun MatrixClient.registerNtfyPusher(
+ endpoint: String,
+ gatewayUrl: String,
+) {
+ api.push.setPushers(
+ SetPushers.Request.Set(
+ appId = PIXELDRAGON_UNIFIED_PUSH_APP_ID,
+ pushkey = endpoint,
+ kind = "http",
+ appDisplayName = "PixelDragon",
+ deviceDisplayName = deviceDisplayName(),
+ lang = Locale.getDefault().toLanguageTag(),
+ data = PusherData(
+ url = gatewayUrl,
+ format = "event_id_only",
+ ),
+ append = true,
+ )
+ ).getOrThrow()
+}
+
+private fun MatrixClient.deviceDisplayName(): String {
+ return listOfNotNull(
+ Build.MANUFACTURER.takeIf { it.isNotBlank() },
+ Build.MODEL.takeIf { it.isNotBlank() },
+ deviceId?.takeIf { it.isNotBlank() },
+ ).joinToString(" ").ifBlank { "Android" }
+}
+
+fun Throwable.toPushError(): String {
+ return message?.takeIf { it.isNotBlank() } ?: this::class.simpleName ?: "Unknown error"
+}
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonNotifier.kt b/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonNotifier.kt
new file mode 100755
index 0000000..7139767
--- /dev/null
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonNotifier.kt
@@ -0,0 +1,74 @@
+/*
+ * Created by sweetbread
+ * Copyright (c) 2026. All rights reserved.
+ */
+
+package ru.risdeveau.pixeldragon.push
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import ru.risdeveau.pixeldragon.R
+import ru.risdeveau.pixeldragon.ui.activity.MainActivity
+
+private const val MATRIX_CHANNEL_ID = "matrix_messages"
+private const val MATRIX_NOTIFICATION_ID = 1001
+
+object PixelDragonNotifier {
+ fun showWakeUpNotification(context: Context) {
+ val appContext = context.applicationContext
+ if (!appContext.canPostNotifications()) return
+
+ ensureChannel(appContext)
+
+ val intent = Intent(appContext, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ appContext,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val notification = NotificationCompat.Builder(appContext, MATRIX_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("New Matrix activity")
+ .setContentText("Open PixelDragon to sync and decrypt the latest messages.")
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+
+ NotificationManagerCompat.from(appContext).notify(MATRIX_NOTIFICATION_ID, notification)
+ }
+
+ private fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val notificationManager = context.getSystemService(NotificationManager::class.java)
+ val channel = NotificationChannel(
+ MATRIX_CHANNEL_ID,
+ "Matrix messages",
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = "Wake-up notifications from ntfy / UnifiedPush."
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+}
+
+fun Context.canPostNotifications(): Boolean {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+}
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonUnifiedPushService.kt b/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonUnifiedPushService.kt
new file mode 100755
index 0000000..b3dc14a
--- /dev/null
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonUnifiedPushService.kt
@@ -0,0 +1,64 @@
+/*
+ * Created by sweetbread
+ * Copyright (c) 2026. All rights reserved.
+ */
+
+package ru.risdeveau.pixeldragon.push
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.PushService
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+import ru.risdeveau.pixeldragon.client
+
+class PixelDragonUnifiedPushService : PushService() {
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ UnifiedPushSettingsStore.setEndpoint(applicationContext, endpoint.url)
+ UnifiedPushSettingsStore.setLastError(applicationContext, null)
+
+ serviceScope.launch {
+ MatrixUnifiedPushManager.registerMatrixPusher(applicationContext)
+ }
+ }
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ UnifiedPushSettingsStore.recordPush(applicationContext)
+
+ serviceScope.launch {
+ runCatching {
+ client?.startSync()
+ }
+ PixelDragonNotifier.showWakeUpNotification(applicationContext)
+ }
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) {
+ UnifiedPushSettingsStore.setLastError(applicationContext, reason.name)
+ }
+
+ override fun onTempUnavailable(instance: String) {
+ UnifiedPushSettingsStore.setLastError(
+ context = applicationContext,
+ error = "Distributor backend is temporarily unavailable",
+ )
+ }
+
+ override fun onUnregistered(instance: String) {
+ serviceScope.launch {
+ MatrixUnifiedPushManager.unregisterMatrixPusher(applicationContext)
+ UnifiedPushSettingsStore.clear(applicationContext)
+ }
+ }
+
+ override fun onDestroy() {
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/push/UnifiedPushSettingsStore.kt b/app/src/main/java/ru/risdeveau/pixeldragon/push/UnifiedPushSettingsStore.kt
new file mode 100755
index 0000000..1a80efe
--- /dev/null
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/push/UnifiedPushSettingsStore.kt
@@ -0,0 +1,137 @@
+/*
+ * Created by sweetbread
+ * Copyright (c) 2026. All rights reserved.
+ */
+
+package ru.risdeveau.pixeldragon.push
+
+import android.content.Context
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+private const val PREFS_NAME = "unified_push_settings"
+private const val KEY_ENABLED = "enabled"
+private const val KEY_ENDPOINT = "endpoint"
+private const val KEY_GATEWAY_OVERRIDE = "gateway_override"
+private const val KEY_LAST_ERROR = "last_error"
+private const val KEY_LAST_PUSH_TS = "last_push_ts"
+private const val KEY_MATRIX_PUSHER_REGISTERED = "matrix_pusher_registered"
+private const val KEY_MATRIX_PUSHER_TS = "matrix_pusher_ts"
+private const val KEY_MATRIX_ERROR = "matrix_error"
+
+data class UnifiedPushSettingsState(
+ val enabled: Boolean = false,
+ val endpoint: String? = null,
+ val gatewayUrlOverride: String? = null,
+ val lastError: String? = null,
+ val lastPushAt: Long? = null,
+ val matrixPusherRegistered: Boolean = false,
+ val matrixPusherUpdatedAt: Long? = null,
+ val matrixError: String? = null,
+) {
+ val gatewayUrl: String?
+ get() = gatewayUrlOverride?.takeIf { it.isNotBlank() }
+ ?: endpoint?.toNtfyMatrixGatewayUrl()
+}
+
+object UnifiedPushSettingsStore {
+ private val state = MutableStateFlow(UnifiedPushSettingsState())
+
+ fun observe(context: Context): StateFlow {
+ refresh(context)
+ return state.asStateFlow()
+ }
+
+ fun refresh(context: Context) {
+ state.value = read(context)
+ }
+
+ fun setEnabled(context: Context, enabled: Boolean) {
+ edit(context) { putBoolean(KEY_ENABLED, enabled) }
+ }
+
+ fun setEndpoint(context: Context, endpoint: String?) {
+ edit(context) {
+ putNullableString(KEY_ENDPOINT, endpoint)
+ putBoolean(KEY_ENABLED, endpoint != null)
+ }
+ }
+
+ fun setGatewayOverride(context: Context, gatewayUrl: String?) {
+ edit(context) { putNullableString(KEY_GATEWAY_OVERRIDE, gatewayUrl?.trim()?.takeIf { it.isNotBlank() }) }
+ }
+
+ fun setLastError(context: Context, error: String?) {
+ edit(context) { putNullableString(KEY_LAST_ERROR, error) }
+ }
+
+ fun recordPush(context: Context) {
+ edit(context) {
+ putLong(KEY_LAST_PUSH_TS, System.currentTimeMillis())
+ remove(KEY_LAST_ERROR)
+ }
+ }
+
+ fun setMatrixPusherStatus(
+ context: Context,
+ registered: Boolean,
+ error: String? = null,
+ ) {
+ edit(context) {
+ putBoolean(KEY_MATRIX_PUSHER_REGISTERED, registered)
+ if (registered) {
+ putLong(KEY_MATRIX_PUSHER_TS, System.currentTimeMillis())
+ remove(KEY_MATRIX_ERROR)
+ } else {
+ remove(KEY_MATRIX_PUSHER_TS)
+ putNullableString(KEY_MATRIX_ERROR, error)
+ }
+ }
+ }
+
+ fun clear(context: Context) {
+ prefs(context).edit().clear().apply()
+ refresh(context)
+ }
+
+ fun read(context: Context): UnifiedPushSettingsState {
+ val prefs = prefs(context)
+ return UnifiedPushSettingsState(
+ enabled = prefs.getBoolean(KEY_ENABLED, false),
+ endpoint = prefs.getString(KEY_ENDPOINT, null),
+ gatewayUrlOverride = prefs.getString(KEY_GATEWAY_OVERRIDE, null),
+ lastError = prefs.getString(KEY_LAST_ERROR, null),
+ lastPushAt = prefs.getLongOrNull(KEY_LAST_PUSH_TS),
+ matrixPusherRegistered = prefs.getBoolean(KEY_MATRIX_PUSHER_REGISTERED, false),
+ matrixPusherUpdatedAt = prefs.getLongOrNull(KEY_MATRIX_PUSHER_TS),
+ matrixError = prefs.getString(KEY_MATRIX_ERROR, null),
+ )
+ }
+
+ private fun edit(context: Context, block: android.content.SharedPreferences.Editor.() -> Unit) {
+ prefs(context).edit().apply(block).apply()
+ refresh(context)
+ }
+
+ private fun prefs(context: Context) =
+ context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+}
+
+private fun android.content.SharedPreferences.getLongOrNull(key: String): Long? {
+ return if (contains(key)) getLong(key, 0L) else null
+}
+
+private fun android.content.SharedPreferences.Editor.putNullableString(
+ key: String,
+ value: String?,
+) {
+ if (value == null) remove(key) else putString(key, value)
+}
+
+fun String.toNtfyMatrixGatewayUrl(): String? {
+ val uri = android.net.Uri.parse(this)
+ val scheme = uri.scheme ?: return null
+ val authority = uri.authority ?: return null
+ return "$scheme://$authority/_matrix/push/v1/notify"
+}
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt
index 8a9b3f1..a123511 100755
--- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/activity/MainActivity.kt
@@ -69,10 +69,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client
+import ru.risdeveau.pixeldragon.push.MatrixUnifiedPushManager
import ru.risdeveau.pixeldragon.ui.item.Avatar
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.DevicesSettingsScreen
+import ru.risdeveau.pixeldragon.ui.layout.NotificationsSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
@@ -159,6 +161,10 @@ class MainActivity : ComponentActivity() {
DevicesSettingsScreen(Modifier.padding(innerPadding))
}
+ composable(SettingsRoutes.Notifications) {
+ NotificationsSettingsScreen(Modifier.padding(innerPadding))
+ }
+
composable(SettingsRoutes.Appearance) {
AppearanceSettingsScreen(Modifier.padding(innerPadding))
}
@@ -184,6 +190,7 @@ class MainActivity : ComponentActivity() {
Log.i("MainActivity", "Log in as ${client!!.userId}")
client!!.startSync()
+ MatrixUnifiedPushManager.reconcileRegisteredPusher(appCtx)
isClientReady = true
}
}
diff --git a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt
index 7bfc06b..1addec6 100755
--- a/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt
+++ b/app/src/main/java/ru/risdeveau/pixeldragon/ui/layout/Settings.kt
@@ -5,7 +5,11 @@
package ru.risdeveau.pixeldragon.ui.layout
+import android.Manifest
+import android.app.Activity
import android.content.Context
+import android.content.ContextWrapper
+import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -58,6 +62,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.client.store.KeySignatureTrustLevel
import de.connect2x.trixnity.client.store.KeyStore
@@ -76,7 +81,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
+import org.unifiedpush.android.connector.UnifiedPush
import ru.risdeveau.pixeldragon.client
+import ru.risdeveau.pixeldragon.push.MatrixUnifiedPushManager
+import ru.risdeveau.pixeldragon.push.UnifiedPushSettingsStore
+import ru.risdeveau.pixeldragon.push.canPostNotifications
import ru.risdeveau.pixeldragon.ui.item.Avatar
import java.time.Instant
import java.time.ZoneId
@@ -88,6 +97,7 @@ object SettingsRoutes {
const val Main = "settings/main"
const val Account = "settings/account"
const val Devices = "settings/devices"
+ const val Notifications = "settings/notifications"
const val Appearance = "settings/appearance"
}
@@ -108,6 +118,11 @@ private val settingsCategories = listOf(
title = "Devices",
subtitle = "Sessions, verification status and logout",
),
+ SettingsCategory(
+ route = SettingsRoutes.Notifications,
+ title = "Notifications",
+ subtitle = "ntfy / UnifiedPush delivery and Matrix pusher",
+ ),
SettingsCategory(
route = SettingsRoutes.Appearance,
title = "Appearance",
@@ -133,6 +148,7 @@ fun settingsTitleForRoute(route: String?): String {
SettingsRoutes.Graph -> "Settings"
SettingsRoutes.Account -> "Account"
SettingsRoutes.Devices -> "Devices"
+ SettingsRoutes.Notifications -> "Notifications"
SettingsRoutes.Appearance -> "Appearance"
else -> "Settings"
}
@@ -637,6 +653,252 @@ fun DevicesSettingsScreen(modifier: Modifier = Modifier) {
}
}
+
+@Composable
+fun NotificationsSettingsScreen(modifier: Modifier = Modifier) {
+ val matrixClient = client!!
+ val context = LocalContext.current
+ val activity = context.findActivity()
+ val scope = rememberCoroutineScope()
+ val pushState by UnifiedPushSettingsStore.observe(context).collectAsState()
+
+ val distributors = remember(pushState) { UnifiedPush.getDistributors(context) }
+ val savedDistributor = remember(pushState) { UnifiedPush.getSavedDistributor(context) }
+ val ackDistributor = remember(pushState) { UnifiedPush.getAckDistributor(context) }
+ val notificationPermissionGranted = remember(pushState) { context.canPostNotifications() }
+
+ var isBusy by remember { mutableStateOf(false) }
+ var message by remember { mutableStateOf(null) }
+ var gatewayDialogOpen by remember { mutableStateOf(false) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ message = if (granted) {
+ "Notification permission granted"
+ } else {
+ "Android notification permission was denied"
+ }
+ UnifiedPushSettingsStore.refresh(context)
+ }
+
+ fun requestNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ fun registerWithUnifiedPush(usePicker: Boolean) {
+ if (activity == null) {
+ message = "Could not open distributor picker"
+ return
+ }
+ requestNotificationPermissionIfNeeded()
+ isBusy = true
+ message = "Waiting for UnifiedPush endpoint from distributor"
+
+ val callback: (Boolean) -> Unit = { success ->
+ if (success) {
+ UnifiedPushSettingsStore.setEnabled(context, true)
+ UnifiedPush.register(
+ context = context.applicationContext,
+ messageForDistributor = "PixelDragon Matrix notifications",
+ )
+ message = "Registration requested. ntfy will return an endpoint shortly."
+ } else {
+ UnifiedPushSettingsStore.setLastError(context, "No UnifiedPush distributor selected")
+ message = "No UnifiedPush distributor selected. Install or open ntfy and try again."
+ }
+ isBusy = false
+ }
+
+ if (usePicker) {
+ UnifiedPush.tryPickDistributor(activity, callback)
+ } else {
+ UnifiedPush.tryUseCurrentOrDefaultDistributor(activity, callback)
+ }
+ }
+
+ fun refreshMatrixPusher() {
+ scope.launch {
+ isBusy = true
+ message = null
+ MatrixUnifiedPushManager.refreshMatrixPusherStatus(context)
+ .onSuccess { registered ->
+ message = if (registered) "Matrix pusher is registered" else "Matrix pusher is not registered"
+ }
+ .onFailure { error -> message = error.toSettingsError() }
+ isBusy = false
+ }
+ }
+
+ fun registerMatrixPusher() {
+ scope.launch {
+ isBusy = true
+ message = null
+ MatrixUnifiedPushManager.registerMatrixPusher(context)
+ .onSuccess { message = "Matrix pusher registered" }
+ .onFailure { error -> message = error.toSettingsError() }
+ isBusy = false
+ }
+ }
+
+ fun disableNotifications() {
+ scope.launch {
+ isBusy = true
+ message = null
+ MatrixUnifiedPushManager.unregisterMatrixPusher(context)
+ UnifiedPush.unregister(context.applicationContext)
+ UnifiedPushSettingsStore.clear(context)
+ message = "Notifications disabled"
+ isBusy = false
+ }
+ }
+
+ LaunchedEffect(matrixClient) {
+ MatrixUnifiedPushManager.refreshMatrixPusherStatus(context)
+ }
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ SettingsHeader(
+ title = "Notifications",
+ subtitle = "Use ntfy as the UnifiedPush distributor and Matrix push gateway.",
+ )
+ }
+
+ message?.let { status ->
+ item { SettingsStatusMessage(status) }
+ }
+
+ item {
+ SettingsSection(title = "Status") {
+ SettingsInfoRow(
+ title = "Android permission",
+ value = if (notificationPermissionGranted) "Granted" else "Not granted",
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "Available distributors",
+ value = distributors.ifEmpty { listOf("None") }.joinToString(),
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "Saved distributor",
+ value = savedDistributor ?: "None",
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "Acknowledged distributor",
+ value = ackDistributor ?: "None",
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "UnifiedPush endpoint",
+ value = pushState.endpoint ?: "Waiting for ntfy",
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "Matrix pusher",
+ value = if (pushState.matrixPusherRegistered) "Registered" else "Not registered",
+ )
+ SettingsDivider()
+ SettingsInfoRow(
+ title = "Last push",
+ value = pushState.lastPushAt?.formatLastSeen() ?: "Never",
+ )
+ }
+ }
+
+ item {
+ SettingsSection(title = "ntfy gateway") {
+ SettingsInfoRow(
+ title = "Gateway URL",
+ value = pushState.gatewayUrl ?: "Endpoint is not available yet",
+ )
+ SettingsDivider()
+ SettingsActionRow(
+ title = "Override gateway URL",
+ value = pushState.gatewayUrlOverride ?: "Use endpoint host",
+ actionLabel = "Edit",
+ enabled = !isBusy,
+ onClick = { gatewayDialogOpen = true },
+ )
+ }
+ }
+
+ item {
+ SettingsSection(title = "Actions") {
+ SettingsActionRow(
+ title = "Enable with current/default distributor",
+ value = "Recommended when ntfy is already configured",
+ actionLabel = "Enable",
+ enabled = !isBusy,
+ onClick = { registerWithUnifiedPush(usePicker = false) },
+ )
+ SettingsDivider()
+ SettingsActionRow(
+ title = "Choose distributor",
+ value = "Pick ntfy or another UnifiedPush provider",
+ actionLabel = "Pick",
+ enabled = !isBusy,
+ onClick = { registerWithUnifiedPush(usePicker = true) },
+ )
+ SettingsDivider()
+ SettingsActionRow(
+ title = "Register Matrix pusher",
+ value = "Use the current endpoint and gateway URL",
+ actionLabel = "Register",
+ enabled = !isBusy && !pushState.endpoint.isNullOrBlank(),
+ onClick = { registerMatrixPusher() },
+ )
+ SettingsDivider()
+ SettingsActionRow(
+ title = "Refresh Matrix pusher status",
+ value = "Checks /pushers on the homeserver",
+ actionLabel = "Refresh",
+ enabled = !isBusy,
+ onClick = { refreshMatrixPusher() },
+ )
+ SettingsDivider()
+ SettingsActionRow(
+ title = "Disable notifications",
+ value = "Unregister UnifiedPush and remove Matrix pusher",
+ actionLabel = "Disable",
+ enabled = !isBusy,
+ onClick = { disableNotifications() },
+ )
+ }
+ }
+
+ pushState.lastError?.let { error ->
+ item { SettingsStatusMessage("UnifiedPush error: $error") }
+ }
+
+ pushState.matrixError?.let { error ->
+ item { SettingsStatusMessage("Matrix pusher error: $error") }
+ }
+ }
+
+ if (gatewayDialogOpen) {
+ GatewayUrlDialog(
+ currentGatewayUrl = pushState.gatewayUrlOverride ?: pushState.gatewayUrl.orEmpty(),
+ onDismiss = { gatewayDialogOpen = false },
+ onSave = { gatewayUrl ->
+ UnifiedPushSettingsStore.setGatewayOverride(context, gatewayUrl)
+ gatewayDialogOpen = false
+ message = "Gateway URL saved"
+ },
+ )
+ }
+}
+
@Composable
fun AppearanceSettingsScreen(modifier: Modifier = Modifier) {
LazyColumn(
@@ -1063,6 +1325,50 @@ private fun EditAvatarDialog(
)
}
+
+@Composable
+private fun GatewayUrlDialog(
+ currentGatewayUrl: String,
+ onDismiss: () -> Unit,
+ onSave: (String?) -> Unit,
+) {
+ var gatewayUrl by remember(currentGatewayUrl) { mutableStateOf(currentGatewayUrl) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("ntfy Matrix gateway") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ text = "For a normal ntfy server this is https://your-ntfy-host/_matrix/push/v1/notify. Leave blank to derive it from the UnifiedPush endpoint host.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = gatewayUrl,
+ onValueChange = { gatewayUrl = it },
+ label = { Text("Gateway URL") },
+ singleLine = true,
+ )
+ }
+ },
+ confirmButton = {
+ Button(onClick = { onSave(gatewayUrl.trim().takeIf { it.isNotBlank() }) }) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { onSave(null) }) {
+ Text("Use default")
+ }
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ )
+}
+
@Composable
private fun ChangePasswordDialog(
isBusy: Boolean,
@@ -1198,6 +1504,15 @@ private fun PasswordAuthenticationDialog(
)
}
+
+private tailrec fun Context.findActivity(): Activity? {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+}
+
private suspend fun MatrixClient.uploadAvatar(
context: Context,
uri: Uri,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6134d67..221c4ea 100755
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0"
+unifiedPushConnector = "3.3.2"
uiUnit = "1.10.6"
[libraries]
@@ -54,6 +55,7 @@ trixnity-client = { module = "de.connect2x.trixnity:trixnity-client", version.re
trixnity-client-media-okio = { module = "de.connect2x.trixnity:trixnity-client-media-okio", version.ref = "trixnityClient" }
trixnity-client-repository-room = { module = "de.connect2x.trixnity:trixnity-client-repository-room", version.ref = "trixnityClient" }
trixnity-client-cryptodriver-vodozemac = { module = "de.connect2x.trixnity:trixnity-client-cryptodriver-vodozemac", version.ref = "trixnityClient" }
+unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "unifiedPushConnector" }
androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
[plugins]