Add UI to select Instant notifications

Prepare for UnifiedPush support
This commit is contained in:
sim
2025-08-01 15:15:31 +02:00
parent 53d6d057c6
commit 1a4c30b448
9 changed files with 251 additions and 4 deletions
@@ -148,6 +148,11 @@ dependencies {
//Camera Permission
implementation("com.google.accompanist:accompanist-permissions:0.34.0")
// Push notifications
implementation("org.unifiedpush.android:connector:3.0.10")
// Allow using Play Services if available
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.0.0")
//implementation("androidx.compose.material:material-icons-extended:$compose_version")
//implementation("androidx.compose.ui:ui-util:$compose_version")
@@ -237,6 +237,16 @@ class SimplexApp: Application(), LifecycleEventObserver {
if (mode != NotificationsMode.PERIODIC) {
MessagesFetcherWorker.cancelAll()
}
if (mode == NotificationsMode.INSTANT) {
CoroutineScope(Dispatchers.Default).launch {
SimplexService.initUnifiedPush(this) {
// Change notifications mode only if everything is correctly setup
chatModel.controller.appPrefs.notificationsMode.set(mode)
}
}
} else {
chatModel.controller.appPrefs.notificationsMode.set(mode)
}
SimplexService.showBackgroundServiceNoticeIfNeeded(showOffAlert = false)
}
@@ -246,6 +256,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
NotificationsMode.SERVICE -> CoroutineScope(Dispatchers.Default).launch { platform.androidServiceStart() }
NotificationsMode.PERIODIC -> SimplexApp.context.schedulePeriodicWakeUp()
NotificationsMode.OFF -> {}
NotificationsMode.INSTANT -> {}
}
}
@@ -370,6 +381,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
override fun androidCreateActiveCallState(): Closeable = ActiveCallState()
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
override val supportsPushNotifications = true
}
}
@@ -22,6 +22,7 @@ import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.work.*
import chat.simplex.app.model.NtfManager
import chat.simplex.app.platform.PushManager
import chat.simplex.common.AppLock
import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.ChatController
@@ -427,6 +428,10 @@ class SimplexService: Service() {
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
suspend fun initUnifiedPush(scope: CoroutineScope, onSuccess: () -> Unit) {
PushManager.initUnifiedPush(androidAppContext, scope, onSuccess)
}
fun showBackgroundServiceNoticeIfNeeded(showOffAlert: Boolean = true) {
val appPrefs = ChatController.appPrefs
val mode = appPrefs.notificationsMode.get()
@@ -0,0 +1,205 @@
package chat.simplex.app.platform
import SectionItemView
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.getUserServers
import chat.simplex.common.platform.Log
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.networkAndServers.showAddServerDialog
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import org.unifiedpush.android.connector.UnifiedPush
/**
* Object with functions to interact with push services
*/
object PushManager {
private const val TAG = "PushManager"
/**
* Show an alert if NTF server isn't define,
* If a single distrib is available, use it
* If many, ask distributor to use with a dialog
* Else alert about missing service
*/
suspend fun initUnifiedPush(context: Context, scope: CoroutineScope, onSuccess: () -> Unit) {
val distributors = UnifiedPush.getDistributors(context)
when (distributors.size) {
0 -> {
Log.d(TAG, "No distributor found")
showMissingPushServiceDialog()
}
1 -> {
Log.d(TAG, "Found only one distributor installed")
UnifiedPush.saveDistributor(context, distributors.first())
register(context)
onSuccess()
}
else -> {
Log.d(TAG, "Found many distributors installed")
showSelectPushServiceIntroDialog {
showSelectPushServiceDialog(context, distributors) {
UnifiedPush.saveDistributor(context, it)
register(context)
onSuccess
}
}
}
}
}
private fun register(context: Context) {
// TODO: add VAPID
UnifiedPush.register(context)
}
/**
* Show a dialog to inform about missing push service
*/
private fun showMissingPushServiceDialog() = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers
)
Text(
stringResource(MR.strings.icon_descr_instant_notifications),
fontWeight = FontWeight.Bold
)
}
},
text = {
Text(
buildAnnotatedString {
append(stringResource(MR.strings.warning_push_needs_push_service))
withLink(LinkAnnotation.Url(url = "https://unifiedpush.org")) {
withStyle(style = SpanStyle(color = MaterialTheme.colors.primary)) {
append("https://unifiedpush.org")
}
}
}
)
},
confirmButton = {
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.ok)) }
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
/**
* Show an intro to explain why they need to select a push service
*/
private fun showSelectPushServiceIntroDialog(onConfirm: () -> Unit) = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
painterResource(MR.images.ic_notifications),
contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers
)
Text(
stringResource(MR.strings.icon_descr_instant_notifications),
fontWeight = FontWeight.Bold
)
}
},
text = {
Text(stringResource(MR.strings.select_push_service_intro))
},
confirmButton = {
TextButton(
onClick = {
AlertManager.shared.hideAlert()
onConfirm()
}
) { Text(stringResource(MR.strings.select_push_service)) }
},
dismissButton = {
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) }
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
/**
* Show a dialog to select push service
*
* @param distributors List of installed distributors' packageName
* @param onSelected run when a distributor is selected, its param is the selected distrib packageName
*/
private fun showSelectPushServiceDialog(context: Context, distributors: List<String>, onSelected: (String) -> Unit) = AlertManager.shared.showAlert {
AlertDialog(
onDismissRequest = AlertManager.shared::hideAlert,
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
painterResource(MR.images.ic_notifications),
contentDescription = null, // The icon doesn't add any meaning and must not be transcripted with screen readers
)
Text(
stringResource(MR.strings.select_push_service),
fontWeight = FontWeight.Bold
)
}
},
text = {
Column {
distributors.map {
if (it == context.packageName) it to "Play Services"
else it to context.getApplicationName(it)
}.forEach {
SectionItemView({
AlertManager.shared.hideAlert()
onSelected(it.first)
}) {
Text(it.second)
}
}
}
},
confirmButton = {
TextButton(onClick = AlertManager.shared::hideAlert) { Text(stringResource(android.R.string.cancel)) }
},
shape = RoundedCornerShape(corner = CornerSize(25.dp))
)
}
private fun Context.getApplicationName(applicationId: String): String {
return try {
val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getApplicationInfo(
applicationId,
PackageManager.ApplicationInfoFlags.of(
PackageManager.GET_META_DATA.toLong()
)
)
} else {
packageManager.getApplicationInfo(applicationId, 0)
}
packageManager.getApplicationLabel(ai)
} catch (e: PackageManager.NameNotFoundException) {
applicationId
} as String
}
}
@@ -9,7 +9,7 @@ val NotificationsMode.requiresIgnoringBatterySinceSdk: Int get() = when(this) {
NotificationsMode.OFF -> Int.MAX_VALUE
NotificationsMode.PERIODIC -> Build.VERSION_CODES.M
NotificationsMode.SERVICE -> Build.VERSION_CODES.S
/*INSTANT -> Int.MAX_VALUE - for Firebase notifications */
NotificationsMode.INSTANT -> Int.MAX_VALUE
}
val NotificationsMode.requiresIgnoringBattery
@@ -7729,7 +7729,7 @@ sealed class RemoteCtrlError {
}
enum class NotificationsMode() {
OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */;
OFF, PERIODIC, SERVICE, INSTANT;
companion object {
val default: NotificationsMode = SERVICE
@@ -7956,6 +7956,7 @@ enum class AppSettingsNotificationMode {
companion object {
fun from(mode: NotificationsMode): AppSettingsNotificationMode =
when (mode) {
NotificationsMode.INSTANT -> INSTANT
NotificationsMode.SERVICE -> INSTANT
NotificationsMode.PERIODIC -> PERIODIC
NotificationsMode.OFF -> OFF
@@ -33,6 +33,7 @@ interface PlatformInterface {
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
@Composable fun desktopShowAppUpdateNotice() {}
val supportsPushNotifications: Boolean get() = false
}
/**
* Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them.
@@ -133,6 +133,15 @@ private fun notificationModes(): List<ValueTitleDesc<NotificationsMode>> {
AnnotatedString(generalGetString(MR.strings.notifications_mode_service_desc)),
)
)
if (platform.supportsPushNotifications) {
res.add(
ValueTitleDesc(
NotificationsMode.INSTANT,
generalGetString(MR.strings.notifications_mode_instant),
AnnotatedString(generalGetString(MR.strings.notifications_mode_instant_desc))
)
)
}
return res
}
@@ -164,6 +173,10 @@ fun notificationPreviewModes(): List<ValueTitleDesc<NotificationPreviewMode>> {
}
fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) {
chatModel.controller.appPrefs.notificationsMode.set(mode)
platform.androidNotificationsModeChanged(mode)
// the preference is updated in androidNotificationsModeChanged for Android
if (appPlatform.isAndroid) {
platform.androidNotificationsModeChanged(mode)
} else {
chatModel.controller.appPrefs.notificationsMode.set(mode)
}
}
@@ -274,9 +274,11 @@
<string name="notifications_mode_off">Runs when app is open</string>
<string name="notifications_mode_periodic">Starts periodically</string>
<string name="notifications_mode_service">Always on</string>
<string name="notifications_mode_instant">Instant</string>
<string name="notifications_mode_off_desc">App can receive notifications only when it\'s running, no background service will be started</string>
<string name="notifications_mode_periodic_desc">Checks new messages every 10 minutes for up to 1 minute</string>
<string name="notifications_mode_service_desc">Background service is always running notifications will be shown as soon as the messages are available.</string>
<string name="notifications_mode_instant_desc">Receive notifications instantly using an external push service.</string>
<string name="notification_preview_mode_message">Message text</string>
<string name="notification_preview_mode_contact">Contact name</string>
<string name="notification_preview_mode_hidden">Hidden</string>
@@ -288,6 +290,9 @@
<string name="notification_new_contact_request">New contact request</string>
<string name="notification_contact_connected">Connected</string>
<string name="error_showing_desktop_notification">Error showing notification, contact developers.</string>
<string name="warning_push_needs_push_service">You don\'t have any push service installed on your device.\n\nPlease installed one and try again.\n\nFor more information, visit\ </string>
<string name="select_push_service">Select Push Service</string>
<string name="select_push_service_intro">Multiple push services are installed on your system, please select the service you wish to use.</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title_simplex_lock">SimpleX Lock</string>