Skip to content

feat: allow bundles to use classes from other bundles #1951

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions app/src/main/java/app/revanced/manager/data/redux/Redux.kt
Original file line number Diff line number Diff line change
@@ -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<S>(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<Action<S>>(capacity = 10)
private val lock = Mutex()

suspend fun dispatch(action: Action<S>) = 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<S> {
suspend fun ActionContext.execute(current: S): S
suspend fun catch(exception: Exception) {
Log.e(tag, "Got exception while executing $this", exception)
}
}
Original file line number Diff line number Diff line change
@@ -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<PatchBundleEntity>

@Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties?>

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

Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ val repositoryModule = module {
createdAtStart()
}
singleOf(::NetworkInfo)
singleOf(::PatchBundlePersistenceRepository)
singleOf(::PatchSelectionRepository)
singleOf(::PatchOptionsRepository)
singleOf(::PatchBundleRepository) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ val viewModelModule = module {
viewModelOf(::InstalledAppsViewModel)
viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)
}
Original file line number Diff line number Diff line change
@@ -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
)
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,43 @@
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

/**
* 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.
Expand All @@ -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(
""
)
}
}
Loading