update package name (even bigger one)

This commit is contained in:
2024-07-16 07:02:50 +03:00
parent 4f9e49003b
commit c8b1d72f08
367 changed files with 12 additions and 25 deletions
@@ -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)
}
}