feat: patch bundle sources system (#24)

This commit is contained in:
Ax333l 2023-05-26 14:58:14 +02:00 committed by GitHub
parent 3de4d84484
commit 91c11da363
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1064 additions and 170 deletions

View File

@ -1,6 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.8.21" kotlin("plugin.serialization") version "1.8.21"
} }
@ -37,6 +38,10 @@ android {
} }
} }
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "11"
} }
@ -78,6 +83,14 @@ dependencies {
// KotlinX // KotlinX
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") 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 // ReVanced
implementation("app.revanced:revanced-patcher:7.1.0") implementation("app.revanced:revanced-patcher:7.1.0")

View File

@ -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')"
]
}
}

View File

@ -7,6 +7,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.compose.domain.manager.PreferencesManager 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.destination.Destination
import app.revanced.manager.compose.ui.screen.* import app.revanced.manager.compose.ui.screen.*
import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme import app.revanced.manager.compose.ui.theme.ReVancedManagerTheme
@ -22,6 +23,7 @@ import org.koin.core.parameter.parametersOf
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val bundleRepository: BundleRepository by inject()
private val mainScope = MainScope() private val mainScope = MainScope()
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ -30,6 +32,8 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
bundleRepository.onAppStart(this@MainActivity)
val context = this val context = this
mainScope.launch(Dispatchers.IO) { mainScope.launch(Dispatchers.IO) {
PM.loadApps(context) PM.loadApps(context)

View File

@ -18,8 +18,10 @@ class ManagerApplication : Application() {
preferencesModule, preferencesModule,
repositoryModule, repositoryModule,
serviceModule, serviceModule,
managerModule,
workerModule, workerModule,
viewModelModule, viewModelModule,
databaseModule,
) )
} }
} }

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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,
)

View File

@ -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())
}
}

View File

@ -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)
}

View File

@ -2,12 +2,16 @@ package app.revanced.manager.compose.di
import app.revanced.manager.compose.domain.repository.ReVancedRepository import app.revanced.manager.compose.domain.repository.ReVancedRepository
import app.revanced.manager.compose.network.api.ManagerAPI 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.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
val repositoryModule = module { val repositoryModule = module {
singleOf(::ReVancedRepository) singleOf(::ReVancedRepository)
singleOf(::ManagerAPI) singleOf(::ManagerAPI)
singleOf(::PatchesRepository) singleOf(::BundleRepository)
singleOf(::SourcePersistenceRepository)
singleOf(::SourceRepository)
} }

View File

@ -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.HttpService
import app.revanced.manager.compose.network.service.ReVancedService 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.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
@ -17,5 +16,4 @@ val serviceModule = module {
single { provideReVancedService(get()) } single { provideReVancedService(get()) }
singleOf(::HttpService) singleOf(::HttpService)
singleOf(::SignerService)
} }

View File

@ -1,10 +1,6 @@
package app.revanced.manager.compose.di package app.revanced.manager.compose.di
import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel import app.revanced.manager.compose.ui.viewmodel.*
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 org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.dsl.module import org.koin.dsl.module
@ -13,11 +9,12 @@ val viewModelModule = module {
viewModel { viewModel {
PatchesSelectorViewModel( PatchesSelectorViewModel(
packageInfo = it.get(), packageInfo = it.get(),
patchesRepository = get() bundleRepository = get()
) )
} }
viewModelOf(::SettingsViewModel) viewModelOf(::SettingsViewModel)
viewModelOf(::AppSelectorViewModel) viewModelOf(::AppSelectorViewModel)
viewModelOf(::SourcesScreenViewModel)
viewModel { viewModel {
InstallerScreenViewModel( InstallerScreenViewModel(
input = it.get(), input = it.get(),

View File

@ -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)
}
}
}
}
}

View File

@ -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 }
}

View File

@ -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() }
}

View File

@ -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 })
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}

View File

@ -7,11 +7,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import app.revanced.manager.compose.domain.repository.ReVancedRepository import app.revanced.manager.compose.domain.repository.ReVancedRepository
import app.revanced.manager.compose.util.ghIntegrations import app.revanced.manager.compose.util.*
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 io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -40,36 +36,19 @@ class ManagerAPI(
downloadProgress = null downloadProgress = null
} }
suspend fun downloadPatchBundle(): File? { private suspend fun patchesAsset() = revancedRepository.findAsset(ghPatches, ".jar")
try { private suspend fun integrationsAsset() = revancedRepository.findAsset(ghIntegrations, ".apk")
val downloadUrl = revancedRepository.findAsset(ghPatches, ".jar").downloadUrl
val patchesFile = app.filesDir.resolve("patch-bundles").also { it.mkdirs() }
.resolve("patchbundle.jar")
downloadAsset(downloadUrl, patchesFile)
return patchesFile suspend fun getLatestBundleVersion() = patchesAsset().version to integrationsAsset().version
} catch (e: Exception) {
Log.e(tag, "Failed to download patch bundle", e)
app.toast("Failed to download patch bundle")
}
return null suspend fun downloadBundle(patchBundle: File, integrations: File): Pair<String, String> {
} val patchBundleAsset = patchesAsset()
val integrationsAsset = integrationsAsset()
suspend fun downloadIntegrations(): File? { downloadAsset(patchBundleAsset.downloadUrl, patchBundle)
try { downloadAsset(integrationsAsset.downloadUrl, integrations)
val downloadUrl = revancedRepository.findAsset(ghIntegrations, ".apk").downloadUrl
val integrationsFile = app.filesDir.resolve("integrations").also { it.mkdirs() }
.resolve("integrations.apk")
downloadAsset(downloadUrl, integrationsFile)
return integrationsFile return patchBundleAsset.version to integrationsAsset.version
} catch (e: Exception) {
Log.e(tag, "Failed to download integrations", e)
app.toast("Failed to download integrations")
}
return null
} }
suspend fun downloadManager(): File? { suspend fun downloadManager(): File? {
@ -87,4 +66,5 @@ class ManagerAPI(
return null return null
} }
} }
class MissingAssetException : Exception() class MissingAssetException : Exception()

View File

@ -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.ReVancedReleases
import app.revanced.manager.compose.network.dto.ReVancedRepositories import app.revanced.manager.compose.network.dto.ReVancedRepositories
import app.revanced.manager.compose.network.utils.APIResponse 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 app.revanced.manager.compose.util.apiURL
import io.ktor.client.request.* import io.ktor.client.request.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -31,10 +31,12 @@ class ReVancedService(
} }
suspend fun findAsset(repo: String, file: String): Assets { 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 -> val asset = releases.tools.find { asset ->
(asset.name.contains(file) && asset.repository.contains(repo)) (asset.name.contains(file) && asset.repository.contains(repo))
} ?: throw MissingAssetException() } ?: throw MissingAssetException()
return Assets(asset.repository, asset.version, asset.timestamp, asset.name,asset.size, asset.downloadUrl, asset.content_type) return Assets(asset.repository, asset.version, asset.timestamp, asset.name,asset.size, asset.downloadUrl, asset.content_type)
} }

View File

@ -14,9 +14,9 @@ sealed interface APIResponse<T> {
data class Failure<T>(val error: APIFailure) : 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( inline fun <T, R> APIResponse<T>.fold(
success: (T) -> R, success: (T) -> R,
@ -32,7 +32,7 @@ inline fun <T, R> APIResponse<T>.fold(
inline fun <T, R> APIResponse<T>.fold( inline fun <T, R> APIResponse<T>.fold(
success: (T) -> R, success: (T) -> R,
fail: (Error) -> R, fail: (Exception) -> R,
): R { ): R {
return when (this) { return when (this) {
is APIResponse.Success -> success(data) is APIResponse.Success -> success(data)

View File

@ -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)
}

View File

@ -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.manager.compose.patcher.PatchClass
import app.revanced.patcher.Patcher import app.revanced.patcher.Patcher
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
@ -8,17 +9,21 @@ import dalvik.system.PathClassLoader
import java.io.File import java.io.File
class PatchBundle(private val loader: Iterable<PatchClass>, val integrations: 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> { object : Iterable<PatchClass> {
private val bundle = PatchBundle.Dex( private val bundle = PatchBundle.Dex(
bundleJar, bundleJar.absolutePath,
PathClassLoader(bundleJar, Patcher::class.java.classLoader) PathClassLoader(bundleJar.absolutePath, Patcher::class.java.classLoader)
) )
override fun iterator() = bundle.loadPatches().iterator() override fun iterator() = bundle.loadPatches().iterator()
}, },
integrations 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. * @return A list of patches that are compatible with this Apk.

View File

@ -4,12 +4,13 @@ import android.content.Context
import android.util.Log import android.util.Log
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters 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.Session
import app.revanced.manager.compose.patcher.aapt.Aapt 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 app.revanced.patcher.extensions.PatchExtensions.patchName
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -19,13 +20,13 @@ import java.io.FileNotFoundException
// TODO: setup wakelock + notification so android doesn't murder us. // TODO: setup wakelock + notification so android doesn't murder us.
class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters), class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters),
KoinComponent { KoinComponent {
private val patchesRepository: PatchesRepository by inject() private val bundleRepository: BundleRepository by inject()
@Serializable @Serializable
data class Args( data class Args(
val input: String, val input: String,
val output: String, val output: String,
val selectedPatches: List<String>, val selectedPatches: PatchesSelection,
val packageName: String, val packageName: String,
val packageVersion: String val packageVersion: String
) )
@ -46,12 +47,17 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineW
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!) val args = Json.decodeFromString<Args>(inputData.getString(ARGS_KEY)!!)
val selected = args.selectedPatches.toSet()
val patchList = patchesRepository.loadPatchClassesFiltered(args.packageName) val bundles = bundleRepository.bundles.value
.filter { selected.contains(it.patchName) } 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) { suspend fun updateProgress(progress: Progress) {
progressManager.handle(progress) progressManager.handle(progress)
@ -64,7 +70,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) : CoroutineW
Session(applicationContext.cacheDir.path, frameworkPath, aaptPath, File(args.input)) { Session(applicationContext.cacheDir.path, frameworkPath, aaptPath, File(args.input)) {
updateProgress(it) updateProgress(it)
}.use { session -> }.use { session ->
session.run(File(args.output), patchList, patchesRepository.getIntegrations()) session.run(File(args.output), patchList, integrations)
} }
Log.i("revanced-worker", "Patching succeeded") Log.i("revanced-worker", "Patching succeeded")

View File

@ -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()
}
}

View File

@ -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")
}
}
}

View File

@ -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")
}
}
}
}
}

View File

@ -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)
}
}
)
}

View File

@ -2,6 +2,7 @@ package app.revanced.manager.compose.ui.destination
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.compose.util.PackageInfo import app.revanced.manager.compose.util.PackageInfo
import app.revanced.manager.compose.util.PatchesSelection
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface Destination : Parcelable { sealed interface Destination : Parcelable {
@ -19,5 +20,5 @@ sealed interface Destination : Parcelable {
data class PatchesSelector(val input: PackageInfo) : Destination data class PatchesSelector(val input: PackageInfo) : Destination
@Parcelize @Parcelize
data class Installer(val input: PackageInfo, val selectedPatches: List<String>) : Destination data class Installer(val input: PackageInfo, val selectedPatches: PatchesSelection) : Destination
} }

View File

@ -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.AppTopBar
import app.revanced.manager.compose.ui.component.GroupHeader import app.revanced.manager.compose.ui.component.GroupHeader
import app.revanced.manager.compose.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.compose.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.compose.util.PatchesSelection
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
const val allowUnsupported = false const val allowUnsupported = false
@ -33,7 +34,7 @@ const val allowUnsupported = false
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun PatchesSelectorScreen( fun PatchesSelectorScreen(
startPatching: (List<String>) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel startPatching: (PatchesSelection) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel
) { ) {
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -56,7 +57,7 @@ fun PatchesSelectorScreen(
}, floatingActionButton = { }, floatingActionButton = {
ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) }, ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) },
icon = { Icon(Icons.Default.Build, null) }, icon = { Icon(Icons.Default.Build, null) },
onClick = { startPatching(vm.selectedPatches.toList()) }) onClick = { startPatching(vm.generateSelection()) })
}) { paddingValues -> }) { paddingValues ->
Column(Modifier.fillMaxSize().padding(paddingValues)) { Column(Modifier.fillMaxSize().padding(paddingValues)) {
TabRow( TabRow(
@ -80,26 +81,26 @@ fun PatchesSelectorScreen(
userScrollEnabled = true, userScrollEnabled = true,
pageContent = { index -> pageContent = { index ->
val bundle = bundles[index] val (bundleName, supportedPatches, unsupportedPatches) = bundles[index]
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items( items(
items = bundle.supported items = supportedPatches
) { patch -> ) { patch ->
PatchItem( PatchItem(
patch = patch, patch = patch,
onOptionsDialog = vm::openOptionsDialog, onOptionsDialog = vm::openOptionsDialog,
onToggle = { onToggle = {
vm.togglePatch(patch) vm.togglePatch(bundleName, patch)
}, },
selected = vm.isSelected(patch), selected = vm.isSelected(bundleName, patch),
supported = true supported = true
) )
} }
if (bundle.unsupported.isNotEmpty()) { if (unsupportedPatches.isNotEmpty()) {
item { item {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp).padding(end = 10.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp).padding(end = 10.dp),
@ -116,16 +117,16 @@ fun PatchesSelectorScreen(
} }
items( items(
items = bundle.unsupported, items = unsupportedPatches,
// key = { it.name } // key = { it.name }
) { patch -> ) { patch ->
PatchItem( PatchItem(
patch = patch, patch = patch,
onOptionsDialog = vm::openOptionsDialog, onOptionsDialog = vm::openOptionsDialog,
onToggle = { onToggle = {
vm.togglePatch(patch) vm.togglePatch(bundleName, patch)
}, },
selected = vm.isSelected(patch), selected = vm.isSelected(bundleName, patch),
supported = allowUnsupported supported = allowUnsupported
) )
} }

View File

@ -1,21 +1,66 @@
package app.revanced.manager.compose.ui.screen package app.revanced.manager.compose.ui.screen
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.*
import androidx.compose.material3.Text import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.compose.R 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 @Composable
fun SourcesScreen() { fun SourcesScreen(vm: SourcesScreenViewModel = getViewModel()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { var showNewSourceDialog by rememberSaveable { mutableStateOf(false) }
Text( val scope = rememberCoroutineScope()
text = stringResource(R.string.no_sources_set),
style = MaterialTheme.typography.titleLarge 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.")
}
} }
} }

View File

@ -23,6 +23,7 @@ import app.revanced.manager.compose.service.InstallService
import app.revanced.manager.compose.service.UninstallService import app.revanced.manager.compose.service.UninstallService
import app.revanced.manager.compose.util.PM import app.revanced.manager.compose.util.PM
import app.revanced.manager.compose.util.PackageInfo import app.revanced.manager.compose.util.PackageInfo
import app.revanced.manager.compose.util.PatchesSelection
import app.revanced.manager.compose.util.toast import app.revanced.manager.compose.util.toast
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -31,11 +32,15 @@ import java.nio.file.Files
class InstallerScreenViewModel( class InstallerScreenViewModel(
input: PackageInfo, input: PackageInfo,
selectedPatches: List<String>, selectedPatches: PatchesSelection,
private val app: Application, private val app: Application,
private val signerService: SignerService private val signerService: SignerService
) : ViewModel() { ) : 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 private set
val packageName = input.packageName val packageName = input.packageName
@ -55,7 +60,6 @@ class InstallerScreenViewModel(
val canInstall by derivedStateOf { patcherStatus == true && !isInstalling } val canInstall by derivedStateOf { patcherStatus == true && !isInstalling }
private val patcherWorker = private val patcherWorker =
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setInputData( .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setInputData(

View File

@ -1,47 +1,44 @@
package app.revanced.manager.compose.ui.viewmodel package app.revanced.manager.compose.ui.viewmodel
import androidx.compose.runtime.getValue import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel 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.patcher.patch.PatchInfo
import app.revanced.manager.compose.util.PackageInfo import app.revanced.manager.compose.util.PackageInfo
import app.revanced.manager.compose.util.PatchesSelection
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class PatchesSelectorViewModel(packageInfo: PackageInfo, patchesRepository: PatchesRepository) : class PatchesSelectorViewModel(packageInfo: PackageInfo, bundleRepository: BundleRepository) : ViewModel() {
ViewModel() { val bundlesFlow = bundleRepository.bundles.map { bundles ->
val bundlesFlow = patchesRepository.getPatchInformation().map { patches -> bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) ->
val supported = mutableListOf<PatchInfo>() val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>() val unsupported = mutableListOf<PatchInfo>()
patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach { patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach {
val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported 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 generateSelection(): PatchesSelection = HashMap<String, MutableList<String>>().apply {
fun togglePatch(patch: PatchInfo) { selectedPatches.forEach { (bundleName, patchName) ->
val name = patch.name this.getOrPut(bundleName, ::mutableListOf).add(patchName)
if (isSelected(patch)) selectedPatches.remove(name) else selectedPatches.add(patch.name) }
} }
data class Bundle( data class Bundle(
val name: String, val name: String, val supported: List<PatchInfo>, val unsupported: List<PatchInfo>
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>
) )
var showOptionsDialog by mutableStateOf(false) var showOptionsDialog by mutableStateOf(false)

View File

@ -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()
}
}

View File

@ -8,4 +8,7 @@ const val ghPatcher = "$team/revanced-patcher"
const val ghManager = "$team/revanced-manager" const val ghManager = "$team/revanced-manager"
const val ghIntegrations = "$team/revanced-integrations" const val ghIntegrations = "$team/revanced-integrations"
const val tag = "ReVanced Manager" const val tag = "ReVanced Manager"
const val apiURL = "https://releases.revanced.app" 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

@ -4,10 +4,19 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.net.toUri 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) { fun Context.openUrl(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply { 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) { fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, string, duration).show() 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()
}
}
}

View 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>

View File

@ -55,7 +55,10 @@
<string name="storage">Storage</string> <string name="storage">Storage</string>
<string name="tab_apps">Apps</string> <string name="tab_apps">Apps</string>
<string name="tab_sources">Sources</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="no_patched_apps_found">No patched apps found</string>
<string name="unsupported_app">Unsupported app</string> <string name="unsupported_app">Unsupported app</string>
<string name="unsupported_patches">Unsupported patches</string> <string name="unsupported_patches">Unsupported patches</string>

View File

@ -1,4 +1,5 @@
plugins { plugins {
id("com.android.application") version "8.0.1" apply false id("com.android.application") version "8.0.1" apply false
id("org.jetbrains.kotlin.android") version "1.8.21" 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
} }