From 91c11da363705e71df740ab487b42bb786163e94 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 26 May 2023 14:58:14 +0200 Subject: [PATCH] feat: patch bundle sources system (#24) --- app/build.gradle.kts | 13 ++ .../1.json | 68 ++++++++ .../revanced/manager/compose/MainActivity.kt | 4 + .../manager/compose/ManagerApplication.kt | 2 + .../manager/compose/data/room/AppDatabase.kt | 13 ++ .../manager/compose/data/room/Converters.kt | 16 ++ .../compose/data/room/sources/SourceDao.kt | 24 +++ .../compose/data/room/sources/SourceEntity.kt | 31 ++++ .../manager/compose/di/DatabaseModule.kt | 15 ++ .../manager/compose/di/ManagerModule.kt | 10 ++ .../manager/compose/di/RepositoryModule.kt | 8 +- .../manager/compose/di/ServiceModule.kt | 2 - .../manager/compose/di/ViewModelModule.kt | 9 +- .../domain/repository/BundleRepository.kt | 50 ++++++ .../repository/SourcePersistenceRepository.kt | 57 +++++++ .../domain/repository/SourceRepository.kt | 92 +++++++++++ .../compose/domain/sources/LocalSource.kt | 25 +++ .../compose/domain/sources/RemoteSource.kt | 28 ++++ .../manager/compose/domain/sources/Source.kt | 51 ++++++ .../manager/compose/network/api/ManagerAPI.kt | 42 ++--- .../network/service/ReVancedService.kt | 6 +- .../compose/network/utils/APIResponse.kt | 6 +- .../data/repository/PatchesRepository.kt | 53 ------- .../patcher/{data => patch}/PatchBundle.kt | 15 +- .../compose/patcher/worker/PatcherWorker.kt | 24 +-- .../compose/ui/component/FileSelector.kt | 21 +++ .../component/sources/LocalBundleSelectors.kt | 32 ++++ .../ui/component/sources/NewSourceDialog.kt | 101 ++++++++++++ .../ui/component/sources/SourceItem.kt | 146 ++++++++++++++++++ .../compose/ui/destination/Destination.kt | 3 +- .../ui/screen/PatchesSelectorScreen.kt | 21 +-- .../compose/ui/screen/SourcesScreen.kt | 69 +++++++-- .../ui/viewmodel/InstallerScreenViewModel.kt | 10 +- .../ui/viewmodel/PatchesSelectorViewModel.kt | 53 +++---- .../ui/viewmodel/SourcesScreenViewModel.kt | 50 ++++++ .../manager/compose/util/Constants.kt | 5 +- .../app/revanced/manager/compose/util/Util.kt | 46 +++++- app/src/main/res/values/plurals.xml | 7 + app/src/main/res/values/strings.xml | 5 +- build.gradle.kts | 1 + 40 files changed, 1064 insertions(+), 170 deletions(-) create mode 100644 app/schemas/app.revanced.manager.compose.data.room.AppDatabase/1.json create mode 100644 app/src/main/java/app/revanced/manager/compose/data/room/AppDatabase.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/data/room/Converters.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceDao.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceEntity.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/di/DatabaseModule.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/di/ManagerModule.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/repository/BundleRepository.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/repository/SourcePersistenceRepository.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/repository/SourceRepository.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/sources/LocalSource.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/sources/RemoteSource.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/domain/sources/Source.kt delete mode 100644 app/src/main/java/app/revanced/manager/compose/patcher/data/repository/PatchesRepository.kt rename app/src/main/java/app/revanced/manager/compose/patcher/{data => patch}/PatchBundle.kt (73%) create mode 100644 app/src/main/java/app/revanced/manager/compose/ui/component/FileSelector.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/ui/component/sources/LocalBundleSelectors.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/ui/component/sources/NewSourceDialog.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/ui/component/sources/SourceItem.kt create mode 100644 app/src/main/java/app/revanced/manager/compose/ui/viewmodel/SourcesScreenViewModel.kt create mode 100644 app/src/main/res/values/plurals.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a6fea23..04fff67 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/schemas/app.revanced.manager.compose.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.compose.data.room.AppDatabase/1.json new file mode 100644 index 0000000..bddce40 --- /dev/null +++ b/app/schemas/app.revanced.manager.compose.data.room.AppDatabase/1.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt index 32f7bb9..13da7ce 100644 --- a/app/src/main/java/app/revanced/manager/compose/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/compose/MainActivity.kt @@ -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) diff --git a/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt index 8db0d4b..0a45281 100644 --- a/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/compose/ManagerApplication.kt @@ -18,8 +18,10 @@ class ManagerApplication : Application() { preferencesModule, repositoryModule, serviceModule, + managerModule, workerModule, viewModelModule, + databaseModule, ) } } diff --git a/app/src/main/java/app/revanced/manager/compose/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/compose/data/room/AppDatabase.kt new file mode 100644 index 0000000..72714e2 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/data/room/AppDatabase.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/compose/data/room/Converters.kt new file mode 100644 index 0000000..9f25029 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/data/room/Converters.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceDao.kt b/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceDao.kt new file mode 100644 index 0000000..9d56967 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceDao.kt @@ -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 + + @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) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceEntity.kt b/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceEntity.kt new file mode 100644 index 0000000..0e22cbb --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/data/room/sources/SourceEntity.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/DatabaseModule.kt b/app/src/main/java/app/revanced/manager/compose/di/DatabaseModule.kt new file mode 100644 index 0000000..44aefd5 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/DatabaseModule.kt @@ -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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/ManagerModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ManagerModule.kt new file mode 100644 index 0000000..21098ec --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/di/ManagerModule.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt index 5376f80..6dbe896 100644 --- a/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/compose/di/RepositoryModule.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt index db041b7..353e6a2 100644 --- a/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/compose/di/ServiceModule.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt index a11ec2e..9ea8df7 100644 --- a/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/compose/di/ViewModelModule.kt @@ -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(), diff --git a/app/src/main/java/app/revanced/manager/compose/domain/repository/BundleRepository.kt b/app/src/main/java/app/revanced/manager/compose/domain/repository/BundleRepository.kt new file mode 100644 index 0000000..5a8c70e --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/repository/BundleRepository.kt @@ -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>(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() + _bundles.emit(map) + + events.collect { (name, new) -> + map[name] = new + _bundles.emit(map) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/domain/repository/SourcePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/compose/domain/repository/SourcePersistenceRepository.kt new file mode 100644 index 0000000..58bedf1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/repository/SourcePersistenceRepository.kt @@ -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 { + 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 } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/domain/repository/SourceRepository.kt b/app/src/main/java/app/revanced/manager/compose/domain/repository/SourceRepository.kt new file mode 100644 index 0000000..01a5494 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/repository/SourceRepository.kt @@ -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> = MutableStateFlow(emptyMap()) + val sources = _sources.asStateFlow() + + suspend fun redownloadRemoteSources() = + sources.value.values.filterIsInstance().forEach { it.downloadLatest() } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/domain/sources/LocalSource.kt b/app/src/main/java/app/revanced/manager/compose/domain/sources/LocalSource.kt new file mode 100644 index 0000000..c117182 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/sources/LocalSource.kt @@ -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 }) + } + } +} diff --git a/app/src/main/java/app/revanced/manager/compose/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/compose/domain/sources/RemoteSource.kt new file mode 100644 index 0000000..0b40efc --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/sources/RemoteSource.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/compose/domain/sources/Source.kt new file mode 100644 index 0000000..63e30a3 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/domain/sources/Source.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt b/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt index 2d5e766..e9ec220 100644 --- a/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt +++ b/app/src/main/java/app/revanced/manager/compose/network/api/ManagerAPI.kt @@ -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 { + 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() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt b/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt index a05ff25..2c7184f 100644 --- a/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt +++ b/app/src/main/java/app/revanced/manager/compose/network/service/ReVancedService.kt @@ -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) } diff --git a/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt b/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt index 7c28e15..2b5a284 100644 --- a/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt +++ b/app/src/main/java/app/revanced/manager/compose/network/utils/APIResponse.kt @@ -14,9 +14,9 @@ sealed interface APIResponse { data class Failure(val error: APIFailure) : APIResponse } -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 APIResponse.fold( success: (T) -> R, @@ -32,7 +32,7 @@ inline fun APIResponse.fold( inline fun APIResponse.fold( success: (T) -> R, - fail: (Error) -> R, + fail: (Exception) -> R, ): R { return when (this) { is APIResponse.Success -> success(data) diff --git a/app/src/main/java/app/revanced/manager/compose/patcher/data/repository/PatchesRepository.kt b/app/src/main/java/app/revanced/manager/compose/patcher/data/repository/PatchesRepository.kt deleted file mode 100644 index 4ffa3c6..0000000 --- a/app/src/main/java/app/revanced/manager/compose/patcher/data/repository/PatchesRepository.kt +++ /dev/null @@ -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>(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) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/patcher/data/PatchBundle.kt b/app/src/main/java/app/revanced/manager/compose/patcher/patch/PatchBundle.kt similarity index 73% rename from app/src/main/java/app/revanced/manager/compose/patcher/data/PatchBundle.kt rename to app/src/main/java/app/revanced/manager/compose/patcher/patch/PatchBundle.kt index f7b511b..6cd616b 100644 --- a/app/src/main/java/app/revanced/manager/compose/patcher/data/PatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/compose/patcher/patch/PatchBundle.kt @@ -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, val integrations: File?) { - constructor(bundleJar: String, integrations: File?) : this( + constructor(bundleJar: File, integrations: File?) : this( object : Iterable { 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. diff --git a/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherWorker.kt index f091b9c..7f00c38 100644 --- a/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/compose/patcher/worker/PatcherWorker.kt @@ -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, + 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(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") diff --git a/app/src/main/java/app/revanced/manager/compose/ui/component/FileSelector.kt b/app/src/main/java/app/revanced/manager/compose/ui/component/FileSelector.kt new file mode 100644 index 0000000..241c739 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/ui/component/FileSelector.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/component/sources/LocalBundleSelectors.kt b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/LocalBundleSelectors.kt new file mode 100644 index 0000000..7b55816 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/LocalBundleSelectors.kt @@ -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") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/component/sources/NewSourceDialog.kt b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/NewSourceDialog.kt new file mode 100644 index 0000000..fe2c43f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/NewSourceDialog.kt @@ -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(null) } + var integrations by rememberSaveable { mutableStateOf(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") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/component/sources/SourceItem.kt b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/SourceItem.kt new file mode 100644 index 0000000..3df4d28 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/ui/component/sources/SourceItem.kt @@ -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) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/compose/ui/destination/Destination.kt index b8eb6fa..25f7b7b 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/destination/Destination.kt @@ -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) : Destination + data class Installer(val input: PackageInfo, val selectedPatches: PatchesSelection) : Destination } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/compose/ui/screen/PatchesSelectorScreen.kt index aad023b..613bc0f 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/screen/PatchesSelectorScreen.kt @@ -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) -> 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 ) } diff --git a/app/src/main/java/app/revanced/manager/compose/ui/screen/SourcesScreen.kt b/app/src/main/java/app/revanced/manager/compose/ui/screen/SourcesScreen.kt index 170e2fe..5d5a421 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/screen/SourcesScreen.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/screen/SourcesScreen.kt @@ -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.") + } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt index d6c8b53..6976293 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/InstallerScreenViewModel.kt @@ -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, + selectedPatches: PatchesSelection, private val app: Application, private val signerService: SignerService ) : ViewModel() { - var stepGroups by mutableStateOf>(PatcherProgressManager.generateGroupsList(app, selectedPatches)) + var stepGroups by mutableStateOf>( + 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( diff --git a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/PatchesSelectorViewModel.kt index c1d72f4..4026664 100644 --- a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/PatchesSelectorViewModel.kt @@ -1,47 +1,44 @@ package app.revanced.manager.compose.ui.viewmodel -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.lifecycle.ViewModel -import app.revanced.manager.compose.patcher.data.repository.PatchesRepository +import app.revanced.manager.compose.domain.repository.BundleRepository import app.revanced.manager.compose.patcher.patch.PatchInfo import app.revanced.manager.compose.util.PackageInfo +import app.revanced.manager.compose.util.PatchesSelection import kotlinx.coroutines.flow.map -class PatchesSelectorViewModel(packageInfo: PackageInfo, patchesRepository: PatchesRepository) : - ViewModel() { - val bundlesFlow = patchesRepository.getPatchInformation().map { patches -> - val supported = mutableListOf() - val unsupported = mutableListOf() +class PatchesSelectorViewModel(packageInfo: PackageInfo, bundleRepository: BundleRepository) : ViewModel() { + val bundlesFlow = bundleRepository.bundles.map { bundles -> + bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) -> + val supported = mutableListOf() + val unsupported = mutableListOf() - patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach { - val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported + patches.filter { it.compatibleWith(packageInfo.packageName) }.forEach { + val targetList = if (it.supportsVersion(packageInfo.packageName)) supported else unsupported - targetList.add(it) + targetList.add(it) + } + + Bundle(name, supported, unsupported) } - - listOf( - Bundle( - name = "official", - supported, unsupported - ) - ) } - val selectedPatches = mutableStateListOf() + private val selectedPatches = mutableStateListOf>() + 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>().apply { + selectedPatches.forEach { (bundleName, patchName) -> + this.getOrPut(bundleName, ::mutableListOf).add(patchName) + } } data class Bundle( - val name: String, - val supported: List, - val unsupported: List + val name: String, val supported: List, val unsupported: List ) var showOptionsDialog by mutableStateOf(false) diff --git a/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/SourcesScreenViewModel.kt b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/SourcesScreenViewModel.kt new file mode 100644 index 0000000..1d7fe50 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/compose/ui/viewmodel/SourcesScreenViewModel.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/Constants.kt b/app/src/main/java/app/revanced/manager/compose/util/Constants.kt index f204fd6..a742542 100644 --- a/app/src/main/java/app/revanced/manager/compose/util/Constants.kt +++ b/app/src/main/java/app/revanced/manager/compose/util/Constants.kt @@ -8,4 +8,7 @@ const val ghPatcher = "$team/revanced-patcher" const val ghManager = "$team/revanced-manager" const val ghIntegrations = "$team/revanced-integrations" const val tag = "ReVanced Manager" -const val apiURL = "https://releases.revanced.app" \ No newline at end of file +const val apiURL = "https://releases.revanced.app" + +const val JAR_MIMETYPE = "application/java-archive" +const val APK_MIMETYPE = "application/vnd.android.package-archive" \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/compose/util/Util.kt b/app/src/main/java/app/revanced/manager/compose/util/Util.kt index 4385c67..6d71edc 100644 --- a/app/src/main/java/app/revanced/manager/compose/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/compose/util/Util.kt @@ -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> 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..7a5bdd9 --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,7 @@ + + + + %d Patch + %d Patches + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa218a6..8cce8c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,7 +55,10 @@ Storage Apps Sources - No sources set + Reload all sources + Failed to download patch bundle: %s + Failed to load updated patch bundle: %s + Failed to update integrations: %s No patched apps found Unsupported app Unsupported patches diff --git a/build.gradle.kts b/build.gradle.kts index e58637e..21183cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 }