diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 22259fc3fa..de7b76f120 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -86,6 +86,7 @@ class MainActivity: FragmentActivity() { } } SimplexApp.context.schedulePeriodicServiceRestartWorker() + SimplexApp.context.schedulePeriodicWakeUp() } override fun onNewIntent(intent: Intent?) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 39a4806add..c2c777c80b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -6,9 +6,9 @@ import android.util.Log import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.getFilesDirectory -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.app.views.usersettings.NotificationsMode import kotlinx.coroutines.* import java.io.BufferedReader import java.io.InputStreamReader @@ -68,23 +68,29 @@ class SimplexApp: Application(), LifecycleEventObserver { Log.d(TAG, "onStateChanged: $event") withApi { when (event) { - Lifecycle.Event.ON_STOP -> - if (!appPreferences.runServiceInBackground.get()) SimplexService.stop(applicationContext) - Lifecycle.Event.ON_START -> - if (chatModel.chatRunning.value != false) SimplexService.start(applicationContext) - Lifecycle.Event.ON_RESUME -> + Lifecycle.Event.ON_RESUME -> { if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { chatController.showBackgroundServiceNoticeIfNeeded() } + /** + * We're starting service here instead of in [Lifecycle.Event.ON_START] because + * after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed. + * It can happen when app was started and a user enables battery optimization while app in background + * */ + if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name) + SimplexService.start(applicationContext) + } else -> {} } } } fun allowToStartServiceAfterAppExit() = with(chatModel.controller) { - appPrefs.runServiceInBackground.get() && isIgnoringBatteryOptimizations(chatModel.controller.appContext) + appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name && isIgnoringBatteryOptimizations(chatModel.controller.appContext) } + private fun allowToStartPeriodically() = chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name + /* * It takes 1-10 milliseconds to process this function. Better to do it in a background thread * */ @@ -109,6 +115,13 @@ class SimplexApp: Application(), LifecycleEventObserver { WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) } + fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch { + if (!allowToStartPeriodically()) { + return@launch + } + MessagesFetcherWorker.scheduleWork() + } + companion object { lateinit var context: SimplexApp private set diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt index 7a4dd8ea71..c1dc9a7af3 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexService.kt @@ -33,7 +33,6 @@ class SimplexService: Service() { Log.d(TAG, "intent action $action") when (action) { Action.START.name -> startService() - Action.STOP.name -> stopService() else -> Log.e(TAG, "No action in the intent") } } else { @@ -56,7 +55,7 @@ class SimplexService: Service() { Log.d(TAG, "Simplex service destroyed") stopService() - // If private notifications are enabled and battery optimization is disabled, restart the service + // If notification service is enabled and battery optimization is disabled, restart the service if (SimplexApp.context.allowToStartServiceAfterAppExit()) sendBroadcast(Intent(this, AutoRestartReceiver::class.java)) super.onDestroy() @@ -154,7 +153,7 @@ class SimplexService: Service() { // Just to make sure that after restart of the app the user will need to re-authenticate MainActivity.clearAuthState() - // If private notifications aren't enabled or battery optimization isn't disabled, we shouldn't restart the service + // If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service if (!SimplexApp.context.allowToStartServiceAfterAppExit()) { return } @@ -212,7 +211,6 @@ class SimplexService: Service() { enum class Action { START, - STOP } enum class ServiceState { @@ -243,7 +241,7 @@ class SimplexService: Service() { suspend fun start(context: Context) = serviceAction(context, Action.START) - suspend fun stop(context: Context) = serviceAction(context, Action.STOP) + fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java)) private suspend fun serviceAction(context: Context, action: Action) { Log.d(TAG, "SimplexService serviceAction: ${action.name}") diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 8593d903bd..5d4e8c9040 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -12,6 +12,8 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.app.views.usersettings.NotificationPreviewMode +import chat.simplex.app.views.usersettings.NotificationsMode import kotlinx.datetime.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -44,7 +46,8 @@ class ChatModel(val controller: ChatController) { val appOpenUrl = mutableStateOf(null) // preferences - val runServiceInBackground = mutableStateOf(true) + val notificationsMode = mutableStateOf(NotificationsMode.default) + var notificationPreviewMode = mutableStateOf(NotificationPreviewMode.default) val performLA = mutableStateOf(false) val showAdvertiseLAUnavailableAlert = mutableStateOf(false) var incognito = mutableStateOf(false) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index c854183a70..ca0aeb4732 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -12,6 +12,7 @@ import chat.simplex.app.* import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.base64ToBitmap import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.views.usersettings.NotificationPreviewMode import kotlinx.datetime.Clock class NtfManager(val context: Context, private val appPreferences: AppPreferences) { @@ -75,9 +76,12 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs) prevNtfTime[chatId] = now + val previewMode = appPreferences.notificationPreviewMode.get() + val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) generalGetString(R.string.notification_preview_somebody) else displayName + val content = if (previewMode != NotificationPreviewMode.MESSAGE.name) generalGetString(R.string.notification_preview_new_message) else msgText val notification = NotificationCompat.Builder(context, MessageChannel) - .setContentTitle(displayName) - .setContentText(msgText) + .setContentTitle(title) + .setContentText(content) .setPriority(NotificationCompat.PRIORITY_HIGH) .setGroup(MessageGroup) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) @@ -132,8 +136,14 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference if (invitation.sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call } ) + val previewMode = appPreferences.notificationPreviewMode.get() + val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) + generalGetString(R.string.notification_preview_somebody) + else + invitation.contact.displayName + ntfBuilder = ntfBuilder - .setContentTitle(invitation.contact.displayName) + .setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_CALL) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 30da6db484..3eec9ba191 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -26,6 +26,8 @@ import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.app.views.usersettings.NotificationPreviewMode +import chat.simplex.app.views.usersettings.NotificationsMode import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -61,7 +63,12 @@ enum class CallOnLockScreen { class AppPreferences(val context: Context) { private val sharedPreferences: SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) - val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) + // deprecated, remove in 2024 + private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) + val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE, + if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name + ) + val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0) @@ -147,6 +154,8 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" + private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode" + private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode" private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown" private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown" private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay" @@ -181,9 +190,14 @@ private const val MESSAGE_TIMEOUT: Int = 15_000_000 open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context, val appPrefs: AppPreferences) { val chatModel = ChatModel(this) private var receiverStarted = false + var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() + private set init { - chatModel.runServiceInBackground.value = appPrefs.runServiceInBackground.get() + chatModel.notificationsMode.value = + kotlin.runCatching { NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!) }.getOrDefault(NotificationsMode.default) + chatModel.notificationPreviewMode.value = + kotlin.runCatching { NotificationPreviewMode.valueOf(appPrefs.notificationPreviewMode.get()!!) }.getOrDefault(NotificationPreviewMode.default) chatModel.performLA.value = appPrefs.performLA.get() chatModel.incognito.value = appPrefs.incognito.get() } @@ -713,6 +727,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } fun processReceivedMsg(r: CR) { + lastMsgReceivedTimestamp = System.currentTimeMillis() chatModel.terminalItems.add(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { @@ -927,56 +942,66 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager } fun showBackgroundServiceNoticeIfNeeded() { + val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!) Log.d(TAG, "showBackgroundServiceNoticeIfNeeded") if (!appPrefs.backgroundServiceNoticeShown.get()) { // the branch for the new users who have never seen service notice - if (isIgnoringBatteryOptimizations(appContext)) { - showBGServiceNotice() + if (!mode.requiresIgnoringBattery || isIgnoringBatteryOptimizations(appContext)) { + showBGServiceNotice(mode) } else { - showBGServiceNoticeIgnoreOptimization() + showBGServiceNoticeIgnoreOptimization(mode) } // set both flags, so that if the user doesn't allow ignoring optimizations, the service will be disabled without additional notice appPrefs.backgroundServiceNoticeShown.set(true) appPrefs.backgroundServiceBatteryNoticeShown.set(true) - } else if (!isIgnoringBatteryOptimizations(appContext) && appPrefs.runServiceInBackground.get()) { + } else if (mode.requiresIgnoringBattery && !isIgnoringBatteryOptimizations(appContext)) { // the branch for users who have app installed, and have seen the service notice, // but the battery optimization for the app is on (Android 12) AND the service is running if (appPrefs.backgroundServiceBatteryNoticeShown.get()) { // users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service - showDisablingServiceNotice() - appPrefs.runServiceInBackground.set(false) - chatModel.runServiceInBackground.value = false + showDisablingServiceNotice(mode) + appPrefs.notificationsMode.set(NotificationsMode.OFF.name) + chatModel.notificationsMode.value = NotificationsMode.OFF SimplexService.StartReceiver.toggleReceiver(false) + MessagesFetcherWorker.cancelAll() + SimplexService.stop(SimplexApp.context) } else { // show battery optimization notice - showBGServiceNoticeIgnoreOptimization() + showBGServiceNoticeIgnoreOptimization(mode) appPrefs.backgroundServiceBatteryNoticeShown.set(true) } } else { - // service is allowed and battery optimization is disabled + // service or periodic mode was chosen and battery optimization is disabled SimplexApp.context.schedulePeriodicServiceRestartWorker() + SimplexApp.context.schedulePeriodicWakeUp() } } - private fun showBGServiceNotice() = AlertManager.shared.showAlert { + private fun showBGServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert { AlertDialog( onDismissRequest = AlertManager.shared::hideAlert, title = { Row { Icon( Icons.Outlined.Bolt, - contentDescription = stringResource(R.string.icon_descr_instant_notifications), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications), + fontWeight = FontWeight.Bold ) - Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { Column { Text( - annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), + if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc), Modifier.padding(bottom = 8.dp) ) - Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)) + Text( + annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown) + ) } }, confirmButton = { @@ -985,7 +1010,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager ) } - private fun showBGServiceNoticeIgnoreOptimization() = AlertManager.shared.showAlert { + private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode) = AlertManager.shared.showAlert { val ignoreOptimization = { AlertManager.shared.hideAlert() askAboutIgnoringBatteryOptimization(appContext) @@ -996,15 +1021,19 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager Row { Icon( Icons.Outlined.Bolt, - contentDescription = stringResource(R.string.icon_descr_instant_notifications), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications) else stringResource(R.string.periodic_notifications), + fontWeight = FontWeight.Bold ) - Text(stringResource(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { Column { Text( - annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), + if (mode == NotificationsMode.SERVICE) annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(R.string.periodic_notifications_desc), Modifier.padding(bottom = 8.dp) ) Text(annotatedStringResource(R.string.turn_off_battery_optimization)) @@ -1016,22 +1045,26 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager ) } - private fun showDisablingServiceNotice() = AlertManager.shared.showAlert { + private fun showDisablingServiceNotice(mode: NotificationsMode) = AlertManager.shared.showAlert { AlertDialog( onDismissRequest = AlertManager.shared::hideAlert, title = { Row { Icon( Icons.Outlined.Bolt, - contentDescription = stringResource(R.string.icon_descr_instant_notifications), + contentDescription = + if (mode == NotificationsMode.SERVICE) stringResource(R.string.icon_descr_instant_notifications) else stringResource(R.string.periodic_notifications), + ) + Text( + if (mode == NotificationsMode.SERVICE) stringResource(R.string.service_notifications_disabled) else stringResource(R.string.periodic_notifications_disabled), + fontWeight = FontWeight.Bold ) - Text(stringResource(R.string.private_instant_notifications_disabled), fontWeight = FontWeight.Bold) } }, text = { Column { Text( - annotatedStringResource(R.string.turning_off_background_service), + annotatedStringResource(R.string.turning_off_service_and_periodic), Modifier.padding(bottom = 8.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt new file mode 100644 index 0000000000..aff69665ad --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt @@ -0,0 +1,83 @@ +package chat.simplex.app.views.helpers + +import android.content.Context +import android.util.Log +import androidx.work.* +import chat.simplex.app.* +import kotlinx.coroutines.* +import java.util.Date +import java.util.concurrent.TimeUnit + +object MessagesFetcherWorker { + private const val UNIQUE_WORK_TAG = BuildConfig.APPLICATION_ID + ".UNIQUE_MESSAGES_FETCHER" + + fun scheduleWork(intervalSec: Int = 600, durationSec: Int = 60) { + val initialDelaySec = intervalSec.toLong() + Log.d(TAG, "Worker: scheduling work to run at ${Date(System.currentTimeMillis() + initialDelaySec * 1000)} for $durationSec sec") + val periodicWorkRequest = OneTimeWorkRequest.Builder(MessagesFetcherWork::class.java) + .setInitialDelay(initialDelaySec, TimeUnit.SECONDS) + .setInputData( + Data.Builder() + .putInt(MessagesFetcherWork.INPUT_DATA_INTERVAL, intervalSec) + .putInt(MessagesFetcherWork.INPUT_DATA_DURATION, durationSec) + .build() + ) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + + WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest) + } + + fun cancelAll() { + Log.d(TAG, "Worker: canceled all tasks") + WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG) + } +} + +class MessagesFetcherWork( + context: Context, + workerParams: WorkerParameters +): CoroutineWorker(context, workerParams) { + companion object { + const val INPUT_DATA_INTERVAL = "interval" + const val INPUT_DATA_DURATION = "duration" + private const val WAIT_AFTER_LAST_MESSAGE: Long = 10_000 + } + + override suspend fun doWork(): Result { + // Skip when Simplex service is currently working + if (SimplexService.getServiceState(SimplexApp.context) == SimplexService.ServiceState.STARTED) { + reschedule() + return Result.success() + } + val durationSeconds = inputData.getInt(INPUT_DATA_DURATION, 60) + try { + withTimeout(durationSeconds * 1000L) { + val chatController = (applicationContext as SimplexApp).chatController + val user = chatController.apiGetActiveUser() ?: return@withTimeout + Log.w(TAG, "Worker: starting work") + chatController.startChat(user) + // Give some time to start receiving messages + delay(10_000) + while (!isStopped) { + if (chatController.lastMsgReceivedTimestamp + WAIT_AFTER_LAST_MESSAGE < System.currentTimeMillis()) { + Log.d(TAG, "Worker: work is done") + break + } + delay(5000) + } + } + } catch (_: TimeoutCancellationException) { // When timeout happens + Log.d(TAG, "Worker: work is done (took $durationSeconds sec)") + } catch (_: CancellationException) { // When user opens the app while the worker is still working + Log.d(TAG, "Worker: interrupted") + } catch (e: Exception) { + Log.d(TAG, "Worker: unexpected exception: ${e.stackTraceToString()}") + } + + reschedule() + return Result.success() + } + + private fun reschedule() = MessagesFetcherWorker.scheduleWork() +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt new file mode 100644 index 0000000000..c3b519f0dc --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt @@ -0,0 +1,274 @@ +package chat.simplex.app.views.usersettings + +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.* +import kotlin.collections.ArrayList + +enum class NotificationsMode(val requiresIgnoringBattery: Boolean) { + OFF(false), PERIODIC(false), SERVICE(true), /*INSTANT(false) - for Firebase notifications */; + + companion object { + val default: NotificationsMode = SERVICE + } +} + +enum class NotificationPreviewMode { + MESSAGE, CONTACT, HIDDEN; + + companion object { + val default: NotificationPreviewMode = MESSAGE + } +} + +@Composable +fun NotificationsSettingsView( + chatModel: ChatModel, + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), +) { + val onNotificationsModeSelected = { mode: NotificationsMode -> + chatModel.controller.appPrefs.notificationsMode.set(mode.name) + if (mode.requiresIgnoringBattery && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { + chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false) + } + chatModel.notificationsMode.value = mode + SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + CoroutineScope(Dispatchers.Default).launch { + if (mode == NotificationsMode.SERVICE) + SimplexService.start(SimplexApp.context) + else + SimplexService.stop(SimplexApp.context) + } + + if (mode != NotificationsMode.PERIODIC) { + MessagesFetcherWorker.cancelAll() + } + chatModel.controller.showBackgroundServiceNoticeIfNeeded() + } + val onNotificationPreviewModeSelected = { mode: NotificationPreviewMode -> + chatModel.controller.appPrefs.notificationPreviewMode.set(mode.name) + chatModel.notificationPreviewMode.value = mode + } + + NotificationsSettingsLayout( + notificationsMode = chatModel.notificationsMode, + notificationPreviewMode = chatModel.notificationPreviewMode, + showPage = { page -> + showCustomModal { _, close -> + ModalView( + close = close, modifier = Modifier, + background = if (isInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + when (page) { + CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode, onNotificationsModeSelected) + CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected) + } + } + }() + }, + ) +} + +enum class CurrentPage { + NOTIFICATIONS_MODE, NOTIFICATION_PREVIEW_MODE +} + +@Composable +fun NotificationsSettingsLayout( + notificationsMode: State, + notificationPreviewMode: State, + showPage: (CurrentPage) -> Unit, +) { + val modes = remember { notificationModes() } + val previewModes = remember { notificationPreviewModes() } + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.notifications), + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + SectionView(null) { + Column( + Modifier.padding(horizontal = 8.dp) + ) { + SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATIONS_MODE) }, padding = PaddingValues()) { + Text(stringResource(R.string.settings_notifications_mode_title)) + Spacer(Modifier.padding(horizontal = 10.dp)) + Text( + modes.first { it.first == notificationsMode.value }.second, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = HighOrLowlight + ) + } + Spacer(Modifier.padding(horizontal = 4.dp)) + SectionItemViewSpaceBetween({ showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }, padding = PaddingValues()) { + Text(stringResource(R.string.settings_notification_preview_mode_title)) + Spacer(Modifier.padding(horizontal = 10.dp)) + Text( + previewModes.first { it.first == notificationPreviewMode.value }.second, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = HighOrLowlight + ) + } + } + } + } +} + +@Composable +fun NotificationsModeView( + notificationsMode: State, + onNotificationsModeSelected: (NotificationsMode) -> Unit, +) { + val modes = remember { notificationModes() } + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.settings_notifications_mode_title).lowercase().capitalize(Locale.current), + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + SectionView(null) { + LazyColumn( + Modifier.padding(horizontal = 8.dp) + ) { + items(modes.size) { index -> + val item = modes[index] + val onClick = { + onNotificationsModeSelected(item.first) + } + SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) { + Text(item.second) + if (notificationsMode.value == item.first) { + Icon(Icons.Outlined.Check, item.second, tint = HighOrLowlight) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + } + } + } + SectionTextFooter(modes.first { it.first == notificationsMode.value }.third) + } +} + +@Composable +fun NotificationPreviewView( + notificationPreviewMode: State, + onNotificationPreviewModeSelected: (NotificationPreviewMode) -> Unit, +) { + val previewModes = remember { notificationPreviewModes() } + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Text( + stringResource(R.string.settings_notification_preview_title), + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + style = MaterialTheme.typography.h1 + ) + + SectionView(null) { + LazyColumn( + Modifier.padding(horizontal = 8.dp) + ) { + items(previewModes.size) { index -> + val item = previewModes[index] + val onClick = { + onNotificationPreviewModeSelected(item.first) + } + SectionItemViewSpaceBetween(onClick, padding = PaddingValues()) { + Text(item.second) + if (notificationPreviewMode.value == item.first) { + Icon(Icons.Outlined.Check, item.second, tint = HighOrLowlight) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + } + } + } + SectionTextFooter(previewModes.first { it.first == notificationPreviewMode.value }.third) + } +} + +// mode, name, description +fun notificationModes(): List> { + val res = ArrayList>() + res.add( + Triple( + NotificationsMode.OFF, + generalGetString(R.string.notifications_mode_off), + generalGetString(R.string.notifications_mode_off_desc), + ) + ) + res.add( + Triple( + NotificationsMode.PERIODIC, + generalGetString(R.string.notifications_mode_periodic), + generalGetString(R.string.notifications_mode_periodic_desc), + ) + ) + res.add( + Triple( + NotificationsMode.SERVICE, + generalGetString(R.string.notifications_mode_service), + generalGetString(R.string.notifications_mode_service_desc), + ) + ) + return res +} + +// preview mode, name, description +fun notificationPreviewModes(): List> { + val res = ArrayList>() + res.add( + Triple( + NotificationPreviewMode.MESSAGE, + generalGetString(R.string.notification_preview_mode_message), + generalGetString(R.string.notification_preview_mode_message_desc), + ) + ) + res.add( + Triple( + NotificationPreviewMode.CONTACT, + generalGetString(R.string.notification_preview_mode_contact), + generalGetString(R.string.notification_preview_mode_contact_desc), + ) + ) + res.add( + Triple( + NotificationPreviewMode.HIDDEN, + generalGetString(R.string.notification_preview_mode_hidden), + generalGetString(R.string.notification_display_mode_hidden_desc), + ) + ) + return res +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index e7c453ef0f..d74d2e9477 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -40,25 +40,13 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { MaintainIncognitoState(chatModel) - fun setRunServiceInBackground(on: Boolean) { - chatModel.controller.appPrefs.runServiceInBackground.set(on) - if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) { - chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false) - } - chatModel.controller.showBackgroundServiceNoticeIfNeeded() - chatModel.runServiceInBackground.value = on - SimplexService.StartReceiver.toggleReceiver(on) - } - if (user != null) { SettingsLayout( profile = user.profile, stopped, chatModel.incognito, chatModel.controller.appPrefs.incognito, - runServiceInBackground = chatModel.runServiceInBackground, developerTools = chatModel.controller.appPrefs.developerTools, - setRunServiceInBackground = ::setRunServiceInBackground, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, showSettingsModal = { modalView -> { ModalManager.shared.showCustomModal { close -> @@ -93,9 +81,7 @@ fun SettingsLayout( stopped: Boolean, incognito: MutableState, incognitoPref: Preference, - runServiceInBackground: MutableState, developerTools: Preference, - setRunServiceInBackground: (Boolean) -> Unit, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -132,7 +118,7 @@ fun SettingsLayout( SectionSpacer() SectionView(stringResource(R.string.settings_section_title_settings)) { - PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped) + SettingsActionItem(Icons.Outlined.Bolt, stringResource(R.string.notifications), showSettingsModal { NotificationsSettingsView(it, showCustomModal) }) SectionDivider() SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped) SectionDivider() @@ -240,41 +226,6 @@ fun MaintainIncognitoState(chatModel: ChatModel) { } } -@Composable private fun PrivateNotificationsItem( - runServiceInBackground: MutableState, - setRunServiceInBackground: (Boolean) -> Unit, - stopped: Boolean -) { - SectionItemView(disabled = stopped) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Outlined.Bolt, - contentDescription = stringResource(R.string.private_notifications), - tint = HighOrLowlight, - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - stringResource(R.string.private_notifications), - Modifier - .padding(end = 24.dp) - .fillMaxWidth() - .weight(1f), - color = if (stopped) HighOrLowlight else Color.Unspecified - ) - Switch( - checked = runServiceInBackground.value, - onCheckedChange = { setRunServiceInBackground(it) }, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colors.primary, - uncheckedThumbColor = HighOrLowlight - ), - modifier = Modifier.padding(end = 6.dp), - enabled = !stopped - ) - } - } -} - @Composable fun ChatLockItem(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { SectionItemView() { Row(verticalAlignment = Alignment.CenterVertically) { @@ -406,9 +357,7 @@ fun PreviewSettingsLayout() { stopped = false, incognito = remember { mutableStateOf(false) }, incognitoPref = Preference({ false}, {}), - runServiceInBackground = remember { mutableStateOf(true) }, developerTools = Preference({ false }, {}), - setRunServiceInBackground = {}, setPerformLA = {}, showModal = { {} }, showSettingsModal = { {} }, diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 928cf18d0d..51b80d3f74 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -58,18 +58,40 @@ Мгновенные уведомления - Приватные мгновенные уведомления! - Приватные уведомления выключены! + Мгновенные уведомления! + Мгновенные уведомления выключены! Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает фоновый сервис SimpleX, который потребляет несколько процентов батареи в день. Он может быть выключен через Настройки – вы продолжите получать уведомления о сообщениях пока приложение запущено. - Для использования приватных уведомлений, пожалуйста отключите оптимизацию батареи для SimpleX в слеующем диалоге. Иначе уведомления будут выключены. - Оптимизация батареи включена, поэтому приватные уведомления выключены. Вы можете снова включить их через Настройки. + Для использования этой функции, пожалуйста, отключите оптимизацию батареи для SimpleX в следующем диалоге. Иначе уведомления будут выключены. + Оптимизация батареи включена, поэтому сервис уведомлений выключен. Вы можете снова включить его через Настройки. + Периодические уведомления + Периодические уведомления выключены! + Приложение периодически получает новые сообщения — это потребляет несколько процентов батареи в день. Приложение не использует push уведомления — данные не отправляются с вашего устройства на сервер. SimpleX Chat сервис Приём сообщений… Скрыть + + Сервис уведомлений + Показывать уведомления + Показывать уведомления + Включен, когда приложение открыто + Запускается периодически + Всегда включен + Приложение может получить сообщение только тогда, когда запущено, в фоне сервис запускаться не будет + Проверять новые сообщения раз в 10 минут на протяжении до 1 минуты + Фоновый сервис всегда включен. Уведомления будут показаны без задержки при наличии сообщений + Текст сообщения + Имена контактов + Скрытое + Показывать контакт и сообщение + Показывать только контакт + Скрывать контакт и сообщение + Контакт скрыт: + новое сообщение + Блокировка SimpleX Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки. @@ -276,7 +298,6 @@ Форматирование сообщений Соединиться с разработчиками Отправить email - Приватные уведомления Блокировка SimpleX Консоль SMP серверы diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 9e3651b5dd..ffa77d9582 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -58,18 +58,40 @@ Instant notifications - Private instant notifications! - Private notifications disabled! + Instant notifications! + Instant notifications are disabled! To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. It can be disabled via settings – notifications will still be shown while the app is running. In order to use it, please disable battery optimization for SimpleX in the next dialog. Otherwise, the notifications will be disabled. - Battery optimization is active, turning off Private notifications. You can re-enable them via settings. + Battery optimization is active, turning off background service and periodic requests for new messages. You can re-enable them via settings. + Periodic notifications + Periodic notifications are disabled! + The app fetches new messages periodically — it uses a few percent of the battery per day. The app doesn\'t use push notifications — data from your device is not sent to the servers. SimpleX Chat service Receiving messages… Hide + + Notification service + Show preview + Notification preview + Runs when app is open + Starts periodically + Always on + 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. + Message text + Contact name + Hidden + Show contact and message + Show only contact + Hide contact and message + Contact hidden: + new message + SimpleX Lock To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled. @@ -280,7 +302,6 @@ Markdown in messages Connect to the developers Send us email - Private notifications SimpleX Lock Chat console SMP servers