Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bb8d014
introduce protocol handlers for opening source streams
mostafaNazari702 Jul 12, 2026
81bef8b
redesign stream access around callbacks and complete the file protoco…
mostafaNazari702 Jul 15, 2026
a9e8048
move UI requests into a generic communication layer
mostafaNazari702 Jul 15, 2026
3c664f3
specialize the request layer to file picking
mostafaNazari702 Jul 16, 2026
783c703
parse sources as URIs and route remote transport through protocol han…
mostafaNazari702 Jul 16, 2026
caa7808
turn the default source into a plain URL
mostafaNazari702 Jul 16, 2026
844dce7
route local imports through the protocol handlers
mostafaNazari702 Jul 16, 2026
85ef81b
merge local and remote sources into one
mostafaNazari702 Jul 16, 2026
9ea6088
provide the content resolver through the koin graph
mostafaNazari702 Jul 16, 2026
9d5de81
remove the local and remote distinction outside the handlers
mostafaNazari702 Jul 17, 2026
602a23c
store sources as plain URLs and migrate old rows on boot
mostafaNazari702 Jul 17, 2026
1168d70
remove the remaining remote references outside the handlers
mostafaNazari702 Jul 19, 2026
ece3dbc
enable auto update for the default source
mostafaNazari702 Jul 19, 2026
fdfa64c
rebuild the import dialog around a method dropdown
mostafaNazari702 Jul 23, 2026
f521703
offer the file picker from the auto method
mostafaNazari702 Jul 27, 2026
7238979
check for updates through the version resource
mostafaNazari702 Jul 27, 2026
2d81ce2
fill the input with the picked file
mostafaNazari702 Jul 27, 2026
5b2e425
stored sources as urls and renamed the column
mostafaNazari702 Aug 2, 2026
9019fc3
name the patches and downloader url errors
mostafaNazari702 Aug 2, 2026
897ff3f
fix the add button never enabling for http
mostafaNazari702 Aug 2, 2026
c4d27a5
read the changelog from the source url
mostafaNazari702 Aug 2, 2026
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
2 changes: 2 additions & 0 deletions app/src/main/java/app/revanced/manager/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import app.revanced.manager.util.SupportedLocales
import app.revanced.manager.util.deepLinkedComposable
import app.revanced.manager.util.navigateSafe
import app.revanced.manager.util.popBackStackSafe
import app.revanced.manager.ui.component.FilePickerRequestHost
import app.revanced.manager.util.resetListItemColorsCached
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
Expand Down Expand Up @@ -103,6 +104,7 @@ class MainActivity : AppCompatActivity() {
dynamicColor = dynamicColor,
pureBlackTheme = pureBlackTheme
) {
FilePickerRequestHost()
ReVancedManager(vm)
}
}
Expand Down
14 changes: 10 additions & 4 deletions app/src/main/java/app/revanced/manager/data/room/sources/Source.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.revanced.manager.data.room.sources

import android.net.Uri
import androidx.room.ColumnInfo
import io.ktor.http.Url

Expand All @@ -22,10 +23,15 @@ sealed class Source {
}

companion object {
fun from(value: String) = when (value) {
Local.SENTINEL -> Local
API.SENTINEL -> API
else -> Remote(Url(value))
fun from(value: String): Source {
val uri = Uri.parse(value)

return when (uri.scheme) {
// Rows written before sources were stored as URIs are plain
Comment thread
mostafaNazari702 marked this conversation as resolved.
Outdated
// "local" and "api" values without a scheme.
null -> if (value == API.SENTINEL) API else Local
else -> Remote(Url(value))
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/app/revanced/manager/di/ServiceModule.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
package app.revanced.manager.di

import app.revanced.manager.domain.protocol.ContentProtocolHandler
import app.revanced.manager.domain.protocol.FileProtocolHandler
import app.revanced.manager.domain.protocol.HttpProtocolHandler
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.util.FilePicker
import app.revanced.manager.util.UiFilePicker
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

val serviceModule = module {
singleOf(::HttpService)
}
singleOf(::UiFilePicker) { bind<FilePicker>() }
Comment thread
mostafaNazari702 marked this conversation as resolved.
single { androidContext().contentResolver }
singleOf(::HttpProtocolHandler)
singleOf(::ContentProtocolHandler)
singleOf(::FileProtocolHandler)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.revanced.manager.domain.manager

import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.annotation.StringRes
import app.revanced.manager.R
Expand All @@ -11,13 +12,13 @@ import app.revanced.manager.data.redux.Store
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.sources.Source as SourceInfo
import app.revanced.manager.data.room.sources.SourceProperties
import app.revanced.manager.domain.sources.APISource
import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull
import app.revanced.manager.domain.sources.LocalSource
import app.revanced.manager.domain.sources.RemoteSource
import app.revanced.manager.domain.protocol.ContentProtocolHandler
import app.revanced.manager.domain.protocol.FileProtocolHandler
import app.revanced.manager.domain.protocol.HttpProtocolHandler
import app.revanced.manager.domain.protocol.ProtocolHandler
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.domain.sources.UnsupportedRemoteSourceException
import app.revanced.manager.domain.sources.asRemoteSourceException
import app.revanced.manager.domain.sources.UnsupportedSourceException
import app.revanced.manager.domain.sources.asSourceException
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.getOrThrow
Expand All @@ -37,10 +38,11 @@ import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.io.File
import java.io.InputStream
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
Expand All @@ -56,6 +58,14 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
protected val prefs: PreferencesManager by inject()
protected val networkInfo: NetworkInfo by inject()
protected val http: HttpService by inject()
protected val json: Json by inject()

protected val protocolHandlers: Map<String, ProtocolHandler> = mapOf(
"http" to get<HttpProtocolHandler>(),
"https" to get<HttpProtocolHandler>(),
"content" to get<ContentProtocolHandler>(),
"file" to get<FileProtocolHandler>(),
)

protected abstract suspend fun dbGetAll(): List<DB>
protected abstract suspend fun dbGetProps(uid: Int): SourceProperties?
Expand Down Expand Up @@ -129,7 +139,7 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
updateDb(uid) {
it.copy(
name = newName,
releasedAt = (src as? RemoteSource)?.releasedAt?.toEpochMillis()
releasedAt = src.releasedAt?.toEpochMillis()
)
}
sources[uid] = src.copy(name = newName)
Expand Down Expand Up @@ -225,57 +235,58 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
)
}

suspend fun createLocal(createStream: suspend () -> InputStream) =
dispatchAction("Add local") { state ->
suspend fun importFrom(uri: Uri) =
dispatchAction("Import ($uri)") { state ->
val entity = createEntity("", SourceInfo.Local)
with(loadEntity(entity) as LocalSource<LOADED>) {
with(loadEntity(entity)) {
try {
createStream().use { patches -> replace(patches) }
replace(uri)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while creating local source", e)
Log.e(tag, "Got exception while importing source", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(replaceFail, e.simpleMessage()))
}

deleteLocalFile()
deleteFile()
}
}

doReload(state)
}

suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add remote ($url)") { state ->
suspend fun create(url: String, autoUpdate: Boolean) =
dispatchAction("Add ($url)") { state ->
val entity = createEntity("", SourceInfo.from(url), autoUpdate)
val src = loadEntity(entity) as RemoteSource<LOADED>
val src = loadEntity(entity)
update(src)
state.copy(sources = state.sources.toMutableMap().also { it[src.uid] = src })
}

suspend fun reloadApiSources() = dispatchAction("Reload API sources") { state ->
this@SourceManager.store.state.value.sources.values.filterIsInstance<APISource<LOADED>>()
this@SourceManager.store.state.value.sources.values
Comment thread
mostafaNazari702 marked this conversation as resolved.
.filter { it.isDefault }
.forEach { src ->
with(src) { deleteLocalFile() }
with(src) { deleteFile() }
updateDb(src.uid) { it.copy(versionHash = null, releasedAt = null) }
}

doReload(state)
}

suspend fun RemoteSource<LOADED>.setAutoUpdate(value: Boolean) =
suspend fun Source<LOADED>.setAutoUpdate(value: Boolean) =
dispatchAction("Set auto update ($name, $value)") { state ->
updateDb(uid) { it.copy(autoUpdate = value) }
val newSrc = state.sources[uid]?.asRemoteOrNull?.copy(autoUpdate = value)
val newSrc = state.sources[uid]?.copy(autoUpdate = value)
?: return@dispatchAction state

state.copy(sources = state.sources.toMutableMap().also { it[uid] = newSrc })
}

suspend fun RemoteSource<LOADED>.setEndpoint(value: String) =
suspend fun Source<LOADED>.setEndpoint(value: String) =
dispatchAction("Set endpoint ($name, $value)") { state ->
val current = state.sources[uid]?.asRemoteOrNull ?: return@dispatchAction state
if (current.endpoint == value) return@dispatchAction state
val current = state.sources[uid] ?: return@dispatchAction state
if (current.uri.toString() == value) return@dispatchAction state

updateDb(uid) { props ->
if (props.source !is SourceInfo.Remote) return@updateDb props
Expand All @@ -285,12 +296,12 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
releasedAt = null
)
}
with(current) { deleteLocalFile() }
with(current) { deleteFile() }

val newSources = state.sources.toMutableMap()
newSources[uid] = current.copy(
error = null,
endpoint = value,
uri = Uri.parse(value),
versionHash = null,
releasedAt = null
)
Expand All @@ -304,15 +315,15 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
}

suspend fun update(
vararg sources: RemoteSource<LOADED>,
vararg sources: Source<LOADED>,
showToast: Boolean = false,
force: Boolean = true
) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
}

suspend fun redownloadRemote() =
suspend fun redownload() =
store.dispatch(Update(force = true, redownload = true))

/**
Expand All @@ -325,33 +336,33 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
) { it.autoUpdate }
)

suspend fun validateRemoteUrl(url: String): String? = withContext(Dispatchers.IO) {
suspend fun validateUrl(url: String): String? = withContext(Dispatchers.IO) {
runCatching {
http.request<ReVancedAsset> {
url(url)
}.getOrThrow()
}.exceptionOrNull()?.toRemoteValidationMessage()
}.exceptionOrNull()?.toValidationMessage()
}

private fun Throwable.toRemoteValidationMessage() = when (asRemoteSourceException()) {
private fun Throwable.toValidationMessage() = when (asSourceException()) {
// wtf is this? this data is not a bundle, at least something!
is UnsupportedRemoteSourceException -> app.getString(R.string.remote_source_url_unsupported)
is UnsupportedSourceException -> app.getString(R.string.remote_source_url_unsupported)
Comment thread
mostafaNazari702 marked this conversation as resolved.
Outdated

// wtf is this? this is not a data at all and more like a webpage or something else!
else -> app.getString(R.string.remote_source_url_validation_failed)
}

private fun Throwable.toRemoteUpdateMessage() = when (asRemoteSourceException()) {
private fun Throwable.toUpdateMessage() = when (asSourceException()) {
// wtf is this? this data is not a bundle, at least something!
is UnsupportedRemoteSourceException -> app.getString(R.string.remote_source_url_unsupported)
is UnsupportedSourceException -> app.getString(R.string.remote_source_url_unsupported)
else -> simpleMessage()
}

private inner class Update(
private val force: Boolean = false,
private val redownload: Boolean = false,
private val showToast: Boolean = false,
private val predicate: (source: RemoteSource<LOADED>) -> Boolean = { true },
private val predicate: (source: Source<LOADED>) -> Boolean = { true },
) : Action<State<LOADED, OUTPUT>> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
Expand All @@ -367,7 +378,6 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
val outdated = current.outdatedSources.toMutableSet()

val results = current.sources.values
.filterIsInstance<RemoteSource<LOADED>>()
.filter { predicate(it) }
.also { targets ->
// Clear errors for sources we are updating.
Expand All @@ -383,7 +393,7 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
val updateResult = it.runCatching {
when {
redownload -> downloadLatest()
checkOnly -> getUpdateInfo()?.let { info -> RemoteSource.UpdateResult(info.version, info.createdAt) }
checkOnly -> getUpdateInfo()?.let { info -> Source.UpdateResult(info.version, info.createdAt) }
else -> update()
} ?: return@update null
}
Expand Down Expand Up @@ -429,7 +439,7 @@ abstract class SourceManager<DB : SourceManager.DatabaseEntity, LOADED, OUTPUT>(
when {
!showToast -> {}
hasErrors -> {
val error = errors.values.first().toRemoteUpdateMessage()
val error = errors.values.first().toUpdateMessage()
toast(updateFailed, error)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package app.revanced.manager.domain.protocol

import android.content.ContentResolver
import android.net.Uri
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.util.FilePicker
import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.io.InputStream

// Opens streams for the URI scheme it is registered for.
interface ProtocolHandler {
// Opens a stream to the resource behind [uri] and passes it to [block].
// The stream is only valid inside [block] and is closed automatically afterwards.
suspend fun <T> getStream(uri: Uri, block: suspend (InputStream) -> T): T
}

class HttpProtocolHandler(private val http: HttpService) : ProtocolHandler {
override suspend fun <T> getStream(uri: Uri, block: suspend (InputStream) -> T) =
http.getStream(block) { url(uri.toString()) }
}

// Opens content:// URIs, which the platform grants the app access to.
class ContentProtocolHandler(private val contentResolver: ContentResolver) : ProtocolHandler {
override suspend fun <T> getStream(uri: Uri, block: suspend (InputStream) -> T): T {
val stream = withContext(Dispatchers.IO) {
contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri")
}

return stream.use { block(it) }
}
}

// Reading file:// URIs directly requires storage permissions, which the app avoids.
// The user instead picks the file through the system file picker, which yields
// a content:// URI the app is allowed to open.
class FileProtocolHandler(
private val filePicker: FilePicker,
private val contentProtocolHandler: ContentProtocolHandler
) : ProtocolHandler {
override suspend fun <T> getStream(uri: Uri, block: suspend (InputStream) -> T): T {
val picked = filePicker.pickFile() ?: throw IOException("No file was selected")
return contentProtocolHandler.getStream(picked, block)
}
}

// Opens a stream to [uri] with the handler registered for its scheme.
suspend fun <T> Map<String, ProtocolHandler>.getStream(
uri: Uri,
block: suspend (InputStream) -> T
): T {
val handler = this[uri.scheme] ?: throw IOException("No handler for $uri")
return handler.getStream(uri, block)
}
Loading