feat: patch bundle sources system (#24)

This commit is contained in:
Ax333l 2023-05-26 14:58:14 +02:00 committed by GitHub
parent a4842c078b
commit c22371e0c5
40 changed files with 1064 additions and 170 deletions

View file

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

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.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)

View file

@ -18,8 +18,10 @@ class ManagerApplication : Application() {
preferencesModule,
repositoryModule,
serviceModule,
managerModule,
workerModule,
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.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)
}

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

View file

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

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.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()

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

View file

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

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

View file

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

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

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

View file

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

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.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(

View file

@ -1,18 +1,16 @@
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 ->
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>()
@ -22,26 +20,25 @@ class PatchesSelectorViewModel(packageInfo: PackageInfo, patchesRepository: Patc
targetList.add(it)
}
listOf(
Bundle(
name = "official",
supported, unsupported
)
)
Bundle(name, 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)

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

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

View file

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

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="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>

View file

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