From b51d1ee47a0ee29f85e394747b0f264a89bf68d9 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Mon, 14 Jul 2025 20:35:27 +0700 Subject: [PATCH 1/6] fix: Transparent status on fullscreen dialog (#2654) --- .../manager/ui/component/FullscreenDialog.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/FullscreenDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/FullscreenDialog.kt index 97fb98d9..539d599c 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/FullscreenDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/FullscreenDialog.kt @@ -1,6 +1,7 @@ package app.revanced.manager.ui.component import android.view.WindowManager +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color @@ -9,6 +10,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat private val properties = DialogProperties( usePlatformDefaultWidth = false, @@ -22,11 +24,17 @@ fun FullscreenDialog(onDismissRequest: () -> Unit, content: @Composable () -> Un onDismissRequest = onDismissRequest, properties = properties ) { - val window = (LocalView.current.parent as DialogWindowProvider).window - LaunchedEffect(Unit) { + val view = LocalView.current + val isDarkTheme = isSystemInDarkTheme() + LaunchedEffect(isDarkTheme) { + val window = (view.parent as DialogWindowProvider).window window.statusBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb() window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) + + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !isDarkTheme + insetsController.isAppearanceLightNavigationBars = !isDarkTheme } content() From 789f9ec86747d5a8b48807737334df3ec3d1fdcd Mon Sep 17 00:00:00 2001 From: Ax333l Date: Tue, 15 Jul 2025 14:28:40 +0200 Subject: [PATCH 2/6] feat: allow bundles to use classes from other bundles (#1951) --- .../app/revanced/manager/data/redux/Redux.kt | 74 ++++ .../data/room/bundles/PatchBundleDao.kt | 17 +- .../data/room/bundles/PatchBundleEntity.kt | 4 +- .../revanced/manager/di/RepositoryModule.kt | 1 - .../revanced/manager/di/ViewModelModule.kt | 1 + .../domain/bundles/LocalPatchBundle.kt | 22 +- .../domain/bundles/PatchBundleSource.kt | 101 +---- .../domain/bundles/RemotePatchBundle.kt | 82 ++-- .../PatchBundlePersistenceRepository.kt | 58 --- .../repository/PatchBundleRepository.kt | 402 ++++++++++++++---- .../manager/patcher/patch/PatchBundle.kt | 128 +++--- .../manager/patcher/patch/PatchBundleInfo.kt | 145 +++++++ .../patcher/runtime/CoroutineRuntime.kt | 14 +- .../manager/patcher/runtime/ProcessRuntime.kt | 12 +- .../patcher/runtime/process/Parameters.kt | 3 +- .../patcher/runtime/process/PatcherProcess.kt | 7 +- .../ui/component/bundle/BaseBundleDialog.kt | 198 --------- .../bundle/BundleInformationDialog.kt | 261 +++++++++--- .../manager/ui/component/bundle/BundleItem.kt | 34 +- .../component/bundle/BundlePatchesDialog.kt | 29 +- .../ui/component/bundle/BundleSelector.kt | 20 +- .../ui/component/bundle/ImportBundleDialog.kt | 12 +- .../revanced/manager/ui/model/BundleInfo.kt | 111 ----- .../manager/ui/screen/BundleListScreen.kt | 95 +++-- .../manager/ui/screen/DashboardScreen.kt | 30 +- .../ui/screen/PatchesSelectorScreen.kt | 2 +- .../ui/screen/RequiredOptionsScreen.kt | 3 +- .../settings/DownloadsSettingsScreen.kt | 268 ++++++------ .../settings/ImportExportSettingsScreen.kt | 16 +- .../ui/viewmodel/BundleListViewModel.kt | 76 ++++ .../ui/viewmodel/DashboardViewModel.kt | 58 +-- .../ui/viewmodel/PatchesSelectorViewModel.kt | 17 +- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 67 +-- .../main/java/app/revanced/manager/util/PM.kt | 6 +- .../java/app/revanced/manager/util/Util.kt | 6 +- app/src/main/res/values/strings.xml | 6 +- 36 files changed, 1324 insertions(+), 1062 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/data/redux/Redux.kt delete mode 100644 app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt create mode 100644 app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt diff --git a/app/src/main/java/app/revanced/manager/data/redux/Redux.kt b/app/src/main/java/app/revanced/manager/data/redux/Redux.kt new file mode 100644 index 00000000..785dedc4 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/redux/Redux.kt @@ -0,0 +1,74 @@ +package app.revanced.manager.data.redux + +import android.util.Log +import app.revanced.manager.util.tag +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull + +// This file implements React Redux-like state management. + +class Store(private val coroutineScope: CoroutineScope, initialState: S) : ActionContext { + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + // Do not touch these without the lock. + private var isRunningActions = false + private val queueChannel = Channel>(capacity = 10) + private val lock = Mutex() + + suspend fun dispatch(action: Action) = lock.withLock { + Log.d(tag, "Dispatching $action") + queueChannel.send(action) + + if (isRunningActions) return@withLock + isRunningActions = true + coroutineScope.launch { + runActions() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun runActions() { + while (true) { + val action = withTimeoutOrNull(200L) { queueChannel.receive() } + if (action == null) { + Log.d(tag, "Stopping action runner") + lock.withLock { + // New actions may be dispatched during the timeout. + isRunningActions = !queueChannel.isEmpty + if (!isRunningActions) return + } + continue + } + + Log.d(tag, "Running $action") + _state.value = try { + with(action) { this@Store.execute(_state.value) } + } catch (c: CancellationException) { + // This is done without the lock, but cancellation usually means the store is no longer needed. + isRunningActions = false + throw c + } catch (e: Exception) { + action.catch(e) + continue + } + } + } +} + +interface ActionContext + +interface Action { + suspend fun ActionContext.execute(current: S): S + suspend fun catch(exception: Exception) { + Log.e(tag, "Got exception while executing $this", exception) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index bf8b6224..38513365 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -1,25 +1,15 @@ package app.revanced.manager.data.room.bundles import androidx.room.* -import kotlinx.coroutines.flow.Flow @Dao interface PatchBundleDao { @Query("SELECT * FROM patch_bundles") suspend fun all(): List - @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid") - fun getPropsById(uid: Int): Flow - @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid") suspend fun updateVersionHash(uid: Int, patches: String?) - @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") - suspend fun setAutoUpdate(uid: Int, value: Boolean) - - @Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid") - suspend fun setName(uid: Int, value: String) - @Query("DELETE FROM patch_bundles WHERE uid != 0") suspend fun purgeCustomBundles() @@ -32,6 +22,9 @@ interface PatchBundleDao { @Query("DELETE FROM patch_bundles WHERE uid = :uid") suspend fun remove(uid: Int) - @Insert - suspend fun add(source: PatchBundleEntity) + @Query("SELECT name, version, auto_update, source FROM patch_bundles WHERE uid = :uid") + suspend fun getProps(uid: Int): PatchBundleProperties? + + @Upsert + suspend fun upsert(source: PatchBundleEntity) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index 8fa6e8a7..9119d500 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt @@ -38,7 +38,9 @@ data class PatchBundleEntity( @ColumnInfo(name = "auto_update") val autoUpdate: Boolean ) -data class BundleProperties( +data class PatchBundleProperties( + @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, + @ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index 159436d4..5fce77ec 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -15,7 +15,6 @@ val repositoryModule = module { createdAtStart() } singleOf(::NetworkInfo) - singleOf(::PatchBundlePersistenceRepository) singleOf(::PatchSelectionRepository) singleOf(::PatchOptionsRepository) singleOf(::PatchBundleRepository) { diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 4846510f..6970e886 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -23,4 +23,5 @@ val viewModelModule = module { viewModelOf(::InstalledAppsViewModel) viewModelOf(::InstalledAppInfoViewModel) viewModelOf(::UpdatesSettingsViewModel) + viewModelOf(::BundleListViewModel) } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt index a940e05d..ec05c8df 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt @@ -1,21 +1,29 @@ package app.revanced.manager.domain.bundles +import app.revanced.manager.data.redux.ActionContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream -class LocalPatchBundle(name: String, id: Int, directory: File) : - PatchBundleSource(name, id, directory) { - suspend fun replace(patches: InputStream) { +class LocalPatchBundle( + name: String, + uid: Int, + error: Throwable?, + directory: File +) : PatchBundleSource(name, uid, error, directory) { + suspend fun ActionContext.replace(patches: InputStream) { withContext(Dispatchers.IO) { patchBundleOutputStream().use { outputStream -> patches.copyTo(outputStream) } } - - reload()?.also { - saveVersionHash(it.patchBundleManifestAttributes?.version) - } } + + override fun copy(error: Throwable?, name: String) = LocalPatchBundle( + name, + uid, + error, + directory + ) } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index bd779dd1..8414e2e1 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -1,22 +1,10 @@ package app.revanced.manager.domain.bundles -import android.app.Application -import android.util.Log -import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import app.revanced.manager.R -import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository +import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.patcher.patch.PatchBundle -import app.revanced.manager.util.tag import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import kotlinx.coroutines.withContext import java.io.File import java.io.OutputStream @@ -24,27 +12,32 @@ import java.io.OutputStream * A [PatchBundle] source. */ @Stable -sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent { - protected val configRepository: PatchBundlePersistenceRepository by inject() - private val app: Application by inject() +sealed class PatchBundleSource( + val name: String, + val uid: Int, + error: Throwable?, + protected val directory: File +) { protected val patchesFile = directory.resolve("patches.jar") - private val _state = MutableStateFlow(load()) - val state = _state.asStateFlow() + val state = when { + error != null -> State.Failed(error) + !hasInstalled() -> State.Missing + else -> State.Available(PatchBundle(patchesFile.absolutePath)) + } - private val _nameFlow = MutableStateFlow(initialName) - val nameFlow = - _nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.patches_name_default else R.string.patches_name_fallback) } } + val patchBundle get() = (state as? State.Available)?.bundle + val version get() = patchBundle?.manifestAttributes?.version + val isNameOutOfDate get() = patchBundle?.manifestAttributes?.name?.let { it != name } == true + val error get() = (state as? State.Failed)?.throwable - suspend fun getName() = nameFlow.first() + suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) { + patchesFile.delete() + } - val versionFlow = state.map { it.patchBundleOrNull()?.patchBundleManifestAttributes?.version } - val patchCountFlow = state.map { it.patchBundleOrNull()?.patches?.size ?: 0 } + abstract fun copy(error: Throwable? = this.error, name: String = this.name): PatchBundleSource - /** - * Returns true if the bundle has been downloaded to local storage. - */ - fun hasInstalled() = patchesFile.exists() + protected fun hasInstalled() = patchesFile.exists() protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) { // Android 14+ requires dex containers to be readonly. @@ -56,62 +49,14 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil } } - private fun load(): State { - if (!hasInstalled()) return State.Missing - - return try { - State.Loaded(PatchBundle(patchesFile)) - } catch (t: Throwable) { - Log.e(tag, "Failed to load patch bundle with UID $uid", t) - State.Failed(t) - } - } - - suspend fun reload(): PatchBundle? { - val newState = load() - _state.value = newState - - val bundle = newState.patchBundleOrNull() - // Try to read the name from the patch bundle manifest if the bundle does not have a name. - if (bundle != null && _nameFlow.value.isEmpty()) { - bundle.patchBundleManifestAttributes?.name?.let { setName(it) } - } - - return bundle - } - - /** - * Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource]. - * The flow will emit null if the associated [PatchBundleSource] is deleted. - */ - fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default) - suspend fun getProps() = propsFlow().first()!! - - suspend fun currentVersionHash() = getProps().versionHash - protected suspend fun saveVersionHash(version: String?) = - configRepository.updateVersionHash(uid, version) - - suspend fun setName(name: String) { - configRepository.setName(uid, name) - _nameFlow.value = name - } - sealed interface State { - fun patchBundleOrNull(): PatchBundle? = null - data object Missing : State data class Failed(val throwable: Throwable) : State - data class Loaded(val bundle: PatchBundle) : State { - override fun patchBundleOrNull() = bundle - } + data class Available(val bundle: PatchBundle) : State } companion object Extensions { val PatchBundleSource.isDefault inline get() = uid == 0 val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle - val PatchBundleSource.nameState - @Composable inline get() = nameFlow.collectAsStateWithLifecycle( - "" - ) } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 897312f7..ebec8471 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -1,6 +1,6 @@ package app.revanced.manager.domain.bundles -import androidx.compose.runtime.Stable +import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.service.HttpService @@ -8,15 +8,24 @@ import app.revanced.manager.network.utils.getOrThrow import io.ktor.client.request.url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File -@Stable -sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) : - PatchBundleSource(name, id, directory) { +sealed class RemotePatchBundle( + name: String, + uid: Int, + protected val versionHash: String?, + error: Throwable?, + directory: File, + val endpoint: String, + val autoUpdate: Boolean, +) : PatchBundleSource(name, uid, error, directory), KoinComponent { protected val http: HttpService by inject() protected abstract suspend fun getLatestInfo(): ReVancedAsset + abstract fun copy(error: Throwable? = this.error, name: String = this.name, autoUpdate: Boolean = this.autoUpdate): RemotePatchBundle + override fun copy(error: Throwable?, name: String): RemotePatchBundle = copy(error, name, this.autoUpdate) private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { patchBundleOutputStream().use { @@ -25,47 +34,72 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo } } - saveVersionHash(info.version) - reload() + info.version } - suspend fun downloadLatest() { - download(getLatestInfo()) - } + /** + * Downloads the latest version regardless if there is a new update available. + */ + suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) - suspend fun update(): Boolean = withContext(Dispatchers.IO) { + suspend fun ActionContext.update(): String? = withContext(Dispatchers.IO) { val info = getLatestInfo() - if (hasInstalled() && info.version == currentVersionHash()) - return@withContext false + if (hasInstalled() && info.version == versionHash) + return@withContext null download(info) - true } - suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) { - patchesFile.delete() - reload() - } - - suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value) - companion object { const val updateFailMsg = "Failed to update patches" } } -class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : - RemotePatchBundle(name, id, directory, endpoint) { +class JsonPatchBundle( + name: String, + uid: Int, + versionHash: String?, + error: Throwable?, + directory: File, + endpoint: String, + autoUpdate: Boolean, +) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { http.request { url(endpoint) }.getOrThrow() } + + override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = JsonPatchBundle( + name, + uid, + versionHash, + error, + directory, + endpoint, + autoUpdate, + ) } -class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) : - RemotePatchBundle(name, id, directory, endpoint) { +class APIPatchBundle( + name: String, + uid: Int, + versionHash: String?, + error: Throwable?, + directory: File, + endpoint: String, + autoUpdate: Boolean, +) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) { private val api: ReVancedAPI by inject() override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow() + override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = APIPatchBundle( + name, + uid, + versionHash, + error, + directory, + endpoint, + autoUpdate, + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt deleted file mode 100644 index 16fb5976..00000000 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -package app.revanced.manager.domain.repository - -import app.revanced.manager.data.room.AppDatabase -import app.revanced.manager.data.room.AppDatabase.Companion.generateUid -import app.revanced.manager.data.room.bundles.PatchBundleEntity -import app.revanced.manager.data.room.bundles.Source -import kotlinx.coroutines.flow.distinctUntilChanged - -class PatchBundlePersistenceRepository(db: AppDatabase) { - private val dao = db.patchBundleDao() - - suspend fun loadConfiguration(): List { - val all = dao.all() - if (all.isEmpty()) { - dao.add(defaultSource) - return listOf(defaultSource) - } - - return all - } - - suspend fun reset() = dao.reset() - - suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) = - PatchBundleEntity( - uid = generateUid(), - name = name, - versionHash = null, - source = source, - autoUpdate = autoUpdate - ).also { - dao.add(it) - } - - suspend fun delete(uid: Int) = dao.remove(uid) - - /** - * Sets the version hash used for updates. - */ - suspend fun updateVersionHash(uid: Int, versionHash: String?) = - dao.updateVersionHash(uid, versionHash) - - suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value) - - suspend fun setName(uid: Int, name: String) = dao.setName(uid, name) - - fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged() - - private companion object { - val defaultSource = PatchBundleEntity( - uid = 0, - name = "", - versionHash = null, - source = Source.API, - autoUpdate = false - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index 3afe4e39..ab8dbb19 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -3,55 +3,78 @@ package app.revanced.manager.domain.repository import android.app.Application import android.content.Context import android.util.Log +import androidx.annotation.StringRes import app.revanced.library.mostCommonCompatibleVersions import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo +import app.revanced.manager.data.redux.Action +import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.data.redux.Store +import app.revanced.manager.data.room.AppDatabase +import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.bundles.PatchBundleEntity +import app.revanced.manager.data.room.bundles.PatchBundleProperties +import app.revanced.manager.data.room.bundles.Source import app.revanced.manager.domain.bundles.APIPatchBundle import app.revanced.manager.domain.bundles.JsonPatchBundle import app.revanced.manager.data.room.bundles.Source as SourceInfo import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.util.flatMapLatestAndCombine +import app.revanced.manager.patcher.patch.PatchBundle +import app.revanced.manager.patcher.patch.PatchBundleInfo +import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag -import app.revanced.manager.util.uiSafe +import app.revanced.manager.util.toast +import kotlinx.collections.immutable.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.InputStream +import kotlin.collections.joinToString +import kotlin.collections.map +import kotlin.text.ifEmpty class PatchBundleRepository( private val app: Application, - private val persistenceRepo: PatchBundlePersistenceRepository, private val networkInfo: NetworkInfo, private val prefs: PreferencesManager, + db: AppDatabase, ) { + private val dao = db.patchBundleDao() private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE) - private val _sources: MutableStateFlow> = - MutableStateFlow(emptyMap()) - val sources = _sources.map { it.values.toList() } + private val store = Store(CoroutineScope(Dispatchers.Default), State()) - val bundles = sources.flatMapLatestAndCombine( - combiner = { - it.mapNotNull { (uid, state) -> - val bundle = state.patchBundleOrNull() ?: return@mapNotNull null - uid to bundle - }.toMap() + val sources = store.state.map { it.sources.values.toList() } + val bundles = store.state.map { + it.sources.mapNotNull { (uid, src) -> + uid to (src.patchBundle ?: return@mapNotNull null) + }.toMap() + } + val bundleInfoFlow = store.state.map { it.info } + + fun scopedBundleInfoFlow(packageName: String, version: String?) = bundleInfoFlow.map { + it.map { (_, bundleInfo) -> + bundleInfo.forPackage( + packageName, + version + ) } - ) { - it.state.map { state -> it.uid to state } } - val suggestedVersions = bundles.map { + val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } } + + val suggestedVersions = bundleInfoFlow.map { val allPatches = it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet() @@ -74,6 +97,100 @@ class PatchBundleRepository( } } + private suspend inline fun dispatchAction( + name: String, + crossinline block: suspend ActionContext.(current: State) -> State + ) { + store.dispatch(object : Action { + override suspend fun ActionContext.execute(current: State) = block(current) + override fun toString() = name + }) + } + + /** + * Performs a reload. Do not call this outside of a store action. + */ + private suspend fun doReload(): State { + val entities = loadFromDb().onEach { + Log.d(tag, "Bundle: $it") + } + + val sources = entities.associate { it.uid to it.load() }.toPersistentMap() + + val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate } + if (hasOutOfDateNames) dispatchAction( + "Sync names" + ) { state -> + val nameChanges = state.sources.mapNotNull { (_, src) -> + if (!src.isNameOutOfDate) return@mapNotNull null + val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name } + ?: return@mapNotNull null + + src.uid to newName + } + val sources = state.sources.toMutableMap() + val info = state.info.toMutableMap() + nameChanges.forEach { (uid, name) -> + updateDb(uid) { it.copy(name = name) } + sources[uid] = sources[uid]!!.copy(name = name) + info[uid] = info[uid]?.copy(name = name) ?: return@forEach + } + + State(sources.toPersistentMap(), info.toPersistentMap()) + } + val info = loadMetadata(sources).toPersistentMap() + + return State(sources, info) + } + + suspend fun reload() = dispatchAction("Full reload") { + doReload() + } + + private suspend fun loadFromDb(): List { + val all = dao.all() + if (all.isEmpty()) { + dao.upsert(defaultSource) + return listOf(defaultSource) + } + + return all + } + + private suspend fun loadMetadata(sources: Map): Map { + // Map bundles -> sources + val map = sources.mapNotNull { (_, src) -> + (src.patchBundle ?: return@mapNotNull null) to src + }.toMap() + + val metadata = try { + PatchBundle.Loader.metadata(map.keys) + } catch (error: Throwable) { + val uids = map.values.map { it.uid } + + dispatchAction("Mark bundles as failed") { state -> + state.copy(sources = state.sources.mutate { + uids.forEach { uid -> + it[uid] = it[uid]?.copy(error = error) ?: return@forEach + } + }) + } + + Log.e(tag, "Failed to load bundles", error) + emptyMap() + } + + return metadata.entries.associate { (bundle, patches) -> + val src = map[bundle]!! + src.uid to PatchBundleInfo.Global( + src.name, + bundle.manifestAttributes?.version, + src.uid, + patches + ) + } + } + suspend fun isVersionAllowed(packageName: String, version: String) = withContext(Dispatchers.Default) { if (!prefs.suggestedVersionSafeguard.get()) return@withContext true @@ -89,96 +206,211 @@ class PatchBundleRepository( private fun PatchBundleEntity.load(): PatchBundleSource { val dir = directoryOf(uid) + val actualName = + name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.patches_name_fallback) } return when (source) { - is SourceInfo.Local -> LocalPatchBundle(name, uid, dir) - is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL) - is SourceInfo.Remote -> JsonPatchBundle( - name, + is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, dir) + is SourceInfo.API -> APIPatchBundle( + actualName, uid, + versionHash, + null, dir, - source.url.toString() + SourceInfo.API.SENTINEL, + autoUpdate, + ) + + is SourceInfo.Remote -> JsonPatchBundle( + actualName, + uid, + versionHash, + null, + dir, + source.url.toString(), + autoUpdate, ) } } - suspend fun reload() = withContext(Dispatchers.Default) { - val entities = persistenceRepo.loadConfiguration().onEach { - Log.d(tag, "Bundle: $it") + private suspend fun createEntity(name: String, source: Source, autoUpdate: Boolean = false) = + PatchBundleEntity( + uid = generateUid(), + name = name, + versionHash = null, + source = source, + autoUpdate = autoUpdate + ).also { + dao.upsert(it) } - _sources.value = entities.associate { - it.uid to it.load() - } + /** + * Updates a patch bundle in the database. Do not use this outside an action. + */ + private suspend fun updateDb( + uid: Int, + block: (PatchBundleProperties) -> PatchBundleProperties + ) { + val previous = dao.getProps(uid)!! + val new = block(previous) + dao.upsert( + PatchBundleEntity( + uid = uid, + name = new.name, + versionHash = new.versionHash, + source = new.source, + autoUpdate = new.autoUpdate, + ) + ) } - suspend fun reset() = withContext(Dispatchers.Default) { - persistenceRepo.reset() - _sources.value = emptyMap() - bundlesDir.apply { - deleteRecursively() - mkdirs() - } - - reload() + suspend fun reset() = dispatchAction("Reset") { state -> + dao.reset() + state.sources.keys.forEach { directoryOf(it).deleteRecursively() } + doReload() } - suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) { - persistenceRepo.delete(bundle.uid) - directoryOf(bundle.uid).deleteRecursively() + suspend fun remove(vararg bundles: PatchBundleSource) = + dispatchAction("Remove (${bundles.map { it.uid }.joinToString(",")})") { state -> + val sources = state.sources.toMutableMap() + val info = state.info.toMutableMap() + bundles.forEach { + if (it.isDefault) return@forEach - _sources.update { - it.filterKeys { key -> - key != bundle.uid + dao.remove(it.uid) + directoryOf(it.uid).deleteRecursively() + sources.remove(it.uid) + info.remove(it.uid) } - } - } - private fun addBundle(patchBundle: PatchBundleSource) = - _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } } - - suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) { - val uid = persistenceRepo.create("", SourceInfo.Local).uid - val bundle = LocalPatchBundle("", uid, directoryOf(uid)) - - bundle.replace(patches) - addBundle(bundle) - } - - suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) { - val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate) - addBundle(entity.load()) - } - - private suspend inline fun getBundlesByType() = - sources.first().filterIsInstance() - - suspend fun reloadApiBundles() { - getBundlesByType().forEach { - it.deleteLocalFiles() + State(sources.toPersistentMap(), info.toPersistentMap()) } - reload() - } - - suspend fun redownloadRemoteBundles() = - getBundlesByType().forEach { it.downloadLatest() } - - suspend fun updateCheck() = - uiSafe(app, R.string.patches_download_fail, "Failed to update bundles") { - coroutineScope { - if (!networkInfo.isSafe()) { - Log.d(tag, "Skipping update check because the network is down or metered.") - return@coroutineScope + suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") { + with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) { + try { + createStream().use { patches -> replace(patches) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(tag, "Got exception while importing bundle", e) + withContext(Dispatchers.Main) { + app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage())) } - getBundlesByType().forEach { - launch { - if (!it.getProps().autoUpdate) return@launch - Log.d(tag, "Updating patch bundle: ${it.getName()}") - it.update() + deleteLocalFile() + } + } + + doReload() + } + + suspend fun createRemote(url: String, autoUpdate: Boolean) = + dispatchAction("Add bundle ($url)") { state -> + val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle + update(src) + state.copy(sources = state.sources.put(src.uid, src)) + } + + suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") { + this@PatchBundleRepository.sources.first().filterIsInstance().forEach { + with(it) { deleteLocalFile() } + updateDb(it.uid) { it.copy(versionHash = null) } + } + + doReload() + } + + suspend fun RemotePatchBundle.setAutoUpdate(value: Boolean) = + dispatchAction("Set auto update ($name, $value)") { state -> + updateDb(uid) { it.copy(autoUpdate = value) } + val newSrc = (state.sources[uid] as? RemotePatchBundle)?.copy(autoUpdate = value) + ?: return@dispatchAction state + + state.copy(sources = state.sources.put(uid, newSrc)) + } + + suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) { + val uids = sources.map { it.uid }.toSet() + store.dispatch(Update(showToast = showToast) { it.uid in uids }) + } + + suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true)) + + /** + * Updates all bundles that should be automatically updated. + */ + suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate }) + + private inner class Update( + private val force: Boolean = false, + private val showToast: Boolean = false, + private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true }, + ) : Action { + private suspend fun toast(@StringRes id: Int, vararg args: Any?) = + withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) } + + override fun toString() = if (force) "Redownload remote bundles" else "Update check" + + override suspend fun ActionContext.execute( + current: State + ) = coroutineScope { + if (!networkInfo.isSafe()) { + Log.d(tag, "Skipping update check because the network is down or metered.") + return@coroutineScope current + } + + val updated = current.sources.values + .filterIsInstance() + .filter { predicate(it) } + .map { + async { + Log.d(tag, "Updating patch bundle: ${it.name}") + + val newVersion = with(it) { + if (force) downloadLatest() else update() + } ?: return@async null + + it to newVersion } } + .awaitAll() + .filterNotNull() + .toMap() + if (updated.isEmpty()) { + if (showToast) toast(R.string.patches_update_unavailable) + return@coroutineScope current } + + updated.forEach { (src, newVersionHash) -> + val name = src.patchBundle?.manifestAttributes?.name ?: src.name + + updateDb(src.uid) { + it.copy(versionHash = newVersionHash, name = name) + } + } + + if (showToast) toast(R.string.patches_update_success) + doReload() } + + override suspend fun catch(exception: Exception) { + Log.e(tag, "Failed to update patches", exception) + toast(R.string.patches_download_fail, exception.simpleMessage()) + } + } + + data class State( + val sources: PersistentMap = persistentMapOf(), + val info: PersistentMap = persistentMapOf() + ) + + private companion object { + val defaultSource = PatchBundleEntity( + uid = 0, + name = "", + versionHash = null, + source = Source.API, + autoUpdate = false + ) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt index e894f748..3a1a0172 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt @@ -1,80 +1,84 @@ package app.revanced.manager.patcher.patch -import android.util.Log -import app.revanced.manager.util.tag -import app.revanced.patcher.patch.Patch -import app.revanced.patcher.patch.PatchLoader +import kotlinx.parcelize.IgnoredOnParcel +import android.os.Parcelable +import app.revanced.patcher.patch.loadPatchesFromDex +import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException import java.util.jar.JarFile +import kotlin.collections.filter -class PatchBundleManifestAttributes( - val name: String?, - val version: String?, - val description: String?, - val source: String?, - val author: String?, - val contact: String?, - val website: String?, - val license: String? -) - -class PatchBundle(val patchesJar: File) { - private val loader = object : Iterable> { - private fun load(): Iterable> { - patchesJar.setReadOnly() - return PatchLoader.Dex(setOf(patchesJar)) - } - - override fun iterator(): Iterator> = load().iterator() - } - - init { - Log.d(tag, "Loaded patch bundle: $patchesJar") - } - - /** - * A list containing the metadata of every patch inside this bundle. - */ - val patches = loader.map(::PatchInfo) - +@Parcelize +data class PatchBundle(val patchesJar: String) : Parcelable { /** * The [java.util.jar.Manifest] of [patchesJar]. */ - private val manifest = try { - JarFile(patchesJar).use { it.manifest } - } catch (_: IOException) { - null + @IgnoredOnParcel + private val manifest by lazy { + try { + JarFile(patchesJar).use { it.manifest } + } catch (_: IOException) { + null + } } - val patchBundleManifestAttributes = if(manifest != null) - PatchBundleManifestAttributes( - name = readManifestAttribute("name"), - version = readManifestAttribute("version"), - description = readManifestAttribute("description"), - source = readManifestAttribute("source"), - author = readManifestAttribute("author"), - contact = readManifestAttribute("contact"), - website = readManifestAttribute("website"), - license = readManifestAttribute("license") - ) else + @IgnoredOnParcel + val manifestAttributes by lazy { + if (manifest != null) + ManifestAttributes( + name = readManifestAttribute("name"), + version = readManifestAttribute("version"), + description = readManifestAttribute("description"), + source = readManifestAttribute("source"), + author = readManifestAttribute("author"), + contact = readManifestAttribute("contact"), + website = readManifestAttribute("website"), + license = readManifestAttribute("license") + ) else null + } - private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)?.takeIf { it.isNotBlank() } // If empty, set it to null instead. + private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name) + ?.takeIf { it.isNotBlank() } // If empty, set it to null instead. - /** - * Load all patches compatible with the specified package. - */ - fun patches(packageName: String) = loader.filter { patch -> - val compatiblePackages = patch.compatiblePackages - ?: // The patch has no compatibility constraints, which means it is universal. - return@filter true + data class ManifestAttributes( + val name: String?, + val version: String?, + val description: String?, + val source: String?, + val author: String?, + val contact: String?, + val website: String?, + val license: String? + ) - if (!compatiblePackages.any { (name, _) -> name == packageName }) { - // Patch is not compatible with this package. - return@filter false - } + object Loader { + private fun patches(bundles: Iterable) = + loadPatchesFromDex( + bundles.map { File(it.patchesJar) }.toSet() + ).byPatchesFile.mapKeys { (file, _) -> + val absPath = file.absolutePath + bundles.single { absPath == it.patchesJar } + } - true + fun metadata(bundles: Iterable) = + patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) } + + fun patches(bundles: Iterable, packageName: String) = + patches(bundles).mapValues { (_, patches) -> + patches.filter { patch -> + val compatiblePackages = patch.compatiblePackages + ?: // The patch has no compatibility constraints, which means it is universal. + return@filter true + + if (!compatiblePackages.any { (name, _) -> name == packageName }) { + // Patch is not compatible with this package. + return@filter false + } + + true + }.toSet() + } } } diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt new file mode 100644 index 00000000..e31cecf6 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt @@ -0,0 +1,145 @@ +package app.revanced.manager.patcher.patch + +import app.revanced.manager.util.PatchSelection + +/** + * A base class for storing [PatchBundle] metadata. + */ +sealed class PatchBundleInfo { + /** + * The name of the bundle. + */ + abstract val name: String + + /** + * The version of the bundle. + */ + abstract val version: String? + + /** + * The unique ID of the bundle. + */ + abstract val uid: Int + + /** + * The patch list. + */ + abstract val patches: List + + /** + * Information about a bundle and all the patches it contains. + * + * @see [PatchBundleInfo] + */ + data class Global( + override val name: String, + override val version: String?, + override val uid: Int, + override val patches: List + ) : PatchBundleInfo() { + /** + * Create a [PatchBundleInfo.Scoped] that only contains information about patches that are relevant for a specific [packageName]. + */ + fun forPackage(packageName: String, version: String?): Scoped { + val relevantPatches = patches.filter { it.compatibleWith(packageName) } + val compatible = mutableListOf() + val incompatible = mutableListOf() + val universal = mutableListOf() + + relevantPatches.forEach { + val targetList = when { + it.compatiblePackages == null -> universal + it.supports( + packageName, + version + ) -> compatible + + else -> incompatible + } + + targetList.add(it) + } + + return Scoped( + name, + this.version, + uid, + relevantPatches, + compatible, + incompatible, + universal + ) + } + } + + /** + * Contains information about a bundle that is relevant for a specific package name. + * + * @param compatible Patches that are compatible with the specified package name and version. + * @param incompatible Patches that are compatible with the specified package name but not version. + * @param universal Patches that are compatible with all packages. + * @see [PatchBundleInfo.Global.forPackage] + * @see [PatchBundleInfo] + */ + data class Scoped( + override val name: String, + override val version: String?, + override val uid: Int, + override val patches: List, + val compatible: List, + val incompatible: List, + val universal: List + ) : PatchBundleInfo() { + fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) { + patches.asSequence() + } else { + sequence { + yieldAll(compatible) + yieldAll(universal) + } + } + } + + companion object Extensions { + inline fun Iterable.toPatchSelection( + allowIncompatible: Boolean, + condition: (Int, PatchInfo) -> Boolean + ): PatchSelection = this.associate { bundle -> + val patches = + bundle.patchSequence(allowIncompatible) + .mapNotNullTo(mutableSetOf()) { patch -> + patch.name.takeIf { + condition( + bundle.uid, + patch + ) + } + } + + bundle.uid to patches + } + + /** + * Algorithm for determining whether all required options have been set. + */ + inline fun Iterable.requiredOptionsSet( + allowIncompatible: Boolean, + crossinline isSelected: (Scoped, PatchInfo) -> Boolean, + crossinline optionsForPatch: (Scoped, PatchInfo) -> Map? + ) = all bundle@{ bundle -> + bundle + .patchSequence(allowIncompatible) + .filter { isSelected(bundle, it) } + .all patch@{ + if (it.options.isNullOrEmpty()) return@patch true + val opts by lazy { optionsForPatch(bundle, it).orEmpty() } + + it.options.all option@{ option -> + if (!option.required || option.default != null) return@option true + + option.key in opts + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt index eb50bd35..50a96a1f 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt @@ -3,6 +3,7 @@ package app.revanced.manager.patcher.runtime import android.content.Context import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.logger.Logger +import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.ui.model.State import app.revanced.manager.util.Options @@ -23,14 +24,17 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { onPatchCompleted: suspend () -> Unit, onProgress: ProgressEventHandler, ) { - val bundles = bundles() - val selectedBundles = selectedPatches.keys - val allPatches = bundles.filterKeys { selectedBundles.contains(it) } - .mapValues { (_, bundle) -> bundle.patches(packageName) } + val bundles = bundles() + val uids = bundles.entries.associate { (key, value) -> value to key } + + val allPatches = + PatchBundle.Loader.patches(bundles.values, packageName) + .mapKeys { (b, _) -> uids[b]!! } + .filterKeys { it in selectedBundles } val patchList = selectedPatches.flatMap { (bundle, selected) -> - allPatches[bundle]?.filter { selected.contains(it.name) } + allPatches[bundle]?.filter { it.name in selected } ?: throw IllegalArgumentException("Patch bundle $bundle does not exist") } diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt index 95f32fd5..2e026298 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt @@ -142,8 +142,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { } } - val bundles = bundles() - val parameters = Parameters( aaptPath = aaptPath, frameworkDir = frameworkPath, @@ -151,13 +149,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { packageName = packageName, inputFile = inputFile, outputFile = outputFile, - configurations = selectedPatches.map { (id, patches) -> - val bundle = bundles[id]!! - + configurations = bundles().map { (uid, bundle) -> PatchConfiguration( - bundle.patchesJar.absolutePath, - patches, - options[id].orEmpty() + bundle, + selectedPatches[uid].orEmpty(), + options[uid].orEmpty() ) } ) diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt index b00d558a..9cdd99e9 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt @@ -1,6 +1,7 @@ package app.revanced.manager.patcher.runtime.process import android.os.Parcelable +import app.revanced.manager.patcher.patch.PatchBundle import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue @@ -17,7 +18,7 @@ data class Parameters( @Parcelize data class PatchConfiguration( - val bundlePath: String, + val bundle: PatchBundle, val patches: Set, val options: @RawValue Map> ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt index 82508b31..f117f201 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt @@ -56,11 +56,10 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") + val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName) val patchList = parameters.configurations.flatMap { config -> - val bundle = PatchBundle(File(config.bundlePath)) - - val patches = - bundle.patches(parameters.packageName).filter { it.name in config.patches } + val patches = (allPatches[config.bundle] ?: return@flatMap emptyList()) + .filter { it.name in config.patches } .associateBy { it.name } config.options.forEach { (patchName, opts) -> diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt deleted file mode 100644 index 3e07af24..00000000 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt +++ /dev/null @@ -1,198 +0,0 @@ -package app.revanced.manager.ui.component.bundle - -import android.webkit.URLUtil -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowRight -import androidx.compose.material.icons.automirrored.outlined.Send -import androidx.compose.material.icons.outlined.Commit -import androidx.compose.material.icons.outlined.Description -import androidx.compose.material.icons.outlined.Gavel -import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Sell -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import app.revanced.manager.R -import app.revanced.manager.patcher.patch.PatchBundleManifestAttributes -import app.revanced.manager.ui.component.ColumnWithScrollbar -import app.revanced.manager.ui.component.TextInputDialog -import app.revanced.manager.ui.component.haptics.HapticSwitch - -@Composable -fun BaseBundleDialog( - modifier: Modifier = Modifier, - isDefault: Boolean, - remoteUrl: String?, - onRemoteUrlChange: ((String) -> Unit)? = null, - patchCount: Int, - version: String?, - autoUpdate: Boolean, - bundleManifestAttributes: PatchBundleManifestAttributes?, - onAutoUpdateChange: (Boolean) -> Unit, - onPatchesClick: () -> Unit, - extraFields: @Composable ColumnScope.() -> Unit = {} -) { - ColumnWithScrollbar( - modifier = Modifier - .fillMaxWidth() - .then(modifier), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - version?.let { - Tag(Icons.Outlined.Sell, it) - } - bundleManifestAttributes?.description?.let { - Tag(Icons.Outlined.Description, it) - } - bundleManifestAttributes?.source?.let { - Tag(Icons.Outlined.Commit, it) - } - bundleManifestAttributes?.author?.let { - Tag(Icons.Outlined.Person, it) - } - bundleManifestAttributes?.contact?.let { - Tag(Icons.AutoMirrored.Outlined.Send, it) - } - bundleManifestAttributes?.website?.let { - Tag(Icons.Outlined.Language, it, isUrl = true) - } - bundleManifestAttributes?.license?.let { - Tag(Icons.Outlined.Gavel, it) - } - } - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) - - if (remoteUrl != null) { - BundleListItem( - headlineText = stringResource(R.string.auto_update), - supportingText = stringResource(R.string.auto_update_description), - trailingContent = { - HapticSwitch( - checked = autoUpdate, - onCheckedChange = onAutoUpdateChange - ) - }, - modifier = Modifier.clickable { - onAutoUpdateChange(!autoUpdate) - } - ) - } - - remoteUrl?.takeUnless { isDefault }?.let { url -> - var showUrlInputDialog by rememberSaveable { - mutableStateOf(false) - } - if (showUrlInputDialog) { - TextInputDialog( - initial = url, - title = stringResource(R.string.patches_url), - onDismissRequest = { showUrlInputDialog = false }, - onConfirm = { - showUrlInputDialog = false - onRemoteUrlChange?.invoke(it) - }, - validator = { - if (it.isEmpty()) return@TextInputDialog false - - URLUtil.isValidUrl(it) - } - ) - } - - BundleListItem( - modifier = Modifier.clickable( - enabled = onRemoteUrlChange != null, - onClick = { - showUrlInputDialog = true - } - ), - headlineText = stringResource(R.string.patches_url), - supportingText = url.ifEmpty { - stringResource(R.string.field_not_set) - } - ) - } - - val patchesClickable = patchCount > 0 - BundleListItem( - headlineText = stringResource(R.string.patches), - supportingText = stringResource(R.string.view_patches), - modifier = Modifier.clickable( - enabled = patchesClickable, - onClick = onPatchesClick - ) - ) { - if (patchesClickable) { - Icon( - Icons.AutoMirrored.Outlined.ArrowRight, - stringResource(R.string.patches) - ) - } - } - - extraFields() - } -} - -@Composable -private fun Tag( - icon: ImageVector, - text: String, - isUrl: Boolean = false -) { - val uriHandler = LocalUriHandler.current - - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = if (isUrl) { - Modifier - .clickable { - try { - uriHandler.openUri(text) - } catch (_: Exception) {} - } - } - else - Modifier, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = if(isUrl) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 20d2d7c4..9e0bb7c9 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,69 +1,99 @@ package app.revanced.manager.ui.component.bundle +import android.webkit.URLUtil.isValidUrl import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Commit import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Gavel +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Sell import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.dp import app.revanced.manager.R +import app.revanced.manager.R.string.auto_update +import app.revanced.manager.R.string.auto_update_description +import app.revanced.manager.R.string.field_not_set +import app.revanced.manager.R.string.patches +import app.revanced.manager.R.string.patches_url +import app.revanced.manager.R.string.view_patches import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState +import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.FullscreenDialog +import app.revanced.manager.ui.component.TextInputDialog +import app.revanced.manager.ui.component.haptics.HapticSwitch import kotlinx.coroutines.launch import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun BundleInformationDialog( + src: PatchBundleSource, + patchCount: Int, onDismissRequest: () -> Unit, onDeleteRequest: () -> Unit, - bundle: PatchBundleSource, onUpdate: () -> Unit, ) { + val bundleRepo = koinInject() val networkInfo = koinInject() val hasNetwork = remember { networkInfo.isConnected() } val composableScope = rememberCoroutineScope() var viewCurrentBundlePatches by remember { mutableStateOf(false) } - val isLocal = bundle is LocalPatchBundle - val state by bundle.state.collectAsStateWithLifecycle() - val props by remember(bundle) { - bundle.propsFlow() - }.collectAsStateWithLifecycle(null) - val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) - val version by bundle.versionFlow.collectAsStateWithLifecycle(null) - val bundleManifestAttributes = state.patchBundleOrNull()?.patchBundleManifestAttributes + val isLocal = src is LocalPatchBundle + val bundleManifestAttributes = src.patchBundle?.manifestAttributes + val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } ?: (null to null) + + fun onAutoUpdateChange(new: Boolean) = composableScope.launch { + with(bundleRepo) { + src.asRemoteOrNull?.setAutoUpdate(new) + } + } if (viewCurrentBundlePatches) { BundlePatchesDialog( + src = src, onDismissRequest = { viewCurrentBundlePatches = false - }, - bundle = bundle + } ) } FullscreenDialog( onDismissRequest = onDismissRequest, ) { - val bundleName by bundle.nameState - Scaffold( topBar = { BundleTopBar( - title = bundleName, + title = src.name, onBackClick = onDismissRequest, backIcon = { Icon( @@ -72,7 +102,7 @@ fun BundleInformationDialog( ) }, actions = { - if (!bundle.isDefault) { + if (!src.isDefault) { IconButton(onClick = onDeleteRequest) { Icon( Icons.Outlined.DeleteOutline, @@ -92,54 +122,175 @@ fun BundleInformationDialog( ) }, ) { paddingValues -> - BaseBundleDialog( - modifier = Modifier.padding(paddingValues), - isDefault = bundle.isDefault, - remoteUrl = bundle.asRemoteOrNull?.endpoint, - patchCount = patchCount, - version = version, - autoUpdate = props?.autoUpdate == true, - bundleManifestAttributes = bundleManifestAttributes, - onAutoUpdateChange = { - composableScope.launch { - bundle.asRemoteOrNull?.setAutoUpdate(it) + ColumnWithScrollbar( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Tag(Icons.Outlined.Sell, src.name) + bundleManifestAttributes?.description?.let { + Tag(Icons.Outlined.Description, it) } - }, - onPatchesClick = { - viewCurrentBundlePatches = true - }, - extraFields = { - (state as? PatchBundleSource.State.Failed)?.throwable?.let { - var showDialog by rememberSaveable { - mutableStateOf(false) + bundleManifestAttributes?.source?.let { + Tag(Icons.Outlined.Commit, it) + } + bundleManifestAttributes?.author?.let { + Tag(Icons.Outlined.Person, it) + } + bundleManifestAttributes?.contact?.let { + Tag(Icons.AutoMirrored.Outlined.Send, it) + } + bundleManifestAttributes?.website?.let { + Tag(Icons.Outlined.Language, it, isUrl = true) + } + bundleManifestAttributes?.license?.let { + Tag(Icons.Outlined.Gavel, it) + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + if (autoUpdate != null) { + BundleListItem( + headlineText = stringResource(auto_update), + supportingText = stringResource(auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = ::onAutoUpdateChange + ) + }, + modifier = Modifier.clickable { + onAutoUpdateChange(!autoUpdate) } - if (showDialog) ExceptionViewerDialog( - onDismiss = { showDialog = false }, - text = remember(it) { it.stackTraceToString() } - ) + ) + } - BundleListItem( - headlineText = stringResource(R.string.patches_error), - supportingText = stringResource(R.string.patches_error_description), - trailingContent = { - Icon( - Icons.AutoMirrored.Outlined.ArrowRight, - null - ) + endpoint?.takeUnless { src.isDefault }?.let { url -> + var showUrlInputDialog by rememberSaveable { + mutableStateOf(false) + } + if (showUrlInputDialog) { + TextInputDialog( + initial = url, + title = stringResource(patches_url), + onDismissRequest = { showUrlInputDialog = false }, + onConfirm = { + showUrlInputDialog = false + TODO("Not implemented.") }, - modifier = Modifier.clickable { showDialog = true } + validator = { + if (it.isEmpty()) return@TextInputDialog false + + isValidUrl(it) + } ) } - if (state is PatchBundleSource.State.Missing && !isLocal) { - BundleListItem( - headlineText = stringResource(R.string.patches_error), - supportingText = stringResource(R.string.patches_not_downloaded), - modifier = Modifier.clickable(onClick = onUpdate) + BundleListItem( + modifier = Modifier.clickable( + enabled = false, + onClick = { + showUrlInputDialog = true + } + ), + headlineText = stringResource(patches_url), + supportingText = url.ifEmpty { + stringResource(field_not_set) + } + ) + } + + val patchesClickable = patchCount > 0 + BundleListItem( + headlineText = stringResource(patches), + supportingText = stringResource(view_patches), + modifier = Modifier.clickable( + enabled = patchesClickable, + onClick = { + viewCurrentBundlePatches = true + } + ) + ) { + if (patchesClickable) { + Icon( + Icons.AutoMirrored.Outlined.ArrowRight, + stringResource(patches) ) } } - ) + + src.error?.let { + var showDialog by rememberSaveable { + mutableStateOf(false) + } + if (showDialog) ExceptionViewerDialog( + onDismiss = { showDialog = false }, + text = remember(it) { it.stackTraceToString() } + ) + + BundleListItem( + headlineText = stringResource(R.string.patches_error), + supportingText = stringResource(R.string.patches_error_description), + trailingContent = { + Icon( + Icons.AutoMirrored.Outlined.ArrowRight, + null + ) + }, + modifier = Modifier.clickable { showDialog = true } + ) + } + if (src.state is PatchBundleSource.State.Missing && !isLocal) { + BundleListItem( + headlineText = stringResource(R.string.patches_error), + supportingText = stringResource(R.string.patches_not_downloaded), + modifier = Modifier.clickable(onClick = onUpdate) + ) + } + } } } +} + +@Composable +private fun Tag( + icon: ImageVector, + text: String, + isUrl: Boolean = false +) { + val uriHandler = LocalUriHandler.current + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = if (isUrl) { + Modifier + .clickable { + try { + uriHandler.openUri(text) + } catch (_: Exception) { + } + } + } else + Modifier, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = if (isUrl) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt index 3501e26e..958616af 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt @@ -24,38 +24,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.haptics.HapticCheckbox -import kotlinx.coroutines.flow.map @OptIn(ExperimentalFoundationApi::class) @Composable fun BundleItem( - bundle: PatchBundleSource, - onDelete: () -> Unit, - onUpdate: () -> Unit, + src: PatchBundleSource, + patchCount: Int, selectable: Boolean, - onSelect: () -> Unit, isBundleSelected: Boolean, toggleSelection: (Boolean) -> Unit, + onSelect: () -> Unit, + onDelete: () -> Unit, + onUpdate: () -> Unit, ) { var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } - val state by bundle.state.collectAsStateWithLifecycle() - - val version by bundle.versionFlow.collectAsStateWithLifecycle(null) - val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) - val name by bundle.nameState if (viewBundleDialogPage) { BundleInformationDialog( + src = src, + patchCount = patchCount, onDismissRequest = { viewBundleDialogPage = false }, onDeleteRequest = { showDeleteConfirmationDialog = true }, - bundle = bundle, onUpdate = onUpdate, ) } @@ -68,7 +62,7 @@ fun BundleItem( viewBundleDialogPage = false }, title = stringResource(R.string.delete), - description = stringResource(R.string.patches_delete_single_dialog_description, name), + description = stringResource(R.string.patches_delete_single_dialog_description, src.name), icon = Icons.Outlined.Delete ) } @@ -90,19 +84,19 @@ fun BundleItem( } } else null, - headlineContent = { Text(name) }, + headlineContent = { Text(src.name) }, supportingContent = { - if (state is PatchBundleSource.State.Loaded) { + if (src.state is PatchBundleSource.State.Available) { Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount)) } }, trailingContent = { Row { - val icon = remember(state) { - when (state) { + val icon = remember(src.state) { + when (src.state) { is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.patches_missing - is PatchBundleSource.State.Loaded -> null + is PatchBundleSource.State.Available -> null } } @@ -115,7 +109,7 @@ fun BundleItem( ) } - version?.let { Text(text = it) } + src.version?.let { Text(text = it) } } }, ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt index 7d28237d..1055578c 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -25,20 +26,26 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.LazyColumnWithScrollbar +import kotlinx.coroutines.flow.mapNotNull +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun BundlePatchesDialog( onDismissRequest: () -> Unit, - bundle: PatchBundleSource, + src: PatchBundleSource, ) { var showAllVersions by rememberSaveable { mutableStateOf(false) } var showOptions by rememberSaveable { mutableStateOf(false) } - val state by bundle.state.collectAsStateWithLifecycle() + val patchBundleRepository: PatchBundleRepository = koinInject() + val patches by remember(src.uid) { + patchBundleRepository.bundleInfoFlow.mapNotNull { it[src.uid]?.patches } + }.collectAsStateWithLifecycle(emptyList()) FullscreenDialog( onDismissRequest = onDismissRequest, @@ -64,16 +71,14 @@ fun BundlePatchesDialog( verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(16.dp) ) { - state.patchBundleOrNull()?.let { bundle -> - items(bundle.patches) { patch -> - PatchItem( - patch, - showAllVersions, - onExpandVersions = { showAllVersions = !showAllVersions }, - showOptions, - onExpandOptions = { showOptions = !showOptions } - ) - } + items(patches) { patch -> + PatchItem( + patch, + showAllVersions, + onExpandVersions = { showAllVersions = !showAllVersions }, + showOptions, + onExpandOptions = { showOptions = !showOptions } + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt index bcad3fe9..7bcb1017 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt @@ -12,26 +12,23 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BundleSelector(bundles: List, onFinish: (PatchBundleSource?) -> Unit) { - LaunchedEffect(bundles) { - if (bundles.size == 1) { - onFinish(bundles[0]) +fun BundleSelector(sources: List, onFinish: (PatchBundleSource?) -> Unit) { + LaunchedEffect(sources) { + if (sources.size == 1) { + onFinish(sources[0]) } } - if (bundles.size < 2) { + if (sources.size < 2) { return } @@ -55,10 +52,7 @@ fun BundleSelector(bundles: List, onFinish: (PatchBundleSourc color = MaterialTheme.colorScheme.onSurface ) } - bundles.forEach { - val name by it.nameState - val version by it.versionFlow.collectAsStateWithLifecycle(null) - + sources.forEach { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -70,7 +64,7 @@ fun BundleSelector(bundles: List, onFinish: (PatchBundleSourc } ) { Text( - "$name $version", + "${it.name} ${it.version}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index 1a05d866..943a9f9c 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -23,10 +23,14 @@ import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticRadioButton -import app.revanced.manager.ui.model.BundleType import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.transparentListItemColors +private enum class BundleType { + Local, + Remote +} + @Composable fun ImportPatchBundleDialog( onDismiss: () -> Unit, @@ -37,7 +41,7 @@ fun ImportPatchBundleDialog( var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var patchBundle by rememberSaveable { mutableStateOf(null) } var remoteUrl by rememberSaveable { mutableStateOf("") } - var autoUpdate by rememberSaveable { mutableStateOf(false) } + var autoUpdate by rememberSaveable { mutableStateOf(true) } val patchActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -117,7 +121,7 @@ fun ImportPatchBundleDialog( } @Composable -fun SelectBundleTypeStep( +private fun SelectBundleTypeStep( bundleType: BundleType, onBundleTypeSelected: (BundleType) -> Unit ) { @@ -168,7 +172,7 @@ fun SelectBundleTypeStep( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ImportBundleStep( +private fun ImportBundleStep( bundleType: BundleType, patchBundle: Uri?, remoteUrl: String, diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt deleted file mode 100644 index 0fb01a76..00000000 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ /dev/null @@ -1,111 +0,0 @@ -package app.revanced.manager.ui.model - -import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.util.PatchSelection -import app.revanced.manager.util.flatMapLatestAndCombine -import kotlinx.coroutines.flow.map - -/** - * A data class that contains patch bundle metadata for use by UI code. - */ -data class BundleInfo( - val name: String, - val version: String?, - val uid: Int, - val compatible: List, - val incompatible: List, - val universal: List -) { - val all = sequence { - yieldAll(compatible) - yieldAll(incompatible) - yieldAll(universal) - } - - fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) { - all - } else { - sequence { - yieldAll(compatible) - yieldAll(universal) - } - } - - companion object Extensions { - inline fun Iterable.toPatchSelection( - allowIncompatible: Boolean, - condition: (Int, PatchInfo) -> Boolean - ): PatchSelection = this.associate { bundle -> - val patches = - bundle.patchSequence(allowIncompatible) - .mapNotNullTo(mutableSetOf()) { patch -> - patch.name.takeIf { - condition( - bundle.uid, - patch - ) - } - } - - bundle.uid to patches - } - - fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) = - sources.flatMapLatestAndCombine( - combiner = { it.filterNotNull() } - ) { source -> - // Regenerate bundle information whenever this source updates. - source.state.map { state -> - val bundle = state.patchBundleOrNull() ?: return@map null - - val compatible = mutableListOf() - val incompatible = mutableListOf() - val universal = mutableListOf() - - bundle.patches.filter { it.compatibleWith(packageName) }.forEach { - val targetList = when { - it.compatiblePackages == null -> universal - it.supports( - packageName, - version - ) -> compatible - - else -> incompatible - } - - targetList.add(it) - } - - BundleInfo(source.getName(), bundle.patchBundleManifestAttributes?.version, source.uid, compatible, incompatible, universal) - } - } - - /** - * Algorithm for determining whether all required options have been set. - */ - inline fun Iterable.requiredOptionsSet( - crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean, - crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map? - ) = all bundle@{ bundle -> - bundle - .all - .filter { isSelected(bundle, it) } - .all patch@{ - if (it.options.isNullOrEmpty()) return@patch true - val opts by lazy { optionsForPatch(bundle, it).orEmpty() } - - it.options.all option@{ option -> - if (!option.required || option.default != null) return@option true - - option.key in opts - } - } - } - } -} - -enum class BundleType { - Local, - Remote -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt index 8704a064..4f23271c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt @@ -3,59 +3,74 @@ package app.revanced.manager.ui.screen import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import app.revanced.manager.domain.bundles.PatchBundleSource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.bundle.BundleItem +import app.revanced.manager.ui.viewmodel.BundleListViewModel +import app.revanced.manager.util.EventEffect +import kotlinx.coroutines.flow.Flow +import org.koin.androidx.compose.koinViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BundleListScreen( - onDelete: (PatchBundleSource) -> Unit, - onUpdate: (PatchBundleSource) -> Unit, - sources: List, - selectedSources: SnapshotStateList, - bundlesSelectable: Boolean, + viewModel: BundleListViewModel = koinViewModel(), + eventsFlow: Flow, + setSelectedSourceCount: (Int) -> Unit ) { - val sortedSources = remember(sources) { - sources.sortedByDescending { source -> - source.state.value.patchBundleOrNull()?.patches?.size ?: 0 - } + val patchCounts by viewModel.patchCounts.collectAsStateWithLifecycle(emptyMap()) + val sources by viewModel.sources.collectAsStateWithLifecycle(emptyList()) + + EventEffect(eventsFlow) { + viewModel.handleEvent(it) + } + LaunchedEffect(viewModel.selectedSources.size) { + setSelectedSourceCount(viewModel.selectedSources.size) } - LazyColumnWithScrollbar( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + PullToRefreshBox( + onRefresh = viewModel::refresh, + isRefreshing = viewModel.isRefreshing ) { - items( - sortedSources, - key = { it.uid } - ) { source -> - BundleItem( - bundle = source, - onDelete = { - onDelete(source) - }, - onUpdate = { - onUpdate(source) - }, - selectable = bundlesSelectable, - onSelect = { - selectedSources.add(source) - }, - isBundleSelected = selectedSources.contains(source), - toggleSelection = { bundleIsNotSelected -> - if (bundleIsNotSelected) { - selectedSources.add(source) - } else { - selectedSources.remove(source) + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + items( + sources, + key = { it.uid } + ) { source -> + BundleItem( + src = source, + patchCount = patchCounts[source.uid] ?: 0, + onDelete = { + viewModel.delete(source) + }, + onUpdate = { + viewModel.update(source) + }, + selectable = viewModel.selectedSources.size > 0, + onSelect = { + viewModel.selectedSources.add(source.uid) + }, + isBundleSelected = source.uid in viewModel.selectedSources, + toggleSelection = { bundleIsNotSelected -> + if (bundleIsNotSelected) { + viewModel.selectedSources.add(source.uid) + } else { + viewModel.selectedSources.remove(source.uid) + } } - } - ) + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 03eff51f..0496c1a9 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,7 +57,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AppTopBar @@ -93,7 +93,8 @@ fun DashboardScreen( onDownloaderPluginClick: () -> Unit, onAppClick: (String) -> Unit ) { - val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.isNotEmpty() } } + var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) } + val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } } val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle( false @@ -160,10 +161,7 @@ fun DashboardScreen( if (showDeleteConfirmationDialog) { ConfirmDialog( onDismiss = { showDeleteConfirmationDialog = false }, - onConfirm = { - vm.selectedSources.forEach { if (!it.isDefault) vm.delete(it) } - vm.cancelSourceSelection() - }, + onConfirm = vm::deleteSources, title = stringResource(R.string.delete), description = stringResource(R.string.patches_delete_multiple_dialog_description), icon = Icons.Outlined.Delete @@ -174,7 +172,7 @@ fun DashboardScreen( topBar = { if (bundlesSelectable) { BundleTopBar( - title = stringResource(R.string.patches_selected, vm.selectedSources.size), + title = stringResource(R.string.patches_selected, selectedSourceCount), onBackClick = vm::cancelSourceSelection, backIcon = { Icon( @@ -194,10 +192,7 @@ fun DashboardScreen( ) } IconButton( - onClick = { - vm.selectedSources.forEach { vm.update(it) } - vm.cancelSourceSelection() - } + onClick = vm::updateSources ) { Icon( Icons.Outlined.Refresh, @@ -349,18 +344,9 @@ fun DashboardScreen( } } - val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) - BundleListScreen( - onDelete = { - vm.delete(it) - }, - onUpdate = { - vm.update(it) - }, - sources = sources, - selectedSources = vm.selectedSources, - bundlesSelectable = bundlesSelectable + eventsFlow = vm.bundleListEventsFlow, + setSelectedSourceCount = { selectedSourceCount = it } ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 2bb29e4a..db0667a0 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -413,7 +413,7 @@ fun PatchesSelectorScreen( style = MaterialTheme.typography.bodyMedium ) Text( - text = bundle.version!!, + text = bundle.version.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt index 1daa4c5f..4f054589 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.patcher.patch.Option +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.patches.OptionItem -import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection @@ -62,6 +62,7 @@ fun RequiredOptionsScreen( val showContinueButton by remember { derivedStateOf { bundles.requiredOptionsSet( + allowIncompatible = vm.allowIncompatiblePatches, isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) }, optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) } ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 3d57ee50..bbe5a4cd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -2,7 +2,6 @@ package app.revanced.manager.ui.screen.settings import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -19,9 +18,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults -import androidx.compose.material3.pulltorefresh.pullToRefresh -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,13 +26,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.network.downloader.DownloaderPluginState @@ -57,7 +52,6 @@ fun DownloadsSettingsScreen( onBackClick: () -> Unit, viewModel: DownloadsViewModel = koinViewModel() ) { - val pullRefreshState = rememberPullToRefreshState() val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -90,152 +84,138 @@ fun DownloadsSettingsScreen( }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> - Box( - contentAlignment = Alignment.TopCenter, - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth() - .zIndex(1f) + PullToRefreshBox( + onRefresh = viewModel::refreshPlugins, + isRefreshing = viewModel.isRefreshingPlugins, + modifier = Modifier.padding(paddingValues) ) { - PullToRefreshDefaults.Indicator( - state = pullRefreshState, - isRefreshing = viewModel.isRefreshingPlugins - ) - } - - LazyColumnWithScrollbar( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .pullToRefresh( - isRefreshing = viewModel.isRefreshingPlugins, - state = pullRefreshState, - onRefresh = viewModel::refreshPlugins - ) - ) { - item { - GroupHeader(stringResource(R.string.downloader_plugins)) - } - pluginStates.forEach { (packageName, state) -> - item(key = packageName) { - var showDialog by rememberSaveable { - mutableStateOf(false) - } - - fun dismiss() { - showDialog = false - } - - val packageInfo = - remember(packageName) { - viewModel.pm.getPackageInfo( - packageName - ) - } ?: return@item - - if (showDialog) { - val signature = - remember(packageName) { - val androidSignature = - viewModel.pm.getSignature(packageName) - val hash = MessageDigest.getInstance("SHA-256") - .digest(androidSignature.toByteArray()) - hash.toHexString(format = HexFormat.UpperCase) - } - - when (state) { - is DownloaderPluginState.Loaded -> TrustDialog( - title = R.string.downloader_plugin_revoke_trust_dialog_title, - body = stringResource( - R.string.downloader_plugin_trust_dialog_body, - packageName, - signature - ), - onDismiss = ::dismiss, - onConfirm = { - viewModel.revokePluginTrust(packageName) - dismiss() - } - ) - - is DownloaderPluginState.Failed -> ExceptionViewerDialog( - text = remember(state.throwable) { - state.throwable.stackTraceToString() - }, - onDismiss = ::dismiss - ) - - is DownloaderPluginState.Untrusted -> TrustDialog( - title = R.string.downloader_plugin_trust_dialog_title, - body = stringResource( - R.string.downloader_plugin_trust_dialog_body, - packageName, - signature - ), - onDismiss = ::dismiss, - onConfirm = { - viewModel.trustPlugin(packageName) - dismiss() - } - ) + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize() + ) { + item { + GroupHeader(stringResource(R.string.downloader_plugins)) + } + pluginStates.forEach { (packageName, state) -> + item(key = packageName) { + var showDialog by rememberSaveable { + mutableStateOf(false) } + + fun dismiss() { + showDialog = false + } + + val packageInfo = + remember(packageName) { + viewModel.pm.getPackageInfo( + packageName + ) + } ?: return@item + + if (showDialog) { + val signature = + remember(packageName) { + val androidSignature = + viewModel.pm.getSignature(packageName) + val hash = MessageDigest.getInstance("SHA-256") + .digest(androidSignature.toByteArray()) + hash.toHexString(format = HexFormat.UpperCase) + } + + when (state) { + is DownloaderPluginState.Loaded -> TrustDialog( + title = R.string.downloader_plugin_revoke_trust_dialog_title, + body = stringResource( + R.string.downloader_plugin_trust_dialog_body, + packageName, + signature + ), + onDismiss = ::dismiss, + onConfirm = { + viewModel.revokePluginTrust(packageName) + dismiss() + } + ) + + is DownloaderPluginState.Failed -> ExceptionViewerDialog( + text = remember(state.throwable) { + state.throwable.stackTraceToString() + }, + onDismiss = ::dismiss + ) + + is DownloaderPluginState.Untrusted -> TrustDialog( + title = R.string.downloader_plugin_trust_dialog_title, + body = stringResource( + R.string.downloader_plugin_trust_dialog_body, + packageName, + signature + ), + onDismiss = ::dismiss, + onConfirm = { + viewModel.trustPlugin(packageName) + dismiss() + } + ) + } + } + + SettingsListItem( + modifier = Modifier.clickable { showDialog = true }, + headlineContent = { + AppLabel( + packageInfo = packageInfo, + style = MaterialTheme.typography.titleLarge + ) + }, + supportingContent = stringResource( + when (state) { + is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted + is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed + is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted + } + ), + trailingContent = { Text(packageInfo.versionName!!) } + ) } + } + if (pluginStates.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_no_plugins_installed), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + item { + GroupHeader(stringResource(R.string.downloaded_apps)) + } + items(downloadedApps, key = { it.packageName to it.version }) { app -> + val selected = app in viewModel.appSelection SettingsListItem( - modifier = Modifier.clickable { showDialog = true }, - headlineContent = { - AppLabel( - packageInfo = packageInfo, - style = MaterialTheme.typography.titleLarge + modifier = Modifier.clickable { viewModel.toggleApp(app) }, + headlineContent = app.packageName, + leadingContent = (@Composable { + HapticCheckbox( + checked = selected, + onCheckedChange = { viewModel.toggleApp(app) } ) - }, - supportingContent = stringResource( - when (state) { - is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted - is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed - is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted - } - ), - trailingContent = { Text(packageInfo.versionName!!) } + }).takeIf { viewModel.appSelection.isNotEmpty() }, + supportingContent = app.version, + tonalElevation = if (selected) 8.dp else 0.dp ) } - } - if (pluginStates.isEmpty()) { - item { - Text( - stringResource(R.string.downloader_no_plugins_installed), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } - - item { - GroupHeader(stringResource(R.string.downloaded_apps)) - } - items(downloadedApps, key = { it.packageName to it.version }) { app -> - val selected = app in viewModel.appSelection - - SettingsListItem( - modifier = Modifier.clickable { viewModel.toggleApp(app) }, - headlineContent = app.packageName, - leadingContent = (@Composable { - HapticCheckbox( - checked = selected, - onCheckedChange = { viewModel.toggleApp(app) } + if (downloadedApps.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_settings_no_apps), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center ) - }).takeIf { viewModel.appSelection.isNotEmpty() }, - supportingContent = app.version, - tonalElevation = if (selected) 8.dp else 0.dp - ) - } - if (downloadedApps.isEmpty()) { - item { - Text( - stringResource(R.string.downloader_settings_no_apps), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) + } } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 9383d44a..181e8dcd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -227,12 +227,12 @@ fun ImportExportSettingsScreen( GroupItem( onClick = { selectorDialog = { - BundleSelector(bundles = patchBundles) { bundle -> - bundle?.also { + BundleSelector(sources = patchBundles) { src -> + src?.also { coroutineScope.launch { vm.resetDialogState = - ResetDialogState.PatchSelectionBundle(bundle.getName()) { - vm.resetSelectionForPatchBundle(bundle) + ResetDialogState.PatchSelectionBundle(it.name) { + vm.resetSelectionForPatchBundle(it) } } } @@ -283,12 +283,12 @@ fun ImportExportSettingsScreen( GroupItem( onClick = { selectorDialog = { - BundleSelector(bundles = patchBundles) { bundle -> - bundle?.also { + BundleSelector(sources = patchBundles) { src -> + src?.also { coroutineScope.launch { vm.resetDialogState = - ResetDialogState.PatchOptionBundle(bundle.getName()) { - vm.resetOptionsForBundle(bundle) + ResetDialogState.PatchOptionBundle(src.name) { + vm.resetOptionsForBundle(src) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt new file mode 100644 index 00000000..49bc0c4b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt @@ -0,0 +1,76 @@ +package app.revanced.manager.ui.viewmodel + +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.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.bundles.RemotePatchBundle +import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.util.mutableStateSetOf +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +class BundleListViewModel : ViewModel(), KoinComponent { + private val patchBundleRepository: PatchBundleRepository = get() + val patchCounts = patchBundleRepository.patchCountsFlow + var isRefreshing by mutableStateOf(false) + private set + + val sources = combine( + patchBundleRepository.sources, + patchBundleRepository.patchCountsFlow + ) { sources, patchCounts -> + isRefreshing = false + sources.sortedByDescending { patchCounts[it.uid] ?: 0 } + } + + val selectedSources = mutableStateSetOf() + + fun refresh() = viewModelScope.launch { + isRefreshing = true + patchBundleRepository.reload() + } + + private suspend fun getSelectedSources() = patchBundleRepository.sources + .first() + .filter { it.uid in selectedSources } + .also { + selectedSources.clear() + } + + fun handleEvent(event: Event) { + when (event) { + Event.CANCEL -> selectedSources.clear() + Event.DELETE_SELECTED -> viewModelScope.launch { + patchBundleRepository.remove(*getSelectedSources().toTypedArray()) + } + + Event.UPDATE_SELECTED -> viewModelScope.launch { + patchBundleRepository.update( + *getSelectedSources().filterIsInstance().toTypedArray(), + showToast = true, + ) + } + } + } + + fun delete(src: PatchBundleSource) = + viewModelScope.launch { patchBundleRepository.remove(src) } + + fun update(src: PatchBundleSource) = viewModelScope.launch { + if (src !is RemotePatchBundle) return@launch + + patchBundleRepository.update(src, showToast = true) + } + + enum class Event { + DELETE_SELECTED, + UPDATE_SELECTED, + CANCEL, + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index ae38bd29..26f86092 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.viewmodel +import android.annotation.SuppressLint import android.app.Application import android.content.ContentResolver import android.net.Uri @@ -24,8 +25,10 @@ import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.util.PM import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch class DashboardViewModel( @@ -38,13 +41,12 @@ class DashboardViewModel( private val pm: PM, ) : ViewModel() { val availablePatches = - patchBundleRepository.bundles.map { it.values.sumOf { bundle -> bundle.patches.size } } + patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } } private val contentResolver: ContentResolver = app.contentResolver private val powerManager = app.getSystemService()!! - val sources = patchBundleRepository.sources - val selectedSources = mutableStateListOf() - val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() } + val newDownloaderPluginsAvailable = + downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() } /** * Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen. @@ -59,6 +61,9 @@ class DashboardViewModel( var showBatteryOptimizationsWarning by mutableStateOf(false) private set + private val bundleListEventsChannel = Channel() + val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow() + init { viewModelScope.launch { checkForManagerUpdates() @@ -70,10 +75,6 @@ class DashboardViewModel( downloaderPluginRepository.acknowledgeAllNewPlugins() } - fun dismissUpdateDialog() { - updatedManagerVersion = null - } - private suspend fun checkForManagerUpdates() { if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return @@ -83,7 +84,8 @@ class DashboardViewModel( } fun updateBatteryOptimizationsWarning() { - showBatteryOptimizationsWarning = !powerManager.isIgnoringBatteryOptimizations(app.packageName) + showBatteryOptimizationsWarning = + !powerManager.isIgnoringBatteryOptimizations(app.packageName) } fun setShowManagerUpdateDialogOnLaunch(value: Boolean) { @@ -112,36 +114,20 @@ class DashboardViewModel( } } - - fun cancelSourceSelection() { - selectedSources.clear() + private fun sendEvent(event: BundleListViewModel.Event) { + viewModelScope.launch { bundleListEventsChannel.send(event) } } - fun createLocalSource(patchBundle: Uri) = - viewModelScope.launch { - contentResolver.openInputStream(patchBundle)!!.use { patchesStream -> - patchBundleRepository.createLocal(patchesStream) - } - } + fun cancelSourceSelection() = sendEvent(BundleListViewModel.Event.CANCEL) + fun updateSources() = sendEvent(BundleListViewModel.Event.UPDATE_SELECTED) + fun deleteSources() = sendEvent(BundleListViewModel.Event.DELETE_SELECTED) - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = - viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, autoUpdate) } + @SuppressLint("Recycle") + fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { + patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! } + } - fun delete(bundle: PatchBundleSource) = - viewModelScope.launch { patchBundleRepository.remove(bundle) } - - fun update(bundle: PatchBundleSource) = viewModelScope.launch { - if (bundle !is RemotePatchBundle) return@launch - - uiSafe( - app, - R.string.patches_download_fail, - RemotePatchBundle.updateFailMsg - ) { - if (bundle.update()) - app.toast(app.getString(R.string.patches_update_success, bundle.getName())) - else - app.toast(app.getString(R.string.patches_update_unavailable, bundle.getName())) - } + fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + patchBundleRepository.createRemote(apiUrl, autoUpdate) } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index dc43c28b..fb458d43 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -17,10 +17,9 @@ import androidx.lifecycle.viewmodel.compose.saveable import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.patcher.patch.PatchBundleInfo +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.ui.model.BundleInfo -import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow -import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection @@ -63,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi val allowIncompatiblePatches = get().disablePatchVersionCompatCheck.getBlocking() val bundlesFlow = - get().bundleInfoFlow(packageName, input.app.version) + get().scopedBundleInfoFlow(packageName, input.app.version) init { viewModelScope.launch { @@ -76,11 +75,11 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi return@launch } - fun BundleInfo.hasDefaultPatches() = + fun PatchBundleInfo.Scoped.hasDefaultPatches() = patchSequence(allowIncompatiblePatches).any { it.include } // Don't show the warning if there are no default patches. - selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches) + selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches) } } @@ -123,7 +122,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi // This is for the required options screen. private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) { bundlesFlow.first().map { bundle -> - bundle to bundle.all.filter { patch -> + bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch -> val opts by lazy { getOptions(bundle.uid, patch).orEmpty() } @@ -136,14 +135,14 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi } val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) } - fun selectionIsValid(bundles: List) = bundles.any { bundle -> + fun selectionIsValid(bundles: List) = bundles.any { bundle -> bundle.patchSequence(allowIncompatiblePatches).any { patch -> isSelected(bundle.uid, patch) } } fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection -> - selection[bundle]?.contains(patch.name) ?: false + selection[bundle]?.contains(patch.name) == true } ?: patch.include fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 1b9b08c2..8b9003a9 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -28,15 +28,14 @@ import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchSelectionRepository +import app.revanced.manager.patcher.patch.PatchBundleInfo +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.network.downloader.ParceledDownloaderData +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.plugin.downloader.GetScope import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.UserInteractionException -import app.revanced.manager.ui.model.BundleInfo -import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow -import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet -import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo @@ -125,16 +124,19 @@ class SelectedAppInfoViewModel( suggestedVersions[input.app.packageName] } + val bundleInfoFlow by derivedStateOf { + bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version) + } + var options: Options by savedStateHandle.saveable { val state = mutableStateOf(emptyMap()) viewModelScope.launch { if (!persistConfiguration) return@launch // TODO: save options for patched apps. + val bundlePatches = bundleInfoFlow.first() + .associate { it.uid to it.patches.associateBy { patch -> patch.name } } options = withContext(Dispatchers.Default) { - val bundlePatches = bundleRepository.bundles.first() - .mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } } - optionsRepository.getOptions(packageName, bundlePatches) } } @@ -176,10 +178,6 @@ class SelectedAppInfoViewModel( } } - val bundleInfoFlow by derivedStateOf { - bundleRepository.bundleInfoFlow(packageName, selectedApp.version) - } - fun showSourceSelector() { dismissSourceSelector() showSourceSelector = true @@ -266,9 +264,11 @@ class SelectedAppInfoViewModel( selectedAppInfo = info } + fun getOptionsFiltered(bundles: List) = options.filtered(bundles) suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow .first() .requiredOptionsSet( + allowIncompatible = prefs.disablePatchVersionCompatCheck.get(), isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! }, optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) }, ) @@ -283,23 +283,23 @@ class SelectedAppInfoViewModel( ) } - fun getOptionsFiltered(bundles: List) = options.filtered(bundles) - - fun getPatches(bundles: List, allowIncompatible: Boolean) = + fun getPatches(bundles: List, allowIncompatible: Boolean) = selectionState.patches(bundles, allowIncompatible) fun getCustomPatches( - bundles: List, + bundles: List, allowIncompatible: Boolean ): PatchSelection? = (selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible) - fun updateConfiguration(selection: PatchSelection?, options: Options) = viewModelScope.launch { - val bundles = bundleInfoFlow.first() + fun updateConfiguration( + selection: PatchSelection?, + options: Options + ) = viewModelScope.launch { selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default - val filteredOptions = options.filtered(bundles) + val filteredOptions = options.filtered(bundleInfoFlow.first()) this@SelectedAppInfoViewModel.options = filteredOptions if (!persistConfiguration) return@launch @@ -319,34 +319,35 @@ class SelectedAppInfoViewModel( /** * Returns a copy with all nonexistent options removed. */ - private fun Options.filtered(bundles: List): Options = buildMap options@{ - bundles.forEach bundles@{ bundle -> - val bundleOptions = this@filtered[bundle.uid] ?: return@bundles + private fun Options.filtered(bundles: List): Options = + buildMap options@{ + bundles.forEach bundles@{ bundle -> + val bundleOptions = this@filtered[bundle.uid] ?: return@bundles - val patches = bundle.all.associateBy { it.name } + val patches = bundle.patches.associateBy { it.name } - this@options[bundle.uid] = buildMap bundleOptions@{ - bundleOptions.forEach patch@{ (patchName, values) -> - // Get all valid option keys for the patch. - val validOptionKeys = - patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch + this@options[bundle.uid] = buildMap bundleOptions@{ + bundleOptions.forEach patch@{ (patchName, values) -> + // Get all valid option keys for the patch. + val validOptionKeys = + patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch - this@bundleOptions[patchName] = values.filterKeys { key -> - key in validOptionKeys + this@bundleOptions[patchName] = values.filterKeys { key -> + key in validOptionKeys + } } } } } - } } } private sealed interface SelectionState : Parcelable { - fun patches(bundles: List, allowIncompatible: Boolean): PatchSelection + fun patches(bundles: List, allowIncompatible: Boolean): PatchSelection @Parcelize data class Customized(val patchSelection: PatchSelection) : SelectionState { - override fun patches(bundles: List, allowIncompatible: Boolean) = + override fun patches(bundles: List, allowIncompatible: Boolean) = bundles.toPatchSelection( allowIncompatible ) { uid, patch -> @@ -356,7 +357,7 @@ private sealed interface SelectionState : Parcelable { @Parcelize data object Default : SelectionState { - override fun patches(bundles: List, allowIncompatible: Boolean) = + override fun patches(bundles: List, allowIncompatible: Boolean) = bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include } } } diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 3ac633af..c2f16402 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -44,10 +44,10 @@ class PM( ) { private val scope = CoroutineScope(Dispatchers.IO) - val appList = patchBundleRepository.bundles.map { bundles -> + val appList = patchBundleRepository.bundleInfoFlow.map { bundles -> val compatibleApps = scope.async { - val compatiblePackages = bundles.values - .flatMap { it.patches } + val compatiblePackages = bundles + .flatMap { (_, bundle) -> bundle.patches } .flatMap { it.compatiblePackages.orEmpty() } .groupingBy { it.packageName } .eachCount() diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 2c509aa5..5b2dfa33 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -116,10 +116,10 @@ inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle( */ @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow>.flatMapLatestAndCombine( - crossinline combiner: (Array) -> C, - crossinline transformer: (T) -> Flow, + crossinline combiner: suspend (Array) -> C, + crossinline transformer: suspend (T) -> Flow, ): Flow = flatMapLatest { iterable -> - combine(iterable.map(transformer)) { + combine(iterable.map { transformer(it) }) { combiner(it) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60bd51c4..79f9daee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,7 +225,7 @@ Download app Download APK file Failed to download patches: %s - Failed to load updated patches: %s + Failed to import patches: %s No patched apps found Tap on the patches to get more information about them %s selected @@ -344,8 +344,8 @@ Help us improve this application Developer options Options for debugging issues - Successfully updated %s - No update available for %s + Update successful + No update available View patches Any version Any package From 486ed5967f20941aa7e89fe93f299973be392e74 Mon Sep 17 00:00:00 2001 From: brosssh <44944126+brosssh@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:32:49 +0200 Subject: [PATCH 3/6] fix: Show selection warning also on patch option (#2643) --- .../ui/component/patches/OptionFields.kt | 52 +++++++++++++++---- .../patches/SelectionWarningDialog.kt | 17 ++++++ .../ui/screen/PatchesSelectorScreen.kt | 23 +++----- .../ui/screen/RequiredOptionsScreen.kt | 3 +- app/src/main/res/values/strings.xml | 4 +- 5 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/patches/SelectionWarningDialog.kt diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index eaacc38c..2511dda2 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -74,13 +73,11 @@ import app.revanced.manager.util.saver.snapshotStateListSaver import app.revanced.manager.util.saver.snapshotStateSetSaver import app.revanced.manager.util.toast import app.revanced.manager.util.transparentListItemColors -import kotlinx.coroutines.CoroutineScope import kotlinx.parcelize.Parcelize import org.koin.compose.koinInject import org.koin.core.component.KoinComponent import org.koin.core.component.get import sh.calvin.reorderable.ReorderableItem -import sh.calvin.reorderable.rememberReorderableLazyColumnState import sh.calvin.reorderable.rememberReorderableLazyListState import java.io.Serializable import kotlin.random.Random @@ -91,15 +88,28 @@ private class OptionEditorScope( val option: Option, val openDialog: () -> Unit, val dismissDialog: () -> Unit, + val selectionWarningEnabled: Boolean, + val showSelectionWarning: () -> Unit, val value: T?, - val setValue: (T?) -> Unit, + val setValue: (T?) -> Unit ) { fun submitDialog(value: T?) { setValue(value) dismissDialog() } - fun clickAction() = editor.clickAction(this) + fun checkSafeguard(block: () -> Unit) { + if (!option.required && selectionWarningEnabled) + showSelectionWarning() + else + block() + } + + fun clickAction() { + checkSafeguard { + editor.clickAction(this) + } + } @Composable fun ListItemTrailingContent() = editor.ListItemTrailingContent(this) @@ -113,7 +123,7 @@ private interface OptionEditor { @Composable fun ListItemTrailingContent(scope: OptionEditorScope) { - IconButton(onClick = { clickAction(scope) }) { + IconButton(onClick = { scope.checkSafeguard { clickAction(scope) } }) { Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) } } @@ -141,11 +151,14 @@ private inline fun WithOptionEditor( option: Option, value: T?, noinline setValue: (T?) -> Unit, + selectionWarningEnabled: Boolean, crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {}, block: OptionEditorScope.() -> Unit ) { var showDialog by rememberSaveable { mutableStateOf(false) } - val scope = remember(editor, option, value, setValue) { + var showSelectionWarningDialog by rememberSaveable { mutableStateOf(false) } + + val scope = remember(editor, option, value, setValue, selectionWarningEnabled) { OptionEditorScope( editor, option, @@ -154,11 +167,18 @@ private inline fun WithOptionEditor( showDialog = false onDismissDialog() }, + selectionWarningEnabled, + showSelectionWarning = { showSelectionWarningDialog = true }, value, setValue ) } + if (showSelectionWarningDialog) + SelectionWarningDialog( + onDismiss = { showSelectionWarningDialog = false } + ) + if (showDialog) scope.Dialog() scope.block() @@ -169,6 +189,7 @@ fun OptionItem( option: Option, value: T?, setValue: (T?) -> Unit, + selectionWarningEnabled: Boolean ) { val editor = remember(option.type, option.presets) { @Suppress("UNCHECKED_CAST") @@ -181,7 +202,7 @@ fun OptionItem( else baseOptionEditor } - WithOptionEditor(editor, option, value, setValue) { + WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled) { ListItem( modifier = Modifier.clickable(onClick = ::clickAction), headlineContent = { Text(option.title) }, @@ -300,7 +321,7 @@ private object StringOptionEditor : OptionEditor { private abstract class NumberOptionEditor : OptionEditor { @Composable - protected abstract fun NumberDialog( + abstract fun NumberDialog( title: String, current: T?, validator: (T?) -> Boolean, @@ -354,7 +375,14 @@ private object BooleanOptionEditor : OptionEditor { @Composable override fun ListItemTrailingContent(scope: OptionEditorScope) { - HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue) + HapticSwitch( + checked = scope.current, + onCheckedChange = { value -> + scope.checkSafeguard { + scope.setValue(value) + } + } + ) } @Composable @@ -393,6 +421,7 @@ private class PresetOptionEditor(private val innerEditor: OptionEditor< scope.option, scope.value, scope.setValue, + scope.selectionWarningEnabled, onDismissDialog = scope.dismissDialog ) inner@{ var hidePresetsDialog by rememberSaveable { @@ -614,7 +643,8 @@ private class ListOptionEditor(private val elementEditor: Opti elementEditor, elementOption, value = item.value, - setValue = { items[index] = item.copy(value = it) } + setValue = { items[index] = item.copy(value = it) }, + selectionWarningEnabled = scope.selectionWarningEnabled ) { ListItem( modifier = Modifier.combinedClickable( diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/SelectionWarningDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/SelectionWarningDialog.kt new file mode 100644 index 00000000..4ec4d415 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/SelectionWarningDialog.kt @@ -0,0 +1,17 @@ +package app.revanced.manager.ui.component.patches + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.revanced.manager.R +import app.revanced.manager.ui.component.SafeguardDialog + +@Composable +fun SelectionWarningDialog( + onDismiss: () -> Unit +) { + SafeguardDialog( + onDismiss = onDismiss, + title = R.string.warning, + body = stringResource(R.string.selection_warning_description), + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index db0667a0..035c862e 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -73,6 +73,7 @@ import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.patches.OptionItem +import app.revanced.manager.ui.component.patches.SelectionWarningDialog import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_INCOMPATIBLE import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL @@ -181,7 +182,8 @@ fun PatchesSelectorScreen( patch = patch, values = viewModel.getOptions(bundle, patch), reset = { viewModel.resetOptions(bundle, patch) }, - set = { key, value -> viewModel.setOption(bundle, patch, key, value) } + set = { key, value -> viewModel.setOption(bundle, patch, key, value) }, + selectionWarningEnabled = viewModel.selectionWarningEnabled ) } @@ -215,9 +217,7 @@ fun PatchesSelectorScreen( ) { patch -> PatchItem( patch = patch, - onOptionsDialog = { - viewModel.optionsDialog = uid to patch - }, + onOptionsDialog = { viewModel.optionsDialog = uid to patch }, selected = compatible && viewModel.isSelected( uid, patch @@ -472,17 +472,6 @@ fun PatchesSelectorScreen( } } -@Composable -private fun SelectionWarningDialog( - onDismiss: () -> Unit -) { - SafeguardDialog( - onDismiss = onDismiss, - title = R.string.warning, - body = stringResource(R.string.selection_warning_description), - ) -} - @Composable private fun UniversalPatchWarningDialog( onDismiss: () -> Unit @@ -612,6 +601,7 @@ private fun OptionsDialog( reset: () -> Unit, set: (String, Any?) -> Unit, onDismissRequest: () -> Unit, + selectionWarningEnabled: Boolean ) = FullscreenDialog(onDismissRequest = onDismissRequest) { Scaffold( topBar = { @@ -642,7 +632,8 @@ private fun OptionsDialog( value = value, setValue = { set(key, it) - } + }, + selectionWarningEnabled = selectionWarningEnabled ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt index 4f054589..459e61ea 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt @@ -154,7 +154,8 @@ fun RequiredOptionsScreen( value = value, setValue = { new -> vm.setOption(bundle.uid, it, key, new) - } + }, + selectionWarningEnabled = vm.selectionWarningEnabled ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79f9daee..1117b5e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,8 +95,8 @@ Require suggested app version Enforce selection of the suggested app version Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways? - Allow changing patch selection - Do not prevent selecting or deselecting patches + Allow changing patch selection and options + Do not prevent selecting or deselecting patches and customization of options Changing the selection of patches may cause unexpected issues.\n\nEnable anyways? Allow using universal patches Do not prevent using universal patches From 47e4ed833686fb13b7bde9636e11c74065a8af71 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Tue, 15 Jul 2025 17:36:34 +0200 Subject: [PATCH 4/6] feat: Rename "Patch bundle" to "Patches" (#2541) Co-authored-by: Ax333l --- .../screen/settings/ImportExportSettingsScreen.kt | 4 ++-- .../manager/ui/viewmodel/ImportExportViewModel.kt | 4 ++-- app/src/main/res/values/strings.xml | 15 ++++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 181e8dcd..165c3edd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -296,8 +296,8 @@ fun ImportExportSettingsScreen( } } }, - headline = R.string.patch_options_reset, - description = R.string.patch_options_reset_all, + headline = R.string.patch_options_reset_patches, + description = R.string.patch_options_reset_patches_description, ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt index 8ac7119c..6e933696 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt @@ -81,8 +81,8 @@ sealed class ResetDialogState( ) class PatchOptionBundle(dialogOptionName: String, onConfirm: () -> Unit) : ResetDialogState( - titleResId = R.string.patch_options_reset, - descriptionResId = R.string.patch_options_reset_dialog_description, + titleResId = R.string.patch_options_reset_patches, + descriptionResId = R.string.patch_options_reset_patches_dialog_description, onConfirm = onConfirm, dialogOptionName = dialogOptionName ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1117b5e9..c1a3d648 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,21 +131,22 @@ Reset patch options Reset the stored patch options Patch selection has been reset - Reset all patch selection + Reset patch selection globally You are about to reset all the patch selections. You will need to manually select each patch again. - Reset all the patch selections + Resets all the patch selections Reset patch selection for app You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again. Resets patch selection for a single app - Resets patch selection for a specific patches + Reset patch selection (single) You are about to reset the patch selection for \"%s\". You will have to manually select each patch again. - Resets the patch selection for a specific patches + Resets the patch selection for a specific collection of patches Reset patch options for app You are about to reset the patch options for the app \"%s\". You will have to reapply each option again. Resets patch options for a single app - Reset patch options - You are about to reset the patch options for \"%s\". You will have to reapply each option again. - Reset patch options for all + Reset patch options (single) + You are about to reset the patch options for \"%s\". You will have to reapply each option again. + Resets the patch options for a specific collection of patches + Reset patch options globally You are about to reset patch options. You will have to reapply each option again. Resets all patch options Plugins From 244674a603ea008d51ac26d60a7eef17c546935a Mon Sep 17 00:00:00 2001 From: Ax333l Date: Tue, 15 Jul 2025 17:56:24 +0200 Subject: [PATCH 5/6] chore: remove unused dependency --- app/build.gradle.kts | 4 ---- .../java/app/revanced/manager/network/service/HttpService.kt | 1 - gradle/libs.versions.toml | 5 ----- 3 files changed, 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2da5205f..2de52228 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -40,10 +40,6 @@ dependencies { // Placeholder implementation(libs.placeholder.material3) - // HTML Scraper - implementation(libs.skrapeit.dsl) - implementation(libs.skrapeit.parser) - // Coil (async image loading, network image) implementation(libs.coil.compose) implementation(libs.coil.appiconloader) diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt index e0b69aa6..e0dd4499 100644 --- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt @@ -17,7 +17,6 @@ import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.core.isNotEmpty import io.ktor.utils.io.core.readBytes -import it.skrape.core.htmlDocument import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 245dda89..a41ac459 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,6 @@ dev-tools-gradle-plugin = "2.1.10-1.0.29" about-libraries-gradle-plugin = "12.1.2" coil = "2.7.0" app-icon-loader-coil = "1.5.0" -skrapeit = "1.2.2" libsu = "6.0.0" scrollbars = "1.0.4" enumutil = "1.1.1" @@ -99,10 +98,6 @@ ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "k ktor-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } -# HTML Scraper -skrapeit-dsl = { group = "it.skrape", name = "skrapeit-dsl", version.ref = "skrapeit" } -skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version.ref = "skrapeit" } - # Markdown markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } From fc961375673d24be11df6be34cce380c09f49014 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 18 Jul 2025 16:21:47 +0200 Subject: [PATCH 6/6] fix: remove unused function preventing compilation --- .../java/app/revanced/manager/network/service/HttpService.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt index e0dd4499..ea4cfb18 100644 --- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt @@ -8,7 +8,6 @@ import app.revanced.manager.util.tag import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.HttpRequestBuilder -import io.ktor.client.request.get import io.ktor.client.request.prepareGet import io.ktor.client.request.request import io.ktor.client.statement.bodyAsText @@ -92,9 +91,5 @@ class HttpService( builder: HttpRequestBuilder.() -> Unit ) = saveLocation.outputStream().use { streamTo(it, builder) } - suspend fun getHtml(builder: HttpRequestBuilder.() -> Unit) = htmlDocument( - html = http.get(builder).bodyAsText() - ) - class HttpException(status: HttpStatusCode) : Exception("Failed to fetch: http status: $status") } \ No newline at end of file