Поддержка Firebase Remote Config.

Уведомления об обновлении приложения.

Добавлен TopBar с проверкой обновлений.

Удалены некоторые уже не важные логи.
This commit is contained in:
2024-10-06 02:56:18 +04:00
parent 1c773b4713
commit 43cb667614
30 changed files with 728 additions and 159 deletions

View File

@@ -2,4 +2,5 @@ package ru.n08i40k.polytechnic.next
object NotificationChannels {
const val SCHEDULE_UPDATE = "schedule-update"
const val APP_UPDATE = "app-update"
}

View File

@@ -3,6 +3,7 @@ package ru.n08i40k.polytechnic.next
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import ru.n08i40k.polytechnic.next.data.AppContainer
import ru.n08i40k.polytechnic.next.utils.or
import javax.inject.Inject
@HiltAndroidApp
@@ -10,4 +11,10 @@ class PolytechnicApplication : Application() {
@Suppress("unused")
@Inject
lateinit var container: AppContainer
fun getAppVersion(): String {
return applicationContext.packageManager
.getPackageInfo(this.packageName, 0)
.versionName or "1.0.0"
}
}

View File

@@ -30,7 +30,8 @@ open class AuthorizedRequest(
.setAccessToken("").build()
}
}
context.profileViewModel!!.onUnauthorized()
if (context.profileViewModel != null)
context.profileViewModel!!.onUnauthorized()
}
errorListener?.onErrorResponse(it)

View File

@@ -78,8 +78,6 @@ open class CachedRequest(
val logger = Logger.getLogger("CachedRequest")
val repository = appContainer.networkCacheRepository
logger.info("Getting cache status...")
val cacheStatusResult = tryFuture {
ScheduleGetCacheStatus(context, it, it)
}
@@ -87,8 +85,6 @@ open class CachedRequest(
if (cacheStatusResult is MyResult.Success) {
val cacheStatus = cacheStatusResult.data
logger.info("Cache status received successfully!")
runBlocking {
repository.setUpdateDates(
cacheStatus.lastCacheUpdate,
@@ -98,12 +94,10 @@ open class CachedRequest(
}
if (cacheStatus.cacheUpdateRequired) {
logger.info("Cache update was required!")
val updateResult = runBlocking { updateMainPage() }
when (updateResult) {
is MyResult.Success -> {
logger.info("Cache update was successful!")
runBlocking {
repository.setUpdateDates(
updateResult.data.lastCacheUpdate,
@@ -124,12 +118,10 @@ open class CachedRequest(
val cachedResponse = runBlocking { repository.get(url) }
if (cachedResponse != null) {
logger.info("Found cached response!")
listener.onResponse(cachedResponse.data)
return
}
logger.info("Cached response doesn't exists!")
super.send()
}
}

View File

@@ -0,0 +1,18 @@
package ru.n08i40k.polytechnic.next.network.request.fcm
import android.content.Context
import com.android.volley.Response
import ru.n08i40k.polytechnic.next.network.request.AuthorizedRequest
class FcmUpdateCallback(
context: Context,
version: String,
listener: Response.Listener<Unit>,
errorListener: Response.ErrorListener?,
) : AuthorizedRequest(
context, Method.POST,
"fcm/update-callback/$version",
{ listener.onResponse(Unit) },
errorListener,
true
)

View File

@@ -1,8 +1,11 @@
package ru.n08i40k.polytechnic.next.service
import android.Manifest
import android.content.Context
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -11,19 +14,12 @@ import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.work.FcmSetTokenWorker
import java.time.Duration
class MyFirebaseMessagingService : FirebaseMessagingService() {
@@ -34,7 +30,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<SetFcmTokenWorker>()
val request = OneTimeWorkRequestBuilder<FcmSetTokenWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(1))
.setInputData(workDataOf("TOKEN" to token))
@@ -45,66 +41,79 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
.enqueue(request)
}
private fun sendNotification(
channel: String,
@DrawableRes iconId: Int,
title: String,
contentText: String,
priority: Int,
id: Any?,
intent: Intent? = null
) {
val pendingIntent: PendingIntent? =
if (intent != null)
PendingIntent.getActivity(this, 0, intent.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}, PendingIntent.FLAG_IMMUTABLE)
else
null
val notification = NotificationCompat
.Builder(applicationContext, channel)
.setSmallIcon(iconId)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(priority)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
with(NotificationManagerCompat.from(this)) {
if (ActivityCompat.checkSelfPermission(
this@MyFirebaseMessagingService,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return@with
}
notify(id.hashCode(), notification)
}
}
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["type"]
when (type) {
"schedule-update" -> {
val notification = NotificationCompat
.Builder(applicationContext, NotificationChannels.SCHEDULE_UPDATE)
.setSmallIcon(R.drawable.logo)
.setContentTitle(getString(R.string.schedule_update_title))
.setContentText(
getString(
if (message.data["replaced"] == "true")
R.string.schedule_update_replaced
else
R.string.schedule_update_default
)
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
sendNotification(
NotificationChannels.SCHEDULE_UPDATE,
R.drawable.logo,
getString(R.string.schedule_update_title),
getString(
if (message.data["replaced"] == "true")
R.string.schedule_update_replaced
else
R.string.schedule_update_default
),
NotificationCompat.PRIORITY_DEFAULT,
message.data["etag"]
)
}
with(NotificationManagerCompat.from(this)) {
if (ActivityCompat.checkSelfPermission(
this@MyFirebaseMessagingService,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return@with
}
notify(message.data["etag"].hashCode(), notification)
}
"app-update" -> {
sendNotification(
NotificationChannels.APP_UPDATE,
R.drawable.logo,
getString(R.string.app_update_title, message.data["version"]),
getString(R.string.app_update_description),
NotificationCompat.PRIORITY_DEFAULT,
message.data["version"],
Intent(Intent.ACTION_VIEW, Uri.parse(message.data["downloadLink"]))
)
}
}
super.onMessageReceived(message)
}
class SetFcmTokenWorker(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val fcmToken = inputData.getString("TOKEN") ?: return Result.failure()
val accessToken = runBlocking {
applicationContext.settingsDataStore.data.map { it.accessToken }.first()
}
if (accessToken.isEmpty())
return Result.retry()
val setResult = runBlocking {
(applicationContext as PolytechnicApplication)
.container
.profileRepository
.setFcmToken(fcmToken)
}
return when (setResult) {
is MyResult.Success -> Result.success()
is MyResult.Failure -> Result.retry()
}
}
}
}

View File

@@ -1,13 +1,12 @@
package ru.n08i40k.polytechnic.next.ui
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -22,44 +21,76 @@ import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.google.firebase.Firebase
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.remoteConfig
import com.google.firebase.remoteconfig.remoteConfigSettings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.NotificationChannels.SCHEDULE_UPDATE
import ru.n08i40k.polytechnic.next.NotificationChannels
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.work.FcmUpdateCallbackWorker
import ru.n08i40k.polytechnic.next.work.LinkUpdateWorker
import java.time.Duration
import java.util.concurrent.TimeUnit
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@SuppressLint("ObsoleteSdkInt")
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getString(R.string.schedule_channel_name)
val description = getString(R.string.schedule_channel_description)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(SCHEDULE_UPDATE, name, importance)
channel.description = description
val remoteConfig: FirebaseRemoteConfig = Firebase.remoteConfig
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
private val configSettings = remoteConfigSettings {
minimumFetchIntervalInSeconds = 3600
}
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
private fun createNotificationChannel(
notificationManager: NotificationManager,
name: String,
description: String,
channelId: String
) {
if (it) {
createNotificationChannel()
}
val channel = NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = description
notificationManager.createNotificationChannel(channel)
}
private fun createNotificationChannels() {
if (!hasNotificationPermission())
return
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
createNotificationChannel(
notificationManager,
getString(R.string.schedule_channel_name),
getString(R.string.schedule_channel_description),
NotificationChannels.SCHEDULE_UPDATE
)
createNotificationChannel(
notificationManager,
getString(R.string.app_update_channel_name),
getString(R.string.app_update_channel_description),
NotificationChannels.APP_UPDATE
)
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) createNotificationChannels()
}
private fun hasNotificationPermission(): Boolean {
return (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|| ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@@ -71,42 +102,72 @@ class MainActivity : ComponentActivity() {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
class CacheUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
runBlocking {
(applicationContext as PolytechnicApplication)
.container
.scheduleRepository
.getGroup()
}
return Result.success()
}
}
private fun schedulePeriodicRequest() {
fun scheduleLinkUpdate(intervalInMinutes: Long) {
val tag = "schedule-update"
val workRequest = PeriodicWorkRequest.Builder(
CacheUpdateWorker::class.java,
15, TimeUnit.MINUTES
LinkUpdateWorker::class.java,
intervalInMinutes.coerceAtLeast(15), TimeUnit.MINUTES
)
.addTag("schedule-update")
.addTag(tag)
.build()
val workManager = WorkManager.getInstance(applicationContext)
workManager.cancelAllWorkByTag("schedule-update")
workManager.cancelAllWorkByTag(tag)
workManager.enqueue(workRequest)
}
private fun setupFirebaseConfig() {
remoteConfig.setConfigSettingsAsync(configSettings)
remoteConfig.setDefaultsAsync(R.xml.remote_config_defaults)
remoteConfig
.fetchAndActivate()
.addOnCompleteListener {
if (!it.isSuccessful)
Log.w("RemoteConfig", "Failed to fetch and activate!")
scheduleLinkUpdate(remoteConfig.getLong("linkUpdateDelay"))
}
}
private fun handleUpdate() {
lifecycleScope.launch {
val appVersion = (applicationContext as PolytechnicApplication).getAppVersion()
if (settingsDataStore.data.map { it.version }.first() != appVersion) {
settingsDataStore.updateData { it.toBuilder().setVersion(appVersion).build() }
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<FcmUpdateCallbackWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, Duration.ofMinutes(1))
.setInputData(workDataOf("VERSION" to appVersion))
.build()
WorkManager
.getInstance(this@MainActivity)
.enqueue(request)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
schedulePeriodicRequest()
askNotificationPermission()
createNotificationChannels()
if (hasNotificationPermission())
createNotificationChannel()
setupFirebaseConfig()
handleUpdate()
setContent {
Box(Modifier.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))) {

View File

@@ -0,0 +1,3 @@
package ru.n08i40k.polytechnic.next.ui.icons
object AppIcons

View File

@@ -0,0 +1,9 @@
package ru.n08i40k.polytechnic.next.ui.icons.appicons
import ru.n08i40k.polytechnic.next.ui.icons.AppIcons
object FilledGroup
@Suppress("UnusedReceiverParameter")
val AppIcons.Filled: FilledGroup
get() = FilledGroup

View File

@@ -0,0 +1,59 @@
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Round
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
@Suppress("UnusedReceiverParameter")
val FilledGroup.Download: ImageVector
get() {
if (_download != null) {
return _download!!
}
_download = Builder(
name = "Download", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
fill = SolidColor(Color(0x00000000)), stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 2.0f, strokeLineCap = Round, strokeLineJoin =
StrokeJoin.Companion.Round, strokeLineMiter = 4.0f, pathFillType = NonZero
) {
moveTo(3.0f, 12.3f)
verticalLineToRelative(7.0f)
arcToRelative(2.0f, 2.0f, 0.0f, false, false, 2.0f, 2.0f)
horizontalLineTo(19.0f)
arcToRelative(2.0f, 2.0f, 0.0f, false, false, 2.0f, -2.0f)
verticalLineToRelative(-7.0f)
}
path(
fill = SolidColor(Color(0x00000000)), stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 2.0f, strokeLineCap = Round, strokeLineJoin =
StrokeJoin.Companion.Round, strokeLineMiter = 4.0f, pathFillType = NonZero
) {
moveTo(7.9f, 12.3f)
lineToRelative(4.1f, 4.0f)
lineToRelative(4.1f, -4.0f)
}
path(
fill = SolidColor(Color(0x00000000)), stroke = SolidColor(Color(0xFF000000)),
strokeLineWidth = 2.0f, strokeLineCap = Round, strokeLineJoin =
StrokeJoin.Companion.Round, strokeLineMiter = 4.0f, pathFillType = NonZero
) {
moveTo(12.0f, 2.7f)
lineTo(12.0f, 14.2f)
}
}
.build()
return _download!!
}
@Suppress("ObjectPropertyName")
private var _download: ImageVector? = null

View File

@@ -0,0 +1,53 @@
package ru.n08i40k.polytechnic.next.ui.icons.appicons.filled
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ru.n08i40k.polytechnic.next.ui.icons.appicons.FilledGroup
@Suppress("UnusedReceiverParameter")
val FilledGroup.Telegram: ImageVector
get() {
if (_telegram != null) {
return _telegram!!
}
_telegram = Builder(
name = "Telegram", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveToRelative(20.665f, 3.717f)
lineToRelative(-17.73f, 6.837f)
curveToRelative(-1.21f, 0.486f, -1.203f, 1.161f, -0.222f, 1.462f)
lineToRelative(4.552f, 1.42f)
lineToRelative(10.532f, -6.645f)
curveToRelative(0.498f, -0.303f, 0.953f, -0.14f, 0.579f, 0.192f)
lineToRelative(-8.533f, 7.701f)
horizontalLineToRelative(-0.002f)
lineToRelative(0.002f, 0.001f)
lineToRelative(-0.314f, 4.692f)
curveToRelative(0.46f, 0.0f, 0.663f, -0.211f, 0.921f, -0.46f)
lineToRelative(2.211f, -2.15f)
lineToRelative(4.599f, 3.397f)
curveToRelative(0.848f, 0.467f, 1.457f, 0.227f, 1.668f, -0.785f)
lineToRelative(3.019f, -14.228f)
curveToRelative(0.309f, -1.239f, -0.473f, -1.8f, -1.282f, -1.434f)
close()
}
}
.build()
return _telegram!!
}
@Suppress("ObjectPropertyName")
private var _telegram: ImageVector? = null

View File

@@ -1,25 +1,52 @@
package ru.n08i40k.polytechnic.next.ui.main
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -32,13 +59,21 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.MainViewModel
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.R
import ru.n08i40k.polytechnic.next.model.UserRole
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
import ru.n08i40k.polytechnic.next.ui.MainActivity
import ru.n08i40k.polytechnic.next.ui.icons.AppIcons
import ru.n08i40k.polytechnic.next.ui.icons.appicons.Filled
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Download
import ru.n08i40k.polytechnic.next.ui.icons.appicons.filled.Telegram
import ru.n08i40k.polytechnic.next.ui.main.profile.ProfileScreen
import ru.n08i40k.polytechnic.next.ui.main.replacer.ReplacerScreen
import ru.n08i40k.polytechnic.next.ui.main.schedule.ScheduleScreen
import ru.n08i40k.polytechnic.next.ui.model.ProfileUiState
import ru.n08i40k.polytechnic.next.ui.model.ProfileViewModel
import ru.n08i40k.polytechnic.next.ui.model.RemoteConfigViewModel
import ru.n08i40k.polytechnic.next.ui.model.ScheduleReplacerViewModel
import ru.n08i40k.polytechnic.next.ui.model.ScheduleViewModel
import ru.n08i40k.polytechnic.next.ui.model.profileViewModel
@@ -91,6 +126,88 @@ private fun NavHostContainer(
})
}
private fun openLink(context: Context, link: String) {
startActivity(context, Intent(Intent.ACTION_VIEW, Uri.parse(link)), null)
}
@Composable
private fun LinkButton(
text: String,
icon: ImageVector,
link: String,
enabled: Boolean = true,
badged: Boolean = false,
) {
val context = LocalContext.current
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = { openLink(context, link) },
enabled = enabled,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
BadgedBox(badge = { if (badged) Badge() }) {
Icon(imageVector = icon, contentDescription = text)
}
Spacer(Modifier.width(5.dp))
Text(text)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopNavBar(
remoteConfigViewModel: RemoteConfigViewModel
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val remoteConfigUiState by remoteConfigViewModel.uiState.collectAsStateWithLifecycle()
val packageVersion =
(LocalContext.current.applicationContext as PolytechnicApplication).getAppVersion()
val updateAvailable = remoteConfigUiState.currVersion != packageVersion
TopAppBar(
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
},
actions = {
IconButton(onClick = { dropdownExpanded = true }) {
BadgedBox(badge = { if (updateAvailable) Badge() }) {
Icon(imageVector = Icons.Filled.Menu, contentDescription = "TopAppBar Menu")
}
}
DropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
Column(modifier = Modifier.wrapContentWidth()) {
LinkButton(
text = stringResource(R.string.download_update),
icon = AppIcons.Filled.Download,
link = remoteConfigUiState.downloadLink,
enabled = updateAvailable,
badged = updateAvailable
)
LinkButton(
text = stringResource(R.string.telegram_channel),
icon = AppIcons.Filled.Telegram,
link = remoteConfigUiState.telegramLink,
)
}
}
}
)
}
@Composable
private fun BottomNavBar(navController: NavHostController, isAdmin: Boolean) {
NavigationBar {
@@ -131,26 +248,49 @@ fun MainScreen(
if (accessToken.isEmpty()) appNavController.navigate("auth")
}
val scheduleViewModel =
hiltViewModel<ScheduleViewModel>(LocalContext.current as ComponentActivity)
LocalContext.current.profileViewModel =
// profile view model
val profileViewModel: ProfileViewModel =
viewModel(
factory = ProfileViewModel.provideFactory(
profileRepository = mainViewModel.appContainer.profileRepository,
onUnauthorized = { appNavController.navigate("auth") })
)
LocalContext.current.profileViewModel = profileViewModel
val profileUiState by LocalContext.current.profileViewModel!!.uiState.collectAsStateWithLifecycle()
val isAdmin = (profileUiState is ProfileUiState.HasProfile) &&
(profileUiState as ProfileUiState.HasProfile).profile.role == UserRole.ADMIN
// remote config view model
val remoteConfigViewModel: RemoteConfigViewModel =
viewModel(
factory = RemoteConfigViewModel.provideFactory(
appContext = LocalContext.current,
remoteConfig = (LocalContext.current as MainActivity).remoteConfig
)
)
// schedule view model
val scheduleViewModel =
hiltViewModel<ScheduleViewModel>(LocalContext.current as ComponentActivity)
// schedule replacer view model
val profileUiState by profileViewModel.uiState.collectAsStateWithLifecycle()
val isAdmin = when (profileUiState) {
is ProfileUiState.NoProfile -> false
is ProfileUiState.HasProfile -> {
val profile = (profileUiState as ProfileUiState.HasProfile).profile
profile.role == UserRole.ADMIN
}
}
val scheduleReplacerViewModel: ScheduleReplacerViewModel? =
if (isAdmin) hiltViewModel(LocalContext.current as ComponentActivity)
else null
// nav controller
val navController = rememberNavController()
Scaffold(
topBar = { TopNavBar(remoteConfigViewModel) },
bottomBar = { BottomNavBar(navController, isAdmin) }
) { paddingValues ->
NavHostContainer(

View File

@@ -0,0 +1,89 @@
package ru.n08i40k.polytechnic.next.ui.model
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.firebase.remoteconfig.ConfigUpdate
import com.google.firebase.remoteconfig.ConfigUpdateListener
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.FirebaseRemoteConfigException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import ru.n08i40k.polytechnic.next.ui.MainActivity
import java.util.logging.Logger
data class RemoteConfigUiState(
val minVersion: String,
val currVersion: String,
val serverVersion: String,
val downloadLink: String,
val telegramLink: String,
val linkUpdateDelay: Long,
)
class RemoteConfigViewModel(
private val appContext: Context,
private val remoteConfig: FirebaseRemoteConfig,
) : ViewModel() {
private val viewModelState = MutableStateFlow(
RemoteConfigUiState(
minVersion = remoteConfig.getString("minVersion"),
currVersion = remoteConfig.getString("currVersion"),
downloadLink = remoteConfig.getString("downloadLink"),
telegramLink = remoteConfig.getString("telegramLink"),
serverVersion = remoteConfig.getString("serverVersion"),
linkUpdateDelay = remoteConfig.getLong("linkUpdateDelay"),
)
)
val uiState = viewModelState
.stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value)
init {
(appContext as MainActivity)
.scheduleLinkUpdate(viewModelState.value.linkUpdateDelay)
remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener {
override fun onUpdate(configUpdate: ConfigUpdate) {
remoteConfig.activate().addOnCompleteListener {
viewModelState.update {
it.copy(
minVersion = remoteConfig.getString("minVersion"),
currVersion = remoteConfig.getString("currVersion"),
downloadLink = remoteConfig.getString("downloadLink"),
telegramLink = remoteConfig.getString("telegramLink"),
serverVersion = remoteConfig.getString("serverVersion"),
linkUpdateDelay = remoteConfig.getLong("linkUpdateDelay"),
)
}
appContext.scheduleLinkUpdate(viewModelState.value.linkUpdateDelay)
}
}
override fun onError(error: FirebaseRemoteConfigException) {
Logger.getLogger("RemoteConfigViewModel")
.severe("Failed to fetch RemoteConfig update!")
}
})
}
companion object {
fun provideFactory(
appContext: Context,
remoteConfig: FirebaseRemoteConfig,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") return RemoteConfigViewModel(
appContext,
remoteConfig,
) as T
}
}
}
}

View File

@@ -0,0 +1,7 @@
package ru.n08i40k.polytechnic.next.utils
infix fun <T> T?.or(data: T): T {
if (this == null)
return data
return this
}

View File

@@ -0,0 +1,36 @@
package ru.n08i40k.polytechnic.next.work
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
class FcmSetTokenWorker(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val fcmToken = inputData.getString("TOKEN") ?: return Result.failure()
val accessToken = runBlocking {
applicationContext.settingsDataStore.data.map { it.accessToken }.first()
}
if (accessToken.isEmpty())
return Result.retry()
val setResult = runBlocking {
(applicationContext as PolytechnicApplication)
.container
.profileRepository
.setFcmToken(fcmToken)
}
return when (setResult) {
is MyResult.Success -> Result.success()
is MyResult.Failure -> Result.retry()
}
}
}

View File

@@ -0,0 +1,36 @@
package ru.n08i40k.polytechnic.next.work
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.data.MyResult
import ru.n08i40k.polytechnic.next.network.request.fcm.FcmUpdateCallback
import ru.n08i40k.polytechnic.next.network.tryFuture
import ru.n08i40k.polytechnic.next.settings.settingsDataStore
class FcmUpdateCallbackWorker(context: Context, workerParams: WorkerParameters) :
Worker(context, workerParams) {
override fun doWork(): Result {
val version = inputData.getString("VERSION") ?: return Result.failure()
val accessToken = runBlocking {
applicationContext.settingsDataStore.data.map { it.accessToken }.first()
}
if (accessToken.isEmpty())
return Result.retry()
val result = runBlocking {
tryFuture {
FcmUpdateCallback(this@FcmUpdateCallbackWorker.applicationContext, version, it, it)
}
}
return when (result) {
is MyResult.Success -> Result.success()
is MyResult.Failure -> Result.retry()
}
}
}

View File

@@ -0,0 +1,20 @@
package ru.n08i40k.polytechnic.next.work
import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.runBlocking
import ru.n08i40k.polytechnic.next.PolytechnicApplication
class LinkUpdateWorker(context: Context, params: WorkerParameters) :
Worker(context, params) {
override fun doWork(): Result {
runBlocking {
(applicationContext as PolytechnicApplication)
.container
.scheduleRepository
.getGroup()
}
return Result.success()
}
}

View File

@@ -19,4 +19,5 @@ message Settings {
string group = 3;
map<string, CachedResponse> cache_storage = 4;
UpdateDates update_dates = 5;
string version = 6;
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#00000000" android:pathData="M3,12.3v7a2,2 0,0 0,2 2H19a2,2 0,0 0,2 -2v-7" android:strokeColor="#000000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M7.9,12.3l4.1,4l4.1,-4" android:strokeColor="#000000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M12,2.7L12,14.2" android:strokeColor="#000000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#000000" android:pathData="m20.665,3.717 l-17.73,6.837c-1.21,0.486 -1.203,1.161 -0.222,1.462l4.552,1.42 10.532,-6.645c0.498,-0.303 0.953,-0.14 0.579,0.192l-8.533,7.701h-0.002l0.002,0.001 -0.314,4.692c0.46,0 0.663,-0.211 0.921,-0.46l2.211,-2.15 4.599,3.397c0.848,0.467 1.457,0.227 1.668,-0.785l3.019,-14.228c0.309,-1.239 -0.473,-1.8 -1.282,-1.434z"/>
</vector>

View File

@@ -42,7 +42,13 @@
<string name="set_replacer">Загрузить новое расписание</string>
<string name="schedule_channel_name">Обновления расписания</string>
<string name="schedule_channel_description">Информирует об обновлении расписания</string>
<string name="schedule_update_title">Расписание обновлено.</string>
<string name="schedule_update_title">Расписание обновлено!</string>
<string name="schedule_update_replaced">Расписание было обновлено Администратором.</string>
<string name="schedule_update_default">Расписание было обновлено на сайте политехникума.</string>
<string name="download_update">Скачать обновление</string>
<string name="telegram_channel">Телеграм канал</string>
<string name="app_update_channel_name">Обновление приложения</string>
<string name="app_update_channel_description">Информирует о выходе новой версии этого приложения</string>
<string name="app_update_title">Вышла версия %1$s!</string>
<string name="app_update_description">Нажмите что бы загрузить обновление.</string>
</resources>

View File

@@ -42,7 +42,13 @@
<string name="set_replacer">Set new</string>
<string name="schedule_channel_name">Schedule update</string>
<string name="schedule_channel_description">Inform when schedule has been updated</string>
<string name="schedule_update_title">Schedule has been updated.</string>
<string name="schedule_update_title">Schedule has been updated!</string>
<string name="schedule_update_replaced">Schedule was updated by Administrator.</string>
<string name="schedule_update_default">Schedule was updated on polytechnic website.</string>
<string name="download_update">Download update</string>
<string name="telegram_channel">Telegram channel</string>
<string name="app_update_channel_name">Application update</string>
<string name="app_update_channel_description">Inform about new version of this app has been released</string>
<string name="app_update_title">Version %1$s released!</string>
<string name="app_update_description">Click to download new version.</string>
</resources>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<defaults>
<entry>
<key>linkUpdateDelay</key>
<value>15</value>
</entry>
<entry>
<key>minVersion</key>
<value>1.3.0</value>
</entry>
<entry>
<key>currVersion</key>
<value>1.5.0</value>
</entry>
<entry>
<key>downloadLink</key>
<value>https://t.me/polytechnic_next/16</value>
</entry>
<entry>
<key>telegramLink</key>
<value>https://t.me/polytechnic_next</value>
</entry>
<entry>
<key>serverVersion</key>
<value>1.3.0</value>
</entry>
</defaults>