mirror of
https://github.com/revanced/revanced-manager-compose
synced 2024-09-10 08:27:11 +02:00
feat: patch bundle sources system (#24)
This commit is contained in:
parent
3de4d84484
commit
91c11da363
@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.devtools.ksp")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.8.21"
|
||||
}
|
||||
@ -37,6 +38,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
@ -78,6 +83,14 @@ dependencies {
|
||||
// KotlinX
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
|
||||
|
||||
// Room
|
||||
val roomVersion = "2.5.1"
|
||||
implementation("androidx.room:room-runtime:$roomVersion")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
annotationProcessor("androidx.room:room-compiler:$roomVersion")
|
||||
ksp("androidx.room:room-compiler:$roomVersion")
|
||||
|
||||
|
||||
// ReVanced
|
||||
implementation("app.revanced:revanced-patcher:7.1.0")
|
||||
|
||||
|
@ -0,0 +1,68 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "b40f3b048880f3f3c9361f6d1c4aaea5",
|
||||
"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`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "location",
|
||||
"columnName": "location",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionInfo.patches",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "versionInfo.integrations",
|
||||
"columnName": "integrations_version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sources_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sources_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"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, 'b40f3b048880f3f3c9361f6d1c4aaea5')"
|
||||
]
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import app.revanced.manager.compose.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.ui.destination.Destination
|
||||
import app.revanced.manager.compose.ui.screen.*
|
||||
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
|
||||
@ -22,6 +23,7 @@ import org.koin.core.parameter.parametersOf
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val prefs: PreferencesManager by inject()
|
||||
private val bundleRepository: BundleRepository by inject()
|
||||
private val mainScope = MainScope()
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@ -30,6 +32,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
installSplashScreen()
|
||||
|
||||
bundleRepository.onAppStart(this@MainActivity)
|
||||
|
||||
val context = this
|
||||
mainScope.launch(Dispatchers.IO) {
|
||||
PM.loadApps(context)
|
||||
|
@ -18,8 +18,10 @@ class ManagerApplication : Application() {
|
||||
preferencesModule,
|
||||
repositoryModule,
|
||||
serviceModule,
|
||||
managerModule,
|
||||
workerModule,
|
||||
viewModelModule,
|
||||
databaseModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,13 @@
|
||||
package app.revanced.manager.compose.data.room
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import app.revanced.manager.compose.data.room.sources.SourceEntity
|
||||
import app.revanced.manager.compose.data.room.sources.SourceDao
|
||||
|
||||
@Database(entities = [SourceEntity::class], version = 1)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun sourceDao(): SourceDao
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package app.revanced.manager.compose.data.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import app.revanced.manager.compose.data.room.sources.SourceLocation
|
||||
import io.ktor.http.*
|
||||
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun locationFromString(value: String) = when(value) {
|
||||
SourceLocation.Local.SENTINEL -> SourceLocation.Local
|
||||
else -> SourceLocation.Remote(Url(value))
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun locationToString(location: SourceLocation) = location.toString()
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package app.revanced.manager.compose.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)
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package app.revanced.manager.compose.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,
|
||||
)
|
@ -0,0 +1,15 @@
|
||||
package app.revanced.manager.compose.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import app.revanced.manager.compose.data.room.AppDatabase
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val databaseModule = module {
|
||||
fun provideAppDatabase(context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "manager").build()
|
||||
|
||||
single {
|
||||
provideAppDatabase(androidContext())
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.patcher.SignerService
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val managerModule = module {
|
||||
singleOf(::SignerService)
|
||||
}
|
@ -2,12 +2,16 @@ package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.domain.repository.ReVancedRepository
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import app.revanced.manager.compose.patcher.data.repository.PatchesRepository
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourcePersistenceRepository
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val repositoryModule = module {
|
||||
singleOf(::ReVancedRepository)
|
||||
singleOf(::ManagerAPI)
|
||||
singleOf(::PatchesRepository)
|
||||
singleOf(::BundleRepository)
|
||||
singleOf(::SourcePersistenceRepository)
|
||||
singleOf(::SourceRepository)
|
||||
}
|
@ -2,7 +2,6 @@ package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.network.service.HttpService
|
||||
import app.revanced.manager.compose.network.service.ReVancedService
|
||||
import app.revanced.manager.compose.patcher.SignerService
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
@ -17,5 +16,4 @@ val serviceModule = module {
|
||||
|
||||
single { provideReVancedService(get()) }
|
||||
singleOf(::HttpService)
|
||||
singleOf(::SignerService)
|
||||
}
|
@ -1,10 +1,6 @@
|
||||
package app.revanced.manager.compose.di
|
||||
|
||||
import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.InstallerScreenViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.SettingsViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.UpdateSettingsViewModel
|
||||
import app.revanced.manager.compose.ui.viewmodel.*
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
@ -13,11 +9,12 @@ val viewModelModule = module {
|
||||
viewModel {
|
||||
PatchesSelectorViewModel(
|
||||
packageInfo = it.get(),
|
||||
patchesRepository = get()
|
||||
bundleRepository = get()
|
||||
)
|
||||
}
|
||||
viewModelOf(::SettingsViewModel)
|
||||
viewModelOf(::AppSelectorViewModel)
|
||||
viewModelOf(::SourcesScreenViewModel)
|
||||
viewModel {
|
||||
InstallerScreenViewModel(
|
||||
input = it.get(),
|
||||
|
@ -0,0 +1,50 @@
|
||||
package app.revanced.manager.compose.domain.repository
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.revanced.manager.compose.patcher.patch.PatchBundle
|
||||
import app.revanced.manager.compose.util.launchAndRepeatWithViewLifecycle
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BundleRepository(private val sourceRepository: SourceRepository) {
|
||||
/**
|
||||
* A [Flow] that emits whenever the sources change.
|
||||
*
|
||||
* The outer flow emits whenever the sources configuration changes.
|
||||
* The inner flow emits whenever one of the bundles update.
|
||||
*/
|
||||
private val sourceUpdates = sourceRepository.sources.map { sources ->
|
||||
sources.map { (name, source) ->
|
||||
source.bundle.map { bundle ->
|
||||
name to bundle
|
||||
}
|
||||
}.merge().buffer()
|
||||
}
|
||||
|
||||
private val _bundles = MutableStateFlow<Map<String, PatchBundle>>(emptyMap())
|
||||
|
||||
/**
|
||||
* A [Flow] that gives you all loaded [PatchBundle]s.
|
||||
* This is only synced when the app is in the foreground.
|
||||
*/
|
||||
val bundles = _bundles.asStateFlow()
|
||||
|
||||
fun onAppStart(lifecycleOwner: LifecycleOwner) {
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
sourceRepository.loadSources()
|
||||
}
|
||||
|
||||
lifecycleOwner.launchAndRepeatWithViewLifecycle {
|
||||
sourceUpdates.collect { events ->
|
||||
val map = HashMap<String, PatchBundle>()
|
||||
_bundles.emit(map)
|
||||
|
||||
events.collect { (name, new) ->
|
||||
map[name] = new
|
||||
_bundles.emit(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package app.revanced.manager.compose.domain.repository
|
||||
|
||||
import app.revanced.manager.compose.data.room.AppDatabase
|
||||
import app.revanced.manager.compose.data.room.sources.SourceEntity
|
||||
import app.revanced.manager.compose.data.room.sources.SourceLocation
|
||||
import app.revanced.manager.compose.data.room.sources.VersionInfo
|
||||
import app.revanced.manager.compose.util.apiURL
|
||||
import kotlin.random.Random
|
||||
import io.ktor.http.*
|
||||
|
||||
class SourcePersistenceRepository(db: AppDatabase) {
|
||||
private val dao = db.sourceDao()
|
||||
|
||||
private companion object {
|
||||
fun generateUid() = Random.Default.nextInt()
|
||||
|
||||
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 }
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
package app.revanced.manager.compose.domain.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import app.revanced.manager.compose.data.room.sources.SourceEntity
|
||||
import app.revanced.manager.compose.data.room.sources.SourceLocation
|
||||
import app.revanced.manager.compose.domain.sources.RemoteSource
|
||||
import app.revanced.manager.compose.domain.sources.LocalSource
|
||||
import app.revanced.manager.compose.domain.sources.Source
|
||||
import app.revanced.manager.compose.util.tag
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.dataDir.resolve("sources").also { it.mkdirs() }
|
||||
|
||||
/**
|
||||
* 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(uid, dir)
|
||||
is SourceLocation.Remote -> RemoteSource(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.name to source
|
||||
}
|
||||
|
||||
_sources.emit(sources)
|
||||
}
|
||||
|
||||
suspend fun resetConfig() = withContext(Dispatchers.Default) {
|
||||
persistenceRepo.clear()
|
||||
_sources.emit(emptyMap())
|
||||
sourcesDir.apply {
|
||||
delete()
|
||||
mkdirs()
|
||||
}
|
||||
|
||||
loadSources()
|
||||
}
|
||||
|
||||
suspend fun remove(source: Source) = withContext(Dispatchers.Default) {
|
||||
persistenceRepo.delete(source.id)
|
||||
directoryOf(source.id).delete()
|
||||
|
||||
_sources.update {
|
||||
it.filterValues { value ->
|
||||
value.id != source.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSource(name: String, source: Source) =
|
||||
_sources.update { it.toMutableMap().apply { put(name, source) } }
|
||||
|
||||
suspend fun createLocalSource(name: String, patches: InputStream, integrations: InputStream?) {
|
||||
val id = persistenceRepo.create(name, SourceLocation.Local)
|
||||
val source = LocalSource(id, directoryOf(id))
|
||||
|
||||
addSource(name, source)
|
||||
|
||||
source.replace(patches, integrations)
|
||||
}
|
||||
|
||||
suspend fun createRemoteSource(name: String, apiUrl: Url) {
|
||||
val id = persistenceRepo.create(name, SourceLocation.Remote(apiUrl))
|
||||
addSource(name, RemoteSource(id, directoryOf(id)))
|
||||
}
|
||||
|
||||
private val _sources: MutableStateFlow<Map<String, Source>> = MutableStateFlow(emptyMap())
|
||||
val sources = _sources.asStateFlow()
|
||||
|
||||
suspend fun redownloadRemoteSources() =
|
||||
sources.value.values.filterIsInstance<RemoteSource>().forEach { it.downloadLatest() }
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package app.revanced.manager.compose.domain.sources
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
class LocalSource(id: Int, directory: File) : Source(id, directory) {
|
||||
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
patches?.let {
|
||||
Files.copy(it, patchesJar.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
integrations?.let {
|
||||
Files.copy(it, this@LocalSource.integrations.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_bundle.emit(loadBundle { throw it })
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package app.revanced.manager.compose.domain.sources
|
||||
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
class RemoteSource(id: Int, directory: File) : Source(id, directory) {
|
||||
private val api: ManagerAPI by inject()
|
||||
suspend fun downloadLatest() = withContext(Dispatchers.IO) {
|
||||
api.downloadBundle(patchesJar, integrations).also { (patchesVer, integrationsVer) ->
|
||||
saveVersion(patchesVer, integrationsVer)
|
||||
withContext(Dispatchers.Main) {
|
||||
_bundle.emit(loadBundle { err -> throw err })
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext
|
||||
}
|
||||
|
||||
suspend fun update() = withContext(Dispatchers.IO) {
|
||||
val currentVersion = getVersion()
|
||||
if (!hasInstalled() || currentVersion != api.getLatestBundleVersion()) {
|
||||
downloadLatest()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package app.revanced.manager.compose.domain.sources
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.compose.patcher.patch.PatchBundle
|
||||
import app.revanced.manager.compose.domain.repository.SourcePersistenceRepository
|
||||
import app.revanced.manager.compose.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.
|
||||
*/
|
||||
sealed class Source(val id: 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(id)
|
||||
protected suspend fun saveVersion(patches: String, integrations: String) =
|
||||
configRepository.updateVersion(id, 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()
|
||||
}
|
@ -7,11 +7,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import app.revanced.manager.compose.domain.repository.ReVancedRepository
|
||||
import app.revanced.manager.compose.util.ghIntegrations
|
||||
import app.revanced.manager.compose.util.ghManager
|
||||
import app.revanced.manager.compose.util.ghPatches
|
||||
import app.revanced.manager.compose.util.tag
|
||||
import app.revanced.manager.compose.util.toast
|
||||
import app.revanced.manager.compose.util.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
@ -40,36 +36,19 @@ class ManagerAPI(
|
||||
downloadProgress = null
|
||||
}
|
||||
|
||||
suspend fun downloadPatchBundle(): File? {
|
||||
try {
|
||||
val downloadUrl = revancedRepository.findAsset(ghPatches, ".jar").downloadUrl
|
||||
val patchesFile = app.filesDir.resolve("patch-bundles").also { it.mkdirs() }
|
||||
.resolve("patchbundle.jar")
|
||||
downloadAsset(downloadUrl, patchesFile)
|
||||
private suspend fun patchesAsset() = revancedRepository.findAsset(ghPatches, ".jar")
|
||||
private suspend fun integrationsAsset() = revancedRepository.findAsset(ghIntegrations, ".apk")
|
||||
|
||||
return patchesFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to download patch bundle", e)
|
||||
app.toast("Failed to download patch bundle")
|
||||
}
|
||||
suspend fun getLatestBundleVersion() = patchesAsset().version to integrationsAsset().version
|
||||
|
||||
return null
|
||||
}
|
||||
suspend fun downloadBundle(patchBundle: File, integrations: File): Pair<String, String> {
|
||||
val patchBundleAsset = patchesAsset()
|
||||
val integrationsAsset = integrationsAsset()
|
||||
|
||||
suspend fun downloadIntegrations(): File? {
|
||||
try {
|
||||
val downloadUrl = revancedRepository.findAsset(ghIntegrations, ".apk").downloadUrl
|
||||
val integrationsFile = app.filesDir.resolve("integrations").also { it.mkdirs() }
|
||||
.resolve("integrations.apk")
|
||||
downloadAsset(downloadUrl, integrationsFile)
|
||||
downloadAsset(patchBundleAsset.downloadUrl, patchBundle)
|
||||
downloadAsset(integrationsAsset.downloadUrl, integrations)
|
||||
|
||||
return integrationsFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to download integrations", e)
|
||||
app.toast("Failed to download integrations")
|
||||
}
|
||||
|
||||
return null
|
||||
return patchBundleAsset.version to integrationsAsset.version
|
||||
}
|
||||
|
||||
suspend fun downloadManager(): File? {
|
||||
@ -87,4 +66,5 @@ class ManagerAPI(
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class MissingAssetException : Exception()
|
@ -5,7 +5,7 @@ import app.revanced.manager.compose.network.dto.Assets
|
||||
import app.revanced.manager.compose.network.dto.ReVancedReleases
|
||||
import app.revanced.manager.compose.network.dto.ReVancedRepositories
|
||||
import app.revanced.manager.compose.network.utils.APIResponse
|
||||
import app.revanced.manager.compose.network.utils.getOrNull
|
||||
import app.revanced.manager.compose.network.utils.getOrThrow
|
||||
import app.revanced.manager.compose.util.apiURL
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -31,10 +31,12 @@ class ReVancedService(
|
||||
}
|
||||
|
||||
suspend fun findAsset(repo: String, file: String): Assets {
|
||||
val releases = getAssets().getOrNull() ?: throw Exception("Cannot retrieve assets")
|
||||
val releases = getAssets().getOrThrow()
|
||||
|
||||
val asset = releases.tools.find { asset ->
|
||||
(asset.name.contains(file) && asset.repository.contains(repo))
|
||||
} ?: throw MissingAssetException()
|
||||
|
||||
return Assets(asset.repository, asset.version, asset.timestamp, asset.name,asset.size, asset.downloadUrl, asset.content_type)
|
||||
}
|
||||
|
||||
|
@ -14,9 +14,9 @@ sealed interface APIResponse<T> {
|
||||
data class Failure<T>(val error: APIFailure) : APIResponse<T>
|
||||
}
|
||||
|
||||
class APIError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body")
|
||||
class APIError(code: HttpStatusCode, body: String?) : Exception("HTTP Code $code, Body: $body")
|
||||
|
||||
class APIFailure(error: Throwable, body: String?) : Error(body, error)
|
||||
class APIFailure(error: Throwable, body: String?) : Exception(body ?: error.message, error)
|
||||
|
||||
inline fun <T, R> APIResponse<T>.fold(
|
||||
success: (T) -> R,
|
||||
@ -32,7 +32,7 @@ inline fun <T, R> APIResponse<T>.fold(
|
||||
|
||||
inline fun <T, R> APIResponse<T>.fold(
|
||||
success: (T) -> R,
|
||||
fail: (Error) -> R,
|
||||
fail: (Exception) -> R,
|
||||
): R {
|
||||
return when (this) {
|
||||
is APIResponse.Success -> success(data)
|
||||
|
@ -1,53 +0,0 @@
|
||||
package app.revanced.manager.compose.patcher.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.compose.network.api.ManagerAPI
|
||||
import app.revanced.manager.compose.patcher.data.PatchBundle
|
||||
import app.revanced.manager.compose.patcher.patch.PatchInfo
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
class PatchesRepository(private val managerAPI: ManagerAPI) {
|
||||
private val patchInformation =
|
||||
MutableSharedFlow<List<PatchInfo>>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private var bundle: PatchBundle? = null
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Load a new bundle and update state associated with it.
|
||||
*/
|
||||
private suspend fun loadNewBundle(new: PatchBundle) {
|
||||
bundle = new
|
||||
withContext(Dispatchers.Main) {
|
||||
patchInformation.emit(new.loadAllPatches().map { PatchInfo(it) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the [PatchBundle] if needed.
|
||||
*/
|
||||
private suspend fun loadBundle() = bundle ?: PatchBundle(
|
||||
managerAPI.downloadPatchBundle()!!.absolutePath,
|
||||
managerAPI.downloadIntegrations()
|
||||
).also {
|
||||
loadNewBundle(it)
|
||||
}
|
||||
|
||||
suspend fun loadPatchClassesFiltered(packageName: String) =
|
||||
loadBundle().loadPatchesFiltered(packageName)
|
||||
|
||||
fun getPatchInformation() = patchInformation.asSharedFlow().also {
|
||||
scope.launch {
|
||||
try {
|
||||
loadBundle()
|
||||
} catch (e: Throwable) {
|
||||
Log.e("revanced-manager", "Failed to download bundle", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getIntegrations() = listOfNotNull(loadBundle().integrations)
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
package app.revanced.manager.compose.patcher.data
|
||||
package app.revanced.manager.compose.patcher.patch
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.compose.patcher.PatchClass
|
||||
import app.revanced.patcher.Patcher
|
||||
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
|
||||
@ -8,17 +9,21 @@ import dalvik.system.PathClassLoader
|
||||
import java.io.File
|
||||
|
||||
class PatchBundle(private val loader: Iterable<PatchClass>, val integrations: File?) {
|
||||
constructor(bundleJar: String, integrations: File?) : this(
|
||||
constructor(bundleJar: File, integrations: File?) : this(
|
||||
object : Iterable<PatchClass> {
|
||||
private val bundle = PatchBundle.Dex(
|
||||
bundleJar,
|
||||
PathClassLoader(bundleJar, Patcher::class.java.classLoader)
|
||||
bundleJar.absolutePath,
|
||||
PathClassLoader(bundleJar.absolutePath, Patcher::class.java.classLoader)
|
||||
)
|
||||
|
||||
override fun iterator() = bundle.loadPatches().iterator()
|
||||
},
|
||||
integrations
|
||||
)
|
||||
) {
|
||||
Log.d("revanced-manager", "Loaded patch bundle: $bundleJar")
|
||||
}
|
||||
|
||||
val patches = loadAllPatches().map(::PatchInfo)
|
||||
|
||||
/**
|
||||
* @return A list of patches that are compatible with this Apk.
|
@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.patcher.Session
|
||||
import app.revanced.manager.compose.patcher.aapt.Aapt
|
||||
import app.revanced.manager.compose.patcher.data.repository.PatchesRepository
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import app.revanced.patcher.extensions.PatchExtensions.patchName
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@ -19,13 +20,13 @@ import java.io.FileNotFoundException
|
||||
// TODO: setup wakelock + notification so android doesn't murder us.
|
||||
class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters),
|
||||
KoinComponent {
|
||||
private val patchesRepository: PatchesRepository by inject()
|
||||
private val bundleRepository: BundleRepository by inject()
|
||||
|
||||
@Serializable
|
||||
data class Args(
|
||||
val input: String,
|
||||
val output: String,
|
||||
val selectedPatches: List<String>,
|
||||
val selectedPatches: PatchesSelection,
|
||||
val packageName: String,
|
||||
val packageVersion: String
|
||||
)
|
||||
@ -46,12 +47,17 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineW
|
||||
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
|
||||
|
||||
val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!)
|
||||
val selected = args.selectedPatches.toSet()
|
||||
|
||||
val patchList = patchesRepository.loadPatchClassesFiltered(args.packageName)
|
||||
.filter { selected.contains(it.patchName) }
|
||||
val bundles = bundleRepository.bundles.value
|
||||
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
|
||||
|
||||
val progressManager = PatcherProgressManager(applicationContext, args.selectedPatches)
|
||||
val patchList = args.selectedPatches.flatMap { (bundleName, selected) ->
|
||||
bundles[bundleName]?.loadPatchesFiltered(args.packageName)?.filter { selected.contains(it.patchName) }
|
||||
?: throw IllegalArgumentException("Patch bundle $bundleName does not exist")
|
||||
}
|
||||
|
||||
val progressManager =
|
||||
PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { (_, selected) -> selected })
|
||||
|
||||
suspend fun updateProgress(progress: Progress) {
|
||||
progressManager.handle(progress)
|
||||
@ -64,7 +70,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineW
|
||||
Session(applicationContext.cacheDir.path, frameworkPath, aaptPath, File(args.input)) {
|
||||
updateProgress(it)
|
||||
}.use { session ->
|
||||
session.run(File(args.output), patchList, patchesRepository.getIntegrations())
|
||||
session.run(File(args.output), patchList, integrations)
|
||||
}
|
||||
|
||||
Log.i("revanced-worker", "Patching succeeded")
|
||||
|
@ -0,0 +1,21 @@
|
||||
package app.revanced.manager.compose.ui.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun FileSelector(mime: String, onSelect: (Uri) -> Unit, content: @Composable () -> Unit) {
|
||||
val activityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let(onSelect)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
activityLauncher.launch(mime)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package app.revanced.manager.compose.ui.component.sources
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.compose.ui.component.FileSelector
|
||||
import app.revanced.manager.compose.util.APK_MIMETYPE
|
||||
import app.revanced.manager.compose.util.JAR_MIMETYPE
|
||||
|
||||
@Composable
|
||||
fun LocalBundleSelectors(onPatchesSelection: (Uri) -> Unit, onIntegrationsSelection: (Uri) -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
FileSelector(
|
||||
mime = JAR_MIMETYPE,
|
||||
onSelect = onPatchesSelection
|
||||
) {
|
||||
Text("Patches")
|
||||
}
|
||||
|
||||
FileSelector(
|
||||
mime = APK_MIMETYPE,
|
||||
onSelect = onIntegrationsSelection
|
||||
) {
|
||||
Text("Integrations")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
package app.revanced.manager.compose.ui.component.sources
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.compose.R
|
||||
import app.revanced.manager.compose.util.parseUrlOrNull
|
||||
import io.ktor.http.*
|
||||
|
||||
@Composable
|
||||
fun NewSourceDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onRemoteSubmit: (String, Url) -> Unit,
|
||||
onLocalSubmit: (String, Uri, Uri?) -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column {
|
||||
IconButton(onClick = onDismissRequest) {
|
||||
Icon(Icons.Filled.Cancel, stringResource(R.string.cancel))
|
||||
}
|
||||
var isLocal by rememberSaveable { mutableStateOf(false) }
|
||||
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
var remoteUrl by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
val inputsAreValid by remember {
|
||||
derivedStateOf {
|
||||
val nameSize = name.length
|
||||
|
||||
nameSize in 4..19 && if (isLocal) patchBundle != null else {
|
||||
remoteUrl.isNotEmpty() && remoteUrl.parseUrlOrNull() != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isLocal) {
|
||||
integrations = null
|
||||
patchBundle = null
|
||||
remoteUrl = ""
|
||||
}
|
||||
|
||||
Text(text = if (isLocal) "Local" else "Remote")
|
||||
Switch(checked = isLocal, onCheckedChange = { isLocal = it })
|
||||
|
||||
TextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = {
|
||||
Text("Name")
|
||||
}
|
||||
)
|
||||
|
||||
if (isLocal) {
|
||||
LocalBundleSelectors(
|
||||
onPatchesSelection = { patchBundle = it },
|
||||
onIntegrationsSelection = { integrations = it },
|
||||
)
|
||||
} else {
|
||||
TextField(
|
||||
value = remoteUrl,
|
||||
onValueChange = { remoteUrl = it },
|
||||
label = {
|
||||
Text("API Url")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (isLocal) {
|
||||
onLocalSubmit(name, patchBundle!!, integrations)
|
||||
} else {
|
||||
onRemoteSubmit(name, remoteUrl.parseUrlOrNull()!!)
|
||||
}
|
||||
},
|
||||
enabled = inputsAreValid
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
package app.revanced.manager.compose.ui.component.sources
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.compose.R
|
||||
import app.revanced.manager.compose.domain.sources.LocalSource
|
||||
import app.revanced.manager.compose.domain.sources.RemoteSource
|
||||
import app.revanced.manager.compose.domain.sources.Source
|
||||
import app.revanced.manager.compose.ui.viewmodel.SourcesScreenViewModel
|
||||
import app.revanced.manager.compose.util.uiSafe
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.InputStream
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SourceItem(name: String, source: Source, onDelete: () -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var sheetActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val bundle by source.bundle.collectAsStateWithLifecycle()
|
||||
val patchCount = bundle.patches.size
|
||||
val padding = PaddingValues(16.dp, 0.dp)
|
||||
|
||||
if (sheetActive) {
|
||||
val modalSheetState = rememberModalBottomSheetState(
|
||||
confirmValueChange = { it != SheetValue.PartiallyExpanded },
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = modalSheetState,
|
||||
onDismissRequest = { sheetActive = false }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
when (source) {
|
||||
is RemoteSource -> RemoteSourceItem(source)
|
||||
is LocalSource -> LocalSourceItem(source)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
modalSheetState.hide()
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete this source")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
sheetActive = true
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteSourceItem(source: RemoteSource) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val androidContext = LocalContext.current
|
||||
Text(text = "(api url here)")
|
||||
|
||||
Button(onClick = {
|
||||
coroutineScope.launch {
|
||||
uiSafe(androidContext, R.string.source_download_fail, SourcesScreenViewModel.failLogMsg) {
|
||||
source.update()
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(text = "Check for updates")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalSourceItem(source: LocalSource) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val androidContext = LocalContext.current
|
||||
val resolver = remember { androidContext.contentResolver!! }
|
||||
|
||||
fun loadAndReplace(uri: Uri, @StringRes toastMsg: Int, errorLogMsg: String, callback: suspend (InputStream) -> Unit) = coroutineScope.launch {
|
||||
uiSafe(androidContext, toastMsg, errorLogMsg) {
|
||||
resolver.openInputStream(uri)!!.use {
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalBundleSelectors(
|
||||
onPatchesSelection = { uri ->
|
||||
loadAndReplace(uri, R.string.source_replace_fail, "Failed to replace patch bundle") {
|
||||
source.replace(it, null)
|
||||
}
|
||||
},
|
||||
onIntegrationsSelection = { uri ->
|
||||
loadAndReplace(uri, R.string.source_replace_integrations_fail, "Failed to replace integrations") {
|
||||
source.replace(null, it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -2,6 +2,7 @@ package app.revanced.manager.compose.ui.destination
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface Destination : Parcelable {
|
||||
@ -19,5 +20,5 @@ sealed interface Destination : Parcelable {
|
||||
data class PatchesSelector(val input: PackageInfo) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class Installer(val input: PackageInfo, val selectedPatches: List<String>) : Destination
|
||||
data class Installer(val input: PackageInfo, val selectedPatches: PatchesSelection) : Destination
|
||||
}
|
@ -26,6 +26,7 @@ import app.revanced.manager.compose.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||
import app.revanced.manager.compose.ui.component.GroupHeader
|
||||
import app.revanced.manager.compose.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val allowUnsupported = false
|
||||
@ -33,7 +34,7 @@ const val allowUnsupported = false
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PatchesSelectorScreen(
|
||||
startPatching: (List<String>) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel
|
||||
startPatching: (PatchesSelection) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel
|
||||
) {
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@ -56,7 +57,7 @@ fun PatchesSelectorScreen(
|
||||
}, floatingActionButton = {
|
||||
ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) },
|
||||
icon = { Icon(Icons.Default.Build, null) },
|
||||
onClick = { startPatching(vm.selectedPatches.toList()) })
|
||||
onClick = { startPatching(vm.generateSelection()) })
|
||||
}) { paddingValues ->
|
||||
Column(Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
TabRow(
|
||||
@ -80,26 +81,26 @@ fun PatchesSelectorScreen(
|
||||
userScrollEnabled = true,
|
||||
pageContent = { index ->
|
||||
|
||||
val bundle = bundles[index]
|
||||
val (bundleName, supportedPatches, unsupportedPatches) = bundles[index]
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = bundle.supported
|
||||
items = supportedPatches
|
||||
) { patch ->
|
||||
PatchItem(
|
||||
patch = patch,
|
||||
onOptionsDialog = vm::openOptionsDialog,
|
||||
onToggle = {
|
||||
vm.togglePatch(patch)
|
||||
vm.togglePatch(bundleName, patch)
|
||||
},
|
||||
selected = vm.isSelected(patch),
|
||||
selected = vm.isSelected(bundleName, patch),
|
||||
supported = true
|
||||
)
|
||||
}
|
||||
|
||||
if (bundle.unsupported.isNotEmpty()) {
|
||||
if (unsupportedPatches.isNotEmpty()) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp).padding(end = 10.dp),
|
||||
@ -116,16 +117,16 @@ fun PatchesSelectorScreen(
|
||||
}
|
||||
|
||||
items(
|
||||
items = bundle.unsupported,
|
||||
items = unsupportedPatches,
|
||||
// key = { it.name }
|
||||
) { patch ->
|
||||
PatchItem(
|
||||
patch = patch,
|
||||
onOptionsDialog = vm::openOptionsDialog,
|
||||
onToggle = {
|
||||
vm.togglePatch(patch)
|
||||
vm.togglePatch(bundleName, patch)
|
||||
},
|
||||
selected = vm.isSelected(patch),
|
||||
selected = vm.isSelected(bundleName, patch),
|
||||
supported = allowUnsupported
|
||||
)
|
||||
}
|
||||
|
@ -1,21 +1,66 @@
|
||||
package app.revanced.manager.compose.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.compose.R
|
||||
import app.revanced.manager.compose.ui.component.sources.NewSourceDialog
|
||||
import app.revanced.manager.compose.ui.component.sources.SourceItem
|
||||
import app.revanced.manager.compose.ui.viewmodel.SourcesScreenViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
|
||||
@Composable
|
||||
fun SourcesScreen() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_sources_set),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
fun SourcesScreen(vm: SourcesScreenViewModel = getViewModel()) {
|
||||
var showNewSourceDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sources by vm.sources.collectAsStateWithLifecycle()
|
||||
|
||||
if (showNewSourceDialog) NewSourceDialog(
|
||||
onDismissRequest = { showNewSourceDialog = false },
|
||||
onLocalSubmit = { name, patches, integrations ->
|
||||
showNewSourceDialog = false
|
||||
scope.launch {
|
||||
vm.addLocal(name, patches, integrations)
|
||||
}
|
||||
},
|
||||
onRemoteSubmit = { name, url ->
|
||||
showNewSourceDialog = false
|
||||
scope.launch {
|
||||
vm.addRemote(name, url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
sources.forEach { (name, source) ->
|
||||
SourceItem(
|
||||
name = name,
|
||||
source = source,
|
||||
onDelete = {
|
||||
vm.deleteSource(source)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
@ -23,6 +23,7 @@ import app.revanced.manager.compose.service.InstallService
|
||||
import app.revanced.manager.compose.service.UninstallService
|
||||
import app.revanced.manager.compose.util.PM
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import app.revanced.manager.compose.util.toast
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
@ -31,11 +32,15 @@ import java.nio.file.Files
|
||||
|
||||
class InstallerScreenViewModel(
|
||||
input: PackageInfo,
|
||||
selectedPatches: List<String>,
|
||||
selectedPatches: PatchesSelection,
|
||||
private val app: Application,
|
||||
private val signerService: SignerService
|
||||
) : ViewModel() {
|
||||
var stepGroups by mutableStateOf<List<StepGroup>>(PatcherProgressManager.generateGroupsList(app, selectedPatches))
|
||||
var stepGroups by mutableStateOf<List<StepGroup>>(
|
||||
PatcherProgressManager.generateGroupsList(
|
||||
app,
|
||||
selectedPatches.flatMap { (_, selected) -> selected })
|
||||
)
|
||||
private set
|
||||
|
||||
val packageName = input.packageName
|
||||
@ -55,7 +60,6 @@ class InstallerScreenViewModel(
|
||||
|
||||
val canInstall by derivedStateOf { patcherStatus == true && !isInstalling }
|
||||
|
||||
|
||||
private val patcherWorker =
|
||||
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setInputData(
|
||||
|
@ -1,47 +1,44 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.ViewModel
|
||||
import app.revanced.manager.compose.patcher.data.repository.PatchesRepository
|
||||
import app.revanced.manager.compose.domain.repository.BundleRepository
|
||||
import app.revanced.manager.compose.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.compose.util.PackageInfo
|
||||
import app.revanced.manager.compose.util.PatchesSelection
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class PatchesSelectorViewModel(packageInfo: PackageInfo, patchesRepository: PatchesRepository) :
|
||||
ViewModel() {
|
||||
val bundlesFlow = patchesRepository.getPatchInformation().map { patches ->
|
||||
val supported = mutableListOf<PatchInfo>()
|
||||
val unsupported = mutableListOf<PatchInfo>()
|
||||
class PatchesSelectorViewModel(packageInfo: PackageInfo, bundleRepository: BundleRepository) : ViewModel() {
|
||||
val bundlesFlow = bundleRepository.bundles.map { bundles ->
|
||||
bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) ->
|
||||
val supported = mutableListOf<PatchInfo>()
|
||||
val unsupported = mutableListOf<PatchInfo>()
|
||||
|
||||
patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach {
|
||||
val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported
|
||||
patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach {
|
||||
val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported
|
||||
|
||||
targetList.add(it)
|
||||
targetList.add(it)
|
||||
}
|
||||
|
||||
Bundle(name, supported, unsupported)
|
||||
}
|
||||
|
||||
listOf(
|
||||
Bundle(
|
||||
name = "official",
|
||||
supported, unsupported
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val selectedPatches = mutableStateListOf<String>()
|
||||
private val selectedPatches = mutableStateListOf<Pair<String, String>>()
|
||||
fun isSelected(bundle: String, patch: PatchInfo) = selectedPatches.contains(bundle to patch.name)
|
||||
fun togglePatch(bundle: String, patch: PatchInfo) {
|
||||
val pair = bundle to patch.name
|
||||
if (isSelected(bundle, patch)) selectedPatches.remove(pair) else selectedPatches.add(pair)
|
||||
}
|
||||
|
||||
fun isSelected(patch: PatchInfo) = selectedPatches.contains(patch.name)
|
||||
fun togglePatch(patch: PatchInfo) {
|
||||
val name = patch.name
|
||||
if (isSelected(patch)) selectedPatches.remove(name) else selectedPatches.add(patch.name)
|
||||
fun generateSelection(): PatchesSelection = HashMap<String, MutableList<String>>().apply {
|
||||
selectedPatches.forEach { (bundleName, patchName) ->
|
||||
this.getOrPut(bundleName, ::mutableListOf).add(patchName)
|
||||
}
|
||||
}
|
||||
|
||||
data class Bundle(
|
||||
val name: String,
|
||||
val supported: List<PatchInfo>,
|
||||
val unsupported: List<PatchInfo>
|
||||
val name: String, val supported: List<PatchInfo>, val unsupported: List<PatchInfo>
|
||||
)
|
||||
|
||||
var showOptionsDialog by mutableStateOf(false)
|
||||
|
@ -0,0 +1,50 @@
|
||||
package app.revanced.manager.compose.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.compose.R
|
||||
import app.revanced.manager.compose.domain.sources.Source
|
||||
import app.revanced.manager.compose.domain.repository.SourceRepository
|
||||
import app.revanced.manager.compose.util.uiSafe
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SourcesScreenViewModel(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()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addLocal(name: String, patchBundle: Uri, integrations: Uri?) {
|
||||
contentResolver.openInputStream(patchBundle)!!.use { patchesStream ->
|
||||
val integrationsStream = integrations?.let { contentResolver.openInputStream(it) }
|
||||
try {
|
||||
sourceRepository.createLocalSource(name, patchesStream, integrationsStream)
|
||||
} finally {
|
||||
integrationsStream?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addRemote(name: String, apiUrl: Url) = sourceRepository.createRemoteSource(name, apiUrl)
|
||||
|
||||
fun deleteSource(source: Source) = viewModelScope.launch { sourceRepository.remove(source) }
|
||||
|
||||
fun deleteAllSources() = viewModelScope.launch {
|
||||
sourceRepository.resetConfig()
|
||||
}
|
||||
}
|
@ -9,3 +9,6 @@ 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"
|
@ -4,10 +4,19 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
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.launch
|
||||
|
||||
const val APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||
typealias PatchesSelection = Map<String, List<String>>
|
||||
|
||||
fun Context.openUrl(url: String) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply {
|
||||
@ -26,3 +35,38 @@ fun Context.loadIcon(string: String): Drawable? {
|
||||
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.
|
||||
*
|
||||
* @param context The android [Context].
|
||||
* @param toastMsg The toast message to show if [block] throws.
|
||||
* @param logMsg The log message.
|
||||
* @param block The code to execute.
|
||||
*/
|
||||
inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (error: Exception) {
|
||||
context.toast(context.getString(toastMsg, error.message ?: error.cause?.message ?: error::class.simpleName))
|
||||
Log.e(tag, logMsg, error)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
|
||||
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
|
||||
crossinline block: suspend CoroutineScope.() -> Unit
|
||||
) {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(minActiveState) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
7
app/src/main/res/values/plurals.xml
Normal file
7
app/src/main/res/values/plurals.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<plurals name="patches_count">
|
||||
<item quantity="one">%d Patch</item>
|
||||
<item quantity="other">%d Patches</item>
|
||||
</plurals>
|
||||
</resources>
|
@ -55,7 +55,10 @@
|
||||
<string name="storage">Storage</string>
|
||||
<string name="tab_apps">Apps</string>
|
||||
<string name="tab_sources">Sources</string>
|
||||
<string name="no_sources_set">No sources set</string>
|
||||
<string name="reload_sources">Reload all sources</string>
|
||||
<string name="source_download_fail">Failed to download patch bundle: %s</string>
|
||||
<string name="source_replace_fail">Failed to load updated patch bundle: %s</string>
|
||||
<string name="source_replace_integrations_fail">Failed to update integrations: %s</string>
|
||||
<string name="no_patched_apps_found">No patched apps found</string>
|
||||
<string name="unsupported_app">Unsupported app</string>
|
||||
<string name="unsupported_patches">Unsupported patches</string>
|
||||
|
@ -1,4 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.21" apply false
|
||||
id("com.google.devtools.ksp") version "1.8.21-1.0.11" apply false
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user