diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 8168c92bf8..09489a023a 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -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") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 83767f90d7..17414f9f33 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -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 } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 289ecc0a31..0b78d8a57e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -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() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt new file mode 100644 index 0000000000..fe64afc633 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PushManager.kt @@ -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, 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 + } + +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt index 540533e5ad..6ddf0c35ba 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index ea740f238b..7b4c6914da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 448100bc17..47d9b5a1ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -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. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 2fc427cd2e..88a6dc3e4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -133,6 +133,15 @@ private fun notificationModes(): List> { 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> { } 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) + } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index b13e3f4046..b2d8791cd7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -274,9 +274,11 @@ Runs when app is open Starts periodically Always on + Instant App can receive notifications only when it\'s running, no background service will be started Checks new messages every 10 minutes for up to 1 minute Background service is always running – notifications will be shown as soon as the messages are available. + Receive notifications instantly using an external push service. Message text Contact name Hidden @@ -288,6 +290,9 @@ New contact request Connected Error showing notification, contact developers. + You don\'t have any push service installed on your device.\n\nPlease installed one and try again.\n\nFor more information, visit\ + Select Push Service + Multiple push services are installed on your system, please select the service you wish to use. SimpleX Lock