Compare commits

...

10 Commits

Author SHA1 Message Date
semantic-release-bot
b38d0b7022 chore: Release v1.26.0-dev.24 [skip ci]
# app [1.26.0-dev.24](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.23...v1.26.0-dev.24) (2026-02-27)

### Bug Fixes

* Incorrect patcher string value ([f19080a](f19080a986))
* Never show again toggle doing nothing for Update dialog ([6f4219c](6f4219c01b))
* Release builds not working ([00b3c59](00b3c59572))
* Remove quotes from strings ([fc30ba5](fc30ba5e83))
* we actually don't want to exclude reflection symbols ([3222840](32228404c4))

### Features

* Add announcements ([#2948](https://github.com/ReVanced/revanced-manager/issues/2948)) ([813df46](813df46847))
* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](8667051283))
2026-02-27 15:59:57 +00:00
semantic-release-bot
b838851ebe chore: Release API v1.0.0-dev.2 [skip ci]
# api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27)

### Features

* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](8667051283))
2026-02-27 15:59:00 +00:00
Ax333l
00b3c59572 fix: Release builds not working 2026-02-27 16:50:49 +01:00
Ushie
fc30ba5e83 fix: Remove quotes from strings 2026-02-27 08:52:59 +03:00
Plance Shimney
32228404c4 fix: we actually don't want to exclude reflection symbols 2026-02-25 20:36:58 +03:00
Ushie
f19080a986 fix: Incorrect patcher string value 2026-02-23 01:29:48 +03:00
Tornike Khintibidze
813df46847 feat: Add announcements (#2948)
Co-authored-by: Ushie <ushiekane@gmail.com>
Co-authored-by: Ax333l <main@axelen.xyz>
2026-02-23 01:23:18 +03:00
Ushie
6f4219c01b fix: Never show again toggle doing nothing for Update dialog 2026-02-22 08:02:24 +03:00
Ax333l
8667051283 feat: Update to Patcher v22 (#2939) 2026-02-22 00:52:09 +01:00
rushii
e0ce6b4ef3 build: Optimize packaging rules (#2949) 2026-02-22 00:08:41 +01:00
38 changed files with 1068 additions and 194 deletions

View File

@@ -1,3 +1,10 @@
# api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27)
### Features
* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](https://github.com/ReVanced/revanced-manager/commit/8667051283f934a32ac7b7cc76178397dc45a0e6))
# api 1.0.0-dev.1 (2026-02-19)

View File

@@ -23,6 +23,10 @@ kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs.addAll(
"-Xexplicit-backing-fields",
"-Xcontext-parameters",
)
}
}

View File

@@ -1 +1 @@
version = 1.0.0-dev.1
version = 1.0.0-dev.2

View File

@@ -1,3 +1,20 @@
# app [1.26.0-dev.24](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.23...v1.26.0-dev.24) (2026-02-27)
### Bug Fixes
* Incorrect patcher string value ([f19080a](https://github.com/ReVanced/revanced-manager/commit/f19080a9862be4600860b67cd981b9a3a56b21e7))
* Never show again toggle doing nothing for Update dialog ([6f4219c](https://github.com/ReVanced/revanced-manager/commit/6f4219c01ba43b70e130f210c8d48978a6056f68))
* Release builds not working ([00b3c59](https://github.com/ReVanced/revanced-manager/commit/00b3c59572760993f69f2663456237faafff0283))
* Remove quotes from strings ([fc30ba5](https://github.com/ReVanced/revanced-manager/commit/fc30ba5e83f27329b05dc81f484bb8f7471b6a99))
* we actually don't want to exclude reflection symbols ([3222840](https://github.com/ReVanced/revanced-manager/commit/32228404c4ef38bb8931d5cfdf6a7aefde23a89c))
### Features
* Add announcements ([#2948](https://github.com/ReVanced/revanced-manager/issues/2948)) ([813df46](https://github.com/ReVanced/revanced-manager/commit/813df46847620bd854dccc52ff244388b3385e17))
* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](https://github.com/ReVanced/revanced-manager/commit/8667051283f934a32ac7b7cc76178397dc45a0e6))
# app [1.26.0-dev.23](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.22...v1.26.0-dev.23) (2026-02-19)

View File

@@ -217,23 +217,6 @@ android {
includeInBundle = false
}
packaging {
resources.excludes.addAll(
listOf(
"/prebuilt/**",
"META-INF/DEPENDENCIES",
"META-INF/**.version",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/**.properties",
"org/bouncycastle/x509/**.properties",
)
)
jniLibs {
useLegacyPackaging = true
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
@@ -254,12 +237,66 @@ android {
version = "3.22.1"
}
}
packaging {
resources {
// Useless files
excludes += "/XPP3_*_VERSION"
excludes += "/font-awesome-license.txt"
excludes += "/smali.properties"
excludes += "/baksmali.properties"
excludes += "/properties/apktool.properties"
excludes += "/org/antlr/**"
excludes += "/org/mockito/**"
excludes += "/org/bouncycastle/pqc/**.properties"
excludes += "/org/bouncycastle/x509/**.properties"
excludes += "/META-INF/INDEX.LIST"
excludes += "/META-INF/**/*.txt"
excludes += "/META-INF/**/*.properties"
excludes += "/META-INF/DEPENDENCIES"
// Desktop AAPT binaries
excludes += "/prebuilt/**"
}
jniLibs {
// 32-bit x86 is dead
excludes += "/lib/x86/*.so"
// Equivalent of AndroidManifest's extractNativeLibs=true to ensure libs are compressed
useLegacyPackaging = true
}
}
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.apply {
// Debug metadata
add("/META-INF/*.version")
add("/META-INF/*.kotlin_module")
add("/kotlin-tooling-metadata.json")
// Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274)
add("/DebugProbesKt.bin")
}
}
}
kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs.addAll(
"-Xexplicit-backing-fields",
"-Xcontext-parameters",
)
}
}
configurations {
all {
// ReVanced Library has a dependency which conflicts with whatever this is. We don't use protobuf, so it should be fine.
exclude(group = "com.google.api.grpc", module = "proto-google-common-protos")
}
}

View File

@@ -1 +1 @@
version = 1.26.0-dev.23
version = 1.26.0-dev.24

View File

@@ -11,4 +11,7 @@
-dontwarn com.google.j2objc.annotations.*
-dontwarn java.awt.**
-dontwarn javax.**
-dontwarn org.slf4j.**
-dontwarn org.slf4j.**
-dontwarn org.immutables.value.Generated
-dontwarn reactor.blockhound.integration.BlockHoundIntegration

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

@@ -5,8 +5,6 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import app.revanced.manager.patcher.patch.Option
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
@@ -36,7 +34,7 @@ import kotlin.reflect.typeOf
data class Option(
@ColumnInfo(name = "group") val group: Int,
@ColumnInfo(name = "patch_name") val patchName: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "key") val name: String,
// Encoded as Json.
@ColumnInfo(name = "value") val value: SerializedValue,
) {

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

@@ -38,6 +38,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.InputStream
import kotlin.collections.joinToString
@@ -115,32 +116,18 @@ class PatchBundleRepository(
Log.d(tag, "Bundle: $it")
}
val sources = entities.associate { it.uid to it.load() }.toPersistentMap()
val sources = entities
.associateTo(mutableMapOf()) { it.uid to it.load() }
sources.forEach syncName@{ (uid, src) ->
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@syncName
val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate }
if (hasOutOfDateNames) dispatchAction(
"Sync names"
) { state ->
val nameChanges = state.sources.mapNotNull { (_, src) ->
if (!src.isNameOutOfDate) return@mapNotNull null
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@mapNotNull null
src.uid to newName
}
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
nameChanges.forEach { (uid, name) ->
updateDb(uid) { it.copy(name = name) }
sources[uid] = sources[uid]!!.copy(name = name)
info[uid] = info[uid]?.copy(name = name) ?: return@forEach
}
State(sources.toPersistentMap(), info.toPersistentMap())
updateDb(uid) { it.copy(name = newName) }
sources[uid] = src.copy(name = newName)
}
val info = loadMetadata(sources).toPersistentMap()
return State(sources, info)
val info = loadMetadata(sources).toPersistentMap()
return State(sources.toPersistentMap(), info)
}
suspend fun reload() = dispatchAction("Full reload") {
@@ -157,38 +144,46 @@ class PatchBundleRepository(
return all
}
private suspend fun loadMetadata(sources: Map<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
private suspend fun loadMetadata(sources: MutableMap<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
// Map bundles -> sources
val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src
}.toMap()
val metadata = try {
PatchBundle.Loader.metadata(map.keys)
runInterruptible(Dispatchers.Default) {
PatchBundle.Loader.metadata(map.keys)
}
} catch (e: CancellationException) {
throw e
} catch (error: Throwable) {
val uids = map.values.map { it.uid }
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
sources.entries.forEach { entry ->
entry.setValue(entry.value.copy(error = error))
}
Log.e(tag, "Failed to load bundles", error)
emptyMap()
}
return metadata.entries.associate { (bundle, patches) ->
val src = map[bundle]!!
src.uid to PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
patches
)
val output = buildMap {
metadata.forEach { (bundle, result) ->
val src = map[bundle]!!
val error = result.exceptionOrNull()
if (error != null) {
sources[src.uid] = src.copy(error = error)
return@forEach
}
this[src.uid] = PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
result.getOrThrow().toList()
)
}
}
return output
}
suspend fun isVersionAllowed(packageName: String, version: String) =
@@ -313,9 +308,9 @@ class PatchBundleRepository(
}
suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") {
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach {
with(it) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) }
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach { src ->
with(src) { deleteLocalFile() }
updateDb(src.uid) { it.copy(versionHash = null) }
}
doReload()

View File

@@ -35,15 +35,15 @@ class PatchOptionsRepository(db: AppDatabase) {
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
val option =
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.name == dbOption.name }
if (option != null) {
try {
deserializedPatchOptions[option.key] =
deserializedPatchOptions[option.name] =
dbOption.value.deserializeFor(option)
} catch (e: Option.SerializationException) {
Log.w(
tag,
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
"Option ${dbOption.patchName}:${option.name} could not be deserialized",
e
)
}

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

@@ -4,66 +4,71 @@ import app.revanced.library.ApkUtils.applyTo
import app.revanced.manager.patcher.Session.Companion.component1
import app.revanced.manager.patcher.Session.Companion.component2
import app.revanced.manager.patcher.logger.Logger
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.PatchesResult
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult
import app.revanced.patcher.patcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
internal typealias PatchList = List<Patch<*>>
internal typealias PatchList = List<Patch>
private typealias Patcher = (emit: (PatchResult) -> Unit) -> PatchesResult
class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
private val frameworkDir: String,
private val aaptPath: String,
private val logger: Logger,
private val input: File,
private val onEvent: (ProgressEvent) -> Unit,
) : Closeable {
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher(
PatcherConfig(
apkFile = input,
temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir,
aaptBinaryPath = aaptPath
)
)
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
this().collect { (patch, exception) ->
val index = selectedPatches.indexOf(patch)
if (index == -1) return@collect
if (exception != null) {
private suspend fun applyPatchesVerbose(patcher: Patcher, indices: Map<Patch, Int>) =
withContext(
Dispatchers.Default
) {
val context = currentCoroutineContext()
patcher { (patch, exception) ->
// Make the patching process cancelable.
context.ensureActive()
val index = indices[patch] ?: return@patcher
if (exception != null) {
onEvent(
ProgressEvent.Failed(
StepId.ExecutePatch(index),
exception.toRemoteError(),
)
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
}
onEvent(
ProgressEvent.Failed(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
exception.toRemoteError(),
)
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
logger.info("${patch.name} succeeded")
}
onEvent(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
)
)
logger.info("${patch.name} succeeded")
}
}
suspend fun run(output: File, selectedPatches: PatchList) {
runStep(StepId.ExecutePatches, onEvent) {
val indices = HashMap<Patch, Int>(selectedPatches.size)
selectedPatches.forEachIndexed { idx, patch -> indices[patch] = idx }
val result = runStep(StepId.ExecutePatches, onEvent) {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
@@ -73,18 +78,21 @@ class Session(
addHandler(logger.handler)
}
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
val patcher = patcher(
apkFile = input,
temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir,
aaptBinaryPath = File(aaptPath)
) { _packageName, _version ->
selectedPatches.toSet()
}
logger.info("Applying patches...")
applyPatchesVerbose(patcher, indices)
}
runStep(StepId.WriteAPK, onEvent) {
logger.info("Writing patched files...")
val result = patcher.get()
val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) {
@@ -102,7 +110,6 @@ class Session(
override fun close() {
tempDir.deleteRecursively()
patcher.close()
}
companion object {

View File

@@ -2,7 +2,8 @@ package app.revanced.manager.patcher.patch
import kotlinx.parcelize.IgnoredOnParcel
import android.os.Parcelable
import app.revanced.patcher.patch.loadPatchesFromDex
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.loadPatches
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
@@ -54,31 +55,45 @@ data class PatchBundle(val patchesJar: String) : Parcelable {
)
object Loader {
private fun patches(bundles: Iterable<PatchBundle>) =
loadPatchesFromDex(
bundles.map { File(it.patchesJar) }.toSet()
).byPatchesFile.mapKeys { (file, _) ->
val absPath = file.absolutePath
bundles.single { absPath == it.patchesJar }
private fun patches(bundles: Iterable<PatchBundle>) = buildMap {
val bundleMap = bundles.associateBy { it.patchesJar }
loadPatches(
*bundleMap.keys.map(::File).toTypedArray(),
onFailedToLoad = { file, throwable ->
this[bundleMap[file.absolutePath]!!] = Result.failure(throwable)
}
).patchesByFile.forEach { (file, patches) ->
putIfAbsent(bundleMap[file.absolutePath]!!, Result.success(patches))
}
}
fun metadata(bundles: Iterable<PatchBundle>): Map<PatchBundle, Result<Set<PatchInfo>>> =
patches(bundles).mapValues { (_, result) ->
result.map { patches ->
patches.mapTo(
HashSet(patches.size),
::PatchInfo
)
}
}
fun metadata(bundles: Iterable<PatchBundle>) =
patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) }
fun patches(bundles: Iterable<PatchBundle>, packageName: String): Map<PatchBundle, Set<Patch>> =
patches(bundles).mapValues { (_, result) ->
val patches = result.getOrDefault(emptySet())
fun patches(bundles: Iterable<PatchBundle>, packageName: String) =
patches(bundles).mapValues { (_, patches) ->
patches.filter { patch ->
patches.filterTo(HashSet(patches.size)) { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
return@filterTo true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
return@filterTo false
}
true
}.toSet()
}
}
}
}
}

View File

@@ -137,7 +137,7 @@ sealed class PatchBundleInfo {
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
option.name in opts
}
}
}

View File

@@ -17,7 +17,7 @@ data class PatchInfo(
val compatiblePackages: ImmutableList<CompatiblePackage>?,
val options: ImmutableList<Option<*>>?
) {
constructor(patch: Patch<*>) : this(
constructor(patch: Patch) : this(
patch.name.orEmpty(),
patch.description,
patch.use,
@@ -49,7 +49,7 @@ data class PatchInfo(
* The resulting patch cannot be executed.
* This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
*/
fun toPatcherPatch(): Patch<*> =
fun toPatcherPatch(): Patch =
resourcePatch(name = name, description = description, use = include) {
compatiblePackages?.let { pkgs ->
compatibleWith(*pkgs.map { it.packageName to it.versions }.toTypedArray())
@@ -65,8 +65,7 @@ data class CompatiblePackage(
@Immutable
data class Option<T>(
val title: String,
val key: String,
val name: String,
val description: String,
val required: Boolean,
val type: KType,
@@ -75,8 +74,7 @@ data class Option<T>(
val validator: (T?) -> Boolean,
) {
constructor(option: PatchOption<T>) : this(
option.title ?: option.key,
option.key,
option.name,
option.description.orEmpty(),
option.required,
option.type,

View File

@@ -212,7 +212,7 @@ fun PatchItem(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = option.title,
text = option.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)

View File

@@ -205,7 +205,7 @@ fun <T : Any> OptionItem(
WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled) {
ListItem(
modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) },
headlineContent = { Text(option.name) },
supportingContent = {
Column {
Text(option.description)
@@ -250,7 +250,7 @@ private object StringOptionEditor : OptionEditor<String> {
AlertDialog(
onDismissRequest = scope.dismissDialog,
title = { Text(scope.option.title) },
title = { Text(scope.option.name) },
text = {
OutlinedTextField(
value = fieldValue,
@@ -330,7 +330,7 @@ private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
NumberDialog(scope.option.name, scope.value, scope.option.validator) {
if (it == null) return@NumberDialog scope.dismissDialog()
scope.submitDialog(it)
@@ -455,7 +455,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
Text(stringResource(R.string.cancel))
}
},
title = { Text(scope.option.title) },
title = { Text(scope.option.name) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
val presets = remember(scope.option.presets) {
@@ -496,8 +496,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
OptionEditor<List<T>> {
private fun createElementOption(option: Option<List<T>>) = Option<T>(
option.title,
option.key,
option.name,
option.description,
option.required,
option.type.arguments.first().type!!,
@@ -566,7 +565,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
R.plurals.selected_count,
deletionTargets.size,
deletionTargets.size
) else scope.option.title,
) else scope.option.name,
onBackClick = back,
backIcon = {
if (deleteMode) {

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
) {
@@ -130,17 +135,16 @@ fun DashboardScreen(
)
}
var showUpdateDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
val availableUpdate by remember {
derivedStateOf { vm.updatedManagerVersion.takeIf { showUpdateDialog } }
}
var showUpdateDialog by rememberSaveable { mutableStateOf(true) }
val showManagerUpdateDialogOnLaunch by vm.prefs.showManagerUpdateDialogOnLaunch.getAsState()
val availableUpdate = vm.updatedManagerVersion
availableUpdate?.let { version ->
if (showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
AvailableUpdateDialog(
onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick,
newVersion = version
newVersion = availableUpdate
)
}
@@ -220,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))
}
@@ -322,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

@@ -640,17 +640,17 @@ private fun OptionsDialog(
) {
if (patch.options == null) return@LazyColumnWithScrollbar
items(patch.options, key = { it.key }) { option ->
val key = option.key
items(patch.options, key = { it.name }) { option ->
val name = option.name
val value =
if (values == null || !values.contains(key)) option.default else values[key]
if (values == null || !values.contains(name)) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = {
set(key, it)
set(name, it)
},
selectionWarningEnabled = selectionWarningEnabled
)

View File

@@ -144,16 +144,16 @@ fun RequiredOptionsScreen(
val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option ->
val key = option.key
val name = option.name
val value =
if (values == null || key !in values) option.default else values[key]
if (values == null || name !in values) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, key, new)
vm.setOption(bundle.uid, it, name, new)
},
selectionWarningEnabled = vm.selectionWarningEnabled
)

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

@@ -129,7 +129,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
isSelected(
bundle.uid,
patch
) && patch.options?.any { it.required && it.default == null && it.key !in opts } ?: false
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
}.toList()
}.filter { (_, patches) -> patches.isNotEmpty() }
}
@@ -180,13 +180,13 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) {
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
// All patches
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
// The key-value options of an individual patch
val patchToOpts = patchesToOpts
.getOrElse(patch.name, ::persistentMapOf)
.put(key, value)
.put(name, value)
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
}

View File

@@ -325,7 +325,7 @@ class SelectedAppInfoViewModel(
bundleOptions.forEach patch@{ (patchName, values) ->
// Get all valid option keys for the patch.
val validOptionKeys =
patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch
patches[patchName]?.options?.map { it.name }?.toSet() ?: return@patch
this@bundleOptions[patchName] = values.filterKeys { key ->
key in validOptionKeys

View File

@@ -14,7 +14,7 @@ Second \"item\" text"</string>
-->
<resources>
<string name="app_name">ReVanced Manager</string>
<string name="patcher">Patcher test</string>
<string name="patcher">Patcher</string>
<string name="patches">Patches</string>
<string name="cli">CLI</string>
<string name="manager">Manager</string>
@@ -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>
@@ -110,24 +113,24 @@ Second \"item\" text"</string>
<string name="safeguards">Safeguards</string>
<string name="patch_compat_check">Disable version compatibility check</string>
<string name="patch_compat_check_description">Do not restrict patches to compatible app versions</string>
<string name="patch_compat_check_confirmation">"Selecting incompatible patches can result in a broken app.
<string name="patch_compat_check_confirmation">Selecting incompatible patches can result in a broken app.
Do you want to proceed anyways?"</string>
Do you want to proceed anyways?</string>
<string name="suggested_version_safeguard">Require suggested app version</string>
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
<string name="suggested_version_safeguard_confirmation">"Selecting an app that is not the suggested version may cause unexpected issues.
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.
Do you want to proceed anyways?"</string>
Do you want to proceed anyways?</string>
<string name="patch_selection_safeguard">Allow changing patch selection and options</string>
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string>
<string name="patch_selection_safeguard_confirmation">"Changing the selection of patches may cause unexpected issues.
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.
Enable anyways?"</string>
Enable anyways?</string>
<string name="universal_patches_safeguard">Allow using universal patches</string>
<string name="universal_patches_safeguard_description">Do not prevent using universal patches</string>
<string name="universal_patches_safeguard_confirmation">"Universal patches are not as well tested as those that target specific apps.
<string name="universal_patches_safeguard_confirmation">Universal patches are not as well tested as those that target specific apps.
Enable anyways?"</string>
Enable anyways?</string>
<string name="import_keystore">Import keystore</string>
<string name="import_keystore_description">Import a custom keystore</string>
<string name="import_keystore_dialog_title">Enter keystore credentials</string>
@@ -143,9 +146,9 @@ Enable anyways?"</string>
<string name="export_keystore_success">Exported keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string>
<string name="regenerate_keystore_description">Generate a new keystore</string>
<string name="regenerate_keystore_dialog_description">"You are about to regenerate your keystore the manager will use during the patching process.
<string name="regenerate_keystore_dialog_description">You are about to regenerate your keystore the manager will use during the patching process.
You will not be able to update the previously installed apps from this source."</string>
You will not be able to update the previously installed apps from this source.</string>
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
<string name="import_patch_selection">Import patch selection</string>
<string name="import_patch_selection_description">Import patch selection from a JSON file</string>
@@ -270,23 +273,23 @@ You will not be able to update the previously installed apps from this source."<
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
<string name="patch_options_reset_toast">Patch options have been reset</string>
<string name="non_suggested_version_warning_title">Non suggested version</string>
<string name="non_suggested_version_warning_description">"The version of the app you have selected does not match the suggested version.
<string name="non_suggested_version_warning_description">The version of the app you have selected does not match the suggested version.
Please use the suggested version: %s
To continue anyway, disable \"Require suggested app version\" in the advanced settings."</string>
To continue anyway, disable \"Require suggested app version\" in the advanced settings.</string>
<string name="selection_warning_title">Stop using defaults?</string>
<string name="selection_warning_description">"It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
<string name="selection_warning_description">It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches."</string>
<string name="universal_patch_warning_description">"Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches.</string>
<string name="universal_patch_warning_description">Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches."</string>
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches.</string>
<string name="this_version">This version</string>
<string name="universal">Any app</string>
<string name="search_patches">Search patches</string>
<string name="app_version_not_compatible">"This patch is not compatible with the selected app version (%1$s)
<string name="app_version_not_compatible">This patch is not compatible with the selected app version (%1$s)
It is only compatible with the following version(s): %2$s"</string>
It is only compatible with the following version(s): %2$s</string>
<string name="continue_with_version">Continue with this version?</string>
<string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string>
<string name="download_application">Download application?</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>
@@ -428,8 +434,8 @@ It is only compatible with the following version(s): %2$s"</string>
<string name="save_with_count">Save (%1$s)</string>
<string name="update">Update</string>
<string name="empty">Empty</string>
<string name="installing_message">"Tap on <b>Update</b> when prompted.
ReVanced Manager will close when updating."</string>
<string name="installing_message">Tap on <b>Update</b> when prompted.
ReVanced Manager will close when updating.</string>
<string name="no_changelogs_found">No changelogs found</string>
<string name="just_now">Just now</string>
<string name="minutes_ago">%sm ago</string>
@@ -443,14 +449,15 @@ 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>
<string name="failed_to_download_update">Failed to download update: %s</string>
<string name="download">Download</string>
<string name="download_confirmation_metered">"You are currently on a metered connection, and data charges from your service provider may apply.
<string name="download_confirmation_metered">You are currently on a metered connection, and data charges from your service provider may apply.
Do you still want to continue?"</string>
Do you still want to continue?</string>
<string name="download_update_confirmation">Download update?</string>
<string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string>
@@ -488,9 +495,9 @@ Do you still want to continue?"</string>
<string name="patches_prereleases">Use pre-releases</string>
<string name="patches_prereleases_description">Use pre-release versions of %s</string>
<string name="patches_url">Patches URL</string>
<string name="incompatible_patches_dialog">"These patches are not compatible with the selected app version (%1$s).
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).
Click on the patches to see more details."</string>
Click on the patches to see more details.</string>
<string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string>
<string name="never_show_again">Never show again</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"
@@ -17,13 +17,13 @@ serialization = "1.9.0"
collection = "0.4.0"
datetime = "0.7.1"
room-version = "2.8.4"
revanced-patcher = "21.0.0"
revanced-library = "3.0.2"
revanced-patcher = "22.0.0"
revanced-library = "4.0.0"
koin = "4.1.1"
ktor = "3.3.3"
markdown-renderer = "0.39.0"
fading-edges = "1.0.4"
kotlin = "2.3.0"
kotlin = "2.3.10"
android-gradle-plugin = "8.13.2"
dev-tools-gradle-plugin = "2.3.4"
about-libraries = "13.2.1"
@@ -81,8 +81,8 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room-ver
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room-version" }
# Patcher
revanced-patcher = { group = "app.revanced", name = "revanced-patcher", version.ref = "revanced-patcher" }
revanced-library = { group = "app.revanced", name = "revanced-library", version.ref = "revanced-library" }
revanced-patcher = { group = "app.revanced", name = "patcher-android", version.ref = "revanced-patcher" }
revanced-library = { group = "app.revanced", name = "library-android", version.ref = "revanced-library" }
# Koin
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }

View File

@@ -1,5 +1,6 @@
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
google()
gradlePluginPortal()
@@ -8,6 +9,7 @@ pluginManagement {
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenLocal()
mavenCentral()
google()
maven("https://jitpack.io")