feat: Unified Push
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -34,6 +35,14 @@
|
||||
android:name=".ui.activity.Login"
|
||||
android:exported="false"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
+128
@@ -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
|
||||
}
|
||||
+64
@@ -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()
|
||||
}
|
||||
}
|
||||
+137
@@ -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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user