Compare commits

...

12 Commits

Author SHA1 Message Date
Ushie
95cd097519 bleh 2026-03-01 02:55:15 +03:00
Ushie
d685382277 feat: Display more than the latest changelog 2026-02-27 08:34:40 +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
semantic-release-bot
b50688ee10 chore: Release v1.26.0-dev.23 [skip ci]
# 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)

### Bug Fixes

* Flutter migration ([#2946](https://github.com/ReVanced/revanced-manager/issues/2946)) ([31a0f75](31a0f751aa))
2026-02-19 22:06:14 +00:00
Jānis
31a0f751aa fix: Flutter migration (#2946) 2026-02-19 22:57:23 +01:00
semantic-release-bot
4f65555b56 chore: Release v1.26.0-dev.22 [skip ci]
# app [1.26.0-dev.22](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.21...v1.26.0-dev.22) (2026-02-19)

### Bug Fixes

* Create modules path in root installer ([4fc2ba3](4fc2ba339b))
2026-02-19 17:46:45 +00:00
Ax333l
4fc2ba339b fix: Create modules path in root installer 2026-02-19 18:38:20 +01:00
semantic-release-bot
76d0f17770 chore: Release v1.26.0-dev.21 [skip ci]
# app [1.26.0-dev.21](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.20...v1.26.0-dev.21) (2026-02-19)

### Features

* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](0f56377214))
2026-02-19 17:31:54 +00:00
41 changed files with 1252 additions and 245 deletions

View File

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

View File

@@ -1,3 +1,24 @@
# 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)
### Bug Fixes
* Flutter migration ([#2946](https://github.com/ReVanced/revanced-manager/issues/2946)) ([31a0f75](https://github.com/ReVanced/revanced-manager/commit/31a0f751aaff3233f50821963f28454d239318b2))
# app [1.26.0-dev.22](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.21...v1.26.0-dev.22) (2026-02-19)
### Bug Fixes
* Create modules path in root installer ([4fc2ba3](https://github.com/ReVanced/revanced-manager/commit/4fc2ba339b6d275fb9da8b65ff86b74a6594436d))
# app [1.26.0-dev.21](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.20...v1.26.0-dev.21) (2026-02-19)
### Features
* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](https://github.com/ReVanced/revanced-manager/commit/0f56377214fae05693a21177daf7534c103df74e))
# app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09) # app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09)

View File

@@ -135,15 +135,15 @@ android {
buildToolsVersion = "35.0.1" buildToolsVersion = "35.0.1"
defaultConfig { defaultConfig {
applicationId = "app.revanced.manager" applicationId = "app.revanced.manager.flutter"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
val versionStr = if (version == "unspecified") "1.0.0" else version.toString() val versionStr = if (version == "unspecified") "1.0.0" else version.toString()
versionName = versionStr versionName = versionStr
versionCode = with(versionStr.toVersion()) { versionCode = with(versionStr.toVersion()) {
major * 10_000_000 + major * 100_000_000 +
minor * 10_000 + minor * 100_000 +
patch * 100 + patch * 100 +
(preRelease?.substringAfterLast('.')?.toInt() ?: 99) (preRelease?.substringAfterLast('.')?.toInt() ?: 99)
} }
@@ -217,23 +217,6 @@ android {
includeInBundle = false 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 { ksp {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
@@ -254,12 +237,69 @@ android {
version = "3.22.1" 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/**"
// Reflection symbol list (https://stackoverflow.com/a/41073782/13964629)
excludes += "/**/*.kotlin_builtins"
}
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 { kotlin {
jvmToolchain(17) jvmToolchain(17)
compilerOptions { compilerOptions {
jvmTarget = JvmTarget.JVM_17 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.20 version = 1.26.0-dev.23

View File

@@ -1,12 +1,9 @@
package app.revanced.manager package app.revanced.manager
import android.content.ActivityNotFoundException
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
@@ -25,6 +22,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute 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.AppSelector
import app.revanced.manager.ui.model.navigation.ComplexParameter import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard import app.revanced.manager.ui.model.navigation.Dashboard
@@ -33,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.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update 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.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen import app.revanced.manager.ui.screen.InstalledAppInfoScreen
@@ -62,6 +63,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ExperimentalAnimationApi @ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -74,21 +76,10 @@ class MainActivity : AppCompatActivity() {
val vm: MainViewModel = getActivityViewModel() val vm: MainViewModel = getActivityViewModel()
setContent { setContent {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = vm::applyLegacySettings
)
val theme by vm.prefs.theme.getAsState() val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState() val dynamicColor by vm.prefs.dynamicColor.getAsState()
val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState() val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState()
EventEffect(vm.legacyImportActivityFlow) {
try {
launcher.launch(it)
} catch (_: ActivityNotFoundException) {
}
}
ReVancedManagerTheme( ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK, darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
dynamicColor = dynamicColor, dynamicColor = dynamicColor,
@@ -117,7 +108,7 @@ private fun ReVancedManager(vm: MainViewModel) {
enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) }, exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) }, popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }, popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) { ) {
composable<Dashboard> { composable<Dashboard> {
DashboardScreen( DashboardScreen(
@@ -133,6 +124,12 @@ private fun ReVancedManager(vm: MainViewModel) {
}, },
onAppClick = { packageName -> onAppClick = { packageName ->
navController.navigate(InstalledApplicationInfo(packageName)) navController.navigate(InstalledApplicationInfo(packageName))
},
onAnnouncementsClick = {
navController.navigate(Announcements)
},
onAnnouncementClick = { announcement ->
navController.navigateComplex(Announcement, announcement)
} }
) )
} }
@@ -178,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) { navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> { composable<SelectedApplicationInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it) val parentBackStackEntry = navController.navGraphEntry(it)

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ class RootInstaller(
execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app") execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
} }
remoteFS.getFile(modulePath).mkdir() remoteFS.getFile(modulePath).mkdirs()
listOf( listOf(
"service.sh", "service.sh",

View File

@@ -20,6 +20,8 @@ class PreferencesManager(
val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT) val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", 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 firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true) 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>) = protected fun stringSetPreference(key: String, default: Set<String>) =
StringSetPreference(dataStore, key, default) StringSetPreference(dataStore, key, default)
protected fun longSetPreference(key: String, default: Set<Long>) =
LongSetPreference(dataStore, key, default)
protected fun booleanPreference(key: String, default: Boolean) = protected fun booleanPreference(key: String, default: Boolean) =
BooleanPreference(dataStore, key, default) BooleanPreference(dataStore, key, default)
@@ -56,9 +59,13 @@ class EditorContext(private val prefs: MutablePreferences) {
get() = prefs.run { read() } get() = prefs.run { read() }
set(value) = prefs.run { write(value) } 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) write(read() + value)
} }
operator fun <T> Preference<Set<T>>.minusAssign(value: T) = prefs.run {
write(read() subtract setOf(value))
}
} }
abstract class Preference<T>( abstract class Preference<T>(
@@ -125,6 +132,19 @@ class StringSetPreference(
override val key = stringSetPreferencesKey(key) 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( class BooleanPreference(
dataStore: DataStore<Preferences>, dataStore: DataStore<Preferences>,
key: String, 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.coroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import kotlin.collections.joinToString import kotlin.collections.joinToString
@@ -115,32 +116,18 @@ class PatchBundleRepository(
Log.d(tag, "Bundle: $it") 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 } updateDb(uid) { it.copy(name = newName) }
if (hasOutOfDateNames) dispatchAction( sources[uid] = src.copy(name = newName)
"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())
} }
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") { suspend fun reload() = dispatchAction("Full reload") {
@@ -157,38 +144,46 @@ class PatchBundleRepository(
return all 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 // Map bundles -> sources
val map = sources.mapNotNull { (_, src) -> val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src (src.patchBundle ?: return@mapNotNull null) to src
}.toMap() }.toMap()
val metadata = try { val metadata = try {
PatchBundle.Loader.metadata(map.keys) runInterruptible(Dispatchers.Default) {
PatchBundle.Loader.metadata(map.keys)
}
} catch (e: CancellationException) {
throw e
} catch (error: Throwable) { } catch (error: Throwable) {
val uids = map.values.map { it.uid } sources.entries.forEach { entry ->
entry.setValue(entry.value.copy(error = error))
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
} }
Log.e(tag, "Failed to load bundles", error) Log.e(tag, "Failed to load bundles", error)
emptyMap() emptyMap()
} }
return metadata.entries.associate { (bundle, patches) -> val output = buildMap {
val src = map[bundle]!! metadata.forEach { (bundle, result) ->
src.uid to PatchBundleInfo.Global( val src = map[bundle]!!
src.name, val error = result.exceptionOrNull()
bundle.manifestAttributes?.version, if (error != null) {
src.uid, sources[src.uid] = src.copy(error = error)
patches 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) = suspend fun isVersionAllowed(packageName: String, version: String) =
@@ -313,9 +308,9 @@ class PatchBundleRepository(
} }
suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") { suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") {
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach { this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach { src ->
with(it) { deleteLocalFile() } with(src) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) } updateDb(src.uid) { it.copy(versionHash = null) }
} }
doReload() doReload()

View File

@@ -35,15 +35,15 @@ class PatchOptionsRepository(db: AppDatabase) {
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf) bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
val option = 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) { if (option != null) {
try { try {
deserializedPatchOptions[option.key] = deserializedPatchOptions[option.name] =
dbOption.value.deserializeFor(option) dbOption.value.deserializeFor(option)
} catch (e: Option.SerializationException) { } catch (e: Option.SerializationException) {
Log.w( Log.w(
tag, tag,
"Option ${dbOption.patchName}:${option.key} could not be deserialized", "Option ${dbOption.patchName}:${option.name} could not be deserialized",
e e
) )
} }

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.network.api
import app.revanced.manager.BuildConfig import app.revanced.manager.BuildConfig
import app.revanced.manager.domain.manager.PreferencesManager 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.ReVancedAsset
import app.revanced.manager.network.dto.ReVancedGitRepository import app.revanced.manager.network.dto.ReVancedGitRepository
import app.revanced.manager.network.dto.ReVancedInfo import app.revanced.manager.network.dto.ReVancedInfo
@@ -18,16 +20,27 @@ class ReVancedAPI(
) { ) {
private suspend fun apiUrl() = prefs.api.get() private suspend fun apiUrl() = prefs.api.get()
private suspend inline fun <reified T> request(api: String, route: String): APIResponse<T> = private suspend inline fun <reified T> request(
api: String,
route: String,
version: String? = "v4"
): APIResponse<T> =
withContext( withContext(
Dispatchers.IO Dispatchers.IO
) { ) {
client.request { client.request {
url("$api/v4/$route") url("$api/$version/$route")
} }
} }
private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route) private suspend inline fun <reified T> request(
route: String,
version: String = "v4"
) = request<T>(apiUrl(), route, version)
suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
suspend fun getAnnouncementTags() = request<List<ReVancedAnnouncementTag>>("announcements/tags")
suspend fun getAppUpdate() = suspend fun getAppUpdate() =
getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME } getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME }
@@ -35,6 +48,8 @@ class ReVancedAPI(
suspend fun getLatestAppInfo() = suspend fun getLatestAppInfo() =
request<ReVancedAsset>("manager?prerelease=${prefs.useManagerPrereleases.get()}") request<ReVancedAsset>("manager?prerelease=${prefs.useManagerPrereleases.get()}")
suspend fun getAppHistory() = request<ReVancedAsset>("patches/history", "dev")
suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches?prerelease=${prefs.usePatchesPrereleases.get()}") suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches?prerelease=${prefs.usePatchesPrereleases.get()}")
suspend fun getContributors() = request<List<ReVancedGitRepository>>("contributors") suspend fun getContributors() = request<List<ReVancedGitRepository>>("contributors")

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.component1
import app.revanced.manager.patcher.Session.Companion.component2 import app.revanced.manager.patcher.Session.Companion.component2
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.patcher.Patcher import app.revanced.patcher.PatchesResult
import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult import app.revanced.patcher.patch.PatchResult
import app.revanced.patcher.patcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
internal typealias PatchList = List<Patch<*>> internal typealias PatchList = List<Patch>
private typealias Patcher = (emit: (PatchResult) -> Unit) -> PatchesResult
class Session( class Session(
cacheDir: String, cacheDir: String,
frameworkDir: String, private val frameworkDir: String,
aaptPath: String, private val aaptPath: String,
private val logger: Logger, private val logger: Logger,
private val input: File, private val input: File,
private val onEvent: (ProgressEvent) -> Unit, private val onEvent: (ProgressEvent) -> Unit,
) : Closeable { ) : Closeable {
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() } 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( onEvent(
ProgressEvent.Failed( ProgressEvent.Completed(
StepId.ExecutePatch(index), StepId.ExecutePatch(index),
exception.toRemoteError(),
) )
) )
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString()) logger.info("${patch.name} succeeded")
throw exception
} }
onEvent(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
)
)
logger.info("${patch.name} succeeded")
} }
}
suspend fun run(output: File, selectedPatches: PatchList) { 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 { java.util.logging.Logger.getLogger("").apply {
handlers.forEach { handlers.forEach {
it.close() it.close()
@@ -73,18 +78,21 @@ class Session(
addHandler(logger.handler) addHandler(logger.handler)
} }
with(patcher) { val patcher = patcher(
logger.info("Merging integrations") apkFile = input,
this += selectedPatches.toSet() temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir,
logger.info("Applying patches...") aaptBinaryPath = File(aaptPath)
applyPatchesVerbose(selectedPatches.sortedBy { it.name }) ) { _packageName, _version ->
selectedPatches.toSet()
} }
logger.info("Applying patches...")
applyPatchesVerbose(patcher, indices)
} }
runStep(StepId.WriteAPK, onEvent) { runStep(StepId.WriteAPK, onEvent) {
logger.info("Writing patched files...") logger.info("Writing patched files...")
val result = patcher.get()
val patched = tempDir.resolve("result.apk") val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -102,7 +110,6 @@ class Session(
override fun close() { override fun close() {
tempDir.deleteRecursively() tempDir.deleteRecursively()
patcher.close()
} }
companion object { companion object {

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Campaign
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -21,7 +22,7 @@ import app.revanced.manager.ui.component.Markdown
@Composable @Composable
fun Changelog( fun Changelog(
markdown: String, description: String,
version: String, version: String,
publishDate: String publishDate: String
) { ) {
@@ -34,22 +35,32 @@ fun Changelog(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.NewReleases, imageVector = Icons.Outlined.Campaign,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)
) )
Text( Text(
"${version.removePrefix("v")} ($publishDate)", version.removePrefix("v"),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
) {
Tag(
Icons.Outlined.CalendarToday,
publishDate
)
}
Markdown(
description.removeVersionHeaderIfMatches(version),
)
} }
Markdown(
markdown,
)
} }
@Composable @Composable
@@ -70,4 +81,16 @@ private fun Tag(icon: ImageVector, text: String) {
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
} }
}
fun String.removeVersionHeaderIfMatches(version: String): String {
val firstNewlineIndex = indexOf('\n')
if (firstNewlineIndex == -1) return this
val firstLine = substring(0, firstNewlineIndex).trim()
val versionWithoutPrefix = version.removePrefix("v")
if (!firstLine.contains(versionWithoutPrefix)) return this
return substring(firstNewlineIndex + 1).trimStart()
} }

View File

@@ -1,6 +1,7 @@
package app.revanced.manager.ui.model.navigation package app.revanced.manager.ui.model.navigation
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
@@ -22,6 +23,12 @@ data class InstalledApplicationInfo(val packageName: String)
@Serializable @Serializable
data class Update(val downloadOnScreenEntry: Boolean = false) data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object Announcements
@Serializable
data object Announcement : ComplexParameter<ReVancedAnnouncement>
@Serializable @Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> { data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
@Parcelize @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.Add
import androidx.compose.material.icons.filled.BatteryAlert import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close 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.Apps
import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download 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.Refresh
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
@@ -91,6 +94,8 @@ fun DashboardScreen(
onAppSelectorClick: () -> Unit, onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit, onUpdateClick: () -> Unit,
onAnnouncementsClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
onDownloaderClick: () -> Unit, onDownloaderClick: () -> Unit,
onAppClick: (String) -> Unit onAppClick: (String) -> Unit
) { ) {
@@ -130,17 +135,16 @@ fun DashboardScreen(
) )
} }
var showUpdateDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) } var showUpdateDialog by rememberSaveable { mutableStateOf(true) }
val availableUpdate by remember { val showManagerUpdateDialogOnLaunch by vm.prefs.showManagerUpdateDialogOnLaunch.getAsState()
derivedStateOf { vm.updatedManagerVersion.takeIf { showUpdateDialog } } val availableUpdate = vm.updatedManagerVersion
}
availableUpdate?.let { version -> if (showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
AvailableUpdateDialog( AvailableUpdateDialog(
onDismiss = { showUpdateDialog = false }, onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch, setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick, 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) { IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings)) 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( HorizontalPager(

View File

@@ -640,17 +640,17 @@ private fun OptionsDialog(
) { ) {
if (patch.options == null) return@LazyColumnWithScrollbar if (patch.options == null) return@LazyColumnWithScrollbar
items(patch.options, key = { it.key }) { option -> items(patch.options, key = { it.name }) { option ->
val key = option.key val name = option.name
val value = 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") @Suppress("UNCHECKED_CAST")
OptionItem( OptionItem(
option = option as Option<Any>, option = option as Option<Any>,
value = value, value = value,
setValue = { setValue = {
set(key, it) set(name, it)
}, },
selectionWarningEnabled = selectionWarningEnabled selectionWarningEnabled = selectionWarningEnabled
) )

View File

@@ -144,16 +144,16 @@ fun RequiredOptionsScreen(
val values = vm.getOptions(bundle.uid, it) val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option -> it.options?.forEach { option ->
val key = option.key val name = option.name
val value = 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") @Suppress("UNCHECKED_CAST")
OptionItem( OptionItem(
option = option as Option<Any>, option = option as Option<Any>,
value = value, value = value,
setValue = { new -> setValue = { new ->
vm.setOption(bundle.uid, it, key, new) vm.setOption(bundle.uid, it, name, new)
}, },
selectionWarningEnabled = vm.selectionWarningEnabled selectionWarningEnabled = vm.selectionWarningEnabled
) )

View File

@@ -189,7 +189,7 @@ private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) {
) )
) { ) {
Changelog( Changelog(
markdown = releaseInfo.description.replace("`", ""), description = releaseInfo.description.replace("`", ""),
version = releaseInfo.version, version = releaseInfo.version,
publishDate = releaseInfo.createdAt.relativeTime(LocalContext.current) publishDate = releaseInfo.createdAt.relativeTime(LocalContext.current)
) )

View File

@@ -1,12 +1,15 @@
package app.revanced.manager.ui.screen.settings.update package app.revanced.manager.ui.screen.settings.update
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -17,10 +20,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.settings.Changelog import app.revanced.manager.ui.component.settings.Changelog
import app.revanced.manager.ui.viewmodel.ChangelogUiState
import app.revanced.manager.ui.viewmodel.ChangelogsViewModel import app.revanced.manager.ui.viewmodel.ChangelogsViewModel
import app.revanced.manager.util.relativeTime import app.revanced.manager.util.relativeTime
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -43,22 +48,61 @@ fun ChangelogsSettingsScreen(
}, },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues -> ) { paddingValues ->
ColumnWithScrollbar( Box(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize(), .fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, contentAlignment = Alignment.Center
verticalArrangement = if (vm.releaseInfo == null) Arrangement.Center else Arrangement.Top
) { ) {
vm.releaseInfo?.let { info -> when (val state = vm.state) {
Column(modifier = Modifier.padding(16.dp)) { is ChangelogUiState.Loading -> LoadingIndicator()
Changelog(
markdown = info.description.replace("`", ""), is ChangelogUiState.Error -> Text(
version = info.version, text = state.message,
publishDate = info.createdAt.relativeTime(LocalContext.current) style = MaterialTheme.typography.titleLarge
) )
is ChangelogUiState.Success -> {
if (state.changelogs.isEmpty()) {
Text(
text = stringResource(R.string.no_changelogs_found),
style = MaterialTheme.typography.titleLarge
)
} else {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
items(
items = state.changelogs,
key = { it.version }
) { changelog ->
ChangelogItem(
changelog = changelog,
showDivider = changelog != state.changelogs.last()
)
}
}
}
} }
} ?: LoadingIndicator() }
}
}
}
@Composable
private fun ChangelogItem(
changelog: ReVancedAsset,
showDivider: Boolean
) {
Column(modifier = Modifier.padding(16.dp)) {
Changelog(
description = changelog.description,
version = changelog.version,
publishDate = changelog.createdAt.relativeTime(LocalContext.current)
)
if (showDivider) {
HorizontalDivider(
modifier = Modifier.padding(top = 32.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
} }
} }
} }

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

@@ -12,18 +12,57 @@ import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.json.Json
sealed interface ChangelogUiState {
data object Loading : ChangelogUiState
data class Success(val changelogs: List<ReVancedAsset>) : ChangelogUiState
data class Error(val message: String) : ChangelogUiState
}
class ChangelogsViewModel( class ChangelogsViewModel(
private val api: ReVancedAPI, private val api: ReVancedAPI,
private val app: Application, private val app: Application,
) : ViewModel() { ) : ViewModel() {
var releaseInfo: ReVancedAsset? by mutableStateOf(null) val mock = listOf(
ReVancedAsset(
version = "v1.25.2",
createdAt = LocalDateTime.parse("2025-07-07T13:01:12"),
description = """
## [1.25.2](https://github.com/ReVanced/revanced-manager/compare/v1.25.0...v1.25.1) (2025-07-07)
### Bug Fixes
* Disable Impeller Flutter render engine correctly to fix rendering issues
""".trimIndent(),
downloadUrl = "https://github.com/ReVanced/revanced-manager/releases/download/v1.25.1/revanced-manager-1.25.1.apk"
),
ReVancedAsset(
version = "v1.25.1",
createdAt = LocalDateTime.parse("2025-07-07T13:01:12"),
description = """
## [1.25.1](https://github.com/ReVanced/revanced-manager/compare/v1.25.0...v1.25.1) (2025-07-07)
### Bug Fixes
* Disable Impeller Flutter render engine correctly to fix rendering issues
""".trimIndent(),
downloadUrl = "https://github.com/ReVanced/revanced-manager/releases/download/v1.25.1/revanced-manager-1.25.1.apk"
)
)
var state: ChangelogUiState by mutableStateOf(ChangelogUiState.Loading)
private set private set
init { init {
viewModelScope.launch { viewModelScope.launch {
uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") { uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") {
releaseInfo = api.getLatestAppInfo().getOrThrow() api.getLatestAppInfo().getOrThrow()
state = ChangelogUiState.Success(changelogs = mock)
}
if (state is ChangelogUiState.Loading) {
state = ChangelogUiState.Error(app.getString(R.string.changelog_download_fail))
} }
} }
} }

View File

@@ -16,21 +16,29 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.PreferencesManager 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.DownloaderRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.api.ReVancedAPI 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.PM
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardViewModel( class DashboardViewModel(
private val app: Application, private val app: Application,
private val patchBundleRepository: PatchBundleRepository, private val patchBundleRepository: PatchBundleRepository,
private val downloaderRepository: DownloaderRepository, private val downloaderRepository: DownloaderRepository,
private val announcementRepository: AnnouncementRepository,
private val reVancedAPI: ReVancedAPI, private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo, private val networkInfo: NetworkInfo,
val prefs: PreferencesManager, val prefs: PreferencesManager,
@@ -57,12 +65,16 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false) var showBatteryOptimizationsWarning by mutableStateOf(false)
private set private set
var unreadAnnouncement by mutableStateOf<ReVancedAnnouncement?>(null)
private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>() private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow() val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
checkForManagerUpdates() checkForManagerUpdates()
checkForAnnouncements()
updateBatteryOptimizationsWarning() 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() { fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning = showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName) !powerManager.isIgnoringBatteryOptimizations(app.packageName)

View File

@@ -1,18 +1,19 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Activity
import android.app.Application import android.app.Application
import android.content.Intent import android.content.Context.MODE_PRIVATE
import android.util.Base64 import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResult import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection import app.revanced.manager.domain.repository.SerializedSelection
@@ -27,11 +28,16 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.json.JSONObject
import java.io.File
private const val LEGACY_LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu!"
class MainViewModel( class MainViewModel(
private val patchBundleRepository: PatchBundleRepository, private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository, private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository, private val downloadedAppRepository: DownloadedAppRepository,
private val installedAppRepository: InstalledAppRepository,
private val keystoreManager: KeystoreManager, private val keystoreManager: KeystoreManager,
private val app: Application, private val app: Application,
val prefs: PreferencesManager, val prefs: PreferencesManager,
@@ -39,8 +45,6 @@ class MainViewModel(
) : ViewModel() { ) : ViewModel() {
private val appSelectChannel = Channel<SelectedApp>() private val appSelectChannel = Channel<SelectedApp>()
val appSelectFlow = appSelectChannel.receiveAsFlow() val appSelectFlow = appSelectChannel.receiveAsFlow()
private val legacyImportActivityChannel = Channel<Intent>()
val legacyImportActivityFlow = legacyImportActivityChannel.receiveAsFlow()
private suspend fun suggestedVersion(packageName: String) = private suspend fun suggestedVersion(packageName: String) =
patchBundleRepository.suggestedVersions.first()[packageName] patchBundleRepository.suggestedVersions.first()[packageName]
@@ -72,43 +76,55 @@ class MainViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
if (!prefs.firstLaunch.get()) return@launch if (!prefs.firstLaunch.get()) return@launch
legacyImportActivityChannel.send(Intent().apply { val flutterPrefs = app.getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
setClassName( if (flutterPrefs.all.isNotEmpty()) applyLegacySettings(flutterPrefs)
"app.revanced.manager.flutter",
"app.revanced.manager.flutter.ExportSettingsActivity"
)
})
} }
} }
fun applyLegacySettings(result: ActivityResult) { fun applyLegacySettings(flutterPrefs: SharedPreferences) {
if (result.resultCode != Activity.RESULT_OK) { Log.d(tag, "Migrating flutter preferences")
app.toast(app.getString(R.string.legacy_import_failed)) val data = JSONObject().apply {
Log.e( put("keystorePassword", "s3cur3p@ssw0rd")
tag,
"Got unknown result code while importing legacy settings: ${result.resultCode}" val allEntries: Map<String, *> = flutterPrefs.all
) for ((key, value) in allEntries) {
return put(key.replace("flutter.", ""), value)
}
} }
val jsonStr = result.data?.getStringExtra("data") val storedPatchesFile = File(app.filesDir.parentFile.absolutePath, "/app_flutter/selected-patches.json")
if (jsonStr == null) { val patches: SerializedSelection? =
app.toast(app.getString(R.string.legacy_import_failed)) if (storedPatchesFile.exists()) {
Log.e(tag, "Legacy settings data is null") json.decodeFromString<SerializedSelection>(storedPatchesFile.readText())
return } else {
null
}
val keystoreFile = File(app.getExternalFilesDir(null), "/revanced-manager.keystore")
val keystore: ByteArray? = if (keystoreFile.exists()) {
val bytes = keystoreFile.readBytes()
keystoreFile.delete()
bytes
} else {
null
} }
flutterPrefs.edit(commit = true) { clear() }
val settings = try { val settings = try {
json.decodeFromString<LegacySettings>(jsonStr) json.decodeFromString<LegacySettings>(data.toString())
} catch (e: SerializationException) { } catch (e: SerializationException) {
app.toast(app.getString(R.string.legacy_import_failed)) app.toast(app.getString(R.string.legacy_import_failed))
Log.e(tag, "Legacy settings data could not be deserialized", e) Log.e(tag, "Legacy settings data could not be deserialized", e)
return return
} }
applyLegacySettings(settings) applyLegacySettings(settings, patches, keystore)
} }
private fun applyLegacySettings(settings: LegacySettings) = viewModelScope.launch { private fun applyLegacySettings(settings: LegacySettings, patches: SerializedSelection?, keystore: ByteArray?) = viewModelScope.launch {
settings.themeMode?.let { theme -> settings.themeMode?.let { theme ->
val themeMap = mapOf( val themeMap = mapOf(
0 to Theme.SYSTEM, 0 to Theme.SYSTEM,
@@ -144,20 +160,39 @@ class MainViewModel(
settings.patchesChangeEnabled?.let { disableSelectionWarning -> settings.patchesChangeEnabled?.let { disableSelectionWarning ->
prefs.disableSelectionWarning.update(disableSelectionWarning) prefs.disableSelectionWarning.update(disableSelectionWarning)
} }
settings.keystore?.let { keystore -> keystore?.let { keystoreBytes ->
val keystoreBytes = Base64.decode(keystore, Base64.DEFAULT)
keystoreManager.import( keystoreManager.import(
"alias", "alias",
settings.keystorePassword, settings.keystorePassword,
keystoreBytes.inputStream() keystoreBytes.inputStream()
) )
} }
settings.patches?.let { selection -> patches?.let { selection ->
patchSelectionRepository.import(0, selection) patchSelectionRepository.import(0, selection)
} }
settings.patchedApps?.let { apps ->
json.decodeFromString<List<String>>(apps.removePrefix(LEGACY_LIST_PREFIX)).forEach { appJson ->
val patchedApp = json.decodeFromString<LegacyPatchedApp>(appJson)
installedAppRepository.addOrUpdate(
patchedApp.packageName,
patchedApp.packageName,
patchedApp.version,
if (patchedApp.isRooted) InstallType.MOUNT else InstallType.DEFAULT,
mapOf(0 to patchedApp.appliedPatches.toSet())
)
}
}
Log.d(tag, "Imported legacy settings") Log.d(tag, "Imported legacy settings")
} }
@Serializable
private data class LegacyPatchedApp(
val packageName: String,
val version: String,
val isRooted: Boolean,
val appliedPatches: List<String>,
)
@Serializable @Serializable
private data class LegacySettings( private data class LegacySettings(
val keystorePassword: String, val keystorePassword: String,
@@ -168,7 +203,6 @@ class MainViewModel(
val experimentalPatchesEnabled: Boolean? = null, val experimentalPatchesEnabled: Boolean? = null,
val patchesAutoUpdate: Boolean? = null, val patchesAutoUpdate: Boolean? = null,
val patchesChangeEnabled: Boolean? = null, val patchesChangeEnabled: Boolean? = null,
val keystore: String? = null, val patchedApps: String? = null,
val patches: SerializedSelection? = null,
) )
} }

View File

@@ -129,7 +129,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
isSelected( isSelected(
bundle.uid, bundle.uid,
patch 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() }.toList()
}.filter { (_, patches) -> patches.isNotEmpty() } }.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 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 // All patches
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf) val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
// The key-value options of an individual patch // The key-value options of an individual patch
val patchToOpts = patchesToOpts val patchToOpts = patchesToOpts
.getOrElse(patch.name, ::persistentMapOf) .getOrElse(patch.name, ::persistentMapOf)
.put(key, value) .put(name, value)
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts) patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
} }

View File

@@ -325,7 +325,7 @@ class SelectedAppInfoViewModel(
bundleOptions.forEach patch@{ (patchName, values) -> bundleOptions.forEach patch@{ (patchName, values) ->
// Get all valid option keys for the patch. // Get all valid option keys for the patch.
val validOptionKeys = 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 -> this@bundleOptions[patchName] = values.filterKeys { key ->
key in validOptionKeys key in validOptionKeys

View File

@@ -14,7 +14,7 @@ Second \"item\" text"</string>
--> -->
<resources> <resources>
<string name="app_name">ReVanced Manager</string> <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="patches">Patches</string>
<string name="cli">CLI</string> <string name="cli">CLI</string>
<string name="manager">Manager</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_patches">ReVanced Patches</string>
<string name="auto_updates_dialog_note">These settings can be changed later.</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">General</string>
<string name="general_description">Language, theme, dynamic color</string> <string name="general_description">Language, theme, dynamic color</string>
<string name="updates">Updates</string> <string name="updates">Updates</string>
@@ -380,6 +383,7 @@ It is only compatible with the following version(s): %2$s"</string>
<string name="less">Less</string> <string name="less">Less</string>
<string name="continue_">Continue</string> <string name="continue_">Continue</string>
<string name="dismiss">Dismiss</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="permanent_dismiss">Do not show this again</string>
<string name="donate">Donate</string> <string name="donate">Donate</string>
<string name="website">Website</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_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="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="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="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> <string name="developer_options_taps">%d taps remaining</string>
@@ -443,6 +449,7 @@ ReVanced Manager will close when updating."</string>
<string name="failed_to_check_updates">Failed to check for updates: %s</string> <string name="failed_to_check_updates">Failed to check for updates: %s</string>
<string name="no_update_available">No update available</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="update_check">Checking for updates…</string>
<string name="dismiss_temporary">Not now</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="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
@@ -499,4 +506,5 @@ Click on the patches to see more details."</string>
<string name="failed_to_import_keystore">Failed to import keystore</string> <string name="failed_to_import_keystore">Failed to import keystore</string>
<string name="export">Export</string> <string name="export">Export</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="new_announcement">New announcement:\n%s</string>
</resources> </resources>

View File

@@ -1,6 +1,6 @@
[versions] [versions]
ktx = "1.17.0" ktx = "1.17.0"
material3 = "1.4.0" material3 = "1.5.0-alpha14"
ui-tooling = "1.10.0" ui-tooling = "1.10.0"
viewmodel-lifecycle = "2.10.0" viewmodel-lifecycle = "2.10.0"
splash-screen = "1.2.0" splash-screen = "1.2.0"
@@ -9,7 +9,7 @@ appcompat = "1.7.1"
preferences-datastore = "1.2.0" preferences-datastore = "1.2.0"
work-runtime = "2.11.0" work-runtime = "2.11.0"
compose-bom = "2025.12.01" compose-bom = "2025.12.01"
navigation = "2.9.6" navigation = "2.9.7"
accompanist = "0.37.3" accompanist = "0.37.3"
placeholder = "1.0.12" placeholder = "1.0.12"
reorderable = "3.0.0" reorderable = "3.0.0"
@@ -17,8 +17,8 @@ serialization = "1.9.0"
collection = "0.4.0" collection = "0.4.0"
datetime = "0.7.1" datetime = "0.7.1"
room-version = "2.8.4" room-version = "2.8.4"
revanced-patcher = "21.0.0" revanced-patcher = "22.0.0"
revanced-library = "3.0.2" revanced-library = "4.0.0"
koin = "4.1.1" koin = "4.1.1"
ktor = "3.3.3" ktor = "3.3.3"
markdown-renderer = "0.39.0" markdown-renderer = "0.39.0"
@@ -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" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room-version" }
# Patcher # Patcher
revanced-patcher = { group = "app.revanced", name = "revanced-patcher", version.ref = "revanced-patcher" } revanced-patcher = { group = "app.revanced", name = "patcher-android", version.ref = "revanced-patcher" }
revanced-library = { group = "app.revanced", name = "revanced-library", version.ref = "revanced-library" } revanced-library = { group = "app.revanced", name = "library-android", version.ref = "revanced-library" }
# Koin # Koin
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }

View File

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