feat: Add announcements (#2948)

Co-authored-by: Ushie <ushiekane@gmail.com>
Co-authored-by: Ax333l <main@axelen.xyz>
This commit is contained in:
Tornike Khintibidze
2026-02-23 02:23:18 +04:00
committed by GitHub
parent 6f4219c01b
commit 813df46847
17 changed files with 798 additions and 5 deletions

View File

@@ -22,6 +22,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import app.revanced.manager.ui.model.navigation.Announcement
import app.revanced.manager.ui.model.navigation.Announcements
import app.revanced.manager.ui.model.navigation.AppSelector
import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
@@ -30,6 +32,8 @@ import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AnnouncementScreen
import app.revanced.manager.ui.screen.AnnouncementsScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
@@ -104,7 +108,7 @@ private fun ReVancedManager(vm: MainViewModel) {
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable<Dashboard> {
DashboardScreen(
@@ -120,6 +124,12 @@ private fun ReVancedManager(vm: MainViewModel) {
},
onAppClick = { packageName ->
navController.navigate(InstalledApplicationInfo(packageName))
},
onAnnouncementsClick = {
navController.navigate(Announcements)
},
onAnnouncementClick = { announcement ->
navController.navigateComplex(Announcement, announcement)
}
)
}
@@ -165,6 +175,22 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<Announcements> {
AnnouncementsScreen(
onBackClick = navController::popBackStack,
onAnnouncementClick = { announcement ->
navController.navigateComplex(Announcement, announcement)
}
)
}
composable<Announcement> {
AnnouncementScreen(
onBackClick = navController::popBackStack,
announcement = it.getComplexArg()
)
}
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)

View File

@@ -11,6 +11,7 @@ import org.koin.dsl.module
val repositoryModule = module {
singleOf(::ReVancedAPI)
singleOf(::AnnouncementRepository)
singleOf(::Filesystem) {
createdAtStart()
}

View File

@@ -14,6 +14,7 @@ val viewModelModule = module {
viewModelOf(::AppSelectorViewModel)
viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel)
viewModelOf(::AnnouncementsViewModel)
viewModelOf(::ChangelogsViewModel)
viewModelOf(::ImportExportViewModel)
viewModelOf(::AboutViewModel)

View File

@@ -20,6 +20,8 @@ class PreferencesManager(
val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val readAnnouncements = longSetPreference("read_announcements", emptySet())
val selectedAnnouncementTags = stringSetPreference("selected_announcement_tags", setOf("revanced", "manager"))
val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)

View File

@@ -29,6 +29,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String
protected fun stringSetPreference(key: String, default: Set<String>) =
StringSetPreference(dataStore, key, default)
protected fun longSetPreference(key: String, default: Set<Long>) =
LongSetPreference(dataStore, key, default)
protected fun booleanPreference(key: String, default: Boolean) =
BooleanPreference(dataStore, key, default)
@@ -56,9 +59,13 @@ class EditorContext(private val prefs: MutablePreferences) {
get() = prefs.run { read() }
set(value) = prefs.run { write(value) }
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
operator fun <T> Preference<Set<T>>.plusAssign(value: T) = prefs.run {
write(read() + value)
}
operator fun <T> Preference<Set<T>>.minusAssign(value: T) = prefs.run {
write(read() subtract setOf(value))
}
}
abstract class Preference<T>(
@@ -125,6 +132,19 @@ class StringSetPreference(
override val key = stringSetPreferencesKey(key)
}
class LongSetPreference(
dataStore: DataStore<Preferences>,
key: String,
default: Set<Long>
) : Preference<Set<Long>>(dataStore, default) {
private val key = stringSetPreferencesKey(key)
override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default
override fun MutablePreferences.write(value: Set<Long>) {
this[key] = value.mapTo(mutableSetOf()) { it.toString() }
}
}
class BooleanPreference(
dataStore: DataStore<Preferences>,
key: String,

View File

@@ -0,0 +1,34 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
import app.revanced.manager.network.utils.getOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class AnnouncementRepository(
private val api: ReVancedAPI
) {
private val mutex = Mutex()
private var cachedAnnouncements: List<ReVancedAnnouncement>? = null
private var cachedTags: List<ReVancedAnnouncementTag>? = null
suspend fun getAnnouncements(forceRefresh: Boolean = false): List<ReVancedAnnouncement>? {
mutex.withLock {
if (cachedAnnouncements == null || forceRefresh) {
cachedAnnouncements = api.getAnnouncements().getOrNull()
}
return cachedAnnouncements
}
}
suspend fun getTags(forceRefresh: Boolean = false): List<ReVancedAnnouncementTag>? {
mutex.withLock {
if (cachedTags == null || forceRefresh) {
cachedTags = api.getAnnouncementTags().getOrNull()
}
return cachedTags
}
}
}

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.network.api
import app.revanced.manager.BuildConfig
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.dto.ReVancedGitRepository
import app.revanced.manager.network.dto.ReVancedInfo
@@ -29,6 +31,10 @@ class ReVancedAPI(
private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route)
suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
suspend fun getAnnouncementTags() = request<List<ReVancedAnnouncementTag>>("announcements/tags")
suspend fun getAppUpdate() =
getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME }

View File

@@ -0,0 +1,23 @@
package app.revanced.manager.network.dto
import android.os.Parcelable
import kotlinx.datetime.LocalDateTime
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class ReVancedAnnouncement(
val id: Long,
val author: String,
val title: String,
val content: String,
val tags: List<String>,
val attachments: List<String>,
@SerialName("created_at")
val createdAt: LocalDateTime,
@SerialName("archived_at")
val archivedAt: LocalDateTime,
val level: Int,
) : Parcelable

View File

@@ -0,0 +1,8 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.Serializable
@Serializable
data class ReVancedAnnouncementTag(
val name: String
)

View File

@@ -1,6 +1,7 @@
package app.revanced.manager.ui.model.navigation
import android.os.Parcelable
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
@@ -22,6 +23,12 @@ data class InstalledApplicationInfo(val packageName: String)
@Serializable
data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object Announcements
@Serializable
data object Announcement : ComplexParameter<ReVancedAnnouncement>
@Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
@Parcelize

View File

@@ -0,0 +1,136 @@
package app.revanced.manager.ui.screen
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TwoRowsTopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.relativeTime
import org.intellij.lang.annotations.Language
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnnouncementScreen(
onBackClick: () -> Unit,
announcement: ReVancedAnnouncement
) {
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val textColor = MaterialTheme.colorScheme.onSurface
val linkColor = MaterialTheme.colorScheme.primary
Scaffold(
topBar = {
TwoRowsTopAppBar(
title = { expanded ->
Text(
text = announcement.title,
style = if (expanded) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleMedium
)
},
subtitle = {
val createDate = announcement.createdAt.relativeTime(LocalContext.current)
Text("$createDate · ${announcement.author}")
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
AndroidView(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(horizontal = 10.dp),
factory = {
val webView = WebView(it).apply {
setBackgroundColor(0)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
isLongClickable = false
setOnLongClickListener { true }
isHapticFeedbackEnabled = false
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
// Disable WebView's internal scrolling
@SuppressLint("ClickableViewAccessibility")
setOnTouchListener { _, event ->
event.action == MotionEvent.ACTION_MOVE
}
}
FrameLayout(it).apply {
addView(webView)
}
},
update = {
val webView = it.children.first() as WebView
@Language("HTML")
val style = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
color: ${textColor.toCss()};
}
a {
color: ${linkColor.toCss()};
}
</style>
</head>
<body>
${announcement.content}
</body>
</html>
""".trimIndent()
webView.loadData(style, "text/html", "UTF-8")
},
onRelease = {
val webView = it.children.first() as WebView
webView.destroy()
}
)
}
}
private fun Color.toCss(): String {
return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
}

View File

@@ -0,0 +1,329 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material3.Badge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.AnnouncementsViewModel
import app.revanced.manager.util.relativeTime
import app.revanced.manager.util.transparentListItemColors
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.koin.androidx.compose.koinViewModel
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnnouncementsScreen(
onBackClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
vm: AnnouncementsViewModel = koinViewModel(),
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
val tags by vm.tags.collectAsStateWithLifecycle()
val selectedTags by vm.selectedTags.getAsState()
val showArchived by vm.showArchived.collectAsStateWithLifecycle()
val announcements by vm.announcements.collectAsStateWithLifecycle(emptyList())
if (showFilterSheet) {
FilterBottomSheet(
onDismissRequest = { showFilterSheet = false },
tags = tags.orEmpty(),
selectedTags = selectedTags,
showArchived = showArchived,
onShowArchivedChange = { vm.showArchived.value = it },
onReset = vm::resetTagSelection,
changeSelection = vm::changeTagSelection
)
}
Scaffold(
topBar = {
AppTopBar(
title = { Text(stringResource(R.string.announcements)) },
onBackClick = onBackClick,
actions = {
if (tags != null) {
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Outlined.FilterAlt,
contentDescription = stringResource(R.string.announcements_filter_tag)
)
}
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
val readAnnouncements by vm.readAnnouncements.getAsState()
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection),
verticalArrangement = if (announcements.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
announcements?.let { announcements ->
if (announcements.isEmpty()) {
item {
Text(
text = stringResource(id = R.string.no_announcements_found),
style = MaterialTheme.typography.titleLarge
)
}
} else {
itemsIndexed(
items = announcements,
key = { _, announcement ->
announcement.id
}
) { i, announcement ->
if (i != 0) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth()
)
}
AnnouncementCard(
modifier = Modifier
.fillMaxWidth(),
onClick = {
vm.markAnnouncementRead(announcement.id)
onAnnouncementClick(announcement)
},
title = announcement.title,
date = announcement.createdAt.relativeTime(LocalContext.current),
author = announcement.author,
content = announcement.content,
unread = announcement.id !in readAnnouncements,
archived = announcement.archivedAt.toInstant(TimeZone.UTC) < Clock.System.now()
)
}
}
} ?: item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
LoadingIndicator()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterBottomSheet(
onDismissRequest: () -> Unit,
tags: List<String>,
selectedTags: Set<String>,
showArchived: Boolean,
onShowArchivedChange: (Boolean) -> Unit,
onReset: () -> Unit,
changeSelection: (String) -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismissRequest) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.announcements_filter_tag),
style = MaterialTheme.typography.titleMedium
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
FilterChip(
selected = tag in selectedTags,
onClick = {
changeSelection(tag)
},
label = { Text(tag) }
)
}
}
ListItem(
modifier = Modifier.clickable(onClick = { onShowArchivedChange(!showArchived) }),
headlineContent = { Text(text = stringResource(R.string.announcements_show_archived)) },
trailingContent = {
Switch(
checked = showArchived,
onCheckedChange = onShowArchivedChange
)
},
colors = transparentListItemColors
)
TextButton(modifier = Modifier.align(Alignment.End), onClick = onReset) {
Text(stringResource(R.string.reset))
}
}
}
}
@Composable
private fun AnnouncementCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
title: String,
date: String,
author: String,
content: String,
unread: Boolean,
archived: Boolean
) {
Column(
modifier = modifier
.clickable(onClick = onClick)
.background(if (unread) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (unread) FontWeight.ExtraBold else null
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$date$author",
style = MaterialTheme.typography.labelMedium,
fontWeight = if (unread) FontWeight.ExtraBold else null
)
if (archived) {
Icon(
Icons.Outlined.Inventory2,
contentDescription = null,
modifier = Modifier.size(12.dp)
)
}
if (unread) {
Badge(modifier = Modifier.size(6.dp))
}
}
}
Icon(Icons.Default.ChevronRight, contentDescription = null)
}
// TODO add announcement summary
// val textColor = MaterialTheme.colorScheme.onSurface
// val linkColor = MaterialTheme.colorScheme.primary
// AndroidView(
// factory = {
// WebView(it).apply {
// setBackgroundColor(0)
// isVerticalScrollBarEnabled = false
// isHorizontalScrollBarEnabled = false
// isLongClickable = false
// setOnLongClickListener { true }
// isHapticFeedbackEnabled = false
//
// // Disable WebView's internal scrolling
// @SuppressLint("ClickableViewAccessibility")
// setOnTouchListener { _, event ->
// event.action == MotionEvent.ACTION_MOVE
// }
// }
// },
// update = {
// @Language("HTML")
// val body = """
// <html>
// <head>
// <meta name="viewport" content="width=device-width, initial-scale=1" />
// <style>
// * {
// font-size: 12px;
// font-weight: normal;
// }
// body {
// margin: 0;
// padding: 0;
// color: ${textColor.toCss()};
// overflow: hidden;
// display: -webkit-box;
// -webkit-box-orient: vertical;
// -webkit-line-clamp: 3;
// text-overflow: ellipsis;
// }
// a {
// color: ${linkColor.toCss()};
// }
// </style>
// </head>
// <body>
// $content
// </body>
// </html>
// """.trimIndent()
//
// it.loadData(body, "text/html", "UTF-8")
// },
// onReset = {},
// onRelease = { it.destroy() }
// )
}
}
//private fun Color.toCss(): String {
// return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
//}

View File

@@ -19,11 +19,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
@@ -58,6 +60,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
@@ -91,6 +94,8 @@ fun DashboardScreen(
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit,
onAnnouncementsClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
onDownloaderClick: () -> Unit,
onAppClick: (String) -> Unit
) {
@@ -219,6 +224,20 @@ fun DashboardScreen(
}
}
}
IconButton(onClick = onAnnouncementsClick) {
BadgedBox(
badge = {
if (vm.unreadAnnouncement != null) {
Badge(modifier = Modifier.size(6.dp))
}
}
) {
Icon(
Icons.Outlined.Notifications,
stringResource(R.string.announcements)
)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
}
@@ -321,7 +340,29 @@ fun DashboardScreen(
}
)
}
} else null
} else null,
vm.unreadAnnouncement?.let { announcement ->
{
NotificationCard(
text = stringResource(R.string.new_announcement, announcement.title),
icon = Icons.Filled.Notifications,
actions = {
TextButton(onClick = vm::markUnreadAnnouncementRead) {
Text(stringResource(R.string.dismiss))
}
TextButton(
onClick = {
vm.markUnreadAnnouncementRead()
onAnnouncementClick(announcement)
}
) {
Text(stringResource(R.string.view_announcement))
}
},
isWarning = announcement.level > 0
)
}
}
)
HorizontalPager(

View File

@@ -0,0 +1,101 @@
package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.AnnouncementRepository
import app.revanced.manager.network.dto.ReVancedAnnouncement
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
class AnnouncementsViewModel(
private val announcementRepository: AnnouncementRepository,
private val network: NetworkInfo,
private val preferences: PreferencesManager
) : ViewModel() {
private val allAnnouncements = MutableStateFlow<List<ReVancedAnnouncement>?>(null)
private val _tags = MutableStateFlow<List<String>?>(null)
val tags get() = _tags.asStateFlow()
val selectedTags = preferences.selectedAnnouncementTags
val readAnnouncements = preferences.readAnnouncements
val showArchived = MutableStateFlow(false)
val announcements = combine(
allAnnouncements,
_tags,
selectedTags.flow,
showArchived
) { source, tags, selectedTags, showArchived ->
if (source == null) return@combine null
// Only filter by tags that actually exist
val availableTags = tags.orEmpty().toSet()
val validSelected = selectedTags.intersect(availableTags)
val visibleAnnouncements = if (showArchived) {
source
} else {
source.filter { announcement ->
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
}
}
if (validSelected.isEmpty()) {
visibleAnnouncements
} else {
visibleAnnouncements.filter { announcement ->
announcement.tags.any(validSelected::contains)
}
}
}
init {
loadData()
}
fun markAnnouncementRead(id: Long) {
viewModelScope.launch {
preferences.edit {
preferences.readAnnouncements += id
}
}
}
fun changeTagSelection(tag: String) = viewModelScope.launch {
preferences.edit {
if (tag in selectedTags.value) selectedTags -= tag
else selectedTags += tag
}
}
fun resetTagSelection() = viewModelScope.launch {
selectedTags.update(preferences.selectedAnnouncementTags.default)
}
private fun loadData() {
viewModelScope.launch {
if (!network.isConnected()) {
allAnnouncements.value = emptyList()
_tags.value = emptyList()
return@launch
}
withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()?.let {
allAnnouncements.value = it
}
announcementRepository.getTags()?.let {
_tags.value = it.map { tag -> tag.name }
}
}
}
}
}

View File

@@ -16,21 +16,29 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.AnnouncementRepository
import app.revanced.manager.domain.repository.DownloaderRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.PM
import app.revanced.manager.util.uiSafe
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardViewModel(
private val app: Application,
private val patchBundleRepository: PatchBundleRepository,
private val downloaderRepository: DownloaderRepository,
private val announcementRepository: AnnouncementRepository,
private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo,
val prefs: PreferencesManager,
@@ -57,12 +65,16 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false)
private set
var unreadAnnouncement by mutableStateOf<ReVancedAnnouncement?>(null)
private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
init {
viewModelScope.launch {
checkForManagerUpdates()
checkForAnnouncements()
updateBatteryOptimizationsWarning()
}
}
@@ -79,6 +91,44 @@ class DashboardViewModel(
}
}
private suspend fun checkForAnnouncements() {
uiSafe(app, R.string.failed_to_check_updates, "Failed to check for announcements") {
val announcements = withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()
} ?: throw IllegalStateException("Announcements could not be retrieved")
val readAnnouncements = prefs.readAnnouncements.get()
if (readAnnouncements.isEmpty()) {
val announcementIds = announcements.mapTo(mutableSetOf()) { it.id }
prefs.readAnnouncements.update(announcementIds)
return@uiSafe
}
unreadAnnouncement = announcements.firstOrNull { announcement ->
val isNotArchived =
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
val hasRelevantTag = "revanced" in announcement.tags ||
"manager" in announcement.tags
val isUnread = announcement.id !in readAnnouncements
isNotArchived && hasRelevantTag && isUnread
}
}
}
fun markUnreadAnnouncementRead() {
viewModelScope.launch {
unreadAnnouncement?.let {
prefs.edit {
prefs.readAnnouncements += it.id
}
}
unreadAnnouncement = null
}
}
fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName)

View File

@@ -82,6 +82,9 @@ Second \"item\" text"</string>
<string name="auto_updates_dialog_patches">ReVanced Patches</string>
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
<string name="announcements_filter_tag">Filter by tag</string>
<string name="announcements_show_archived">Show archived</string>
<string name="general">General</string>
<string name="general_description">Language, theme, dynamic color</string>
<string name="updates">Updates</string>
@@ -380,6 +383,7 @@ It is only compatible with the following version(s): %2$s"</string>
<string name="less">Less</string>
<string name="continue_">Continue</string>
<string name="dismiss">Dismiss</string>
<string name="view_announcement">View announcement</string>
<string name="permanent_dismiss">Do not show this again</string>
<string name="donate">Donate</string>
<string name="website">Website</string>
@@ -398,6 +402,8 @@ It is only compatible with the following version(s): %2$s"</string>
<string name="patches_delete_single_dialog_description">Are you sure you want to delete \"%s\"?</string>
<string name="patches_delete_multiple_dialog_description">Are you sure you want to delete the selected patches?</string>
<string name="announcements">Announcements</string>
<string name="archive">Archive</string>
<string name="about_revanced_manager">About ReVanced Manager</string>
<string name="revanced_manager_description">ReVanced Manager is an Android application that uses ReVanced Patcher to patch Android apps. It allows you to download and patch apps with custom patches, and manage the patching process.</string>
<string name="developer_options_taps">%d taps remaining</string>
@@ -443,6 +449,7 @@ ReVanced Manager will close when updating."</string>
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
<string name="no_update_available">No update available</string>
<string name="no_announcements_found">No announcements found</string>
<string name="update_check">Checking for updates…</string>
<string name="dismiss_temporary">Not now</string>
<string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
@@ -499,4 +506,5 @@ Click on the patches to see more details."</string>
<string name="failed_to_import_keystore">Failed to import keystore</string>
<string name="export">Export</string>
<string name="confirm">Confirm</string>
<string name="new_announcement">New announcement:\n%s</string>
</resources>

View File

@@ -1,6 +1,6 @@
[versions]
ktx = "1.17.0"
material3 = "1.4.0"
material3 = "1.5.0-alpha14"
ui-tooling = "1.10.0"
viewmodel-lifecycle = "2.10.0"
splash-screen = "1.2.0"
@@ -9,7 +9,7 @@ appcompat = "1.7.1"
preferences-datastore = "1.2.0"
work-runtime = "2.11.0"
compose-bom = "2025.12.01"
navigation = "2.9.6"
navigation = "2.9.7"
accompanist = "0.37.3"
placeholder = "1.0.12"
reorderable = "3.0.0"