a lot of improvements for long polling service and notifications

This commit is contained in:
2024-07-15 05:01:54 +03:00
parent 9481541806
commit 654f47bb94
28 changed files with 473 additions and 388 deletions
-1
View File
@@ -182,5 +182,4 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization) implementation(libs.kotlin.serialization)
implementation("androidx.compose.material3.adaptive:adaptive:1.0.0-beta04")
} }
@@ -1,18 +1,24 @@
package com.meloda.app.fast package com.meloda.app.fast
import android.os.Build
import android.util.Log import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.meloda.app.fast.auth.AuthGraph
import com.meloda.app.fast.common.UserConfig import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.extensions.ifEmpty
import com.meloda.app.fast.common.extensions.listenValue
import com.meloda.app.fast.common.extensions.setValue import com.meloda.app.fast.common.extensions.setValue
import com.meloda.app.fast.common.extensions.updateValue import com.meloda.app.fast.data.db.GetCurrentAccountUseCase
import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.datastore.SettingsController import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.model.BaseError import com.meloda.app.fast.model.BaseError
import com.meloda.app.fast.model.LongPollState import com.meloda.app.fast.navigation.Main
import com.meloda.app.fast.model.MainScreenState
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -21,30 +27,33 @@ import kotlinx.coroutines.launch
interface MainViewModel { interface MainViewModel {
val screenState: StateFlow<MainScreenState> val startDestination: StateFlow<Any?>
val isNeedToOpenAuth: StateFlow<Boolean> val isNeedToReplaceWithAuth: StateFlow<Boolean>
val longPollState: StateFlow<LongPollState> val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val startOnlineService: StateFlow<Boolean> val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
val isNeedToCheckNotificationsPermission: StateFlow<Boolean>
fun useDynamicColorsChanged(use: Boolean) val isNeedToRequestNotifications: StateFlow<Boolean>
fun useDarkThemeChanged(use: Boolean)
fun onRequestNotificationsPermissionClicked(fromRationale: Boolean)
fun onNotificationsAlertNegativeClicked()
fun onNotificationsRequested()
fun onAppPermissionsOpened()
fun onError(error: BaseError) fun onError(error: BaseError)
fun onNavigatedToAuth() fun onNavigatedToAuth()
fun onAppResumed()
@OptIn(ExperimentalPermissionsApi::class)
fun onPermissionCheckStatus(status: PermissionStatus)
fun onPermissionsRequested()
fun onNotificationsDeniedDialogConfirmClicked()
fun onNotificationsDeniedDialogCancelClicked()
fun onNotificationsDeniedDialogDismissed()
fun onNotificationsRationaleDialogDismissed()
fun onNotificationsRationaleDialogCancelClicked()
} }
class MainViewModelImpl( class MainViewModelImpl(
private val accountsRepository: AccountsRepository, private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
private val userSettings: UserSettings private val userSettings: UserSettings
) : MainViewModel, ViewModel() { ) : MainViewModel, ViewModel() {
@@ -52,99 +61,137 @@ class MainViewModelImpl(
loadAccounts() loadAccounts()
} }
override val screenState = MutableStateFlow(MainScreenState.EMPTY) override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToOpenAuth = MutableStateFlow(false) override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val longPollState = MutableStateFlow( override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
if (SettingsController.getBoolean( override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, override val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND override val isNeedToRequestNotifications = MutableStateFlow(false)
)
) {
LongPollState.ForegroundService
} else {
LongPollState.DefaultService
}
)
override val startOnlineService = MutableStateFlow(
SettingsController.getBoolean(
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS,
SettingsKeys.DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS
)
)
override fun useDynamicColorsChanged(use: Boolean) {
screenState.updateValue(screenState.value.copy(useDynamicColors = use))
}
override fun useDarkThemeChanged(use: Boolean) {
screenState.updateValue(screenState.value.copy(useDarkTheme = use))
}
override fun onRequestNotificationsPermissionClicked(fromRationale: Boolean) {
screenState.setValue { old ->
if (fromRationale) {
old.copy(isNeedToOpenAppPermissions = true)
} else {
old.copy(isNeedToRequestNotifications = true)
}
}
}
override fun onNotificationsAlertNegativeClicked() {
SettingsController.edit {
putBoolean(
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
false
)
}
userSettings.setLongPollBackground(false)
}
override fun onNotificationsRequested() {
screenState.setValue { old -> old.copy(isNeedToRequestNotifications = false) }
}
override fun onAppPermissionsOpened() {
screenState.setValue { old -> old.copy(isNeedToOpenAppPermissions = false) }
}
override fun onError(error: BaseError) { override fun onError(error: BaseError) {
when (error) { when (error) {
BaseError.SessionExpired -> { BaseError.SessionExpired -> {
isNeedToOpenAuth.update { true } isNeedToReplaceWithAuth.update { true }
} }
} }
} }
override fun onNavigatedToAuth() { override fun onNavigatedToAuth() {
isNeedToOpenAuth.update { false } isNeedToReplaceWithAuth.update { false }
}
override fun onAppResumed() {
if (isNeedToShowNotificationsRationaleDialog.value) {
isNeedToCheckNotificationsPermission.update { true }
}
userSettings.onLanguageChanged(
AppCompatDelegate.getApplicationLocales()
.toLanguageTags()
.ifEmpty { null }
?: LocaleListCompat.getDefault()
.toLanguageTags()
.split(",")
.firstOrNull()
.orEmpty()
.take(5)
)
userSettings.updateUsingDarkTheme()
}
@ExperimentalPermissionsApi
override fun onPermissionCheckStatus(status: PermissionStatus) {
isNeedToCheckNotificationsPermission.update { false }
when (status) {
is PermissionStatus.Denied -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (status.shouldShowRationale) {
isNeedToShowNotificationsRationaleDialog.update { true }
} else {
isNeedToShowNotificationsDeniedDialog.update { true }
}
}
PermissionStatus.Granted -> {
if (isNeedToShowNotificationsRationaleDialog.value) {
isNeedToShowNotificationsRationaleDialog.update { false }
}
}
}
}
override fun onPermissionsRequested() {
isNeedToRequestNotifications.update { false }
}
override fun onNotificationsDeniedDialogConfirmClicked() {
isNeedToRequestNotifications.update { true }
}
override fun onNotificationsDeniedDialogCancelClicked() {
isNeedToShowNotificationsDeniedDialog.update { false }
disableBackgroundLongPoll()
}
override fun onNotificationsDeniedDialogDismissed() {
isNeedToShowNotificationsDeniedDialog.update { false }
}
override fun onNotificationsRationaleDialogDismissed() {
isNeedToShowNotificationsRationaleDialog.update { false }
}
override fun onNotificationsRationaleDialogCancelClicked() {
isNeedToShowNotificationsRationaleDialog.update { false }
disableBackgroundLongPoll()
}
private fun listenLongPollState() {
userSettings.longPollStateToApply.listenValue { newState ->
if (newState == LongPollState.Background) {
isNeedToCheckNotificationsPermission.update { true }
}
}
} }
private fun loadAccounts() { private fun loadAccounts() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val accounts = accountsRepository.getAccounts() val currentAccount = getCurrentAccountUseCase()
Log.d("MainViewModel", "initUserConfig: accounts: $accounts") Log.d("MainViewModel", "currentAccount: $currentAccount")
if (accounts.isNotEmpty()) { listenLongPollState()
val currentAccount = accounts.find { it.userId == UserConfig.currentUserId }
if (currentAccount != null) { if (currentAccount != null) {
UserConfig.apply { UserConfig.apply {
this.userId = currentAccount.userId this.userId = currentAccount.userId
this.accessToken = currentAccount.accessToken this.accessToken = currentAccount.accessToken
this.fastToken = currentAccount.fastToken this.fastToken = currentAccount.fastToken
this.trustedHash = currentAccount.trustedHash this.trustedHash = currentAccount.trustedHash
}
} }
userSettings.setLongPollStateToApply(
if (SettingsController.isLongPollInBackgroundEnabled) {
LongPollState.Background
} else {
LongPollState.InApp
}
)
} }
screenState.setValue { old -> startDestination.setValue {
old.copy( if (currentAccount == null) AuthGraph
accounts = accounts, else Main
accountsLoaded = true
)
} }
} }
} }
private fun disableBackgroundLongPoll() {
SettingsController.isLongPollInBackgroundEnabled = false
userSettings.setLongPollStateToApply(LongPollState.InApp)
}
} }
@@ -1,7 +0,0 @@
package com.meloda.app.fast.model
sealed class LongPollState {
data object ForegroundService : LongPollState()
data object DefaultService : LongPollState()
data object Stop : LongPollState()
}
@@ -1,26 +0,0 @@
package com.meloda.app.fast.model
import androidx.compose.runtime.Immutable
import com.meloda.app.fast.model.database.AccountEntity
@Immutable
data class MainScreenState(
val accounts: List<AccountEntity>,
val accountsLoaded: Boolean,
val useDarkTheme: Boolean,
val useDynamicColors: Boolean,
val isNeedToRequestNotifications: Boolean,
val isNeedToOpenAppPermissions: Boolean
) {
companion object {
val EMPTY: MainScreenState = MainScreenState(
accounts = emptyList(),
accountsLoaded = false,
useDarkTheme = false,
useDynamicColors = false,
isNeedToRequestNotifications = false,
isNeedToOpenAppPermissions = false
)
}
}
@@ -1,7 +0,0 @@
package com.meloda.app.fast.model
sealed class ServicesState {
data object Started : ServicesState()
data object Stopped : ServicesState()
data object Unknown : ServicesState()
}
@@ -6,17 +6,13 @@ import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.util.Log
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -26,34 +22,33 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowWidthSizeClass import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.meloda.app.fast.MainViewModel import com.meloda.app.fast.MainViewModel
import com.meloda.app.fast.common.UiText import com.meloda.app.fast.MainViewModelImpl
import com.meloda.app.fast.common.extensions.ifEmpty import com.meloda.app.fast.common.AppConstants
import com.meloda.app.fast.common.extensions.isSdkAtLeast import com.meloda.app.fast.common.extensions.isSdkAtLeast
import com.meloda.app.fast.datastore.SettingsController import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.datastore.model.ThemeConfig import com.meloda.app.fast.datastore.model.ThemeConfig
import com.meloda.app.fast.designsystem.AppTheme import com.meloda.app.fast.designsystem.AppTheme
import com.meloda.app.fast.designsystem.CheckPermission
import com.meloda.app.fast.designsystem.LocalTheme import com.meloda.app.fast.designsystem.LocalTheme
import com.meloda.app.fast.designsystem.MaterialDialog
import com.meloda.app.fast.designsystem.RequestPermission
import com.meloda.app.fast.model.MainScreenState
import com.meloda.app.fast.service.OnlineService import com.meloda.app.fast.service.OnlineService
import com.meloda.app.fast.service.longpolling.LongPollingService import com.meloda.app.fast.service.longpolling.LongPollingService
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext import org.koin.compose.KoinContext
import org.koin.compose.koinInject import org.koin.compose.koinInject
import com.meloda.app.fast.designsystem.R as UiR import com.meloda.app.fast.designsystem.R as UiR
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -76,30 +71,64 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
KoinContext { KoinContext {
val context = LocalContext.current
val userSettings: UserSettings = koinInject() val userSettings: UserSettings = koinInject()
LifecycleResumeEffect(true) { val longPollCurrentState by userSettings.longPollCurrentState.collectAsStateWithLifecycle()
userSettings.onLanguageChanged( val longPollStateToApply by userSettings.longPollStateToApply.collectAsStateWithLifecycle()
AppCompatDelegate.getApplicationLocales()
.toLanguageTags()
.ifEmpty { null }
?: LocaleListCompat.getDefault()
.toLanguageTags()
.split(",")
.firstOrNull()
.orEmpty()
.take(5)
)
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LifecycleResumeEffect(true) {
viewModel.onAppResumed()
onPauseOrDispose {} onPauseOrDispose {}
} }
LaunchedEffect(true) { val permissionState =
userSettings.updateUsingDarkTheme() rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
toggleLongPollService(
enable = true,
inBackground = true
)
}
}
} }
val isLongPollInBackground by userSettings.longPollBackground.collectAsStateWithLifecycle() LaunchedEffect(isNeedToRequestPermission) {
toggleLongPollService(true, isLongPollInBackground) if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LaunchedEffect(longPollStateToApply) {
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
)
}
}
val isOnline by userSettings.online.collectAsStateWithLifecycle() val isOnline by userSettings.online.collectAsStateWithLifecycle()
LifecycleResumeEffect(isOnline) { LifecycleResumeEffect(isOnline) {
@@ -110,11 +139,9 @@ class MainActivity : AppCompatActivity() {
} }
} }
val windowAdaptiveInfo = currentWindowAdaptiveInfo() val isDeviceCompact by remember(true) {
val isDeviceCompact by remember(windowAdaptiveInfo) {
derivedStateOf { derivedStateOf {
windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT context.resources.displayMetrics.widthPixels.pxToDp() <= 360
} }
} }
@@ -138,7 +165,7 @@ class MainActivity : AppCompatActivity() {
selectedColorScheme = currentTheme.selectedColorScheme, selectedColorScheme = currentTheme.selectedColorScheme,
useAmoledBackground = currentTheme.usingAmoledBackground, useAmoledBackground = currentTheme.usingAmoledBackground,
) { ) {
RootScreen() RootScreen(viewModel = viewModel)
} }
} }
} }
@@ -147,40 +174,37 @@ class MainActivity : AppCompatActivity() {
private fun createNotificationChannels() { private fun createNotificationChannels() {
isSdkAtLeast(Build.VERSION_CODES.O) { isSdkAtLeast(Build.VERSION_CODES.O) {
val dialogsName = "Dialogs" val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
val dialogsDescriptionText = "Channel for dialogs notifications" val noCategoryDescriptionText = getString(UiR.string.notification_channel_no_category_description)
val dialogsImportance = NotificationManager.IMPORTANCE_HIGH val noCategoryImportance = NotificationManager.IMPORTANCE_HIGH
val dialogsChannel = val noCategoryChannel =
NotificationChannel("simple_notifications", dialogsName, dialogsImportance).apply { NotificationChannel(AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED, noCategoryName, noCategoryImportance).apply {
description = dialogsDescriptionText description = noCategoryDescriptionText
} }
val longPollName = "Long Polling" val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
val longPollDescriptionText = "Channel for long polling service" val longPollDescriptionText = getString(UiR.string.notification_channel_long_polling_service_description)
val longPollImportance = NotificationManager.IMPORTANCE_NONE val longPollImportance = NotificationManager.IMPORTANCE_NONE
val longPollChannel = val longPollChannel =
NotificationChannel("long_polling", longPollName, longPollImportance).apply { NotificationChannel(AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING, longPollName, longPollImportance).apply {
description = longPollDescriptionText description = longPollDescriptionText
} }
val notificationManager: NotificationManager = val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels(listOf(dialogsChannel, longPollChannel)) notificationManager.createNotificationChannels(listOf(noCategoryChannel, longPollChannel))
} }
} }
private fun toggleLongPollService( private fun toggleLongPollService(
enable: Boolean, enable: Boolean,
asForeground: Boolean = SettingsController.getBoolean( inBackground: Boolean = SettingsController.isLongPollInBackgroundEnabled
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
)
) { ) {
if (enable) { if (enable) {
val longPollIntent = Intent(this, LongPollingService::class.java) val longPollIntent = Intent(this, LongPollingService::class.java)
if (asForeground) { if (inBackground) {
ContextCompat.startForegroundService(this, longPollIntent) ContextCompat.startForegroundService(this, longPollIntent)
} else { } else {
startService(longPollIntent) startService(longPollIntent)
@@ -201,104 +225,15 @@ class MainActivity : AppCompatActivity() {
private fun stopServices() { private fun stopServices() {
toggleOnlineService(enable = false) toggleOnlineService(enable = false)
val asForeground = SettingsController.getBoolean( val asForeground = SettingsController.isLongPollInBackgroundEnabled
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
)
if (!asForeground) { if (!asForeground) {
toggleLongPollService(enable = false) toggleLongPollService(enable = false)
} }
} }
private fun setNewLanguage(newLanguage: String) {
val appLocales = AppCompatDelegate.getApplicationLocales()
if (newLanguage.isEmpty()) {
if (!appLocales.isEmpty) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
}
} else {
if (!appLocales.toLanguageTags().startsWith(newLanguage)) {
val newLocale = LocaleListCompat.forLanguageTags(newLanguage)
AppCompatDelegate.setApplicationLocales(newLocale)
}
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
stopServices() stopServices()
} }
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationsPermissionChecker(
screenState: MainScreenState,
viewModel: MainViewModel
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val permission =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
if (screenState.isNeedToOpenAppPermissions) {
viewModel.onAppPermissionsOpened()
LocalContext.current.apply {
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", this.packageName, null)
)
)
}
}
if (screenState.isNeedToRequestNotifications) {
RequestPermission(permission = permission)
}
val isNeedToCheckNotificationsPermission by remember {
derivedStateOf {
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
SettingsController.getBoolean(
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
))
}
}
if (isNeedToCheckNotificationsPermission) {
CheckPermission(
showRationale = {
MaterialDialog(
title = UiText.Resource(UiR.string.warning),
text = UiText.Simple("The application will not be able to work properly without permission to send notifications."),
confirmText = UiText.Simple("Grant"),
confirmAction = {
viewModel.onRequestNotificationsPermissionClicked(true)
},
cancelText = UiText.Resource(UiR.string.cancel),
cancelAction = viewModel::onNotificationsAlertNegativeClicked,
onDismissAction = viewModel::onNotificationsAlertNegativeClicked,
buttonsInvokeDismiss = false
)
},
onDenied = {
MaterialDialog(
title = UiText.Resource(UiR.string.warning),
text = UiText.Simple("The application needs permission to send notifications to update messages and other information."),
confirmText = UiText.Simple("Grant"),
confirmAction = {
viewModel.onRequestNotificationsPermissionClicked(false)
},
cancelText = UiText.Resource(UiR.string.cancel),
onDismissAction = {},
buttonsInvokeDismiss = false
)
},
permission = permission
)
}
}
@@ -1,12 +1,20 @@
package com.meloda.app.fast.presentation package com.meloda.app.fast.presentation
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -14,12 +22,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.meloda.app.fast.MainViewModel import com.meloda.app.fast.MainViewModel
import com.meloda.app.fast.MainViewModelImpl import com.meloda.app.fast.MainViewModelImpl
import com.meloda.app.fast.auth.AuthGraph
import com.meloda.app.fast.auth.authNavGraph import com.meloda.app.fast.auth.authNavGraph
import com.meloda.app.fast.auth.navigateToAuth import com.meloda.app.fast.auth.navigateToAuth
import com.meloda.app.fast.chatmaterials.navigation.chatMaterialsScreen import com.meloda.app.fast.chatmaterials.navigation.chatMaterialsScreen
import com.meloda.app.fast.chatmaterials.navigation.navigateToChatMaterials import com.meloda.app.fast.chatmaterials.navigation.navigateToChatMaterials
import com.meloda.app.fast.common.UserConfig import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.languagepicker.navigation.languagePickerScreen import com.meloda.app.fast.languagepicker.navigation.languagePickerScreen
import com.meloda.app.fast.languagepicker.navigation.navigateToLanguagePicker import com.meloda.app.fast.languagepicker.navigation.navigateToLanguagePicker
import com.meloda.app.fast.messageshistory.navigation.messagesHistoryScreen import com.meloda.app.fast.messageshistory.navigation.messagesHistoryScreen
@@ -31,10 +38,15 @@ import com.meloda.app.fast.settings.navigation.settingsScreen
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun RootScreen(navController: NavHostController = rememberNavController()) { fun RootScreen(
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() navController: NavHostController = rememberNavController(),
val screenState by viewModel.screenState.collectAsStateWithLifecycle() viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val isNeedToOpenAuth by viewModel.isNeedToOpenAuth.collectAsStateWithLifecycle() ) {
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenAuth) { LaunchedEffect(isNeedToOpenAuth) {
if (isNeedToOpenAuth) { if (isNeedToOpenAuth) {
@@ -43,14 +55,63 @@ fun RootScreen(navController: NavHostController = rememberNavController()) {
} }
} }
if (screenState.accountsLoaded) { if (isNeedToShowDeniedDialog) {
val isNeedToShowConversations = remember { AlertDialog(
screenState.accounts.isNotEmpty() && UserConfig.isLoggedIn() onDismissRequest = viewModel::onNotificationsDeniedDialogDismissed,
} title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_denied_text)) },
confirmButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogConfirmClicked) {
Text(text = stringResource(id = R.string.action_request))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsDeniedDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (isNeedToShowRationaleDialog) {
AlertDialog(
onDismissRequest = viewModel::onNotificationsRationaleDialogDismissed,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = stringResource(id = R.string.background_long_poll_rationale_text)) },
confirmButton = {
TextButton(
onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}
) {
Text(text = stringResource(id = R.string.title_settings))
}
},
dismissButton = {
TextButton(onClick = viewModel::onNotificationsRationaleDialogCancelClicked) {
Text(text = stringResource(id = R.string.action_disable))
}
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
)
}
if (startDestination != null) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (isNeedToShowConversations) Main else AuthGraph, startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) }, enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) } exitTransition = { fadeOut(animationSpec = tween(200)) }
) { ) {
@@ -81,11 +142,6 @@ fun RootScreen(navController: NavHostController = rememberNavController()) {
languagePickerScreen(onBack = navController::navigateUp) languagePickerScreen(onBack = navController::navigateUp)
} }
} }
NotificationsPermissionChecker(
screenState = screenState,
viewModel = viewModel
)
} }
fun NavController.navigateToMain() { fun NavController.navigateToMain() {
@@ -12,6 +12,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.conena.nanokt.android.app.stopForegroundCompat import com.conena.nanokt.android.app.stopForegroundCompat
import com.meloda.app.fast.common.AppConstants
import com.meloda.app.fast.common.UserConfig import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.common.VkConstants import com.meloda.app.fast.common.VkConstants
import com.meloda.app.fast.common.extensions.listenValue import com.meloda.app.fast.common.extensions.listenValue
@@ -20,6 +21,9 @@ import com.meloda.app.fast.data.LongPollUseCase
import com.meloda.app.fast.data.processState import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.SettingsController import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.designsystem.R
import com.meloda.app.fast.model.api.data.LongPollUpdates import com.meloda.app.fast.model.api.data.LongPollUpdates
import com.meloda.app.fast.model.api.data.VkLongPollData import com.meloda.app.fast.model.api.data.VkLongPollData
import com.meloda.app.fast.util.NotificationsUtils import com.meloda.app.fast.util.NotificationsUtils
@@ -36,6 +40,8 @@ import kotlin.coroutines.suspendCoroutine
class LongPollingService : Service() { class LongPollingService : Service() {
private val userSettings: UserSettings by inject()
private val job = SupervisorJob() private val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
@@ -44,6 +50,9 @@ class LongPollingService : Service() {
if (throwable !is NoAccessTokenException) { if (throwable !is NoAccessTokenException) {
throwable.printStackTrace() throwable.printStackTrace()
} }
userSettings.updateLongPollCurrentState(LongPollState.Exception)
userSettings.setLongPollStateToApply(LongPollState.Exception)
} }
private val coroutineContext: CoroutineContext private val coroutineContext: CoroutineContext
@@ -70,14 +79,14 @@ class LongPollingService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (startId > 1) return START_STICKY if (startId > 1) return START_STICKY
val asForeground = preferences.getBoolean( val inBackground = preferences.getBoolean(
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
) )
Log.d( Log.d(
STATE_TAG, STATE_TAG,
"onStartCommand: asForeground: $asForeground; flags: $flags; startId: $startId;\ninstance: $this" "onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this"
) )
if (currentJob != null) { if (currentJob != null) {
@@ -105,14 +114,19 @@ class LongPollingService : Service() {
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE
) )
if (asForeground) { userSettings.updateLongPollCurrentState(
if (inBackground) LongPollState.Background
else LongPollState.InApp
)
if (inBackground) {
val notification = val notification =
NotificationsUtils.createNotification( NotificationsUtils.createNotification(
context = this, context = this,
title = "LongPoll", title = getString(R.string.long_polling_service_notification_title),
contentText = "нажмите, чтобы убрать уведомление", contentText = getString(R.string.long_polling_service_notification_content),
notRemovable = false, notRemovable = false,
channelId = "long_polling", channelId = AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
priority = NotificationsUtils.NotificationPriority.Low, priority = NotificationsUtils.NotificationPriority.Low,
category = NotificationCompat.CATEGORY_SERVICE, category = NotificationCompat.CATEGORY_SERVICE,
customNotificationId = NOTIFICATION_ID, customNotificationId = NOTIFICATION_ID,
@@ -240,6 +254,7 @@ class LongPollingService : Service() {
override fun onDestroy() { override fun onDestroy() {
Log.d(STATE_TAG, "onDestroy") Log.d(STATE_TAG, "onDestroy")
userSettings.updateLongPollCurrentState(LongPollState.Stopped)
try { try {
SettingsController.edit { SettingsController.edit {
putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true)
@@ -5,6 +5,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.meloda.app.fast.common.AppConstants
import com.meloda.app.fast.designsystem.R as UiR import com.meloda.app.fast.designsystem.R as UiR
object NotificationsUtils { object NotificationsUtils {
@@ -20,7 +21,7 @@ object NotificationsUtils {
timeStampWhen: Long? = null, timeStampWhen: Long? = null,
notify: Boolean = false, notify: Boolean = false,
notRemovable: Boolean = false, notRemovable: Boolean = false,
channelId: String = "simple_notifications", channelId: String = AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
priority: NotificationPriority = NotificationPriority.Default, priority: NotificationPriority = NotificationPriority.Default,
contentIntent: PendingIntent? = null, contentIntent: PendingIntent? = null,
category: String? = null, category: String? = null,
@@ -7,4 +7,7 @@ object AppConstants {
const val API_VERSION = "5.173" const val API_VERSION = "5.173"
const val URL_OAUTH = "https://oauth.vk.com" const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.com/method" const val URL_API = "https://api.vk.com/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
} }
@@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -141,6 +142,7 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
} }
} }
@ChecksSdkIntAtLeast(parameter = 0, lambda = 1)
fun isSdkAtLeast(sdkInt: Int, action: (() -> Unit)? = null): Boolean { fun isSdkAtLeast(sdkInt: Int, action: (() -> Unit)? = null): Boolean {
return if (Build.VERSION.SDK_INT >= sdkInt) { return if (Build.VERSION.SDK_INT >= sdkInt) {
action?.invoke() action?.invoke()
@@ -6,5 +6,7 @@ interface AccountsRepository {
suspend fun getAccounts(): List<AccountEntity> suspend fun getAccounts(): List<AccountEntity>
suspend fun getAccountById(userId: Int): AccountEntity?
suspend fun storeAccounts(accounts: List<AccountEntity>) suspend fun storeAccounts(accounts: List<AccountEntity>)
} }
@@ -9,6 +9,9 @@ class AccountsRepositoryImpl(
override suspend fun getAccounts(): List<AccountEntity> = accountDao.getAll() override suspend fun getAccounts(): List<AccountEntity> = accountDao.getAll()
override suspend fun getAccountById(userId: Int): AccountEntity? =
accountDao.getById(userId)
override suspend fun storeAccounts( override suspend fun storeAccounts(
accounts: List<AccountEntity> accounts: List<AccountEntity>
) = accountDao.insertAll(accounts) ) = accountDao.insertAll(accounts)
@@ -0,0 +1,13 @@
package com.meloda.app.fast.data.db
import com.meloda.app.fast.common.UserConfig
import com.meloda.app.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId)
}
}
@@ -27,6 +27,7 @@ import com.meloda.app.fast.data.api.users.UsersUseCaseImpl
import com.meloda.app.fast.data.api.videos.VideosRepository import com.meloda.app.fast.data.api.videos.VideosRepository
import com.meloda.app.fast.data.db.AccountsRepository import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.db.AccountsRepositoryImpl import com.meloda.app.fast.data.db.AccountsRepositoryImpl
import com.meloda.app.fast.data.db.GetCurrentAccountUseCase
import com.meloda.app.fast.database.di.databaseModule import com.meloda.app.fast.database.di.databaseModule
import com.meloda.app.fast.datastore.di.dataStoreModule import com.meloda.app.fast.datastore.di.dataStoreModule
import com.meloda.app.fast.network.di.networkModule import com.meloda.app.fast.network.di.networkModule
@@ -67,6 +68,7 @@ val dataModule = module {
singleOf(::VideosRepository) singleOf(::VideosRepository)
singleOf(::AccountsRepositoryImpl) bind AccountsRepository::class singleOf(::AccountsRepositoryImpl) bind AccountsRepository::class
singleOf(::GetCurrentAccountUseCase)
singleOf(::FriendsRepositoryImpl) bind FriendsRepository::class singleOf(::FriendsRepositoryImpl) bind FriendsRepository::class
} }
@@ -10,6 +10,9 @@ abstract class AccountDao : EntityDao<AccountEntity> {
@Query("SELECT * FROM accounts") @Query("SELECT * FROM accounts")
abstract suspend fun getAll(): List<AccountEntity> abstract suspend fun getAll(): List<AccountEntity>
@Query("SELECT * FROM accounts WHERE userId = :userId")
abstract suspend fun getById(userId: Int): AccountEntity?
@Query("DELETE FROM accounts WHERE userId = :userId") @Query("DELETE FROM accounts WHERE userId = :userId")
abstract suspend fun deleteById(userId: Int) abstract suspend fun deleteById(userId: Int)
} }
@@ -50,4 +50,15 @@ object SettingsController {
} }
} }
} }
var isLongPollInBackgroundEnabled: Boolean =
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
get() = getBoolean(
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
)
set(value) {
field = value
put(SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, value)
}
} }
@@ -2,6 +2,8 @@ package com.meloda.app.fast.datastore
import android.content.res.Resources import android.content.res.Resources
import android.os.PowerManager import android.os.PowerManager
import android.util.Log
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.datastore.model.ThemeConfig import com.meloda.app.fast.datastore.model.ThemeConfig
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -9,7 +11,8 @@ import kotlinx.coroutines.flow.update
interface UserSettings { interface UserSettings {
val theme: StateFlow<ThemeConfig> val theme: StateFlow<ThemeConfig>
val longPollBackground: StateFlow<Boolean> val longPollStateToApply: StateFlow<LongPollState>
val longPollCurrentState: StateFlow<LongPollState>
val online: StateFlow<Boolean> val online: StateFlow<Boolean>
val debugSettingsEnabled: StateFlow<Boolean> val debugSettingsEnabled: StateFlow<Boolean>
val useContactNames: StateFlow<Boolean> val useContactNames: StateFlow<Boolean>
@@ -21,7 +24,8 @@ interface UserSettings {
fun useDynamicColorsChanged(use: Boolean) fun useDynamicColorsChanged(use: Boolean)
fun useBlurChanged(use: Boolean) fun useBlurChanged(use: Boolean)
fun useMultiline(use: Boolean) fun useMultiline(use: Boolean)
fun setLongPollBackground(background: Boolean) fun setLongPollStateToApply(newState: LongPollState)
fun updateLongPollCurrentState(currentState: LongPollState)
fun setOnline(use: Boolean) fun setOnline(use: Boolean)
fun enableDebugSettings(enable: Boolean) fun enableDebugSettings(enable: Boolean)
fun onUseContactNamesChanged(use: Boolean) fun onUseContactNamesChanged(use: Boolean)
@@ -45,12 +49,9 @@ class UserSettingsImpl(
) )
) )
override val longPollBackground = MutableStateFlow( override val longPollStateToApply = MutableStateFlow<LongPollState>(LongPollState.Stopped)
SettingsController.getBoolean( override val longPollCurrentState = MutableStateFlow<LongPollState>(LongPollState.Stopped)
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND,
SettingsKeys.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND
)
)
override val online = MutableStateFlow( override val online = MutableStateFlow(
SettingsController.getBoolean( SettingsController.getBoolean(
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS, SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS,
@@ -107,8 +108,14 @@ class UserSettingsImpl(
theme.value = theme.value.copy(multiline = use) theme.value = theme.value.copy(multiline = use)
} }
override fun setLongPollBackground(background: Boolean) { override fun setLongPollStateToApply(newState: LongPollState) {
longPollBackground.value = background longPollStateToApply.update { newState }
Log.d("UserSettings", "setLongPollState: $newState")
}
override fun updateLongPollCurrentState(currentState: LongPollState) {
longPollCurrentState.update { currentState }
Log.d("UserSettings", "updateLongPollCurrentState: $currentState")
} }
override fun setOnline(use: Boolean) { override fun setOnline(use: Boolean) {
@@ -0,0 +1,14 @@
package com.meloda.app.fast.datastore.model
sealed class LongPollState {
data object Stopped : LongPollState()
// TODO: 15/07/2024, Danil Nikolaev: support for android 15
// data object Terminated : LongPollState()
data object InApp : LongPollState()
data object Background : LongPollState()
data object Exception : LongPollState()
fun isLaunched(): Boolean = this in listOf(InApp, Background)
}
@@ -4,15 +4,11 @@ import android.content.res.Configuration
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.meloda.app.fast.common.UiText import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.util.AndroidUtils import com.meloda.app.fast.common.util.AndroidUtils
import com.meloda.app.fast.datastore.SettingsController import com.meloda.app.fast.datastore.SettingsController
@@ -81,32 +77,3 @@ fun Modifier.handleEnterKey(
action.invoke() action.invoke()
} else false } else false
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CheckPermission(
showRationale: @Composable () -> Unit,
onDenied: @Composable () -> Unit,
permission: PermissionState,
) {
when (val status = permission.status) {
is PermissionStatus.Denied -> {
if (status.shouldShowRationale) {
showRationale()
} else {
onDenied()
}
}
is PermissionStatus.Granted -> Unit
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RequestPermission(
permission: PermissionState
) {
LaunchedEffect(Unit) { permission.launchPermissionRequest() }
}
@@ -20,6 +20,10 @@ class ImmutableList<T>(val values: List<T>) : Iterable<T> {
return values.map(transform).toImmutableList() return values.map(transform).toImmutableList()
} }
inline fun <R> mapNotNull(transform: (T) -> R?): ImmutableList<R> {
return values.mapNotNull(transform).toImmutableList()
}
inline fun <R> mapIndexed(transform: (index: Int, T) -> R): ImmutableList<R> { inline fun <R> mapIndexed(transform: (index: Int, T) -> R): ImmutableList<R> {
return values.mapIndexed(transform).toImmutableList() return values.mapIndexed(transform).toImmutableList()
} }
@@ -1,11 +1,5 @@
package com.meloda.app.fast.designsystem package com.meloda.app.fast.designsystem
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -32,15 +26,19 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.meloda.app.fast.common.UiText import com.meloda.app.fast.common.UiText
import com.meloda.app.fast.common.parseString
import com.meloda.app.fast.designsystem.ImmutableList.Companion.toImmutableList import com.meloda.app.fast.designsystem.ImmutableList.Companion.toImmutableList
// TODO: 08.04.2023, Danil Nikolaev: review // TODO: 08.04.2023, Danil Nikolaev: refactor this
@Deprecated("need refactoring")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MaterialDialog( fun MaterialDialog(
@@ -58,17 +56,22 @@ fun MaterialDialog(
items: ImmutableList<UiText> = ImmutableList.empty(), items: ImmutableList<UiText> = ImmutableList.empty(),
onItemClick: ((index: Int) -> Unit)? = null, onItemClick: ((index: Int) -> Unit)? = null,
buttonsInvokeDismiss: Boolean = true, buttonsInvokeDismiss: Boolean = true,
properties: DialogProperties = DialogProperties(),
customContent: (@Composable ColumnScope.() -> Unit)? = null, customContent: (@Composable ColumnScope.() -> Unit)? = null,
) { ) {
var isVisible by remember { var isVisible by rememberSaveable {
mutableStateOf(true) mutableStateOf(true)
} }
val onDismissRequest = { val onDismissRequest = remember {
onDismissAction.invoke() {
isVisible = false onDismissAction.invoke()
isVisible = false
}
} }
val stringTitles = items.map { it.getString().orEmpty() } val context = LocalContext.current
val stringTitles = items.mapNotNull { it.parseString(context.resources) }
var alertItems by remember { var alertItems by remember {
mutableStateOf( mutableStateOf(
@@ -81,11 +84,10 @@ fun MaterialDialog(
) )
} }
AlertAnimation(visible = isVisible) {
if (isVisible) {
// AlertAnimation(visible = isVisible) {
BasicAlertDialog( BasicAlertDialog(
onDismissRequest = onDismissRequest onDismissRequest = onDismissRequest,
properties = properties
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } } val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } }
@@ -251,15 +253,16 @@ fun MaterialDialog(
@Composable @Composable
fun AlertAnimation( fun AlertAnimation(
visible: Boolean, visible: Boolean,
content: @Composable AnimatedVisibilityScope.() -> Unit content: @Composable () -> Unit
) { ) {
AnimatedVisibility( if (visible) content()
visible = visible, // AnimatedVisibility(
enter = fadeIn(animationSpec = tween(400)) + // visible = visible,
scaleIn(animationSpec = tween(400)), // enter = fadeIn(animationSpec = tween(400)) +
exit = fadeOut(animationSpec = tween(150)), // scaleIn(animationSpec = tween(400)),
content = content // exit = fadeOut(animationSpec = tween(150)),
) // content = content
// )
} }
@Composable @Composable
@@ -188,4 +188,14 @@
<string name="settings_activity_send_online_title">Быть «в сети»</string> <string name="settings_activity_send_online_title">Быть «в сети»</string>
<string name="settings_activity_send_online_summary">Статус «в сети» будет отправляться каждые 5 минут</string> <string name="settings_activity_send_online_summary">Статус «в сети» будет отправляться каждые 5 минут</string>
<string name="settings_debug_title">Отладка</string> <string name="settings_debug_title">Отладка</string>
<string name="action_disable">Отключить</string>
<string name="background_long_poll_rationale_text">Приложение не сможет обновлять сообщения в фоне без доступа к уведомлениям</string>
<string name="background_long_poll_denied_text">Приложению нужен доступ к уведомлениям для обновления сообщений в фоне</string>
<string name="action_request">Запросить</string>
<string name="long_polling_service_notification_title">Обновление сообщений в фоне</string>
<string name="long_polling_service_notification_content">Нажмите, чтобы скрыть</string>
<string name="notification_channel_no_category_name">Без категории</string>
<string name="notification_channel_no_category_description">Уведомления без категории</string>
<string name="notification_channel_long_polling_service_name">Сервис обновления сообщений</string>
<string name="notification_channel_long_polling_service_description">Уведомления сервиса обновлений сообщений</string>
</resources> </resources>
@@ -246,5 +246,17 @@
<string name="settings_activity_send_online_title">Send online status</string> <string name="settings_activity_send_online_title">Send online status</string>
<string name="settings_activity_send_online_summary">Online status will be sent every five minutes</string> <string name="settings_activity_send_online_summary">Online status will be sent every five minutes</string>
<string name="settings_debug_title">Debug</string> <string name="settings_debug_title">Debug</string>
<string name="background_long_poll_rationale_text">The app won\'t be able to update messages in the background without access to notifications</string>
<string name="action_disable">Disable</string>
<string name="background_long_poll_denied_text">The app needs access to notifications to update messages in the background</string>
<string name="action_request">Request</string>
</resources> <string name="long_polling_service_notification_title">Updating messages in background</string>
<string name="long_polling_service_notification_content">Tap to hide</string>
<string name="notification_channel_no_category_name">Uncategorized</string>
<string name="notification_channel_no_category_description">Uncategorized notifications</string>
<string name="notification_channel_long_polling_service_name">Message update service</string>
<string name="notification_channel_long_polling_service_description">Message update service notifications</string>
</resources>
@@ -13,13 +13,16 @@ import com.meloda.app.fast.data.State
import com.meloda.app.fast.data.api.users.UsersUseCase import com.meloda.app.fast.data.api.users.UsersUseCase
import com.meloda.app.fast.data.db.AccountsRepository import com.meloda.app.fast.data.db.AccountsRepository
import com.meloda.app.fast.data.processState import com.meloda.app.fast.data.processState
import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.model.database.AccountEntity import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.network.OAuthErrorDomain import com.meloda.app.fast.network.OAuthErrorDomain
import com.meloda.fast.auth.login.model.CaptchaArguments import com.meloda.fast.auth.login.model.CaptchaArguments
import com.meloda.fast.auth.login.model.LoginError import com.meloda.fast.auth.login.model.LoginError
import com.meloda.fast.auth.login.model.LoginScreenState import com.meloda.fast.auth.login.model.LoginScreenState
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginUserBannedArguments import com.meloda.fast.auth.login.model.LoginUserBannedArguments
import com.meloda.fast.auth.login.model.LoginValidationArguments
import com.meloda.fast.auth.login.model.LoginValidationResult import com.meloda.fast.auth.login.model.LoginValidationResult
import com.meloda.fast.auth.login.validation.LoginValidator import com.meloda.fast.auth.login.validation.LoginValidator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -67,7 +70,8 @@ class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase, private val oAuthUseCase: OAuthUseCase,
private val usersUseCase: UsersUseCase, private val usersUseCase: UsersUseCase,
private val accountsRepository: AccountsRepository, private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator private val loginValidator: LoginValidator,
private val userSettings: UserSettings
) : ViewModel(), LoginViewModel { ) : ViewModel(), LoginViewModel {
override val screenState = MutableStateFlow(LoginScreenState.EMPTY) override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
@@ -155,6 +159,8 @@ class LoginViewModelImpl(
UserConfig.trustedHash = account.trustedHash UserConfig.trustedHash = account.trustedHash
} }
startLongPoll()
usersUseCase.get( usersUseCase.get(
userIds = null, userIds = null,
fields = VkConstants.USER_FIELDS, fields = VkConstants.USER_FIELDS,
@@ -238,6 +244,8 @@ class LoginViewModelImpl(
UserConfig.trustedHash = account.trustedHash UserConfig.trustedHash = account.trustedHash
} }
startLongPoll()
accountsRepository.storeAccounts(listOf(currentAccount)) accountsRepository.storeAccounts(listOf(currentAccount))
captchaArguments.update { null } captchaArguments.update { null }
@@ -338,4 +346,14 @@ class LoginViewModelImpl(
} }
} }
} }
private fun startLongPoll() {
userSettings.setLongPollStateToApply(
if (SettingsController.isLongPollInBackgroundEnabled) {
LongPollState.Background
} else {
LongPollState.InApp
}
)
}
} }
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -156,17 +155,13 @@ fun ConversationsScreen(
val view = LocalView.current val view = LocalView.current
val currentTheme = LocalTheme.current val currentTheme = LocalTheme.current
val maxLines by remember { val maxLines by remember(currentTheme) {
derivedStateOf { mutableIntStateOf(if (currentTheme.multiline) 2 else 1)
if (currentTheme.multiline) 2 else 1
}
} }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isListScrollingUp = listState.isScrollingUp() val paginationConditionMet by remember(canPaginate, listState) {
val paginationConditionMet by remember {
derivedStateOf { derivedStateOf {
canPaginate && canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
@@ -292,7 +287,7 @@ fun ConversationsScreen(
Column { Column {
AnimatedVisibility( AnimatedVisibility(
visible = isListScrollingUp, visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) { ) {
@@ -351,10 +346,7 @@ fun ConversationsScreen(
.nestedScroll(pullToRefreshState.nestedScrollConnection) .nestedScroll(pullToRefreshState.nestedScrollConnection)
) { ) {
ConversationsListComposable( ConversationsListComposable(
onConversationsClick = { id -> onConversationsClick = onConversationItemClicked,
onConversationItemClicked(id)
},
onConversationsLongClick = onConversationItemLongClicked, onConversationsLongClick = onConversationItemLongClicked,
screenState = screenState, screenState = screenState,
state = listState, state = listState,
@@ -14,6 +14,7 @@ import com.meloda.app.fast.datastore.SettingsController
import com.meloda.app.fast.datastore.SettingsKeys import com.meloda.app.fast.datastore.SettingsKeys
import com.meloda.app.fast.datastore.UserSettings import com.meloda.app.fast.datastore.UserSettings
import com.meloda.app.fast.datastore.isDebugSettingsShown import com.meloda.app.fast.datastore.isDebugSettingsShown
import com.meloda.app.fast.datastore.model.LongPollState
import com.meloda.app.fast.model.database.AccountEntity import com.meloda.app.fast.model.database.AccountEntity
import com.meloda.app.fast.settings.model.SettingsItem import com.meloda.app.fast.settings.model.SettingsItem
import com.meloda.app.fast.settings.model.SettingsScreenState import com.meloda.app.fast.settings.model.SettingsScreenState
@@ -161,7 +162,14 @@ class SettingsViewModelImpl(
when (key) { when (key) {
SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> { SettingsKeys.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> {
val isEnabled = (newValue as? Boolean) == true val isEnabled = (newValue as? Boolean) == true
userSettings.setLongPollBackground(isEnabled) userSettings.setLongPollStateToApply(
userSettings.longPollStateToApply.value.let { state ->
if (state.isLaunched()) {
if (isEnabled) LongPollState.Background
else LongPollState.InApp
} else state
}
)
if (isEnabled) { if (isEnabled) {
// TODO: 26/11/2023, Danil Nikolaev: implement // TODO: 26/11/2023, Danil Nikolaev: implement
+2 -4
View File
@@ -1,17 +1,15 @@
[versions] [versions]
agp = "8.5.0" agp = "8.5.1"
converterMoshi = "2.11.0" converterMoshi = "2.11.0"
eithernet = "1.9.0" eithernet = "1.9.0"
haze = "0.7.3" haze = "0.7.3"
kotlin = "2.0.0" kotlin = "2.0.0"
ksp = "2.0.0-1.0.22" ksp = "2.0.0-1.0.22"
vkompose = "0.5.4-k2"
compose-bom = "2024.06.00" compose-bom = "2024.06.00"
koin = "3.5.6" koin = "3.5.6"
accompanist = "0.34.0" accompanist = "0.35.1-alpha"
coil = "2.6.0" coil = "2.6.0"
coroutines = "1.9.0-RC" coroutines = "1.9.0-RC"
junit = "4.13.2" junit = "4.13.2"