feat: finish implementing the sources system (#70)

This commit is contained in:
Ax333l 2023-08-03 13:15:42 +02:00 committed by GitHub
parent 1ba97b3e4c
commit d08f6f9ed8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1548 additions and 1063 deletions

View File

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f7e0fef1b937143a8b128e3dbab7c041",
"identityHash": "7142188e25ce489eb233aed8fb76e4cc",
"entities": [
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `location` TEXT NOT NULL, `version` TEXT NOT NULL, `integrations_version` TEXT NOT NULL, PRIMARY KEY(`uid`))",
"tableName": "patch_bundles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `version` TEXT, `integrations_version` TEXT, PRIMARY KEY(`uid`))",
"fields": [
{
"fieldPath": "uid",
@ -21,22 +21,28 @@
"notNull": true
},
{
"fieldPath": "location",
"columnName": "location",
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "autoUpdate",
"columnName": "auto_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "versionInfo.patches",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
"notNull": false
},
{
"fieldPath": "versionInfo.integrations",
"columnName": "integrations_version",
"affinity": "TEXT",
"notNull": true
"notNull": false
}
],
"primaryKey": {
@ -47,20 +53,20 @@
},
"indices": [
{
"name": "index_sources_name",
"name": "index_patch_bundles_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sources_name` ON `${TABLE_NAME}` (`name`)"
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_bundles_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "patch_selections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `source` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`source`) REFERENCES `sources`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "uid",
@ -69,8 +75,8 @@
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"fieldPath": "patchBundle",
"columnName": "patch_bundle",
"affinity": "INTEGER",
"notNull": true
},
@ -89,23 +95,23 @@
},
"indices": [
{
"name": "index_patch_selections_source_package_name",
"name": "index_patch_selections_patch_bundle_package_name",
"unique": true,
"columnNames": [
"source",
"patch_bundle",
"package_name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_source_package_name` ON `${TABLE_NAME}` (`source`, `package_name`)"
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
}
],
"foreignKeys": [
{
"table": "sources",
"table": "patch_bundles",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"source"
"patch_bundle"
],
"referencedColumns": [
"uid"
@ -189,7 +195,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f7e0fef1b937143a8b128e3dbab7c041')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7142188e25ce489eb233aed8fb76e4cc')"
]
}
}

View File

@ -5,6 +5,7 @@
<permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="ReservedSystemPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />

View File

@ -7,7 +7,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
@ -28,22 +28,19 @@ import dev.olshevski.navigation.reimagined.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.android.ext.android.get
import org.koin.androidx.compose.getViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() {
private val prefs: PreferencesManager = get()
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
val vm: MainViewModel = getActivityViewModel()
getActivityViewModel<MainViewModel>()
installSplashScreen()
val scale = this.resources.displayMetrics.density
val pixels = (36 * scale).roundToInt()
@ -57,8 +54,8 @@ class MainActivity : ComponentActivity() {
)
setContent {
val theme by prefs.theme.getAsState()
val dynamicColor by prefs.dynamicColor.getAsState()
val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState()
ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
@ -69,6 +66,11 @@ class MainActivity : ComponentActivity() {
NavBackHandler(navController)
val showAutoUpdatesDialog by vm.prefs.showAutoUpdatesDialog.getAsState()
if (showAutoUpdatesDialog) {
AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
}
AnimatedNavHost(
controller = navController
) { destination ->

View File

@ -3,6 +3,8 @@ package app.revanced.manager
import android.app.Application
import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
@ -14,6 +16,7 @@ import org.koin.core.context.startKoin
class ManagerApplication : Application() {
private val scope = MainScope()
private val prefs: PreferencesManager by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
override fun onCreate() {
super.onCreate()
@ -36,5 +39,11 @@ class ManagerApplication : Application() {
scope.launch {
prefs.preload()
}
scope.launch(Dispatchers.Default) {
with(patchBundleRepository) {
reload()
updateCheck()
}
}
}
}

View File

@ -0,0 +1,19 @@
package app.revanced.manager.data.platform
import android.app.Application
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.core.content.getSystemService
class NetworkInfo(app: Application) {
private val connectivityManager = app.getSystemService<ConnectivityManager>()!!
private fun getCapabilities() = connectivityManager.activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
fun isConnected() = connectivityManager.activeNetwork != null
fun isUnmetered() = getCapabilities()?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) ?: true
/**
* Returns true if it is safe to download large files.
*/
fun isSafe() = isConnected() && isUnmetered()
}

View File

@ -8,14 +8,14 @@ import app.revanced.manager.data.room.apps.DownloadedApp
import app.revanced.manager.data.room.selection.PatchSelection
import app.revanced.manager.data.room.selection.SelectedPatch
import app.revanced.manager.data.room.selection.SelectionDao
import app.revanced.manager.data.room.sources.SourceDao
import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.bundles.PatchBundleDao
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import kotlin.random.Random
@Database(entities = [SourceEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class], version = 1)
@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun sourceDao(): SourceDao
abstract fun patchBundleDao(): PatchBundleDao
abstract fun selectionDao(): SelectionDao
abstract fun appDao(): AppDao

View File

@ -1,19 +1,16 @@
package app.revanced.manager.data.room
import androidx.room.TypeConverter
import app.revanced.manager.data.room.sources.SourceLocation
import app.revanced.manager.data.room.bundles.Source
import io.ktor.http.*
import java.io.File
class Converters {
@TypeConverter
fun locationFromString(value: String) = when(value) {
SourceLocation.Local.SENTINEL -> SourceLocation.Local
else -> SourceLocation.Remote(Url(value))
}
fun sourceFromString(value: String) = Source.from(value)
@TypeConverter
fun locationToString(location: SourceLocation) = location.toString()
fun sourceToString(value: Source) = value.toString()
@TypeConverter
fun fileFromString(value: String) = File(value)

View File

@ -0,0 +1,34 @@
package app.revanced.manager.data.room.bundles
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles")
suspend fun all(): List<PatchBundleEntity>
@Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties>
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid")
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?)
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean)
@Query("DELETE FROM patch_bundles WHERE uid != 0")
suspend fun purgeCustomBundles()
@Transaction
suspend fun reset() {
purgeCustomBundles()
updateVersion(0, null, null) // Reset the main source
}
@Query("DELETE FROM patch_bundles WHERE uid = :uid")
suspend fun remove(uid: Int)
@Insert
suspend fun add(source: PatchBundleEntity)
}

View File

@ -0,0 +1,49 @@
package app.revanced.manager.data.room.bundles
import androidx.room.*
import io.ktor.http.*
sealed class Source {
object Local : Source() {
const val SENTINEL = "local"
override fun toString() = SENTINEL
}
object API : Source() {
const val SENTINEL = "api"
override fun toString() = SENTINEL
}
data class Remote(val url: Url) : Source() {
override fun toString() = url.toString()
}
companion object {
fun from(value: String) = when(value) {
Local.SENTINEL -> Local
API.SENTINEL -> API
else -> Remote(Url(value))
}
}
}
data class VersionInfo(
@ColumnInfo(name = "version") val patches: String? = null,
@ColumnInfo(name = "integrations_version") val integrations: String? = null,
)
@Entity(tableName = "patch_bundles", indices = [Index(value = ["name"], unique = true)])
data class PatchBundleEntity(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "name") val name: String,
@Embedded val versionInfo: VersionInfo,
@ColumnInfo(name = "source") val source: Source,
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean
)
data class BundleProperties(
@Embedded val versionInfo: VersionInfo,
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean
)

View File

@ -5,20 +5,20 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.bundles.PatchBundleEntity
@Entity(
tableName = "patch_selections",
foreignKeys = [ForeignKey(
SourceEntity::class,
PatchBundleEntity::class,
parentColumns = ["uid"],
childColumns = ["source"],
childColumns = ["patch_bundle"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["source", "package_name"], unique = true)]
indices = [Index(value = ["patch_bundle", "package_name"], unique = true)]
)
data class PatchSelection(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "source") val source: Int,
@ColumnInfo(name = "patch_bundle") val patchBundle: Int,
@ColumnInfo(name = "package_name") val packageName: String
)

View File

@ -9,9 +9,9 @@ import androidx.room.Transaction
@Dao
abstract class SelectionDao {
@Transaction
@MapInfo(keyColumn = "source", valueColumn = "patch_name")
@MapInfo(keyColumn = "patch_bundle", valueColumn = "patch_name")
@Query(
"SELECT source, patch_name FROM patch_selections" +
"SELECT patch_bundle, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE package_name = :packageName"
)
@ -22,18 +22,18 @@ abstract class SelectionDao {
@Query(
"SELECT package_name, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE source = :sourceUid"
" WHERE patch_bundle = :bundleUid"
)
abstract suspend fun exportSelection(sourceUid: Int): Map<String, List<String>>
abstract suspend fun exportSelection(bundleUid: Int): Map<String, List<String>>
@Query("SELECT uid FROM patch_selections WHERE source = :sourceUid AND package_name = :packageName")
abstract suspend fun getSelectionId(sourceUid: Int, packageName: String): Int?
@Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName")
abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?
@Insert
abstract suspend fun createSelection(selection: PatchSelection)
@Query("DELETE FROM patch_selections WHERE source = :uid")
abstract suspend fun clearForSource(uid: Int)
@Query("DELETE FROM patch_selections WHERE patch_bundle = :uid")
abstract suspend fun clearForPatchBundle(uid: Int)
@Query("DELETE FROM patch_selections")
abstract suspend fun reset()

View File

@ -1,24 +0,0 @@
package app.revanced.manager.data.room.sources
import androidx.room.*
@Dao
interface SourceDao {
@Query("SELECT * FROM $sourcesTableName")
suspend fun all(): List<SourceEntity>
@Query("SELECT version, integrations_version FROM $sourcesTableName WHERE uid = :uid")
suspend fun getVersionById(uid: Int): VersionInfo
@Query("UPDATE $sourcesTableName SET version=:patches, integrations_version=:integrations WHERE uid=:uid")
suspend fun updateVersion(uid: Int, patches: String, integrations: String)
@Query("DELETE FROM $sourcesTableName")
suspend fun purge()
@Query("DELETE FROM $sourcesTableName WHERE uid=:uid")
suspend fun remove(uid: Int)
@Insert
suspend fun add(source: SourceEntity)
}

View File

@ -1,31 +0,0 @@
package app.revanced.manager.data.room.sources
import androidx.room.*
import io.ktor.http.*
const val sourcesTableName = "sources"
sealed class SourceLocation {
object Local : SourceLocation() {
const val SENTINEL = "local"
override fun toString() = SENTINEL
}
data class Remote(val url: Url) : SourceLocation() {
override fun toString() = url.toString()
}
}
data class VersionInfo(
@ColumnInfo(name = "version") val patches: String,
@ColumnInfo(name = "integrations_version") val integrations: String,
)
@Entity(tableName = sourcesTableName, indices = [Index(value = ["name"], unique = true)])
data class SourceEntity(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "name") val name: String,
@Embedded val versionInfo: VersionInfo,
@ColumnInfo(name = "location") val location: SourceLocation,
)

View File

@ -1,6 +1,7 @@
package app.revanced.manager.di
import app.revanced.manager.data.platform.FileSystem
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.repository.*
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.api.ManagerAPI
@ -12,9 +13,10 @@ val repositoryModule = module {
singleOf(::GithubRepository)
singleOf(::ManagerAPI)
singleOf(::FileSystem)
singleOf(::SourcePersistenceRepository)
singleOf(::NetworkInfo)
singleOf(::PatchBundlePersistenceRepository)
singleOf(::PatchSelectionRepository)
singleOf(::SourceRepository)
singleOf(::PatchBundleRepository)
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
}

View File

@ -6,11 +6,13 @@ import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::MainViewModel)
viewModelOf(::DashboardViewModel)
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::SourcesViewModel)
viewModelOf(::BundlesViewModel)
viewModelOf(::InstallerViewModel)
viewModelOf(::UpdateProgressViewModel)
viewModelOf(::ManagerUpdateChangelogViewModel)

View File

@ -1,4 +1,4 @@
package app.revanced.manager.domain.sources
package app.revanced.manager.domain.bundles
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -7,17 +7,17 @@ import java.io.InputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
class LocalSource(name: String, id: Int, directory: File) : Source(name, id, directory) {
class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
withContext(Dispatchers.IO) {
patches?.let {
Files.copy(it, patchesJar.toPath(), StandardCopyOption.REPLACE_EXISTING)
Files.copy(it, patchesFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
integrations?.let {
Files.copy(it, this@LocalSource.integrations.toPath(), StandardCopyOption.REPLACE_EXISTING)
Files.copy(it, this@LocalPatchBundle.integrationsFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
}
_bundle.emit(loadBundle { throw it })
reload()
}
}

View File

@ -0,0 +1,55 @@
package app.revanced.manager.domain.bundles
import androidx.compose.runtime.Stable
import app.revanced.manager.patcher.patch.PatchBundle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import java.io.File
/**
* A [PatchBundle] source.
*/
@Stable
sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) {
protected val patchesFile = directory.resolve("patches.jar")
protected val integrationsFile = directory.resolve("integrations.apk")
private val _state = MutableStateFlow(load())
val state = _state.asStateFlow()
/**
* Returns true if the bundle has been downloaded to local storage.
*/
fun hasInstalled() = patchesFile.exists()
private fun load(): State {
if (!hasInstalled()) return State.Missing
return try {
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
} catch (t: Throwable) {
State.Failed(t)
}
}
fun reload() {
_state.value = load()
}
sealed interface State {
fun patchBundleOrNull(): PatchBundle? = null
object Missing : State
data class Failed(val throwable: Throwable) : State
data class Loaded(val bundle: PatchBundle) : State {
override fun patchBundleOrNull() = bundle
}
}
companion object {
val PatchBundleSource.isDefault get() = uid == 0
val PatchBundleSource.asRemoteOrNull get() = this as? RemotePatchBundle
fun PatchBundleSource.propsOrNullFlow() = asRemoteOrNull?.propsFlow() ?: flowOf(null)
}
}

View File

@ -0,0 +1,111 @@
package app.revanced.manager.domain.bundles
import androidx.compose.runtime.Stable
import app.revanced.manager.data.room.bundles.VersionInfo
import app.revanced.manager.domain.bundles.APIPatchBundle.Companion.toBundleAsset
import app.revanced.manager.domain.repository.Assets
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
import app.revanced.manager.domain.repository.ReVancedRepository
import app.revanced.manager.network.dto.Asset
import app.revanced.manager.network.dto.BundleAsset
import app.revanced.manager.network.dto.BundleInfo
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.util.ghIntegrations
import app.revanced.manager.util.ghPatches
import io.ktor.client.request.url
import io.ktor.http.Url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@Stable
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
PatchBundleSource(name, id, directory), KoinComponent {
private val configRepository: PatchBundlePersistenceRepository by inject()
protected val http: HttpService by inject()
protected abstract suspend fun getLatestInfo(): BundleInfo
private suspend fun download(info: BundleInfo) = withContext(Dispatchers.IO) {
val (patches, integrations) = info
coroutineScope {
mapOf(
patches.url to patchesFile,
integrations.url to integrationsFile
).forEach { (asset, file) ->
launch {
http.download(file) {
url(asset)
}
}
}
}
saveVersion(patches.version, integrations.version)
reload()
}
suspend fun downloadLatest() {
download(getLatestInfo())
}
suspend fun update(): Boolean = withContext(Dispatchers.IO) {
val info = getLatestInfo()
if (hasInstalled() && VersionInfo(info.patches.version, info.integrations.version) == currentVersion()) {
return@withContext false
}
download(info)
true
}
private suspend fun currentVersion() = configRepository.getProps(uid).first().versionInfo
private suspend fun saveVersion(patches: String, integrations: String) =
configRepository.updateVersion(uid, patches, integrations)
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
arrayOf(patchesFile, integrationsFile).forEach(File::delete)
reload()
}
fun propsFlow() = configRepository.getProps(uid)
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
companion object {
const val updateFailMsg = "Failed to update patch bundle(s)"
}
}
class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) {
override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
http.request<BundleInfo> {
url(endpoint)
}.getOrThrow()
}
}
class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) {
private val api: ReVancedRepository by inject()
override suspend fun getLatestInfo() = api.getAssets().toBundleInfo()
private companion object {
fun Assets.toBundleInfo(): BundleInfo {
val patches = find(ghPatches, ".jar")
val integrations = find(ghIntegrations, ".apk")
return BundleInfo(patches.toBundleAsset(), integrations.toBundleAsset())
}
fun Asset.toBundleAsset() = BundleAsset(version, downloadUrl)
}
}

View File

@ -10,10 +10,15 @@ class PreferencesManager(
val dynamicColor = booleanPreference("dynamic_color", true)
val theme = enumPreference("theme", Theme.SYSTEM)
val api = stringPreference("api_url", "https://releases.revanced.app")
val allowExperimental = booleanPreference("allow_experimental", false)
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val preferSplits = booleanPreference("prefer_splits", false)
val showAutoUpdatesDialog = booleanPreference("show_auto_updates_dialog", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
}

View File

@ -0,0 +1,56 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.data.room.bundles.VersionInfo
import io.ktor.http.*
import kotlinx.coroutines.flow.distinctUntilChanged
class PatchBundlePersistenceRepository(db: AppDatabase) {
private val dao = db.patchBundleDao()
suspend fun loadConfiguration(): List<PatchBundleEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.add(defaultSource)
return listOf(defaultSource)
}
return all
}
suspend fun reset() = dao.reset()
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
PatchBundleEntity(
uid = generateUid(),
name = name,
versionInfo = VersionInfo(),
source = source,
autoUpdate = autoUpdate
).also {
dao.add(it)
}
suspend fun delete(uid: Int) = dao.remove(uid)
suspend fun updateVersion(uid: Int, patches: String, integrations: String) =
dao.updateVersion(uid, patches, integrations)
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
private companion object {
val defaultSource = PatchBundleEntity(
uid = 0,
name = "Main",
versionInfo = VersionInfo(),
source = Source.API,
autoUpdate = false
)
}
}

View File

@ -0,0 +1,143 @@
package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.util.Log
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.domain.bundles.APIPatchBundle
import app.revanced.manager.domain.bundles.JsonPatchBundle
import app.revanced.manager.data.room.bundles.Source as SourceInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import java.io.InputStream
class PatchBundleRepository(
app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo,
) {
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
private val _sources: MutableStateFlow<Map<Int, PatchBundleSource>> =
MutableStateFlow(emptyMap())
val sources = _sources.map { it.values.toList() }
val bundles = sources.flatMapLatestAndCombine(
combiner = {
it.mapNotNull { (uid, state) ->
val bundle = state.patchBundleOrNull() ?: return@mapNotNull null
uid to bundle
}.toMap()
}
) {
it.state.map { state -> it.uid to state }
}
/**
* Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed.
*/
private fun directoryOf(uid: Int) = bundlesDir.resolve(uid.toString()).also { it.mkdirs() }
private fun PatchBundleEntity.load(): PatchBundleSource {
val dir = directoryOf(uid)
return when (source) {
is SourceInfo.Local -> LocalPatchBundle(name, uid, dir)
is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL)
is SourceInfo.Remote -> JsonPatchBundle(
name,
uid,
dir,
source.url.toString()
)
}
}
suspend fun reload() = withContext(Dispatchers.Default) {
val entities = persistenceRepo.loadConfiguration().onEach {
Log.d(tag, "Bundle: $it")
}
_sources.value = entities.associate {
it.uid to it.load()
}
}
suspend fun reset() = withContext(Dispatchers.Default) {
persistenceRepo.reset()
_sources.value = emptyMap()
bundlesDir.apply {
deleteRecursively()
mkdirs()
}
reload()
}
suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) {
persistenceRepo.delete(bundle.uid)
directoryOf(bundle.uid).deleteRecursively()
_sources.update {
it.filterKeys { key ->
key != bundle.uid
}
}
}
private fun addBundle(patchBundle: PatchBundleSource) =
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
suspend fun createLocal(name: String, patches: InputStream, integrations: InputStream?) {
val id = persistenceRepo.create(name, SourceInfo.Local).uid
val bundle = LocalPatchBundle(name, id, directoryOf(id))
bundle.replace(patches, integrations)
addBundle(bundle)
}
suspend fun createRemote(name: String, url: String, autoUpdate: Boolean) {
val entity = persistenceRepo.create(name, SourceInfo.from(url), autoUpdate)
addBundle(entity.load())
}
private suspend inline fun <reified T> getBundlesByType() =
sources.first().filterIsInstance<T>()
suspend fun reloadApiBundles() {
getBundlesByType<APIPatchBundle>().forEach {
it.deleteLocalFiles()
}
reload()
}
suspend fun redownloadRemoteBundles() = getBundlesByType<RemotePatchBundle>().forEach { it.downloadLatest() }
suspend fun updateCheck() = supervisorScope {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@supervisorScope
}
getBundlesByType<RemotePatchBundle>().forEach {
launch {
if (!it.propsFlow().first().autoUpdate) return@launch
Log.d(tag, "Updating patch bundle: ${it.name}")
it.update()
}
}
}
}

View File

@ -3,15 +3,14 @@ package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.selection.PatchSelection
import app.revanced.manager.domain.sources.Source
class PatchSelectionRepository(db: AppDatabase) {
private val dao = db.selectionDao()
private suspend fun getOrCreateSelection(sourceUid: Int, packageName: String) =
dao.getSelectionId(sourceUid, packageName) ?: PatchSelection(
private suspend fun getOrCreateSelection(bundleUid: Int, packageName: String) =
dao.getSelectionId(bundleUid, packageName) ?: PatchSelection(
uid = generateUid(),
source = sourceUid,
patchBundle = bundleUid,
packageName = packageName
).also { dao.createSelection(it) }.uid
@ -28,12 +27,12 @@ class PatchSelectionRepository(db: AppDatabase) {
suspend fun reset() = dao.reset()
suspend fun export(source: Source): SerializedSelection = dao.exportSelection(source.uid)
suspend fun export(bundleUid: Int): SerializedSelection = dao.exportSelection(bundleUid)
suspend fun import(source: Source, selection: SerializedSelection) {
dao.clearForSource(source.uid)
suspend fun import(bundleUid: Int, selection: SerializedSelection) {
dao.clearForPatchBundle(bundleUid)
dao.updateSelections(selection.entries.associate { (packageName, patches) ->
getOrCreateSelection(source.uid, packageName) to patches.toSet()
getOrCreateSelection(bundleUid, packageName) to patches.toSet()
})
}
}

View File

@ -1,13 +1,25 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.api.MissingAssetException
import app.revanced.manager.network.dto.Asset
import app.revanced.manager.network.dto.ReVancedReleases
import app.revanced.manager.network.service.ReVancedService
import app.revanced.manager.network.utils.getOrThrow
class ReVancedRepository(
private val service: ReVancedService
private val service: ReVancedService,
private val prefs: PreferencesManager
) {
suspend fun getAssets() = service.getAssets()
private suspend fun apiUrl() = prefs.api.get()
suspend fun getContributors() = service.getContributors()
suspend fun getContributors() = service.getContributors(apiUrl())
suspend fun findAsset(repo: String, file: String) = service.findAsset(repo, file)
suspend fun getAssets() = Assets(service.getAssets(apiUrl()).getOrThrow())
}
class Assets(private val releases: ReVancedReleases): List<Asset> by releases.tools {
fun find(repo: String, file: String) = find { asset ->
asset.name.contains(file) && asset.repository.contains(repo)
} ?: throw MissingAssetException()
}

View File

@ -1,55 +0,0 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.sources.SourceLocation
import app.revanced.manager.data.room.sources.VersionInfo
import app.revanced.manager.util.apiURL
import io.ktor.http.*
class SourcePersistenceRepository(db: AppDatabase) {
private val dao = db.sourceDao()
private companion object {
val defaultSource = SourceEntity(
uid = generateUid(),
name = "Official",
versionInfo = VersionInfo("", ""),
location = SourceLocation.Remote(Url(apiURL))
)
}
suspend fun loadConfiguration(): List<SourceEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.add(defaultSource)
return listOf(defaultSource)
}
return all
}
suspend fun clear() = dao.purge()
suspend fun create(name: String, location: SourceLocation): Int {
val uid = generateUid()
dao.add(
SourceEntity(
uid = uid,
name = name,
versionInfo = VersionInfo("", ""),
location = location,
)
)
return uid
}
suspend fun delete(uid: Int) = dao.remove(uid)
suspend fun updateVersion(uid: Int, patches: String, integrations: String) =
dao.updateVersion(uid, patches, integrations)
suspend fun getVersion(id: Int) = dao.getVersionById(id).let { it.patches to it.integrations }
}

View File

@ -1,101 +0,0 @@
package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.util.Log
import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.sources.SourceLocation
import app.revanced.manager.domain.sources.LocalSource
import app.revanced.manager.domain.sources.RemoteSource
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.tag
import io.ktor.http.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
class SourceRepository(app: Application, private val persistenceRepo: SourcePersistenceRepository) {
private val sourcesDir = app.getDir("sources", Context.MODE_PRIVATE)
private val _sources: MutableStateFlow<Map<Int, Source>> = MutableStateFlow(emptyMap())
val sources = _sources.map { it.values.toList() }
val bundles = sources.flatMapLatestAndCombine(
combiner = { it.toMap() }
) {
it.bundle.map { bundle -> it.uid to bundle }
}
/**
* Get the directory of the [Source] with the specified [uid], creating it if needed.
*/
private fun directoryOf(uid: Int) = sourcesDir.resolve(uid.toString()).also { it.mkdirs() }
private fun SourceEntity.load(dir: File) = when (location) {
is SourceLocation.Local -> LocalSource(name, uid, dir)
is SourceLocation.Remote -> RemoteSource(name, uid, dir)
}
suspend fun loadSources() = withContext(Dispatchers.Default) {
val sourcesConfig = persistenceRepo.loadConfiguration().onEach {
Log.d(tag, "Source: $it")
}
val sources = sourcesConfig.associate {
val dir = directoryOf(it.uid)
val source = it.load(dir)
it.uid to source
}
_sources.emit(sources)
}
suspend fun resetConfig() = withContext(Dispatchers.Default) {
persistenceRepo.clear()
_sources.emit(emptyMap())
sourcesDir.apply {
deleteRecursively()
mkdirs()
}
loadSources()
}
suspend fun remove(source: Source) = withContext(Dispatchers.Default) {
persistenceRepo.delete(source.uid)
directoryOf(source.uid).deleteRecursively()
_sources.update {
it.filterValues { value ->
value.uid != source.uid
}
}
}
private fun addSource(source: Source) =
_sources.update { it.toMutableMap().apply { put(source.uid, source) } }
suspend fun createLocalSource(name: String, patches: InputStream, integrations: InputStream?) {
val id = persistenceRepo.create(name, SourceLocation.Local)
val source = LocalSource(name, id, directoryOf(id))
addSource(source)
source.replace(patches, integrations)
}
suspend fun createRemoteSource(name: String, apiUrl: Url) {
val id = persistenceRepo.create(name, SourceLocation.Remote(apiUrl))
addSource(RemoteSource(name, id, directoryOf(id)))
}
suspend fun redownloadRemoteSources() =
sources.first().filterIsInstance<RemoteSource>().forEach { it.downloadLatest() }
}

View File

@ -1,28 +0,0 @@
package app.revanced.manager.domain.sources
import androidx.compose.runtime.Stable
import app.revanced.manager.network.api.ManagerAPI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.get
import java.io.File
@Stable
class RemoteSource(name: String, id: Int, directory: File) : Source(name, id, directory) {
private val api: ManagerAPI = get()
suspend fun downloadLatest() = withContext(Dispatchers.IO) {
api.downloadBundle(patchesJar, integrations).also { (patchesVer, integrationsVer) ->
saveVersion(patchesVer, integrationsVer)
_bundle.emit(loadBundle { err -> throw err })
}
return@withContext
}
suspend fun update() = withContext(Dispatchers.IO) {
val currentVersion = getVersion()
if (!hasInstalled() || currentVersion != api.getLatestBundleVersion()) {
downloadLatest()
}
}
}

View File

@ -1,53 +0,0 @@
package app.revanced.manager.domain.sources
import android.util.Log
import androidx.compose.runtime.Stable
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.domain.repository.SourcePersistenceRepository
import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
/**
* A [PatchBundle] source.
*/
@Stable
sealed class Source(val name: String, val uid: Int, directory: File) : KoinComponent {
private val configRepository: SourcePersistenceRepository by inject()
protected companion object {
/**
* A placeholder [PatchBundle].
*/
val emptyPatchBundle = PatchBundle(emptyList(), null)
fun logError(err: Throwable) {
Log.e(tag, "Failed to load bundle", err)
}
}
protected val patchesJar = directory.resolve("patches.jar")
protected val integrations = directory.resolve("integrations.apk")
/**
* Returns true if the bundle has been downloaded to local storage.
*/
fun hasInstalled() = patchesJar.exists()
protected suspend fun getVersion() = configRepository.getVersion(uid)
protected suspend fun saveVersion(patches: String, integrations: String) =
configRepository.updateVersion(uid, patches, integrations)
// TODO: Communicate failure states better.
protected fun loadBundle(onFail: (Throwable) -> Unit = ::logError) = if (!hasInstalled()) emptyPatchBundle
else try {
PatchBundle(patchesJar, integrations.takeIf { it.exists() })
} catch (err: Throwable) {
onFail(err)
emptyPatchBundle
}
protected val _bundle = MutableStateFlow(loadBundle())
val bundle = _bundle.asStateFlow()
}

View File

@ -1,59 +1,43 @@
package app.revanced.manager.network.api
import android.app.Application
import android.os.Environment
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import app.revanced.manager.domain.repository.Assets
import app.revanced.manager.domain.repository.ReVancedRepository
import app.revanced.manager.network.dto.Asset
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.util.*
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.util.cio.*
import io.ktor.utils.io.*
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.url
import java.io.File
// TODO: merge ReVancedRepository into this class
class ManagerAPI(
private val client: HttpClient,
private val http: HttpService,
private val revancedRepository: ReVancedRepository
) {
var downloadProgress: Float? by mutableStateOf(null)
var downloadedSize: Long? by mutableStateOf(null)
var totalSize: Long? by mutableStateOf(null)
private suspend fun downloadAsset(downloadUrl: String, saveLocation: File) {
client.get(downloadUrl) {
private suspend fun downloadAsset(asset: Asset, saveLocation: File) {
http.download(saveLocation) {
url(asset.downloadUrl)
onDownload { bytesSentTotal, contentLength ->
downloadProgress = (bytesSentTotal.toFloat() / contentLength.toFloat())
downloadedSize = bytesSentTotal
totalSize = contentLength
}
}.bodyAsChannel().copyAndClose(saveLocation.writeChannel())
}
downloadProgress = null
}
private suspend fun patchesAsset() = revancedRepository.findAsset(ghPatches, ".jar")
private suspend fun integrationsAsset() = revancedRepository.findAsset(ghIntegrations, ".apk")
suspend fun getLatestBundleVersion() = patchesAsset().version to integrationsAsset().version
suspend fun downloadBundle(patchBundle: File, integrations: File): Pair<String, String> {
val patchBundleAsset = patchesAsset()
val integrationsAsset = integrationsAsset()
downloadAsset(patchBundleAsset.downloadUrl, patchBundle)
downloadAsset(integrationsAsset.downloadUrl, integrations)
return patchBundleAsset.version to integrationsAsset.version
}
suspend fun downloadManager(location: File) {
val managerAsset = revancedRepository.findAsset(ghManager, ".apk")
downloadAsset(managerAsset.downloadUrl, location)
val managerAsset = revancedRepository.getAssets().find(ghManager, ".apk")
downloadAsset(managerAsset, location)
}
}
class MissingAssetException : Exception()

View File

@ -0,0 +1,9 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.Serializable
@Serializable
data class BundleInfo(val patches: BundleAsset, val integrations: BundleAsset)
@Serializable
data class BundleAsset(val version: String, val url: String)

View File

@ -6,7 +6,6 @@ import app.revanced.manager.network.dto.ReVancedReleases
import app.revanced.manager.network.dto.ReVancedRepositories
import app.revanced.manager.network.utils.APIResponse
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.util.apiURL
import io.ktor.client.request.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -14,33 +13,20 @@ import kotlinx.coroutines.withContext
class ReVancedService(
private val client: HttpService,
) {
suspend fun getAssets(): APIResponse<ReVancedReleases> {
suspend fun getAssets(api: String): APIResponse<ReVancedReleases> {
return withContext(Dispatchers.IO) {
client.request {
url("$apiUrl/tools")
url("$api/tools")
}
}
}
suspend fun getContributors(): APIResponse<ReVancedRepositories> {
suspend fun getContributors(api: String): APIResponse<ReVancedRepositories> {
return withContext(Dispatchers.IO) {
client.request {
url("$apiUrl/contributors")
url("$api/contributors")
}
}
}
suspend fun findAsset(repo: String, file: String): Asset {
val releases = getAssets().getOrThrow()
val asset = releases.tools.find { asset ->
(asset.name.contains(file) && asset.repository.contains(repo))
} ?: throw MissingAssetException()
return asset
}
private companion object {
private const val apiUrl = apiURL
}
}

View File

@ -16,7 +16,7 @@ import androidx.work.WorkerParameters
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.Session
@ -43,7 +43,7 @@ class PatcherWorker(
parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val sourceRepository: SourceRepository by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
@ -124,7 +124,7 @@ class PatcherWorker(
val frameworkPath =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val bundles = sourceRepository.bundles.first()
val bundles = patchBundleRepository.bundles.first()
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)

View File

@ -0,0 +1,115 @@
package app.revanced.manager.ui.component
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@Composable
fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
var patchesEnabled by rememberSaveable { mutableStateOf(true) }
var managerEnabled by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = {},
confirmButton = {
TextButton(
onClick = { onSubmit(managerEnabled, patchesEnabled) }
) {
Text(stringResource(R.string.save))
}
},
icon = {
Icon(Icons.Outlined.Update, null)
},
title = {
Text(
text = stringResource(R.string.auto_updates_dialog_title),
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurface,
)
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.auto_updates_dialog_description),
style = MaterialTheme.typography.bodyMedium,
)
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_manager,
icon = Icons.Outlined.Update,
checked = managerEnabled,
onCheckedChange = { managerEnabled = it }
)
Divider()
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_patches,
icon = Icons.Outlined.Source,
checked = patchesEnabled,
onCheckedChange = { patchesEnabled = it }
)
Text(
text = stringResource(R.string.auto_updates_dialog_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
}
)
}
@Composable
private fun AutoUpdatesItem(
@StringRes headline: Int,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
ListItem(
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurface) },
headlineContent = {
Text(
text = stringResource(headline),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
trailingContent = {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
},
modifier = Modifier.clickable { onCheckedChange(!checked) }
)
}

View File

@ -1,96 +0,0 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.sources.RemoteSource
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.ui.component.bundle.BundleInformationDialog
import app.revanced.manager.ui.viewmodel.SourcesViewModel
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun SourceItem(
source: Source, onDelete: () -> Unit,
coroutineScope: CoroutineScope,
) {
var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
val bundle by source.bundle.collectAsStateWithLifecycle()
val patchCount = bundle.patches.size
val padding = PaddingValues(16.dp, 0.dp)
val androidContext = LocalContext.current
if (viewBundleDialogPage) {
BundleInformationDialog(
onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = {
viewBundleDialogPage = false
onDelete()
},
source = source,
patchCount = patchCount,
onRefreshButton = {
coroutineScope.launch {
uiSafe(
androidContext,
R.string.source_download_fail,
SourcesViewModel.failLogMsg
) {
if (source is RemoteSource) source.update()
}
}
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
.clickable {
viewBundleDialogPage = true
}
) {
Text(
text = source.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(padding)
)
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = pluralStringResource(R.plurals.patches_count, patchCount, patchCount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(padding)
)
}
}

View File

@ -0,0 +1,149 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
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.outlined.ArrowRight
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@Composable
fun BaseBundleDialog(
modifier: Modifier = Modifier,
isDefault: Boolean,
name: String,
onNameChange: (String) -> Unit = {},
remoteUrl: String?,
onRemoteUrlChange: (String) -> Unit = {},
patchCount: Int,
version: String?,
autoUpdate: Boolean,
onAutoUpdateChange: (Boolean) -> Unit,
onPatchesClick: () -> Unit,
onBundleTypeClick: () -> Unit = {},
extraFields: @Composable ColumnScope.() -> Unit = {}
) = Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.then(modifier)
) {
Column(
modifier = Modifier.padding(
start = 24.dp,
top = 16.dp,
end = 24.dp,
)
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = name,
onValueChange = onNameChange,
label = {
Text(stringResource(R.string.bundle_input_name))
}
)
remoteUrl?.takeUnless { isDefault }?.let {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = it,
onValueChange = onRemoteUrlChange,
label = {
Text(stringResource(R.string.bundle_input_source_url))
}
)
}
extraFields()
}
Column(
Modifier.padding(
start = 8.dp,
top = 8.dp,
end = 4.dp,
)
) Info@{
if (remoteUrl != null) {
BundleListItem(
headlineText = stringResource(R.string.automatically_update),
supportingText = stringResource(R.string.automatically_update_description),
trailingContent = {
Switch(
checked = autoUpdate,
onCheckedChange = onAutoUpdateChange
)
}
)
}
BundleListItem(
headlineText = stringResource(R.string.bundle_type),
supportingText = stringResource(R.string.bundle_type_description)
) {
FilledTonalButton(
onClick = onBundleTypeClick,
content = {
if (remoteUrl == null) {
Text(stringResource(R.string.local))
} else {
Text(stringResource(R.string.remote))
}
}
)
}
if (version == null && patchCount < 1) return@Info
Text(
text = stringResource(R.string.information),
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 12.dp
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
BundleListItem(
headlineText = stringResource(R.string.patches),
supportingText = if (patchCount == 0) stringResource(R.string.no_patches)
else stringResource(R.string.patches_available, patchCount),
trailingContent = {
if (patchCount > 0) {
IconButton(onClick = onPatchesClick) {
Icon(
Icons.Outlined.ArrowRight,
stringResource(R.string.patches)
)
}
}
}
)
if (version == null) return@Info
BundleListItem(
headlineText = stringResource(R.string.version),
supportingText = version,
)
}
}

View File

@ -1,87 +0,0 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowRight
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@Composable
fun BundleInfoContent(
switchChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
patchInfoText: String,
patchCount: Int,
onArrowClick: () -> Unit,
isLocal: Boolean,
tonalButtonOnClick: () -> Unit = {},
tonalButtonContent: @Composable RowScope.() -> Unit,
) {
if(!isLocal) {
BundleInfoListItem(
headlineText = stringResource(R.string.automatically_update),
supportingText = stringResource(R.string.automatically_update_description),
trailingContent = {
Switch(
checked = switchChecked,
onCheckedChange = onCheckedChange
)
}
)
}
BundleInfoListItem(
headlineText = stringResource(R.string.bundle_type),
supportingText = stringResource(R.string.bundle_type_description)
) {
FilledTonalButton(
onClick = tonalButtonOnClick,
content = tonalButtonContent,
)
}
Text(
text = stringResource(R.string.information),
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 12.dp
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
BundleInfoListItem(
headlineText = stringResource(R.string.patches),
supportingText = patchInfoText,
trailingContent = {
if (patchCount > 0) {
IconButton(onClick = onArrowClick) {
Icon(
Icons.Outlined.ArrowRight,
stringResource(R.string.patches)
)
}
}
}
)
BundleInfoListItem(
headlineText = stringResource(R.string.patches_version),
supportingText = "1.0.0",
)
BundleInfoListItem(
headlineText = stringResource(R.string.integrations_version),
supportingText = "1.0.0",
)
}

View File

@ -1,10 +1,6 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.filled.ArrowBack
import androidx.compose.material.icons.outlined.DeleteOutline
@ -13,46 +9,51 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.sources.LocalSource
import app.revanced.manager.domain.sources.RemoteSource
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundleInformationDialog(
onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit,
source: Source,
remoteName: String = "",
patchCount: Int = 0,
bundle: PatchBundleSource,
onRefreshButton: () -> Unit,
) {
var checked by remember { mutableStateOf(true) }
val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = source is LocalSource
val patchInfoText = if (patchCount == 0) stringResource(R.string.no_patches)
else stringResource(R.string.patches_available, patchCount)
val isLocal = bundle is LocalPatchBundle
val patchCount by remember(bundle) {
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
}.collectAsStateWithLifecycle(0)
val props by remember(bundle) {
bundle.propsOrNullFlow()
}.collectAsStateWithLifecycle(null)
if (viewCurrentBundlePatches) {
BundlePatchesDialog(
onDismissRequest = {
viewCurrentBundlePatches = false
},
source = source,
bundle = bundle,
)
}
@ -75,13 +76,15 @@ fun BundleInformationDialog(
)
},
actions = {
IconButton(onClick = onDeleteRequest) {
Icon(
Icons.Outlined.DeleteOutline,
stringResource(R.string.delete)
)
if (!bundle.isDefault) {
IconButton(onClick = onDeleteRequest) {
Icon(
Icons.Outlined.DeleteOutline,
stringResource(R.string.delete)
)
}
}
if(!isLocal) {
if (!isLocal) {
IconButton(onClick = onRefreshButton) {
Icon(
Icons.Outlined.Refresh,
@ -93,51 +96,23 @@ fun BundleInformationDialog(
)
},
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier.padding(
start = 24.dp,
top = 16.dp,
end = 24.dp,
)
) {
BundleTextContent(
name = source.name,
isLocal = isLocal,
remoteUrl = remoteName,
)
}
Column(
Modifier.padding(
start = 8.dp,
top = 8.dp,
end = 4.dp,
)
) {
BundleInfoContent(
switchChecked = checked,
onCheckedChange = { checked = it },
patchInfoText = patchInfoText,
patchCount = patchCount,
isLocal = isLocal,
onArrowClick = {
viewCurrentBundlePatches = true
},
tonalButtonContent = {
when(source) {
is RemoteSource -> Text(stringResource(R.string.remote))
is LocalSource -> Text(stringResource(R.string.local))
}
},
)
}
}
BaseBundleDialog(
modifier = Modifier.padding(paddingValues),
isDefault = bundle.isDefault,
name = bundle.name,
remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount,
version = props?.versionInfo?.patches,
autoUpdate = props?.autoUpdate ?: false,
onAutoUpdateChange = {
composableScope.launch {
bundle.asRemoteOrNull?.setAutoUpdate(it)
}
},
onPatchesClick = {
viewCurrentBundlePatches = true
},
)
}
}
}

View File

@ -0,0 +1,108 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow
import kotlinx.coroutines.flow.map
@Composable
fun BundleItem(
bundle: PatchBundleSource,
onDelete: () -> Unit,
onUpdate: () -> Unit
) {
var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
val state by bundle.state.collectAsStateWithLifecycle()
val version by remember(bundle) {
bundle.propsOrNullFlow().map { props -> props?.versionInfo?.patches }
}.collectAsStateWithLifecycle(null)
if (viewBundleDialogPage) {
BundleInformationDialog(
onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = {
viewBundleDialogPage = false
onDelete()
},
bundle = bundle,
onRefreshButton = onUpdate,
)
}
ListItem(
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
.clickable {
viewBundleDialogPage = true
},
headlineContent = {
Text(
text = bundle.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
Text(
text = pluralStringResource(R.plurals.patches_count, patchCount, patchCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
trailingContent = {
Row {
val icon = remember(state) {
when (state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.bundle_missing
is PatchBundleSource.State.Loaded -> null
}
}
icon?.let { (vector, description) ->
Icon(
imageVector = vector,
contentDescription = stringResource(description),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.error
)
}
version?.let { txt ->
Text(
text = txt,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}

View File

@ -6,7 +6,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun BundleInfoListItem(
fun BundleListItem(
headlineText: String,
supportingText: String = "",
trailingContent: @Composable (() -> Unit)? = null,

View File

@ -28,17 +28,17 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.NotificationCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundlePatchesDialog(
onDismissRequest: () -> Unit,
source: Source,
bundle: PatchBundleSource,
) {
var informationCardVisible by remember { mutableStateOf(true) }
val bundle by source.bundle.collectAsStateWithLifecycle()
val state by bundle.state.collectAsStateWithLifecycle()
Dialog(
onDismissRequest = onDismissRequest,
@ -84,27 +84,29 @@ fun BundlePatchesDialog(
}
}
items(bundle.patches.size) { bundleIndex ->
val patch = bundle.patches[bundleIndex]
ListItem(
headlineContent = {
Text(
text = patch.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
patch.description?.let {
state.patchBundleOrNull()?.let { bundle ->
items(bundle.patches.size) { bundleIndex ->
val patch = bundle.patches[bundleIndex]
ListItem(
headlineContent = {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = patch.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
patch.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
)
Divider()
)
Divider()
}
}
}
}

View File

@ -15,18 +15,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.bundles.PatchBundleSource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SourceSelector(sources: List<Source>, onFinish: (Source?) -> Unit) {
LaunchedEffect(sources) {
if (sources.size == 1) {
onFinish(sources[0])
fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) {
LaunchedEffect(bundles) {
if (bundles.size == 1) {
onFinish(bundles[0])
}
}
if (sources.size < 2) {
if (bundles.size < 2) {
return
}
@ -50,7 +50,7 @@ fun SourceSelector(sources: List<Source>, onFinish: (Source?) -> Unit) {
color = MaterialTheme.colorScheme.onSurface
)
}
sources.forEach {
bundles.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,

View File

@ -1,43 +0,0 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@Composable
fun BundleTextContent(
name: String,
onNameChange: (String) -> Unit = {},
isLocal: Boolean,
remoteUrl: String,
onRemoteUrlChange: (String) -> Unit = {},
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = name,
onValueChange = onNameChange,
label = {
Text(stringResource(R.string.bundle_input_name))
}
)
if (!isLocal) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = remoteUrl,
onValueChange = onRemoteUrlChange,
label = {
Text(stringResource(R.string.bundle_input_source_url))
}
)
}
}

View File

@ -1,24 +1,21 @@
package app.revanced.manager.ui.component.bundle
import android.net.Uri
import android.webkit.URLUtil
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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.filled.Close
import androidx.compose.material.icons.filled.Topic
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -34,20 +31,17 @@ import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.JAR_MIMETYPE
import app.revanced.manager.util.parseUrlOrNull
import io.ktor.http.Url
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportBundleDialog(
onDismissRequest: () -> Unit,
onRemoteSubmit: (String, Url) -> Unit,
onLocalSubmit: (String, Uri, Uri?) -> Unit,
patchCount: Int = 0,
onRemoteSubmit: (String, String, Boolean) -> Unit,
onLocalSubmit: (String, Uri, Uri?) -> Unit
) {
var name by rememberSaveable { mutableStateOf("") }
var remoteUrl by rememberSaveable { mutableStateOf("") }
var checked by remember { mutableStateOf(true) }
var autoUpdate by rememberSaveable { mutableStateOf(true) }
var isLocal by rememberSaveable { mutableStateOf(false) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
@ -58,8 +52,10 @@ fun ImportBundleDialog(
val inputsAreValid by remember {
derivedStateOf {
val nameSize = name.length
nameSize in 4..19 && if (isLocal) patchBundle != null else {
remoteUrl.isNotEmpty() && remoteUrl.parseUrlOrNull() != null
when {
nameSize !in 1..19 -> false
isLocal -> patchBundle != null
else -> remoteUrl.isNotEmpty() && URLUtil.isValidUrl(remoteUrl)
}
}
}
@ -68,19 +64,11 @@ fun ImportBundleDialog(
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { patchBundle = it }
}
val integrationsActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { integrations = it }
}
val onPatchLauncherClick = {
patchActivityLauncher.launch(JAR_MIMETYPE)
}
val onIntegrationLauncherClick = {
integrationsActivityLauncher.launch(APK_MIMETYPE)
}
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(
@ -100,115 +88,89 @@ fun ImportBundleDialog(
)
},
actions = {
Text(
text = stringResource(R.string.import_),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(end = 16.dp)
.clickable {
if (inputsAreValid) {
if (isLocal) {
onLocalSubmit(name, patchBundle!!, integrations)
} else {
onRemoteSubmit(name, remoteUrl.parseUrlOrNull()!!)
}
}
TextButton(
enabled = inputsAreValid,
onClick = {
if (isLocal) {
onLocalSubmit(name, patchBundle!!, integrations)
} else {
onRemoteSubmit(
name,
remoteUrl,
autoUpdate
)
}
)
},
modifier = Modifier.padding(end = 16.dp)
) {
Text(stringResource(R.string.import_))
}
}
)
},
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
BaseBundleDialog(
modifier = Modifier.padding(paddingValues),
isDefault = false,
name = name,
onNameChange = { name = it },
remoteUrl = remoteUrl.takeUnless { isLocal },
onRemoteUrlChange = { remoteUrl = it },
patchCount = 0,
version = null,
autoUpdate = autoUpdate,
onAutoUpdateChange = { autoUpdate = it },
onPatchesClick = {},
onBundleTypeClick = { isLocal = !isLocal },
) {
Column(
modifier = Modifier.padding(
start = 24.dp,
top = 16.dp,
end = 24.dp,
)
) {
BundleTextContent(
name = name,
onNameChange = { name = it },
isLocal = isLocal,
remoteUrl = remoteUrl,
onRemoteUrlChange = { remoteUrl = it },
)
if(isLocal) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = patchBundleText,
onValueChange = {},
label = {
Text("Patches Source File")
},
trailingIcon = {
IconButton(
onClick = onPatchLauncherClick
) {
Icon(
imageVector = Icons.Default.Topic,
contentDescription = null
)
}
}
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = integrationText,
onValueChange = {},
label = {
Text("Integrations Source File")
},
trailingIcon = {
IconButton(onClick = onIntegrationLauncherClick) {
Icon(
imageVector = Icons.Default.Topic,
contentDescription = null
)
}
}
)
}
}
Column(
Modifier.padding(
start = 8.dp,
top = 8.dp,
end = 4.dp,
)
) {
BundleInfoContent(
switchChecked = checked,
onCheckedChange = { checked = it },
patchInfoText = stringResource(R.string.no_patches),
patchCount = patchCount,
onArrowClick = {},
tonalButtonContent = {
if (isLocal) {
Text(stringResource(R.string.local))
} else {
Text(stringResource(R.string.remote))
}
if (isLocal) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = patchBundleText,
onValueChange = {},
label = {
Text("Patches Source File")
},
tonalButtonOnClick = { isLocal = !isLocal },
isLocal = isLocal,
trailingIcon = {
IconButton(
onClick = {
patchActivityLauncher.launch(JAR_MIMETYPE)
}
) {
Icon(
imageVector = Icons.Default.Topic,
contentDescription = null
)
}
}
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
value = integrationText,
onValueChange = {},
label = {
Text("Integrations Source File")
},
trailingIcon = {
IconButton(
onClick = {
integrationsActivityLauncher.launch(APK_MIMETYPE)
}
) {
Icon(
imageVector = Icons.Default.Topic,
contentDescription = null
)
}
}
)
}
}
}
}
}
}

View File

@ -0,0 +1,35 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.ui.component.bundle.BundleItem
import app.revanced.manager.ui.viewmodel.BundlesViewModel
import org.koin.androidx.compose.getViewModel
@Composable
fun BundlesScreen(
vm: BundlesViewModel = getViewModel(),
) {
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
Column(
modifier = Modifier
.fillMaxSize(),
) {
sources.forEach {
BundleItem(
bundle = it,
onDelete = {
vm.delete(it)
},
onUpdate = {
vm.update(it)
}
)
}
}
}

View File

@ -23,34 +23,66 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.bundle.ImportBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
enum class DashboardPage(
val titleResId: Int,
val icon: ImageVector
) {
DASHBOARD(R.string.tab_apps, Icons.Outlined.Apps),
SOURCES(R.string.tab_sources, Icons.Outlined.Source),
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
vm: DashboardViewModel = getViewModel(),
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
) {
var showImportBundleDialog by rememberSaveable { mutableStateOf(false) }
val pages: Array<DashboardPage> = DashboardPage.values()
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val androidContext = LocalContext.current
val pagerState = rememberPagerState()
val composableScope = rememberCoroutineScope()
if (showImportBundleDialog) {
fun dismiss() {
showImportBundleDialog = false
}
ImportBundleDialog(
onDismissRequest = ::dismiss,
onLocalSubmit = { name, patches, integrations ->
dismiss()
vm.createLocalSource(name, patches, integrations)
},
onRemoteSubmit = { name, url, autoUpdate ->
dismiss()
vm.createRemoteSource(name, url, autoUpdate)
},
)
}
Scaffold(
topBar = {
AppTopBar(
@ -66,10 +98,28 @@ fun DashboardScreen(
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
if (pagerState.currentPage == DashboardPage.DASHBOARD.ordinal)
onAppSelectorClick()
}
FloatingActionButton(
onClick = {
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) {
androidContext.toast(androidContext.getString(R.string.patches_unavailable))
composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal
)
}
return@FloatingActionButton
}
onAppSelectorClick()
}
DashboardPage.BUNDLES.ordinal -> {
showImportBundleDialog = true
}
}
}
) {
Icon(Icons.Default.Add, stringResource(R.string.add))
}
@ -103,8 +153,8 @@ fun DashboardScreen(
InstalledAppsScreen()
}
DashboardPage.SOURCES -> {
SourcesScreen()
DashboardPage.BUNDLES -> {
BundlesScreen()
}
}
}

View File

@ -68,7 +68,7 @@ fun PatchesSelectorScreen(
val pagerState = rememberPagerState()
val composableScope = rememberCoroutineScope()
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyArray())
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList())
if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog(

View File

@ -1,69 +0,0 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.ui.component.bundle.ImportBundleDialog
import app.revanced.manager.ui.component.SourceItem
import app.revanced.manager.ui.viewmodel.SourcesViewModel
import org.koin.androidx.compose.getViewModel
@Composable
fun SourcesScreen(
vm: SourcesViewModel = getViewModel(),
) {
var showNewSourceDialog by rememberSaveable { mutableStateOf(false) }
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
if (showNewSourceDialog) {
ImportBundleDialog(
onDismissRequest = { showNewSourceDialog = false },
onLocalSubmit = { name, patches, integrations ->
showNewSourceDialog = false
vm.addLocal(name, patches, integrations)
},
onRemoteSubmit = { name, url ->
showNewSourceDialog = false
vm.addRemote(name, url)
},
)
}
Column(
modifier = Modifier
.fillMaxSize(),
) {
sources.forEach {
SourceItem(
source = it,
onDelete = {
vm.delete(it)
},
coroutineScope = vm.viewModelScope
)
}
Button(onClick = vm::redownloadAllSources) {
Text(stringResource(R.string.reload_sources))
}
Button(onClick = { showNewSourceDialog = true }) {
Text("Create new source")
}
Button(onClick = vm::deleteAllSources) {
Text("Reset everything.")
}
}
}

View File

@ -1,34 +1,59 @@
package app.revanced.manager.ui.screen.settings
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.outlined.Http
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdvancedSettingsScreen(onBackClick: () -> Unit) {
fun AdvancedSettingsScreen(
onBackClick: () -> Unit,
vm: AdvancedSettingsViewModel = getViewModel()
) {
val context = LocalContext.current
val memoryLimit = remember {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
context.getString(R.string.device_memory_limit_format, activityManager.memoryClass, activityManager.largeMemoryClass)
val activityManager = context.getSystemService<ActivityManager>()!!
context.getString(
R.string.device_memory_limit_format,
activityManager.memoryClass,
activityManager.largeMemoryClass
)
}
Scaffold(
topBar = {
AppTopBar(
@ -43,6 +68,37 @@ fun AdvancedSettingsScreen(onBackClick: () -> Unit) {
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
val apiUrl by vm.apiUrl.getAsState()
var showApiUrlDialog by rememberSaveable { mutableStateOf(false) }
if (showApiUrlDialog) {
APIUrlDialog(apiUrl) {
showApiUrlDialog = false
it?.let(vm::setApiUrl)
}
}
ListItem(
headlineContent = { Text(stringResource(R.string.api_url)) },
supportingContent = { Text(apiUrl) },
modifier = Modifier.clickable {
showApiUrlDialog = true
}
)
GroupHeader(stringResource(R.string.patch_bundles_section))
ListItem(
headlineContent = { Text(stringResource(R.string.patch_bundles_redownload)) },
modifier = Modifier.clickable {
vm.redownloadBundles()
}
)
ListItem(
headlineContent = { Text(stringResource(R.string.patch_bundles_reset)) },
modifier = Modifier.clickable {
vm.resetBundles()
}
)
GroupHeader(stringResource(R.string.device))
ListItem(
headlineContent = { Text(stringResource(R.string.device_model)) },
@ -62,4 +118,58 @@ fun AdvancedSettingsScreen(onBackClick: () -> Unit) {
)
}
}
}
@Composable
private fun APIUrlDialog(currentUrl: String, onSubmit: (String?) -> Unit) {
var url by rememberSaveable(currentUrl) { mutableStateOf(currentUrl) }
AlertDialog(
onDismissRequest = { onSubmit(null) },
confirmButton = {
TextButton(
onClick = {
onSubmit(url)
}
) {
Text(stringResource(R.string.api_url_dialog_save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
Text(stringResource(R.string.cancel))
}
},
icon = {
Icon(Icons.Outlined.Http, null)
},
title = {
Text(
text = stringResource(R.string.api_url_dialog_title),
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurface,
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.api_url_dialog_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.api_url_dialog_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(R.string.api_url)) }
)
}
}
)
}

View File

@ -31,7 +31,7 @@ import app.revanced.manager.ui.viewmodel.ImportExportViewModel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.PasswordField
import app.revanced.manager.ui.component.bundle.SourceSelector
import app.revanced.manager.ui.component.bundle.BundleSelector
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
@ -63,12 +63,12 @@ fun ImportExportSettingsScreen(
}
}
if (vm.selectedSource == null) {
SourceSelector(sources) {
if (vm.selectedBundle == null) {
BundleSelector(sources) {
if (it == null) {
vm.clearSelectionAction()
} else {
vm.selectSource(it)
vm.selectBundle(it)
launcher.launch(action.activityArg)
}
}

View File

@ -0,0 +1,37 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AdvancedSettingsViewModel(
prefs: PreferencesManager,
private val app: Application,
private val patchBundleRepository: PatchBundleRepository
) : ViewModel() {
val apiUrl = prefs.api
fun setApiUrl(value: String) = viewModelScope.launch(Dispatchers.Default) {
if (value == apiUrl.get()) return@launch
apiUrl.update(value)
patchBundleRepository.reloadApiBundles()
}
fun redownloadBundles() = viewModelScope.launch {
uiSafe(app, R.string.source_download_fail, RemotePatchBundle.updateFailMsg) {
patchBundleRepository.redownloadRemoteBundles()
}
}
fun resetBundles() = viewModelScope.launch {
patchBundleRepository.reset()
}
}

View File

@ -0,0 +1,33 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch
class BundlesViewModel(
private val app: Application,
private val patchBundleRepository: PatchBundleRepository
) : ViewModel() {
val sources = patchBundleRepository.sources
fun delete(bundle: PatchBundleSource) =
viewModelScope.launch { patchBundleRepository.remove(bundle) }
fun update(bundle: PatchBundleSource) = viewModelScope.launch {
if (bundle !is RemotePatchBundle) return@launch
uiSafe(
app,
R.string.source_download_fail,
RemotePatchBundle.updateFailMsg
) {
bundle.update()
}
}
}

View File

@ -0,0 +1,35 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.ContentResolver
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.PatchBundleRepository
import io.ktor.http.Url
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class DashboardViewModel(
app: Application,
private val patchBundleRepository: PatchBundleRepository
) : ViewModel() {
val availablePatches =
patchBundleRepository.bundles.map { it.values.sumOf { bundle -> bundle.patches.size } }
private val contentResolver: ContentResolver = app.contentResolver
fun createLocalSource(name: String, patchBundle: Uri, integrations: Uri?) =
viewModelScope.launch {
contentResolver.openInputStream(patchBundle)!!.use { patchesStream ->
val integrationsStream = integrations?.let { contentResolver.openInputStream(it) }
try {
patchBundleRepository.createLocal(name, patchesStream, integrationsStream)
} finally {
integrationsStream?.close()
}
}
}
fun createRemoteSource(name: String, apiUrl: String, autoUpdate: Boolean) =
viewModelScope.launch { patchBundleRepository.createRemote(name, apiUrl, autoUpdate) }
}

View File

@ -14,8 +14,8 @@ import app.revanced.manager.R
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.util.JSON_MIMETYPE
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
@ -37,11 +37,11 @@ class ImportExportViewModel(
private val app: Application,
private val keystoreManager: KeystoreManager,
private val selectionRepository: PatchSelectionRepository,
sourceRepository: SourceRepository
patchBundleRepository: PatchBundleRepository
) : ViewModel() {
private val contentResolver = app.contentResolver
val sources = sourceRepository.sources
var selectedSource by mutableStateOf<Source?>(null)
val sources = patchBundleRepository.sources
var selectedBundle by mutableStateOf<PatchBundleSource?>(null)
private set
var selectionAction by mutableStateOf<SelectionAction?>(null)
private set
@ -107,20 +107,20 @@ class ImportExportViewModel(
}
fun executeSelectionAction(target: Uri) = viewModelScope.launch {
val source = selectedSource!!
val source = selectedBundle!!
val action = selectionAction!!
clearSelectionAction()
action.execute(source, target)
action.execute(source.uid, target)
}
fun selectSource(source: Source) {
selectedSource = source
fun selectBundle(bundle: PatchBundleSource) {
selectedBundle = bundle
}
fun clearSelectionAction() {
selectionAction = null
selectedSource = null
selectedBundle = null
}
fun importSelection() = clearSelectionAction().also {
@ -132,7 +132,7 @@ class ImportExportViewModel(
}
sealed interface SelectionAction {
suspend fun execute(source: Source, location: Uri)
suspend fun execute(bundleUid: Int, location: Uri)
val activityContract: ActivityResultContract<String, Uri?>
val activityArg: String
}
@ -140,7 +140,7 @@ class ImportExportViewModel(
private inner class Import : SelectionAction {
override val activityContract = ActivityResultContracts.GetContent()
override val activityArg = JSON_MIMETYPE
override suspend fun execute(source: Source, location: Uri) = uiSafe(
override suspend fun execute(bundleUid: Int, location: Uri) = uiSafe(
app,
R.string.restore_patches_selection_fail,
"Failed to restore patches selection"
@ -151,19 +151,19 @@ class ImportExportViewModel(
}
}
selectionRepository.import(source, selection)
selectionRepository.import(bundleUid, selection)
}
}
private inner class Export : SelectionAction {
override val activityContract = ActivityResultContracts.CreateDocument(JSON_MIMETYPE)
override val activityArg = "selection.json"
override suspend fun execute(source: Source, location: Uri) = uiSafe(
override suspend fun execute(bundleUid: Int, location: Uri) = uiSafe(
app,
R.string.backup_patches_selection_fail,
"Failed to backup patches selection"
) {
val selection = selectionRepository.export(source)
val selection = selectionRepository.export(bundleUid)
withContext(Dispatchers.IO) {
contentResolver.openOutputStream(location, "wt")!!.use {

View File

@ -2,16 +2,31 @@ package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainViewModel(
sourceRepository: SourceRepository
private val patchBundleRepository: PatchBundleRepository,
val prefs: PreferencesManager
) : ViewModel() {
init {
with(viewModelScope) {
launch {
sourceRepository.loadSources()
fun applyAutoUpdatePrefs(manager: Boolean, patches: Boolean) = viewModelScope.launch {
prefs.showAutoUpdatesDialog.update(false)
prefs.managerAutoUpdates.update(manager)
if (patches) {
with(patchBundleRepository) {
sources
.first()
.find { it.uid == 0 }
?.asRemoteOrNull
?.setAutoUpdate(true)
updateCheck()
}
}
}

View File

@ -15,7 +15,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
@ -43,21 +43,23 @@ class PatchesSelectorViewModel(
private val savedStateHandle: SavedStateHandle = get()
val allowExperimental = get<PreferencesManager>().allowExperimental
val bundlesFlow = get<SourceRepository>().sources.flatMapLatestAndCombine(
combiner = { it }
val bundlesFlow = get<PatchBundleRepository>().sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() }
) { source ->
// Regenerate bundle information whenever this source updates.
source.bundle.map { bundle ->
source.state.map { state ->
val bundle = state.patchBundleOrNull() ?: return@map null
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(selectedApp.packageName) }.forEach {
val targetList =
if (it.compatiblePackages == null) universal else if (it.supportsVersion(selectedApp.version))
supported
else
unsupported
val targetList = when {
it.compatiblePackages == null -> universal
it.supportsVersion(selectedApp.version) -> supported
else -> unsupported
}
targetList.add(it)
}
@ -66,30 +68,36 @@ class PatchesSelectorViewModel(
}
}
private val selectedPatches: SnapshotStatePatchesSelection by savedStateHandle.saveable(saver = patchesSelectionSaver, init = {
val map: SnapshotStatePatchesSelection = mutableStateMapOf()
viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first()
val filteredSelection =
selectionRepository.getSelection(selectedApp.packageName).mapValues { (uid, patches) ->
// Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid }
?.let { bundle ->
val allPatches = bundle.all.map { it.name }
patches.filter { allPatches.contains(it) }
private val selectedPatches: SnapshotStatePatchesSelection by savedStateHandle.saveable(
saver = patchesSelectionSaver,
init = {
val map: SnapshotStatePatchesSelection = mutableStateMapOf()
viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first()
val filteredSelection =
selectionRepository.getSelection(selectedApp.packageName)
.mapValues { (uid, patches) ->
// Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid }
?.let { bundle ->
val allPatches = bundle.all.map { it.name }
patches.filter { allPatches.contains(it) }
}
?: patches
filteredPatches.toMutableStateSet()
}
?: patches
filteredPatches.toMutableStateSet()
withContext(Dispatchers.Main) {
map.putAll(filteredSelection)
}
withContext(Dispatchers.Main) {
map.putAll(filteredSelection)
}
}
return@saveable map
})
private val patchOptions: SnapshotStateOptions by savedStateHandle.saveable(saver = optionsSaver, init = ::mutableStateMapOf)
return@saveable map
})
private val patchOptions: SnapshotStateOptions by savedStateHandle.saveable(
saver = optionsSaver,
init = ::mutableStateMapOf
)
/**
* Show the patch options dialog for this patch.

View File

@ -1,51 +0,0 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.ContentResolver
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.util.uiSafe
import io.ktor.http.*
import kotlinx.coroutines.launch
class SourcesViewModel(
private val app: Application,
private val sourceRepository: SourceRepository
) : ViewModel() {
val sources = sourceRepository.sources
private val contentResolver: ContentResolver = app.contentResolver
companion object {
const val failLogMsg = "Failed to update patch bundle(s)"
}
fun redownloadAllSources() = viewModelScope.launch {
uiSafe(app, R.string.source_download_fail, failLogMsg) {
sourceRepository.redownloadRemoteSources()
}
}
fun addLocal(name: String, patchBundle: Uri, integrations: Uri?) = viewModelScope.launch {
contentResolver.openInputStream(patchBundle)!!.use { patchesStream ->
val integrationsStream = integrations?.let { contentResolver.openInputStream(it) }
try {
sourceRepository.createLocalSource(name, patchesStream, integrationsStream)
} finally {
integrationsStream?.close()
}
}
}
fun addRemote(name: String, apiUrl: Url) =
viewModelScope.launch { sourceRepository.createRemoteSource(name, apiUrl) }
fun delete(source: Source) = viewModelScope.launch { sourceRepository.remove(source) }
fun deleteAllSources() = viewModelScope.launch {
sourceRepository.resetConfig()
}
}

View File

@ -8,7 +8,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.ui.model.SelectedApp
@ -28,7 +28,7 @@ class VersionSelectorViewModel(
val packageName: String
) : ViewModel(), KoinComponent {
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val sourceRepository: SourceRepository by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val pm: PM by inject()
private val appDownloader: AppDownloader = APKMirror()
@ -41,7 +41,7 @@ class VersionSelectorViewModel(
val downloadableVersions = mutableStateSetOf<SelectedApp.Download>()
val supportedVersions = sourceRepository.bundles.map { bundles ->
val supportedVersions = patchBundleRepository.bundles.map { bundles ->
var patchesWithoutVersions = 0
bundles.flatMap { (_, bundle) ->

View File

@ -8,7 +8,6 @@ const val ghPatcher = "$team/revanced-patcher"
const val ghManager = "$team/revanced-manager"
const val ghIntegrations = "$team/revanced-integrations"
const val tag = "ReVanced Manager"
const val apiURL = "https://releases.revanced.app"
const val JAR_MIMETYPE = "application/java-archive"
const val APK_MIMETYPE = "application/vnd.android.package-archive"

View File

@ -13,7 +13,7 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope
@ -40,11 +40,11 @@ data class AppInfo(
@Suppress("DEPRECATION")
class PM(
private val app: Application,
sourceRepository: SourceRepository
patchBundleRepository: PatchBundleRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
val appList = sourceRepository.bundles.map { bundles ->
val appList = patchBundleRepository.bundles.map { bundles ->
val compatibleApps = scope.async {
val compatiblePackages = bundles.values
.flatMap { it.patches }

View File

@ -11,7 +11,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import io.ktor.http.Url
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -33,12 +32,6 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, string, duration).show()
}
fun String.parseUrlOrNull() = try {
Url(this)
} catch (_: Throwable) {
null
}
/**
* Safely perform an operation that may fail to avoid crashing the app.
* If [block] fails, the error will be logged and a toast will be shown to the user to inform them that the action failed.

View File

@ -12,11 +12,20 @@
<string name="select_patches">Select patches</string>
<string name="import_">Import</string>
<string name="import_bundle">Import Bundle</string>
<string name="import_bundle">Import patch bundle</string>
<string name="bundle_information">Bundle information</string>
<string name="bundle_patches">Bundle patches</string>
<string name="bundle_missing">Missing</string>
<string name="bundle_error">Error</string>
<string name="select_version">Select version</string>
<string name="auto_updates_dialog_title">Select updates to receive</string>
<string name="auto_updates_dialog_description">Periodically connect to update providers to check for updates.</string>
<string name="auto_updates_dialog_manager">Manager updates</string>
<string name="auto_updates_dialog_patches">Patches</string>
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
<string name="general">General</string>
<string name="general_description">General settings</string>
@ -90,22 +99,30 @@
<string name="dark">Dark</string>
<string name="appearance">Appearance</string>
<string name="downloaded_apps">Downloaded apps</string>
<string name="api_url">API URL</string>
<string name="api_url_dialog_title">Set custom API URL</string>
<string name="api_url_dialog_description">You may have issues with features when using a custom API URL.</string>
<string name="api_url_dialog_warning">Only use API\'s you trust!</string>
<string name="api_url_dialog_save">Set</string>
<string name="device">Device</string>
<string name="device_android_version">Android version</string>
<string name="device_model">Model</string>
<string name="device_architectures">CPU Architectures</string>
<string name="device_memory_limit">Memory limits</string>
<string name="device_memory_limit_format">Normal: %1$d MB, Large: %2$d MB</string>
<string name="patch_bundles_section">Patch bundles</string>
<string name="patch_bundles_redownload">Redownload all patch bundles</string>
<string name="patch_bundles_reset">Reset patch bundles</string>
<string name="patching">Patching</string>
<string name="signing">Signing</string>
<string name="storage">Storage</string>
<string name="patches_unavailable">No patches are available. Check your bundles</string>
<string name="tab_apps">Apps</string>
<string name="tab_sources">Sources</string>
<string name="tab_bundles">Patch bundles</string>
<string name="delete">Delete</string>
<string name="refresh">Refresh</string>
<string name="remote">Remote</string>
<string name="local">Local</string>
<string name="reload_sources">Reload all sources</string>
<string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string>
<string name="download_app">Download app</string>
@ -181,8 +198,6 @@
<string name="automatically_update_description">Automatically update this bundle when ReVanced starts</string>
<string name="bundle_type">Bundle type</string>
<string name="bundle_type_description">Choose the type of bundle you want</string>
<string name="patches_version">Patches version</string>
<string name="integrations_version">Integrations version</string>
<string name="about_revanced_manager">About ReVanced Manager</string>
<string name="revanced_manager_description">ReVanced Manager is an application designed to work with ReVanced Patcher, which allows for long-lasting patches to be created for Android apps. The patching system is designed to automatically work with new versions of apps with minimal maintenance.</string>
<string name="update_notification">A minor update for ReVanced Manager is available. Click here to update and get the latest features and fixes!</string>
@ -199,6 +214,7 @@
<string name="downloading_manager_update">Downloading update…</string>
<string name="download_manager_failed">Failed to download update: %s</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="update">Update</string>
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
</resources>