Skip to content

Comments

feat: Add announcements#2948

Open
X1nto wants to merge 20 commits intoReVanced:devfrom
X1nto:compose/announcements
Open

feat: Add announcements#2948
X1nto wants to merge 20 commits intoReVanced:devfrom
X1nto:compose/announcements

Conversation

@X1nto
Copy link

@X1nto X1nto commented Feb 18, 2026

This adds announcement pages: a list of all announcements and a singular announcement. Announcement contents use WebViews to render the text. The list of all announcements was supposed to contain small previews, but truncating WebView texts is tricky, so I left some half-finished code commented out with a TODO label.

The app keeps track of which announcements are read to display unread indicators and notification cards when latest announcements arrive. On first launch, all current announcements are automatically marked as read.

It is also possible to filter announcements based on their tag. By default only ReVanced and manager tags are selected.

This also updates the material3 library to 1.5.0 alpha in order to utilize new flexible topbar APIs.

Here are some screenshots:

image image image image

@oSumAtrIX
Copy link
Member

but truncating WebView texts is tricky

Regarding this, is it possible to inject a global size reduction CSS? announcement h1 would turn into android-h1 size * 0.8 for example.

@X1nto
Copy link
Author

X1nto commented Feb 18, 2026

but truncating WebView texts is tricky

Regarding this, is it possible to inject a global size reduction CSS? announcement h1 would turn into android-h1 size * 0.8 for example.

I do just that here: https://github.com/ReVanced/revanced-manager/pull/2948/changes#diff-fde2123669c1efe0c6b6171854031f931086b2311dd0b5dd4b3b3907de42b0f1R114

@X1nto
Copy link
Author

X1nto commented Feb 18, 2026

The problem isn't really reducing text size, it's more of actually maxLines = 3-ing the text in WebView and truncating it to have ellipsis at the end.

@oSumAtrIX
Copy link
Member

Is it not possible to limit the height of views and hide the scroller? I am not really versed in android so pardon my inexperience, im just comparing it to web CSS

@oSumAtrIX
Copy link
Member

Another thing that could increase consistency with https://revanced.app/announcements would be the search bar. Is it possible to implement that?

Comment on lines 62 to 64
withContext(Dispatchers.IO) {
reVancedAPI.getAnnouncements().getOrNull()
}?.let {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of fetching the announcements like this, instead directly fetch using the tags filter: https://api.revanced.app/v4/announcements?tag=%E2%9C%A8%20ReVanced&tag=manager

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That'd require refetching every time new tags are applied. Sounds good?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean when the API has new tags? Yep. The /tags endpoint can be fetched every time to ensure it's up to date. It's cached by CloudFlare. The announcements are too but when you filter based on tag, sometimes there's cache misses

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant refetching announcements every time it's filtered with a new set of tags. Fortunately Flows support debouncing so it won't be that big of a deal.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, as far as I can tell, the website also doesn't make a request when filtering.

Copy link
Member

@Ushie Ushie Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since most users will only be using this to view "revanced" and "manager" tagged announcements, I think it's fine but if it's possible the moment that the user changes the tag selection can the manager switch to fetching the entire announcements and only then filtering them locally? To avoid refetching every time a tag is changed, because the user is more likely to continue changing tags

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant refetching announcements every time it's filtered with a new set of tags. Fortunately Flows support debouncing so it won't be that big of a deal.

Works, but cant you keep a Map<Tags, Announcements> and extend the map with cursor & count parameters? This way when you filter, it uses the cached results.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So cache for every tag combination?

@oSumAtrIX
Copy link
Member

image

Alignment should be looked into

@Ushie Ushie changed the title feat: Add announcements page to Compose Manager feat: Add announcements Feb 18, 2026
@Ushie Ushie linked an issue Feb 18, 2026 that may be closed by this pull request
3 tasks
@Ushie Ushie changed the base branch from compose-dev to dev February 18, 2026 23:01
@X1nto
Copy link
Author

X1nto commented Feb 18, 2026

Is it not possible to limit the height of views and hide the scroller? I am not really versed in android so pardon my inexperience, im just comparing it to web CSS

Heights don't take text line heights into account, since they're fixed. I tried to limit the body via CSS but it didn't quire work.

@X1nto
Copy link
Author

X1nto commented Feb 18, 2026

Another thing that could increase consistency with revanced.app/announcements would be the search bar. Is it possible to implement that?

I don't see why it's not possible, as long as all announcements are fetched.

@oSumAtrIX
Copy link
Member

Website currently scopes search to the loaded announcements

@X1nto
Copy link
Author

X1nto commented Feb 19, 2026

Website currently scopes search to the loaded announcements

Perhaps it's better to revisit this once the API provides searching capabilities?

@oSumAtrIX
Copy link
Member

Sure, I can add a query parameter next to the existing one for content filtering. One issue would be that the contents are html so just plain text filtering wouldn't work.

@X1nto
Copy link
Author

X1nto commented Feb 19, 2026

image Alignment should be looked into

I guess it doesn't account for action buttons when it centers the icon. I can fix that, but I think it's outside of this PR's scope.

@Ushie
Copy link
Member

Ushie commented Feb 19, 2026

It is, it's infact a deliberate design choice, keep it as is

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a comprehensive announcement system to the ReVanced Manager app. It introduces functionality to fetch, display, filter, and track read status of announcements from the ReVancedAPI. The implementation includes a list view of all announcements with filtering by tags, a detailed view for individual announcements rendered with WebViews, and notification badges for unread announcements on the dashboard.

Changes:

  • Added announcement data models, repository layer, and API integration for fetching announcements and tags
  • Implemented AnnouncementsViewModel for managing announcement state, filtering by tags, and tracking read status
  • Created UI screens for displaying announcement lists with tag-based filtering and individual announcement details using WebView
  • Updated Material3 library to version 1.5.0-alpha14 to utilize TwoRowsTopAppBar for announcement detail screens

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
gradle/libs.versions.toml Upgraded Material3 library to 1.5.0-alpha14 for new topbar APIs
app/src/main/res/values/strings.xml Added string resources for announcement-related UI elements
app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt Added announcement checking on startup and unread announcement notification logic
app/src/main/java/app/revanced/manager/ui/viewmodel/AnnouncementsViewModel.kt New ViewModel managing announcement data, filtering, and read state tracking
app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt Added announcement notification card and navigation button with unread badge
app/src/main/java/app/revanced/manager/ui/screen/AnnouncementsScreen.kt New screen displaying announcement list with tag filtering and unread indicators
app/src/main/java/app/revanced/manager/ui/screen/AnnouncementScreen.kt New screen displaying individual announcement content using WebView
app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt Added navigation destinations for announcements screens
app/src/main/java/app/revanced/manager/network/dto/ReVancedAnnouncementTag.kt DTO for announcement tag data from API
app/src/main/java/app/revanced/manager/network/dto/ReVancedAnnouncement.kt DTO for announcement data from API with Parcelable support
app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt Added API endpoints for fetching announcements and tags
app/src/main/java/app/revanced/manager/domain/repository/AnnouncementRepository.kt Repository implementing caching for announcements and tags data
app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt Added preferences for read announcements and selected announcement tags
app/src/main/java/app/revanced/manager/di/ViewModelModule.kt Registered AnnouncementsViewModel in DI container
app/src/main/java/app/revanced/manager/di/RepositoryModule.kt Registered AnnouncementRepository in DI container
app/src/main/java/app/revanced/manager/MainActivity.kt Added routing for announcements screens

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

</style>
</head>
<body>
${announcement.content}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using string interpolation directly in HTML content (${announcement.content}) without sanitization creates an XSS vulnerability. If the announcement content contains malicious script tags or event handlers, they will be executed in the WebView. Consider using proper HTML escaping/sanitization before inserting the content, or use loadDataWithBaseURL with a safe base URL and implement Content Security Policy headers.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think WebView disables JavaScript by default. @Ushie thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, I'm pretty sure ReVanced doesn't want to disable JS, besides I don't know how anything can escalate here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReVanced makes a point out of this here with the "limited character count" section
https://revanced.app/announcements/12-revanced-announcements

It seems like clients are meant to display things as is

Copy link
Member

@oSumAtrIX oSumAtrIX Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JS can be enabled by default. We intend remote code execution here in case we want to do something funky with js on the announcement, simply enabling js should work

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably a good idea to control whether or not JS is enabled based on the announcement itself

Comment on lines 92 to 111
val announcements = withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()
} ?: return

val readAnnouncements = prefs.readAnnouncements.get()
if (readAnnouncements.isEmpty()) {
val announcementIds = announcements.mapTo(mutableSetOf()) { it.id.toString() }
prefs.readAnnouncements.update(announcementIds)
return
}

unreadAnnouncement = announcements.firstOrNull { announcement ->
val hasRelevantTag = "revanced" in announcement.tags ||
"manager" in announcement.tags

val isUnread = announcement.id.toString() !in readAnnouncements

hasRelevantTag && isUnread
}

Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkForAnnouncements function silently fails when the API call returns null or throws an exception, unlike checkForManagerUpdates which uses uiSafe for error handling. Users won't be notified if announcements fail to load. Consider wrapping the API call in uiSafe or adding proper error handling to inform users when announcements cannot be retrieved.

Suggested change
val announcements = withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()
} ?: return
val readAnnouncements = prefs.readAnnouncements.get()
if (readAnnouncements.isEmpty()) {
val announcementIds = announcements.mapTo(mutableSetOf()) { it.id.toString() }
prefs.readAnnouncements.update(announcementIds)
return
}
unreadAnnouncement = announcements.firstOrNull { announcement ->
val hasRelevantTag = "revanced" in announcement.tags ||
"manager" in announcement.tags
val isUnread = announcement.id.toString() !in readAnnouncements
hasRelevantTag && isUnread
}
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.toString() }
prefs.readAnnouncements.update(announcementIds)
return@uiSafe
}
unreadAnnouncement = announcements.firstOrNull { announcement ->
val hasRelevantTag = "revanced" in announcement.tags ||
"manager" in announcement.tags
val isUnread = announcement.id.toString() !in readAnnouncements
hasRelevantTag && isUnread
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +97
private fun observeSelectedTags() {
viewModelScope.launch {
snapshotFlow { selectedTags.toList() }.collect { _ ->
saveSelectedTags()
applyTagFilter()
}
}
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observeSelectedTags flow will trigger immediately on initialization, causing saveSelectedTags and applyTagFilter to be called before allAnnouncements is loaded. This could lead to applying filters on null data or unnecessary preference updates. The snapshotFlow will emit immediately with the current state. Consider adding a check to ensure data is loaded before applying filters, or restructure the initialization order.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +179
FilterChip(
selected = selected,
onClick = {
if (selected) {
selectedTags.remove(tag)
} else {
selectedTags.add(tag)
}
},
label = { Text(tag) }
)
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FilterChip used here lacks a leadingIcon with a check mark when selected, which is inconsistent with other FilterChip usage in the codebase. Other parts of the codebase use CheckedFilterChip (see PatchesSelectorScreen.kt:152, 158) which provides a check icon for selected chips. Consider using CheckedFilterChip instead of FilterChip for consistency.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember I added a checkmark. @Ushie did you remove it accidentally when merging?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it intentionally to avoid layout shifts, but I didn't know that the checkmark was already used in other places of the app

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I added it in patch search screen when I worked on it.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@Ushie
Copy link
Member

Ushie commented Feb 19, 2026

@Axelen123 since moving to a Serializable data class instead of Parcel, I've faced this crash

FATAL EXCEPTION: main
Process: app.revanced.manager.debug, PID: 8076
java.lang.IllegalArgumentException: Route app.revanced.manager.ui.model.navigation.Announcement could not find any NavType for argument announcement of type app.revanced.manager.network.dto.ReVancedAnnouncement - typeMap received was {}
at androidx.navigation.serialization.RouteSerializerKt.forEachIndexedKType(RouteSerializer.kt:190)
at androidx.navigation.serialization.RouteSerializerKt.generateRoutePattern(RouteSerializer.kt:64)
at androidx.navigation.serialization.RouteSerializerKt.generateRoutePattern$default(RouteSerializer.kt:47)
at androidx.navigation.NavDestinationBuilder.(NavDestinationBuilder.android.kt:67)
at androidx.navigation.compose.ComposeNavigatorDestinationBuilder.(ComposeNavigatorDestinationBuilder.kt:96)
at androidx.navigation.compose.NavGraphBuilderKt.composable(NavGraphBuilder.kt:265)
at app.revanced.manager.MainActivityKt.ReVancedManager$lambda$5$0(MainActivity.kt:1526)
at app.revanced.manager.MainActivityKt.$r8$lambda$zzZd2CoNttBZiUtAOhBOcSNWOnQ(Unknown Source:0)
at app.revanced.manager.MainActivityKt$$ExternalSyntheticLambda56.invoke(D8$$SyntheticClass:0)
at androidx.navigation.compose.NavHostKt.NavHost(NavHost.kt:879)
at app.revanced.manager.MainActivityKt.ReVancedManager(MainActivity.kt:118)
at app.revanced.manager.MainActivityKt.access$ReVancedManager(MainActivity.kt:1)
at app.revanced.manager.MainActivity.onCreate$lambda$0$5(MainActivity.kt:101)
at app.revanced.manager.MainActivity.$r8$lambda$xZPjh5HhxDoRiEPj4p32zgUJqUM(Unknown Source:0)
at app.revanced.manager.MainActivity$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass:0)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:398)
at androidx.compose.material3.TextKt.ProvideTextStyle(Text.kt:667)
at androidx.compose.material3.MaterialThemeKt.MaterialTheme$lambda$1$0(MaterialTheme.kt:109)
at androidx.compose.material3.MaterialThemeKt.$r8$lambda$suSUQ5yhLzITLN7oUTjMnLLWbl8(Unknown Source:0)
at androidx.compose.material3.MaterialThemeKt$$ExternalSyntheticLambda7.invoke(D8$$SyntheticClass:0)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.material3.PrecisionPointer_androidKt.EnsurePrecisionPointerListenersRegistered(PrecisionPointer.android.kt:52)
at androidx.compose.material3.MaterialThemeKt.MaterialTheme$lambda$1(MaterialTheme.kt:108)
at androidx.compose.material3.MaterialThemeKt.$r8$lambda$kuVxulD_QHVMEkgBXqLGVlLapD4(Unknown Source:0)
at androidx.compose.material3.MaterialThemeKt$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass:0)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:378)
at androidx.compose.material3.MaterialThemeKt.MaterialTheme(MaterialTheme.kt:100)
at androidx.compose.material3.MaterialThemeKt.MaterialTheme(MaterialTheme.kt:60)
at app.revanced.manager.ui.theme.ThemeKt.ReVancedManagerTheme(Theme.kt:118)
at app.revanced.manager.MainActivity.onCreate$lambda$0(MainActivity.kt:96)
at app.revanced.manager.MainActivity.$r8$lambda$LudulKSUqA9dGFSBgcTUfJ8ia9E(Unknown Source:0)
at app.revanced.manager.MainActivity$$ExternalSyntheticLambda1.invoke(D8$$SyntheticClass:0)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.ui.platform.ComposeView.Content(ComposeView.android.kt:446)
2026-02-20 01:01:45.413 8076-8076 AndroidRuntime app.revanced.manager.debug E at androidx.compose.ui.platform.AbstractComposeView$ensureCompositionCreated$1.invoke(ComposeView.android.kt:265)
at androidx.compose.ui.platform.AbstractComposeView$ensureCompositionCreated$1.invoke(ComposeView.android.kt:265)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:378)
at androidx.compose.ui.platform.CompositionLocalsKt.ProvideCommonCompositionLocals(CompositionLocals.kt:217)
at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt$ProvideAndroidCompositionLocals$2.invoke(AndroidCompositionLocals.android.kt:138)
at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt$ProvideAndroidCompositionLocals$2.invoke(AndroidCompositionLocals.android.kt:137)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:378)
at androidx.compose.ui.platform.AndroidCompositionLocals_androidKt.ProvideAndroidCompositionLocals(AndroidCompositionLocals.android.kt:126)
at androidx.compose.ui.platform.WrappedComposition$setContent$1$1$3.invoke(Wrapper.android.kt:142)
at androidx.compose.ui.platform.WrappedComposition$setContent$1$1$3.invoke(Wrapper.android.kt:141)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.CompositionLocalKt.CompositionLocalProvider(CompositionLocal.kt:398)
at androidx.compose.ui.platform.WrappedComposition$setContent$1$1.invoke(Wrapper.android.kt:141)
at androidx.compose.ui.platform.WrappedComposition$setContent$1$1.invoke(Wrapper.android.kt:125)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:122)
at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:52)
at androidx.compose.runtime.internal.Expect_jvmKt.invokeComposable(Expect.jvm.kt:24)
at androidx.compose.runtime.ComposerImpl.doCompose-aFTiNEg(ComposerImpl.kt:2647)
at androidx.compose.runtime.ComposerImpl.composeContent--ZbOJvo$runtime(ComposerImpl.kt:2551)
at androidx.compose.runtime.CompositionImpl.composeContent(Composition.kt:835)
at androidx.compose.runtime.Recomposer.composeInitial$runtime(Recomposer.kt:1266)
at androidx.compose.runtime.CompositionImpl.composeInitial(Composition.kt:672)
at androidx.compose.runtime.CompositionImpl.setContent(Composition.kt:639)
at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:125)
at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:116)
at androidx.compose.ui.platform.AndroidComposeView.setOnViewTreeOwnersAvailable(AndroidComposeView.android.kt:2153)
at androidx.compose.ui.platform.WrappedComposition.setContent(Wrapper.android.kt:116)
at androidx.compose.ui.platform.WrappedComposition.onStateChanged(Wrapper.android.kt:170)
at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.jvm.kt:316)
at androidx.lifecycle.LifecycleRegistry.addObserver(LifecycleRegistry.jvm.kt:193)
at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:123)
at androidx.compose.ui.platform.WrappedComposition$setContent$1.invoke(Wrapper.android.kt:116)
at androidx.compose.ui.platform.AndroidComposeView.onAttachedToWindow(AndroidComposeView.android.kt:2248)
at android.view.View.dispatchAttachedToWindow(View.java:24320)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3740)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
2026-02-20 01:01:45.413 8076-8076 AndroidRuntime app.revanced.manager.debug E at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
at android.view.ViewGroup.dispatchAttachedToWindow(ViewGroup.java:3747)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:4546)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:3892)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:12869)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1901)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1910)
at android.view.Choreographer.doCallbacks(Choreographer.java:1367)
at android.view.Choreographer.doFrame(Choreographer.java:1292)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1870)
at android.os.Handler.handleCallback(Handler.java:995)
at android.os.Handler.dispatchMessage(Handler.java:103)
at android.os.Looper.loopOnce(Looper.java:273)
at android.os.Looper.loop(Looper.java:363)
at android.app.ActivityThread.main(ActivityThread.java:9983)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:632)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:975)

@X1nto
Copy link
Author

X1nto commented Feb 20, 2026

Serializable would make sense if Navigation3 was used. Since this is Navigation2, I think parcelables are the only (sane) solution.

@Ushie
Copy link
Member

Ushie commented Feb 22, 2026

This is complete and ready for final review for merge

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +9 to +22
@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,
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReVancedAnnouncement is marked @Parcelize but includes kotlinx.datetime.LocalDateTime fields (createdAt/archivedAt). Unless you add a custom Parceler/@TypeParceler for LocalDateTime, Parcelize typically can't generate Parcelable implementations for this type, which will break navigation where the whole announcement is placed into savedStateHandle. Consider passing only an ID (and re-fetching), or storing timestamps/ISO strings instead of LocalDateTime, or adding an explicit parceler for LocalDateTime.

Copilot uses AI. Check for mistakes.

private fun loadData() {
viewModelScope.launch {
if (!network.isConnected()) return@launch
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there’s no network, loadData() returns early without updating any state. On the screen, announcements will remain null, so the UI can get stuck showing the loading spinner indefinitely. Consider setting announcements to an empty list (or an explicit error/offline state) before returning so the UI can render a proper empty/offline message.

Suggested change
if (!network.isConnected()) return@launch
if (!network.isConnected()) {
allAnnouncements = emptyList()
announcements = emptyList()
tags = emptyList()
return@launch
}

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +166
private fun FilterBottomSheet(
onDismissRequest: () -> Unit,
tags: List<String>,
selectedTags: SnapshotStateList<String>,
showArchived: Boolean,
onShowArchivedChange: (Boolean) -> Unit,
onReset: () -> Unit,
onSave: () -> Unit
) {
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSave is passed into FilterBottomSheet but never used, and tag selection is already being persisted from observeSelectedTags(). Either wire up an explicit “Save/Apply” action that calls onSave, or remove the unused parameter and the call site to avoid dead code and confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +37
fun invalidateCache() {
cachedAnnouncements = null
cachedTags = null
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidateCache() mutates cachedAnnouncements/cachedTags without taking the same mutex used by getAnnouncements()/getTags(). That can create a data race if invalidateCache() is ever called concurrently with a read. Consider either making invalidateCache() suspend and wrapping it in mutex.withLock { ... }, or using atomic/volatile state for the cache fields.

Suggested change
fun invalidateCache() {
cachedAnnouncements = null
cachedTags = null
suspend fun invalidateCache() {
mutex.withLock {
cachedAnnouncements = null
cachedTags = null
}

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +45
var showArchived by mutableStateOf(false)

private var savedTags = emptySet<String>()

init {
loadData()
observeSelectedTags()
}
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showArchived affects filtering in applyTagFilter(), but changing showArchived never triggers applyTagFilter() again. As a result, toggling “Show archived” in the UI likely won’t update the list until the tag selection changes or the screen reloads. Consider observing showArchived (e.g., snapshotFlow { showArchived }.distinctUntilChanged()) or invoking applyTagFilter() whenever showArchived is updated.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +110
vm.announcements?.let { repositories ->
if (repositories.isEmpty()) {
item {
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lambda variable name repositories is misleading here since the list contains announcements. Renaming it (and related occurrences) to announcements would make the code easier to follow.

Copilot uses AI. Check for mistakes.
) : Preference<Set<Long>>(dataStore, default) {
private val key = stringSetPreferencesKey(key)

override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LongSetPreference parses stored strings via it.toLong(). If the DataStore value is ever corrupted (or the key is reused with non-numeric values), this will throw and break preference reads across the app. Consider using toLongOrNull() and ignoring invalid entries (or falling back to default) to make this preference resilient.

Suggested change
override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default
override fun Preferences.read(): Set<Long> {
val stringSet = this[key] ?: return default
val longSet = stringSet.mapNotNullTo(mutableSetOf()) { it.toLongOrNull() }
// If there were stored values but none could be parsed, treat as corruption and fall back to default.
return if (stringSet.isNotEmpty() && longSet.isEmpty()) default else longSet
}

Copilot uses AI. Check for mistakes.
@oSumAtrIX oSumAtrIX self-requested a review February 22, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Show announcements from ReVanced API

4 participants