feat: Unified Push

This commit is contained in:
2026-05-07 05:40:45 +03:00
parent 11ad22c818
commit 7a5889a351
9 changed files with 738 additions and 1 deletions
+1
View File
@@ -105,4 +105,5 @@ dependencies {
implementation(libs.splitties.base) // Syntax sugar implementation(libs.splitties.base) // Syntax sugar
implementation(libs.jsoup) // HTML parser implementation(libs.jsoup) // HTML parser
implementation(libs.iconsax.compose) // Material icons implementation(libs.iconsax.compose) // Material icons
implementation(libs.unifiedpush.connector) // UnifiedPush / ntfy notifications
} }
+10 -1
View File
@@ -1,13 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
~ Created by sweetbread ~ Created by sweetbread
~ Copyright (c) 2025. All rights reserved. ~ Copyright (c) 2026. All rights reserved.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -34,6 +35,14 @@
android:name=".ui.activity.Login" android:name=".ui.activity.Login"
android:exported="false" android:exported="false"
android:theme="@style/Theme.PixelDragon" /> android:theme="@style/Theme.PixelDragon" />
<service
android:name=".push.PixelDragonUnifiedPushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
@@ -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<Boolean> {
val state = UnifiedPushSettingsStore.read(context)
if (!state.enabled || state.endpoint.isNullOrBlank()) {
return Result.success(false)
}
return registerMatrixPusher(context)
}
suspend fun registerMatrixPusher(context: Context): Result<Boolean> {
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<Boolean> {
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<Boolean> {
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"
}
@@ -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
}
@@ -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()
}
}
@@ -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<UnifiedPushSettingsState> {
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"
}
@@ -69,10 +69,12 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.risdeveau.pixeldragon.R import ru.risdeveau.pixeldragon.R
import ru.risdeveau.pixeldragon.client import ru.risdeveau.pixeldragon.client
import ru.risdeveau.pixeldragon.push.MatrixUnifiedPushManager
import ru.risdeveau.pixeldragon.ui.item.Avatar import ru.risdeveau.pixeldragon.ui.item.Avatar
import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen import ru.risdeveau.pixeldragon.ui.layout.AccountSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen import ru.risdeveau.pixeldragon.ui.layout.AppearanceSettingsScreen
import ru.risdeveau.pixeldragon.ui.layout.DevicesSettingsScreen 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.Room
import ru.risdeveau.pixeldragon.ui.layout.RoomList import ru.risdeveau.pixeldragon.ui.layout.RoomList
import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen import ru.risdeveau.pixeldragon.ui.layout.SettingsMainScreen
@@ -159,6 +161,10 @@ class MainActivity : ComponentActivity() {
DevicesSettingsScreen(Modifier.padding(innerPadding)) DevicesSettingsScreen(Modifier.padding(innerPadding))
} }
composable(SettingsRoutes.Notifications) {
NotificationsSettingsScreen(Modifier.padding(innerPadding))
}
composable(SettingsRoutes.Appearance) { composable(SettingsRoutes.Appearance) {
AppearanceSettingsScreen(Modifier.padding(innerPadding)) AppearanceSettingsScreen(Modifier.padding(innerPadding))
} }
@@ -184,6 +190,7 @@ class MainActivity : ComponentActivity() {
Log.i("MainActivity", "Log in as ${client!!.userId}") Log.i("MainActivity", "Log in as ${client!!.userId}")
client!!.startSync() client!!.startSync()
MatrixUnifiedPushManager.reconcileRegisteredPusher(appCtx)
isClientReady = true isClientReady = true
} }
} }
@@ -5,7 +5,11 @@
package ru.risdeveau.pixeldragon.ui.layout package ru.risdeveau.pixeldragon.ui.layout
import android.Manifest
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult 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.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.connect2x.trixnity.client.MatrixClient import de.connect2x.trixnity.client.MatrixClient
import de.connect2x.trixnity.client.store.KeySignatureTrustLevel import de.connect2x.trixnity.client.store.KeySignatureTrustLevel
import de.connect2x.trixnity.client.store.KeyStore import de.connect2x.trixnity.client.store.KeyStore
@@ -76,7 +81,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import org.unifiedpush.android.connector.UnifiedPush
import ru.risdeveau.pixeldragon.client 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 ru.risdeveau.pixeldragon.ui.item.Avatar
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -88,6 +97,7 @@ object SettingsRoutes {
const val Main = "settings/main" const val Main = "settings/main"
const val Account = "settings/account" const val Account = "settings/account"
const val Devices = "settings/devices" const val Devices = "settings/devices"
const val Notifications = "settings/notifications"
const val Appearance = "settings/appearance" const val Appearance = "settings/appearance"
} }
@@ -108,6 +118,11 @@ private val settingsCategories = listOf(
title = "Devices", title = "Devices",
subtitle = "Sessions, verification status and logout", subtitle = "Sessions, verification status and logout",
), ),
SettingsCategory(
route = SettingsRoutes.Notifications,
title = "Notifications",
subtitle = "ntfy / UnifiedPush delivery and Matrix pusher",
),
SettingsCategory( SettingsCategory(
route = SettingsRoutes.Appearance, route = SettingsRoutes.Appearance,
title = "Appearance", title = "Appearance",
@@ -133,6 +148,7 @@ fun settingsTitleForRoute(route: String?): String {
SettingsRoutes.Graph -> "Settings" SettingsRoutes.Graph -> "Settings"
SettingsRoutes.Account -> "Account" SettingsRoutes.Account -> "Account"
SettingsRoutes.Devices -> "Devices" SettingsRoutes.Devices -> "Devices"
SettingsRoutes.Notifications -> "Notifications"
SettingsRoutes.Appearance -> "Appearance" SettingsRoutes.Appearance -> "Appearance"
else -> "Settings" 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<String?>(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 @Composable
fun AppearanceSettingsScreen(modifier: Modifier = Modifier) { fun AppearanceSettingsScreen(modifier: Modifier = Modifier) {
LazyColumn( 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 @Composable
private fun ChangePasswordDialog( private fun ChangePasswordDialog(
isBusy: Boolean, 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( private suspend fun MatrixClient.uploadAvatar(
context: Context, context: Context,
uri: Uri, uri: Uri,
+2
View File
@@ -17,6 +17,7 @@ navigationCompose = "2.9.7"
room = "2.6.1" room = "2.6.1"
splittiesFunPackAndroidBase = "3.0.0" splittiesFunPackAndroidBase = "3.0.0"
trixnityClient = "5.2.0" trixnityClient = "5.2.0"
unifiedPushConnector = "3.3.2"
uiUnit = "1.10.6" uiUnit = "1.10.6"
[libraries] [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-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-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" } 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" } androidx-compose-ui-unit = { group = "androidx.compose.ui", name = "ui-unit", version.ref = "uiUnit" }
[plugins] [plugins]