From 7a5889a3517c28956fdbee9a3f94535d9025e97b Mon Sep 17 00:00:00 2001 From: Sweetbread Date: Thu, 7 May 2026 05:40:45 +0300 Subject: [PATCH] feat: Unified Push --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 11 +- .../push/MatrixUnifiedPushManager.kt | 128 +++++++ .../pixeldragon/push/PixelDragonNotifier.kt | 74 ++++ .../push/PixelDragonUnifiedPushService.kt | 64 ++++ .../push/UnifiedPushSettingsStore.kt | 137 ++++++++ .../pixeldragon/ui/activity/MainActivity.kt | 7 + .../pixeldragon/ui/layout/Settings.kt | 315 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 9 files changed, 738 insertions(+), 1 deletion(-) create mode 100755 app/src/main/java/ru/risdeveau/pixeldragon/push/MatrixUnifiedPushManager.kt create mode 100755 app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonNotifier.kt create mode 100755 app/src/main/java/ru/risdeveau/pixeldragon/push/PixelDragonUnifiedPushService.kt create mode 100755 app/src/main/java/ru/risdeveau/pixeldragon/push/UnifiedPushSettingsStore.kt 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]