forked from melod1n/fast-messenger
update package name (even bigger one)
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package dev.meloda.fast
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import dev.meloda.fast.auth.AuthGraph
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.UserConfig
|
||||
import dev.meloda.fast.common.extensions.ifEmpty
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.extensions.setValue
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.data.db.GetCurrentAccountUseCase
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.navigation.Main
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
interface MainViewModel {
|
||||
|
||||
val startDestination: StateFlow<Any?>
|
||||
val isNeedToReplaceWithAuth: StateFlow<Boolean>
|
||||
|
||||
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
|
||||
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
|
||||
val isNeedToCheckNotificationsPermission: StateFlow<Boolean>
|
||||
val isNeedToRequestNotifications: StateFlow<Boolean>
|
||||
|
||||
fun onError(error: BaseError)
|
||||
|
||||
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(
|
||||
private val getCurrentAccountUseCase: GetCurrentAccountUseCase,
|
||||
private val userSettings: UserSettings,
|
||||
private val longPollController: LongPollController
|
||||
) : MainViewModel, ViewModel() {
|
||||
|
||||
init {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
override val startDestination = MutableStateFlow<Any?>(null)
|
||||
override val isNeedToReplaceWithAuth = MutableStateFlow(false)
|
||||
|
||||
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
|
||||
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
|
||||
override val isNeedToCheckNotificationsPermission = MutableStateFlow(false)
|
||||
override val isNeedToRequestNotifications = MutableStateFlow(false)
|
||||
|
||||
override fun onError(error: BaseError) {
|
||||
when (error) {
|
||||
BaseError.SessionExpired -> {
|
||||
isNeedToReplaceWithAuth.update { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigatedToAuth() {
|
||||
isNeedToReplaceWithAuth.update { false }
|
||||
}
|
||||
|
||||
override fun onAppResumed() {
|
||||
if (isNeedToShowNotificationsRationaleDialog.value) {
|
||||
isNeedToShowNotificationsRationaleDialog.update { false }
|
||||
isNeedToCheckNotificationsPermission.update { true }
|
||||
}
|
||||
|
||||
val newLanguage = AppCompatDelegate.getApplicationLocales()
|
||||
.toLanguageTags()
|
||||
.ifEmpty { null }
|
||||
?: LocaleListCompat.getDefault()
|
||||
.toLanguageTags()
|
||||
.split(",")
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
.take(5)
|
||||
|
||||
userSettings.onAppLanguageChanged(newLanguage)
|
||||
}
|
||||
|
||||
@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() {
|
||||
longPollController.stateToApply.listenValue { newState ->
|
||||
if (newState == LongPollState.Background) {
|
||||
isNeedToCheckNotificationsPermission.update { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAccounts() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentAccount = getCurrentAccountUseCase()
|
||||
|
||||
Log.d("MainViewModel", "currentAccount: $currentAccount")
|
||||
|
||||
listenLongPollState()
|
||||
|
||||
if (currentAccount != null) {
|
||||
UserConfig.apply {
|
||||
this.userId = currentAccount.userId
|
||||
this.accessToken = currentAccount.accessToken
|
||||
this.fastToken = currentAccount.fastToken
|
||||
this.trustedHash = currentAccount.trustedHash
|
||||
}
|
||||
|
||||
longPollController.setStateToApply(
|
||||
if (AppSettings.Debug.longPollInBackground) {
|
||||
LongPollState.Background
|
||||
} else {
|
||||
LongPollState.InApp
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
startDestination.setValue {
|
||||
if (currentAccount == null) AuthGraph
|
||||
else Main
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disableBackgroundLongPoll() {
|
||||
AppSettings.Debug.longPollInBackground = false
|
||||
longPollController.setStateToApply(LongPollState.InApp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.meloda.fast.common
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.PreferenceManager
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import dev.meloda.fast.common.di.applicationModule
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
import org.koin.core.context.GlobalContext.startKoin
|
||||
|
||||
class AppGlobal : Application(), ImageLoaderFactory {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
AppSettings.init(preferences)
|
||||
UserConfig.init(preferences)
|
||||
|
||||
initKoin()
|
||||
}
|
||||
|
||||
private fun initKoin() {
|
||||
startKoin {
|
||||
androidLogger()
|
||||
androidContext(this@AppGlobal)
|
||||
modules(applicationModule)
|
||||
}
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader = get()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.meloda.fast.common.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.os.PowerManager
|
||||
import androidx.preference.PreferenceManager
|
||||
import dev.meloda.fast.MainViewModelImpl
|
||||
import dev.meloda.fast.auth.authModule
|
||||
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
|
||||
import dev.meloda.fast.common.provider.Provider
|
||||
import dev.meloda.fast.conversations.di.conversationsModule
|
||||
import dev.meloda.fast.data.di.dataModule
|
||||
import dev.meloda.fast.friends.di.friendsModule
|
||||
import dev.meloda.fast.languagepicker.di.languagePickerModule
|
||||
import dev.meloda.fast.messageshistory.di.messagesHistoryModule
|
||||
import dev.meloda.fast.photoviewer.di.photoViewModule
|
||||
import dev.meloda.fast.profile.di.profileModule
|
||||
import dev.meloda.fast.provider.ApiLanguageProvider
|
||||
import dev.meloda.fast.service.longpolling.di.longPollModule
|
||||
import dev.meloda.fast.settings.di.settingsModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.core.qualifier.qualifier
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val applicationModule = module {
|
||||
includes(dataModule)
|
||||
includes(
|
||||
authModule,
|
||||
conversationsModule,
|
||||
settingsModule,
|
||||
messagesHistoryModule,
|
||||
photoViewModule,
|
||||
languagePickerModule,
|
||||
longPollModule,
|
||||
friendsModule,
|
||||
profileModule,
|
||||
chatMaterialsModule
|
||||
)
|
||||
|
||||
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
|
||||
// TODO: 14/05/2024, Danil Nikolaev: extract all operations with preferences to standalone class
|
||||
singleOf(PreferenceManager::getDefaultSharedPreferences)
|
||||
single<Resources> { androidContext().resources }
|
||||
factory<PowerManager> { androidContext().getSystemService(Context.POWER_SERVICE) as PowerManager }
|
||||
|
||||
singleOf(::ApiLanguageProvider) bind Provider::class
|
||||
|
||||
viewModelOf(::MainViewModelImpl) {
|
||||
qualifier = qualifier("main")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.meloda.fast.model
|
||||
|
||||
data class BottomNavigationItem(
|
||||
val titleResId: Int,
|
||||
val selectedIconResId: Int,
|
||||
val unselectedIconResId: Int,
|
||||
val route: Any,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.meloda.fast.navigation
|
||||
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.meloda.fast.conversations.navigation.Conversations
|
||||
import dev.meloda.fast.friends.navigation.Friends
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.model.BottomNavigationItem
|
||||
import dev.meloda.fast.presentation.MainScreen
|
||||
import dev.meloda.fast.profile.navigation.Profile
|
||||
import kotlinx.serialization.Serializable
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
@Serializable
|
||||
object MainGraph
|
||||
|
||||
@Serializable
|
||||
object Main
|
||||
|
||||
fun NavGraphBuilder.mainScreen(
|
||||
onError: (BaseError) -> Unit,
|
||||
onSettingsButtonClicked: () -> Unit,
|
||||
onConversationClicked: (conversationId: Int) -> Unit,
|
||||
) {
|
||||
val navigationItems = listOf(
|
||||
BottomNavigationItem(
|
||||
titleResId = UiR.string.title_friends,
|
||||
selectedIconResId = UiR.drawable.baseline_people_alt_24,
|
||||
unselectedIconResId = UiR.drawable.outline_people_alt_24,
|
||||
route = Friends,
|
||||
),
|
||||
BottomNavigationItem(
|
||||
titleResId = UiR.string.title_conversations,
|
||||
selectedIconResId = UiR.drawable.baseline_chat_24,
|
||||
unselectedIconResId = UiR.drawable.outline_chat_24,
|
||||
route = Conversations
|
||||
),
|
||||
BottomNavigationItem(
|
||||
titleResId = UiR.string.title_profile,
|
||||
selectedIconResId = UiR.drawable.baseline_account_circle_24,
|
||||
unselectedIconResId = UiR.drawable.outline_account_circle_24,
|
||||
route = Profile
|
||||
)
|
||||
)
|
||||
|
||||
composable<Main> {
|
||||
MainScreen(
|
||||
navigationItems = navigationItems,
|
||||
onError = onError,
|
||||
onSettingsButtonClicked = onSettingsButtonClicked,
|
||||
onConversationItemClicked = onConversationClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package dev.meloda.fast.presentation
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.conena.nanokt.android.content.pxToDp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import dev.meloda.fast.MainViewModel
|
||||
import dev.meloda.fast.MainViewModelImpl
|
||||
import dev.meloda.fast.common.AppConstants
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.extensions.isSdkAtLeast
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
import dev.meloda.fast.service.OnlineService
|
||||
import dev.meloda.fast.service.longpolling.LongPollingService
|
||||
import dev.meloda.fast.ui.model.ThemeConfig
|
||||
import dev.meloda.fast.ui.theme.AppTheme
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.KoinContext
|
||||
import org.koin.compose.koinInject
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
AppSettings.deviceId = Settings.Secure.getString(
|
||||
contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
val systemBarStyle = when (currentNightMode) {
|
||||
Configuration.UI_MODE_NIGHT_NO -> SystemBarStyle.light(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb()
|
||||
)
|
||||
|
||||
Configuration.UI_MODE_NIGHT_YES -> SystemBarStyle.dark(Color.Transparent.toArgb())
|
||||
else -> error("Illegal State, current mode is $currentNightMode")
|
||||
}
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = systemBarStyle,
|
||||
navigationBarStyle = systemBarStyle,
|
||||
)
|
||||
|
||||
createNotificationChannels()
|
||||
|
||||
setContent {
|
||||
KoinContext {
|
||||
val context = LocalContext.current
|
||||
|
||||
val userSettings: UserSettings = koinInject()
|
||||
val longPollController: LongPollController = koinInject()
|
||||
|
||||
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
|
||||
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
|
||||
|
||||
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||
|
||||
LifecycleResumeEffect(true) {
|
||||
viewModel.onAppResumed()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val permissionState =
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isNeedToRequestPermission) {
|
||||
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 sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
|
||||
LifecycleResumeEffect(sendOnline) {
|
||||
toggleOnlineService(sendOnline)
|
||||
|
||||
onPauseOrDispose {
|
||||
toggleOnlineService(false)
|
||||
}
|
||||
}
|
||||
|
||||
val isDeviceCompact by remember(true) {
|
||||
derivedStateOf {
|
||||
context.resources.displayMetrics.widthPixels.pxToDp() <= 360
|
||||
}
|
||||
}
|
||||
|
||||
val themeConfig = ThemeConfig(
|
||||
darkMode = isNeedToEnableDarkMode(userSettings.darkMode.value),
|
||||
dynamicColors = userSettings.enableDynamicColors.value,
|
||||
selectedColorScheme = 0,
|
||||
amoledDark = userSettings.enableAmoledDark.value,
|
||||
enableBlur = userSettings.useBlur.value,
|
||||
enableMultiline = userSettings.enableMultiline.value,
|
||||
isDeviceCompact = isDeviceCompact
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalThemeConfig provides themeConfig) {
|
||||
AppTheme(
|
||||
useDarkTheme = themeConfig.darkMode,
|
||||
useDynamicColors = themeConfig.dynamicColors,
|
||||
selectedColorScheme = themeConfig.selectedColorScheme,
|
||||
useAmoledBackground = themeConfig.amoledDark,
|
||||
) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
isSdkAtLeast(Build.VERSION_CODES.O) {
|
||||
val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
|
||||
val noCategoryDescriptionText =
|
||||
getString(UiR.string.notification_channel_no_category_description)
|
||||
val noCategoryImportance = NotificationManagerCompat.IMPORTANCE_HIGH
|
||||
val noCategoryChannel =
|
||||
NotificationChannel(
|
||||
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
|
||||
noCategoryName,
|
||||
noCategoryImportance
|
||||
).apply {
|
||||
description = noCategoryDescriptionText
|
||||
}
|
||||
|
||||
val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
|
||||
val longPollDescriptionText =
|
||||
getString(UiR.string.notification_channel_long_polling_service_description)
|
||||
val longPollImportance = NotificationManagerCompat.IMPORTANCE_NONE
|
||||
val longPollChannel =
|
||||
NotificationChannel(
|
||||
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
|
||||
longPollName,
|
||||
longPollImportance
|
||||
).apply {
|
||||
description = longPollDescriptionText
|
||||
}
|
||||
|
||||
val notificationManager: NotificationManager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannels(
|
||||
listOf(
|
||||
noCategoryChannel,
|
||||
longPollChannel
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleLongPollService(
|
||||
enable: Boolean,
|
||||
inBackground: Boolean = AppSettings.Debug.longPollInBackground
|
||||
) {
|
||||
if (enable) {
|
||||
val longPollIntent = Intent(this, LongPollingService::class.java)
|
||||
|
||||
if (inBackground) {
|
||||
ContextCompat.startForegroundService(this, longPollIntent)
|
||||
} else {
|
||||
startService(longPollIntent)
|
||||
}
|
||||
} else {
|
||||
stopService(Intent(this, LongPollingService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleOnlineService(enable: Boolean) {
|
||||
if (enable) {
|
||||
startService(Intent(this, OnlineService::class.java))
|
||||
} else {
|
||||
stopService(Intent(this, OnlineService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopServices() {
|
||||
toggleOnlineService(enable = false)
|
||||
|
||||
val asForeground = AppSettings.Debug.longPollInBackground
|
||||
|
||||
if (!asForeground) {
|
||||
toggleLongPollService(enable = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopServices()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package dev.meloda.fast.presentation
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarDefaults
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.meloda.fast.conversations.navigation.conversationsScreen
|
||||
import dev.meloda.fast.ui.theme.LocalBottomPadding
|
||||
import dev.meloda.fast.ui.theme.LocalHazeState
|
||||
import dev.meloda.fast.ui.theme.LocalThemeConfig
|
||||
import dev.meloda.fast.friends.navigation.friendsScreen
|
||||
import dev.meloda.fast.model.BaseError
|
||||
import dev.meloda.fast.model.BottomNavigationItem
|
||||
import dev.meloda.fast.navigation.MainGraph
|
||||
import dev.meloda.fast.profile.navigation.profileScreen
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navigationItems: List<BottomNavigationItem>,
|
||||
onError: (BaseError) -> Unit = {},
|
||||
onSettingsButtonClicked: () -> Unit = {},
|
||||
onConversationItemClicked: (conversationId: Int) -> Unit = {}
|
||||
) {
|
||||
val currentTheme = LocalThemeConfig.current
|
||||
val hazeState = remember { HazeState() }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var selectedItemIndex by rememberSaveable {
|
||||
mutableIntStateOf(1)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar(
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (currentTheme.enableBlur) {
|
||||
Modifier.hazeChild(
|
||||
state = hazeState,
|
||||
style = HazeMaterials.thick()
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
containerColor = NavigationBarDefaults.containerColor.copy(
|
||||
alpha = if (currentTheme.enableBlur) 0f else 1f
|
||||
)
|
||||
) {
|
||||
navigationItems.forEachIndexed { index, item ->
|
||||
NavigationBarItem(
|
||||
selected = selectedItemIndex == index,
|
||||
onClick = {
|
||||
if (selectedItemIndex != index) {
|
||||
val currentRoute = navigationItems[selectedItemIndex].route
|
||||
|
||||
selectedItemIndex = index
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(route = currentRoute) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (selectedItemIndex == index) item.selectedIconResId
|
||||
else item.unselectedIconResId
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = if (currentTheme.enableBlur) 0.dp else padding.calculateBottomPadding())
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalHazeState provides hazeState,
|
||||
LocalBottomPadding provides if (currentTheme.enableBlur) padding.calculateBottomPadding() else 0.dp
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = MainGraph,
|
||||
enterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(200)) }
|
||||
) {
|
||||
navigation<MainGraph>(startDestination = navigationItems[selectedItemIndex].route) {
|
||||
friendsScreen(
|
||||
onError = onError,
|
||||
navController = navController
|
||||
)
|
||||
conversationsScreen(
|
||||
onError = onError,
|
||||
onConversationItemClicked = onConversationItemClicked,
|
||||
navController = navController
|
||||
)
|
||||
profileScreen(
|
||||
onError = onError,
|
||||
onSettingsButtonClicked = onSettingsButtonClicked,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package dev.meloda.fast.presentation
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.meloda.fast.MainViewModel
|
||||
import dev.meloda.fast.MainViewModelImpl
|
||||
import dev.meloda.fast.auth.authNavGraph
|
||||
import dev.meloda.fast.auth.navigateToAuth
|
||||
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
|
||||
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
|
||||
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
|
||||
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
|
||||
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
|
||||
import dev.meloda.fast.navigation.Main
|
||||
import dev.meloda.fast.navigation.mainScreen
|
||||
import dev.meloda.fast.settings.navigation.navigateToSettings
|
||||
import dev.meloda.fast.settings.navigation.settingsScreen
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
|
||||
) {
|
||||
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) {
|
||||
if (isNeedToOpenAuth) {
|
||||
viewModel.onNavigatedToAuth()
|
||||
navController.navigateToAuth(clearBackStack = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNeedToShowDeniedDialog) {
|
||||
AlertDialog(
|
||||
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(
|
||||
navController = navController,
|
||||
startDestination = requireNotNull(startDestination),
|
||||
enterTransition = { fadeIn(animationSpec = tween(200)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(200)) }
|
||||
) {
|
||||
authNavGraph(
|
||||
onNavigateToMain = navController::navigateToMain,
|
||||
navController = navController
|
||||
)
|
||||
mainScreen(
|
||||
onError = viewModel::onError,
|
||||
onSettingsButtonClicked = navController::navigateToSettings,
|
||||
onConversationClicked = navController::navigateToMessagesHistory
|
||||
)
|
||||
|
||||
messagesHistoryScreen(
|
||||
onError = viewModel::onError,
|
||||
onBack = navController::navigateUp,
|
||||
onChatMaterialsDropdownItemClicked = navController::navigateToChatMaterials
|
||||
)
|
||||
chatMaterialsScreen(
|
||||
onBack = navController::navigateUp
|
||||
)
|
||||
|
||||
settingsScreen(
|
||||
onBack = navController::navigateUp,
|
||||
onLogOutButtonClicked = { navController.navigateToAuth(true) },
|
||||
onLanguageItemClicked = navController::navigateToLanguagePicker
|
||||
)
|
||||
languagePickerScreen(onBack = navController::navigateUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NavController.navigateToMain() {
|
||||
this.navigate(Main) {
|
||||
popUpTo(0) {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.meloda.fast.provider
|
||||
|
||||
import dev.meloda.fast.common.model.ApiLanguage
|
||||
import dev.meloda.fast.common.provider.Provider
|
||||
import dev.meloda.fast.datastore.UserSettings
|
||||
|
||||
class ApiLanguageProvider(private val userSettings: UserSettings) : Provider<ApiLanguage> {
|
||||
|
||||
override fun provide(): ApiLanguage? {
|
||||
val language = userSettings.appLanguage.value
|
||||
|
||||
return when {
|
||||
language == "ru-RU" -> "ru"
|
||||
language.startsWith("en") -> "en"
|
||||
language == "uk-UA" -> "ua"
|
||||
else -> null
|
||||
}?.let(::ApiLanguage)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.meloda.fast.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
|
||||
class DownloadManagerReceiver : BroadcastReceiver() {
|
||||
|
||||
var onReceiveAction: (() -> Unit)? = null
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
onReceiveAction?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package dev.meloda.fast.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import dev.meloda.fast.common.UserConfig
|
||||
import dev.meloda.fast.common.extensions.createTimerFlow
|
||||
import dev.meloda.fast.data.api.account.AccountUseCase
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class OnlineService : Service() {
|
||||
|
||||
private val job = SupervisorJob()
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.d(TAG, "error: $throwable")
|
||||
throwable.printStackTrace()
|
||||
}
|
||||
|
||||
private val coroutineContext: CoroutineContext
|
||||
get() = Dispatchers.Default + job + exceptionHandler
|
||||
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
|
||||
private val useCase: AccountUseCase by inject()
|
||||
|
||||
private var timerJob: Job? = null
|
||||
private var onlineJob: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
Log.d(STATE_TAG, "onBind: intent: $intent")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (startId > 1) return START_STICKY
|
||||
|
||||
Log.d(STATE_TAG, "onStartCommand: flags: $flags; startId: $startId\ninstance: $this")
|
||||
|
||||
// TODO: 05/05/2024, Danil Nikolaev: implement
|
||||
// if (AppGlobal.preferences.getBoolean(
|
||||
// SettingsKeys.KEY_VISIBILITY_SEND_ONLINE_STATUS,
|
||||
// SettingsKeys.DEFAULT_VALUE_KEY_VISIBILITY_SEND_ONLINE_STATUS
|
||||
// )
|
||||
// ) {
|
||||
// createTimer()
|
||||
// }
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun createTimer() {
|
||||
timerJob = createTimerFlow(
|
||||
isNeedToEndCondition = { false },
|
||||
onStartAction = ::setOnline,
|
||||
onTickAction = ::setOnline,
|
||||
interval = 5.minutes
|
||||
).launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun setOnline() {
|
||||
if (onlineJob != null) return
|
||||
|
||||
|
||||
// TODO: 05/05/2024, Danil Nikolaev: implement
|
||||
// if (!AppGlobal.preferences.getBoolean(
|
||||
// SettingsKeys.KEY_VISIBILITY_SEND_ONLINE_STATUS,
|
||||
// SettingsKeys.DEFAULT_VALUE_KEY_VISIBILITY_SEND_ONLINE_STATUS
|
||||
// )
|
||||
// ) return
|
||||
|
||||
Log.d(TAG, "setOnline()")
|
||||
|
||||
onlineJob = coroutineScope.launch {
|
||||
val token = UserConfig.fastToken ?: UserConfig.accessToken
|
||||
|
||||
if (token.isBlank()) {
|
||||
Log.d(TAG, "setOnline: token is empty")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val response = useCase.setOnline(
|
||||
voip = false,
|
||||
accessToken = token
|
||||
)
|
||||
Log.d(TAG, "setOnline: response: $response")
|
||||
}.also { coroutine -> coroutine.invokeOnCompletion { onlineJob = null } }
|
||||
}
|
||||
|
||||
private suspend fun setOffline() {
|
||||
Log.d(TAG, "setOffline()")
|
||||
|
||||
val response = useCase.setOffline(
|
||||
accessToken = UserConfig.accessToken
|
||||
)
|
||||
|
||||
Log.d(TAG, "setOffline: response: $response")
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
Log.d(STATE_TAG, "onLowMemory")
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(STATE_TAG, "onDestroy")
|
||||
|
||||
timerJob?.cancel("OnlineService destroyed")
|
||||
onlineJob?.cancel("OnlineService destroyed")
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OnlineService"
|
||||
private const val STATE_TAG = "OnlineServiceState"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package dev.meloda.fast.service.longpolling
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.conena.nanokt.android.app.stopForegroundCompat
|
||||
import dev.meloda.fast.common.AppConstants
|
||||
import dev.meloda.fast.common.LongPollController
|
||||
import dev.meloda.fast.common.UserConfig
|
||||
import dev.meloda.fast.common.VkConstants
|
||||
import dev.meloda.fast.common.extensions.listenValue
|
||||
import dev.meloda.fast.common.model.LongPollState
|
||||
import dev.meloda.fast.data.LongPollUpdatesParser
|
||||
import dev.meloda.fast.data.LongPollUseCase
|
||||
import dev.meloda.fast.data.processState
|
||||
import dev.meloda.fast.datastore.AppSettings
|
||||
import dev.meloda.fast.model.api.data.LongPollUpdates
|
||||
import dev.meloda.fast.model.api.data.VkLongPollData
|
||||
import dev.meloda.fast.ui.R
|
||||
import dev.meloda.fast.util.NotificationsUtils
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class LongPollingService : Service() {
|
||||
|
||||
private val longPollController: LongPollController by inject()
|
||||
|
||||
private val job = SupervisorJob()
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
Log.e(TAG, "error: $throwable")
|
||||
|
||||
if (throwable !is NoAccessTokenException) {
|
||||
throwable.printStackTrace()
|
||||
}
|
||||
|
||||
longPollController.updateCurrentState(LongPollState.Exception)
|
||||
longPollController.setStateToApply(LongPollState.Exception)
|
||||
}
|
||||
|
||||
private val coroutineContext: CoroutineContext
|
||||
get() = Dispatchers.IO + job + exceptionHandler
|
||||
|
||||
private val coroutineScope = CoroutineScope(coroutineContext)
|
||||
|
||||
private val longPollUseCase: LongPollUseCase by inject()
|
||||
private val updatesParser: LongPollUpdatesParser by inject()
|
||||
|
||||
private var currentJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(STATE_TAG, "onCreate()")
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
Log.d(STATE_TAG, "onBind: intent: $intent")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (startId > 1) return START_STICKY
|
||||
|
||||
val inBackground = AppSettings.Debug.longPollInBackground
|
||||
|
||||
Log.d(
|
||||
STATE_TAG,
|
||||
"onStartCommand: asForeground: $inBackground; flags: $flags; startId: $startId;\ninstance: $this"
|
||||
)
|
||||
|
||||
if (currentJob != null) {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
currentJob = startPolling().also { it.join() }
|
||||
}
|
||||
|
||||
val openCategorySettingsIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, "long_polling")
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.fromParts("package", packageName, null))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val openCategorySettingsPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
openCategorySettingsIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
longPollController.updateCurrentState(
|
||||
if (inBackground) LongPollState.Background
|
||||
else LongPollState.InApp
|
||||
)
|
||||
|
||||
if (inBackground) {
|
||||
val notification =
|
||||
NotificationsUtils.createNotification(
|
||||
context = this,
|
||||
title = getString(R.string.long_polling_service_notification_title),
|
||||
contentText = getString(R.string.long_polling_service_notification_content),
|
||||
notRemovable = false,
|
||||
channelId = AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
|
||||
priority = NotificationsUtils.NotificationPriority.Low,
|
||||
category = NotificationCompat.CATEGORY_SERVICE,
|
||||
customNotificationId = NOTIFICATION_ID,
|
||||
contentIntent = openCategorySettingsPendingIntent
|
||||
).build()
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
} else {
|
||||
stopForegroundCompat(ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startPolling(): Job {
|
||||
if (job.isCompleted || job.isCancelled) {
|
||||
Log.d(STATE_TAG, "job is completed or cancelled")
|
||||
throw Exception("Job is over")
|
||||
}
|
||||
|
||||
Log.d(STATE_TAG, "job started")
|
||||
|
||||
return coroutineScope.launch {
|
||||
if (UserConfig.accessToken.isEmpty()) {
|
||||
throw NoAccessTokenException
|
||||
}
|
||||
|
||||
var serverInfo = getServerInfo()
|
||||
?: throw LongPollException(message = "bad VK response (server info)")
|
||||
|
||||
var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo)
|
||||
?: throw LongPollException(message = "initiation error: bad VK response (last updates)")
|
||||
|
||||
var failCount = 0
|
||||
|
||||
while (job.isActive) {
|
||||
if (lastUpdatesResponse == null) {
|
||||
failCount++
|
||||
serverInfo = getServerInfo()
|
||||
?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)")
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
when (lastUpdatesResponse.failed) {
|
||||
1 -> {
|
||||
val newTs = lastUpdatesResponse.ts ?: kotlin.run {
|
||||
failCount++
|
||||
serverInfo.ts
|
||||
}
|
||||
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
|
||||
}
|
||||
|
||||
2, 3 -> {
|
||||
serverInfo = getServerInfo()
|
||||
?: throw LongPollException(
|
||||
message = "failed retrieving server info after error: bad VK response (server info #3)"
|
||||
)
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val newTs = lastUpdatesResponse.ts
|
||||
|
||||
if (newTs == null) {
|
||||
failCount++
|
||||
} else {
|
||||
val updates = lastUpdatesResponse.updates
|
||||
|
||||
if (updates == null) {
|
||||
failCount++
|
||||
} else {
|
||||
updates.forEach(updatesParser::parseNextUpdate)
|
||||
}
|
||||
|
||||
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
|
||||
longPollUseCase.getLongPollServer(
|
||||
needPts = true,
|
||||
version = VkConstants.LP_VERSION
|
||||
).listenValue(coroutineScope) { state ->
|
||||
state.processState(
|
||||
success = { response ->
|
||||
Log.d(TAG, "getServerInfo: serverInfoResponse: $response")
|
||||
it.resume(response)
|
||||
},
|
||||
error = { error ->
|
||||
Log.e(TAG, "getServerInfo: $error")
|
||||
it.resume(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getUpdatesResponse(
|
||||
server: VkLongPollData
|
||||
): LongPollUpdates? = suspendCoroutine {
|
||||
longPollUseCase.getLongPollUpdates(
|
||||
serverUrl = "https://${server.server}",
|
||||
key = server.key,
|
||||
ts = server.ts,
|
||||
wait = 25,
|
||||
mode = 2 or 8 or 32 or 64 or 128,
|
||||
version = VkConstants.LP_VERSION
|
||||
).listenValue(coroutineScope) { state ->
|
||||
state.processState(
|
||||
success = { response ->
|
||||
Log.d(TAG, "lastUpdateResponse: $response")
|
||||
it.resume(response)
|
||||
},
|
||||
error = { error ->
|
||||
Log.d(TAG, "getUpdatesResponse: error: $error")
|
||||
it.resume(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(STATE_TAG, "onDestroy")
|
||||
longPollController.updateCurrentState(LongPollState.Stopped)
|
||||
try {
|
||||
AppSettings.edit { putBoolean(KEY_LONG_POLL_WAS_DESTROYED, true) }
|
||||
job.cancel()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
Log.d(STATE_TAG, "onLowMemory")
|
||||
longPollController.updateCurrentState(LongPollState.Stopped)
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "LongPollTask"
|
||||
|
||||
private const val STATE_TAG = "LongPollServiceState"
|
||||
|
||||
const val KEY_LONG_POLL_WAS_DESTROYED = "long_poll_was_destroyed"
|
||||
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
}
|
||||
}
|
||||
|
||||
private data class LongPollException(override val message: String) : Throwable()
|
||||
private data object NoAccessTokenException : Throwable()
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.meloda.fast.service.longpolling.di
|
||||
|
||||
import dev.meloda.fast.data.LongPollUpdatesParser
|
||||
import dev.meloda.fast.data.LongPollUseCase
|
||||
import dev.meloda.fast.data.LongPollUseCaseImpl
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
|
||||
val longPollModule = module {
|
||||
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
|
||||
singleOf(::LongPollUpdatesParser)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.meloda.fast.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.meloda.fast.common.AppConstants
|
||||
import dev.meloda.fast.ui.R as UiR
|
||||
|
||||
object NotificationsUtils {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun createNotification(
|
||||
context: Context,
|
||||
title: String? = null,
|
||||
contentText: String? = null,
|
||||
bigText: String? = null,
|
||||
customNotificationId: Int? = null,
|
||||
showWhen: Boolean = false,
|
||||
timeStampWhen: Long? = null,
|
||||
notify: Boolean = false,
|
||||
notRemovable: Boolean = false,
|
||||
channelId: String = AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
|
||||
priority: NotificationPriority = NotificationPriority.Default,
|
||||
contentIntent: PendingIntent? = null,
|
||||
category: String? = null,
|
||||
actions: List<NotificationCompat.Action> = emptyList(),
|
||||
): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(UiR.drawable.ic_fast_logo)
|
||||
.setContentTitle(title)
|
||||
.setPriority(priority.value)
|
||||
.setContentIntent(contentIntent)
|
||||
.setAutoCancel(true)
|
||||
.setShowWhen(showWhen)
|
||||
.setOngoing(notRemovable)
|
||||
|
||||
if (category != null) {
|
||||
builder.setCategory(category)
|
||||
}
|
||||
|
||||
if (contentText != null) {
|
||||
builder.setContentText(contentText)
|
||||
}
|
||||
|
||||
if (bigText != null) {
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
|
||||
}
|
||||
|
||||
if (timeStampWhen != null) {
|
||||
builder.setWhen(timeStampWhen)
|
||||
}
|
||||
|
||||
actions.forEach(builder::addAction)
|
||||
|
||||
if (notify) {
|
||||
try {
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
notify(customNotificationId ?: -1, builder.build())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
enum class NotificationPriority(val value: Int) {
|
||||
Default(0), Low(-1), Min(-2), High(1), Max(2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user