mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2026-03-13 08:41:57 +08:00
Compare commits
12 Commits
api@1.0.0-
...
feat/chang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cd097519 | ||
|
|
d685382277 | ||
|
|
f19080a986 | ||
|
|
813df46847 | ||
|
|
6f4219c01b | ||
|
|
8667051283 | ||
|
|
e0ce6b4ef3 | ||
|
|
b50688ee10 | ||
|
|
31a0f751aa | ||
|
|
4f65555b56 | ||
|
|
4fc2ba339b | ||
|
|
76d0f17770 |
@@ -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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
version = 1.26.0-dev.20
|
version = 1.26.0-dev.23
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package app.revanced.manager.network.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedAnnouncementTag(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
//}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user