From bb8d01468e40213ef9073c504451757047f1d742 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Sun, 12 Jul 2026 15:05:00 +0200 Subject: [PATCH 01/21] introduce protocol handlers for opening source streams --- .../app/revanced/manager/di/ServiceModule.kt | 6 ++ .../domain/protocol/ProtocolHandler.kt | 36 ++++++++++++ .../manager/network/service/HttpService.kt | 55 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index cfda5030db..6d75a41623 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -1,9 +1,15 @@ package app.revanced.manager.di +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.network.service.HttpService +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(::HttpProtocolHandler) { bind() } + singleOf(::FileProtocolHandler) { bind() } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt new file mode 100644 index 0000000000..0ae7824b4d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt @@ -0,0 +1,36 @@ +package app.revanced.manager.domain.protocol + +import android.app.Application +import android.net.Uri +import app.revanced.manager.network.service.HttpService +import io.ktor.client.request.url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.InputStream + + +// open streams for URIs of the [schemes] it is registered for. +interface ProtocolHandler { + val schemes: Set + + + // here we open a stream to the resource behind [uri]. caller is responsible for closing it. + suspend fun getStream(uri: Uri): InputStream +} + +class HttpProtocolHandler(private val http: HttpService) : ProtocolHandler { + override val schemes = setOf("http", "https") + + override suspend fun getStream(uri: Uri): InputStream = http.getStream { + url(uri.toString()) + } +} + +class FileProtocolHandler(private val app: Application) : ProtocolHandler { + override val schemes = setOf("file", "content") + + override suspend fun getStream(uri: Uri): InputStream = withContext(Dispatchers.IO) { + app.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") + } +} 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 6a4a87b25b..cce9812fd5 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 @@ -16,12 +16,22 @@ import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.exhausted import io.ktor.utils.io.readRemaining +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.io.asSink import kotlinx.serialization.json.Json import java.io.File +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.util.concurrent.atomic.AtomicReference /** * @author Aliucord Authors, DiamondMiner88 @@ -30,6 +40,8 @@ class HttpService( val json: Json, val http: HttpClient, ) { + private val writerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + suspend inline fun request(builder: HttpRequestBuilder.() -> Unit = {}): APIResponse { var body: String? = null @@ -85,6 +97,49 @@ class HttpService( } } + // Make a GET request and returns the response body as a stream. + // Caller is responsible for closing the returned stream. + // Failures happening while streaming are thrown from the stream's read methods. + fun getStream(builder: HttpRequestBuilder.() -> Unit): InputStream { + val failure = AtomicReference(null) + val stream = PipedInputStream(DEFAULT_BUFFER_SIZE) + val output = PipedOutputStream(stream) + + val writer = writerScope.launch { + try { + streamTo(output, builder) + } catch (c: CancellationException) { + throw c + } catch (t: Throwable) { + failure.set(t) + } finally { + runCatching { output.close() } // Unblocks the reader, failure must be recorded before this happens. + } + } + + return object : FilterInputStream(stream) { + override fun read() = rethrowingFailure { super.read() } + override fun read(b: ByteArray, off: Int, len: Int) = + rethrowingFailure { super.read(b, off, len) } + + private inline fun rethrowingFailure(read: () -> Int): Int { + val result = try { + read() + } catch (e: IOException) { + throw failure.get() ?: e + } + // failure closes the pipe early which then looks like a normal end of stream. + if (result == -1) failure.get()?.let { throw it } + return result + } + + override fun close() { + writer.cancel() + super.close() + } + } + } + suspend fun download( saveLocation: File, builder: HttpRequestBuilder.() -> Unit From 81bef8b41fe730ce9da5d844ff74546acc7dc2a8 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Wed, 15 Jul 2026 22:38:55 +0200 Subject: [PATCH 02/21] redesign stream access around callbacks and complete the file protocol handler --- .../java/app/revanced/manager/MainActivity.kt | 30 +++++++++ .../app/revanced/manager/di/ServiceModule.kt | 18 +++++- .../domain/protocol/ProtocolHandler.kt | 48 ++++++++++----- .../manager/network/service/HttpService.kt | 61 +++---------------- 4 files changed, 89 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 588d528b9e..646dc8bca4 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -3,8 +3,10 @@ package app.revanced.manager import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.EaseOutQuart @@ -15,7 +17,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle @@ -27,6 +31,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import app.revanced.manager.domain.protocol.FilePickRequest import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.ui.model.navigation.Announcement import app.revanced.manager.ui.model.navigation.Announcements @@ -72,13 +77,17 @@ import app.revanced.manager.util.deepLinkedComposable import app.revanced.manager.util.navigateSafe import app.revanced.manager.util.popBackStackSafe import app.revanced.manager.util.resetListItemColorsCached +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import java.util.Locale import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel class MainActivity : AppCompatActivity() { + private val filePickRequests: Channel by inject() + @ExperimentalAnimationApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -103,6 +112,7 @@ class MainActivity : AppCompatActivity() { dynamicColor = dynamicColor, pureBlackTheme = pureBlackTheme ) { + FilePickerHost(filePickRequests) ReVancedManager(vm) } } @@ -117,6 +127,26 @@ class MainActivity : AppCompatActivity() { } } +// Answers file pick requests from the file protocol handler by launching the system file picker. +@Composable +private fun FilePickerHost(requests: Channel) { + var current by remember { mutableStateOf(null) } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + current?.result?.complete(uri) + current = null + } + + LaunchedEffect(Unit) { + for (request in requests) { + current = request + picker.launch("*/*") + + // One pick at a time. + request.result.await() + } + } +} + @Composable private fun ReVancedManager(vm: MainViewModel) { val navController = rememberNavController() diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index 6d75a41623..84f9ad3802 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -1,15 +1,27 @@ package app.revanced.manager.di +import app.revanced.manager.domain.protocol.ContentProtocolHandler +import app.revanced.manager.domain.protocol.FilePickRequest 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.network.service.HttpService -import org.koin.core.module.dsl.bind +import kotlinx.coroutines.channels.Channel import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val serviceModule = module { singleOf(::HttpService) - singleOf(::HttpProtocolHandler) { bind() } - singleOf(::FileProtocolHandler) { bind() } + single { Channel() } + singleOf(::HttpProtocolHandler) + singleOf(::ContentProtocolHandler) + singleOf(::FileProtocolHandler) + single> { + mapOf( + "http" to get(), + "https" to get(), + "content" to get(), + "file" to get(), + ) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt index 0ae7824b4d..7f6d079848 100644 --- a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt +++ b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt @@ -4,33 +4,53 @@ import android.app.Application import android.net.Uri import app.revanced.manager.network.service.HttpService import io.ktor.client.request.url +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withContext import java.io.IOException import java.io.InputStream - -// open streams for URIs of the [schemes] it is registered for. +// Opens streams for the URI scheme it is registered for. interface ProtocolHandler { - val schemes: Set - - - // here we open a stream to the resource behind [uri]. caller is responsible for closing it. - suspend fun getStream(uri: Uri): InputStream + // 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 getStream(uri: Uri, block: suspend (InputStream) -> T): T } class HttpProtocolHandler(private val http: HttpService) : ProtocolHandler { - override val schemes = setOf("http", "https") + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T) = + http.getStream({ url(uri.toString()) }, block) +} + +// Opens content:// URIs, which the platform grants the app access to. +class ContentProtocolHandler(private val app: Application) : ProtocolHandler { + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { + val stream = withContext(Dispatchers.IO) { + app.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") + } - override suspend fun getStream(uri: Uri): InputStream = http.getStream { - url(uri.toString()) + return stream.use { block(it) } } } -class FileProtocolHandler(private val app: Application) : ProtocolHandler { - override val schemes = setOf("file", "content") +// A request for the UI to let the user pick a file, answered with a content:// URI. +class FilePickRequest { + val result = CompletableDeferred() +} - override suspend fun getStream(uri: Uri): InputStream = withContext(Dispatchers.IO) { - app.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") +// 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 pickRequests: Channel, + private val content: ContentProtocolHandler +) : ProtocolHandler { + override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { + val request = FilePickRequest() + pickRequests.send(request) + + val picked = request.result.await() ?: throw IOException("No file was selected") + return content.getStream(picked, block) } } 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 cce9812fd5..c3d7a21f2e 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 @@ -16,22 +16,14 @@ import io.ktor.http.isSuccess import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.exhausted import io.ktor.utils.io.readRemaining -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope +import io.ktor.utils.io.jvm.javaio.toInputStream import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.io.asSink import kotlinx.serialization.json.Json import java.io.File -import java.io.FilterInputStream -import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.util.concurrent.atomic.AtomicReference /** * @author Aliucord Authors, DiamondMiner88 @@ -40,8 +32,6 @@ class HttpService( val json: Json, val http: HttpClient, ) { - private val writerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - suspend inline fun request(builder: HttpRequestBuilder.() -> Unit = {}): APIResponse { var body: String? = null @@ -97,47 +87,16 @@ class HttpService( } } - // Make a GET request and returns the response body as a stream. - // Caller is responsible for closing the returned stream. - // Failures happening while streaming are thrown from the stream's read methods. - fun getStream(builder: HttpRequestBuilder.() -> Unit): InputStream { - val failure = AtomicReference(null) - val stream = PipedInputStream(DEFAULT_BUFFER_SIZE) - val output = PipedOutputStream(stream) - - val writer = writerScope.launch { - try { - streamTo(output, builder) - } catch (c: CancellationException) { - throw c - } catch (t: Throwable) { - failure.set(t) - } finally { - runCatching { output.close() } // Unblocks the reader, failure must be recorded before this happens. - } - } - - return object : FilterInputStream(stream) { - override fun read() = rethrowingFailure { super.read() } - override fun read(b: ByteArray, off: Int, len: Int) = - rethrowingFailure { super.read(b, off, len) } + // Makes a GET request and passes the response body to [block] as a stream. + // The stream is only valid inside [block] and is closed automatically afterwards. + suspend fun getStream( + builder: HttpRequestBuilder.() -> Unit, + block: suspend (InputStream) -> T + ): T = http.prepareGet(builder).execute { response -> + if (!response.status.isSuccess()) throw HttpException(response.status) - private inline fun rethrowingFailure(read: () -> Int): Int { - val result = try { - read() - } catch (e: IOException) { - throw failure.get() ?: e - } - // failure closes the pipe early which then looks like a normal end of stream. - if (result == -1) failure.get()?.let { throw it } - return result - } - - override fun close() { - writer.cancel() - super.close() - } - } + val channel: ByteReadChannel = response.body() + block(channel.toInputStream()) } suspend fun download( From a9e80480cc4a252307187c9261a6af09dbdb56db Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Wed, 15 Jul 2026 23:15:59 +0200 Subject: [PATCH 03/21] move UI requests into a generic communication layer --- .../java/app/revanced/manager/MainActivity.kt | 32 +-------------- .../app/revanced/manager/di/ServiceModule.kt | 20 +++------- .../domain/protocol/ProtocolHandler.kt | 27 +++++-------- .../manager/network/service/HttpService.kt | 8 ++-- .../manager/ui/component/UiRequestHost.kt | 39 +++++++++++++++++++ .../app/revanced/manager/util/UiRequest.kt | 36 +++++++++++++++++ 6 files changed, 95 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt create mode 100644 app/src/main/java/app/revanced/manager/util/UiRequest.kt diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 646dc8bca4..1aa15f5718 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -3,10 +3,8 @@ package app.revanced.manager import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.EaseOutQuart @@ -17,9 +15,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle @@ -31,7 +27,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import app.revanced.manager.domain.protocol.FilePickRequest import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.ui.model.navigation.Announcement import app.revanced.manager.ui.model.navigation.Announcements @@ -76,18 +71,15 @@ 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.UiRequestHost import app.revanced.manager.util.resetListItemColorsCached -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import java.util.Locale import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel class MainActivity : AppCompatActivity() { - private val filePickRequests: Channel by inject() - @ExperimentalAnimationApi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -112,7 +104,7 @@ class MainActivity : AppCompatActivity() { dynamicColor = dynamicColor, pureBlackTheme = pureBlackTheme ) { - FilePickerHost(filePickRequests) + UiRequestHost() ReVancedManager(vm) } } @@ -127,26 +119,6 @@ class MainActivity : AppCompatActivity() { } } -// Answers file pick requests from the file protocol handler by launching the system file picker. -@Composable -private fun FilePickerHost(requests: Channel) { - var current by remember { mutableStateOf(null) } - val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - current?.result?.complete(uri) - current = null - } - - LaunchedEffect(Unit) { - for (request in requests) { - current = request - picker.launch("*/*") - - // One pick at a time. - request.result.await() - } - } -} - @Composable private fun ReVancedManager(vm: MainViewModel) { val navController = rememberNavController() diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index 84f9ad3802..daeeee3335 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -1,27 +1,19 @@ package app.revanced.manager.di import app.revanced.manager.domain.protocol.ContentProtocolHandler -import app.revanced.manager.domain.protocol.FilePickRequest 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.network.service.HttpService -import kotlinx.coroutines.channels.Channel +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.singleOf import org.koin.dsl.module val serviceModule = module { singleOf(::HttpService) - single { Channel() } + single { UiFilePicker() } singleOf(::HttpProtocolHandler) - singleOf(::ContentProtocolHandler) + single { ContentProtocolHandler(androidContext().contentResolver) } singleOf(::FileProtocolHandler) - single> { - mapOf( - "http" to get(), - "https" to get(), - "content" to get(), - "file" to get(), - ) - } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt index 7f6d079848..cc5bddf94a 100644 --- a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt +++ b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt @@ -1,12 +1,11 @@ package app.revanced.manager.domain.protocol -import android.app.Application +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.CompletableDeferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withContext import java.io.IOException import java.io.InputStream @@ -20,37 +19,29 @@ interface ProtocolHandler { class HttpProtocolHandler(private val http: HttpService) : ProtocolHandler { override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T) = - http.getStream({ url(uri.toString()) }, block) + http.getStream(block) { url(uri.toString()) } } // Opens content:// URIs, which the platform grants the app access to. -class ContentProtocolHandler(private val app: Application) : ProtocolHandler { +class ContentProtocolHandler(private val contentResolver: ContentResolver) : ProtocolHandler { override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { val stream = withContext(Dispatchers.IO) { - app.contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") + contentResolver.openInputStream(uri) ?: throw IOException("Cannot open $uri") } return stream.use { block(it) } } } -// A request for the UI to let the user pick a file, answered with a content:// URI. -class FilePickRequest { - val result = CompletableDeferred() -} - // 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 pickRequests: Channel, - private val content: ContentProtocolHandler + private val filePicker: FilePicker, + private val contentProtocolHandler: ContentProtocolHandler ) : ProtocolHandler { override suspend fun getStream(uri: Uri, block: suspend (InputStream) -> T): T { - val request = FilePickRequest() - pickRequests.send(request) - - val picked = request.result.await() ?: throw IOException("No file was selected") - return content.getStream(picked, block) + val picked = filePicker.pickFile() ?: throw IOException("No file was selected") + return contentProtocolHandler.getStream(picked, block) } } 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 c3d7a21f2e..64e31559da 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 @@ -25,9 +25,7 @@ import java.io.File import java.io.InputStream import java.io.OutputStream -/** - * @author Aliucord Authors, DiamondMiner88 - */ +// Additional authors: Aliucord Authors, DiamondMiner88 class HttpService( val json: Json, val http: HttpClient, @@ -90,8 +88,8 @@ class HttpService( // Makes a GET request and passes the response body to [block] as a stream. // The stream is only valid inside [block] and is closed automatically afterwards. suspend fun getStream( - builder: HttpRequestBuilder.() -> Unit, - block: suspend (InputStream) -> T + block: suspend (InputStream) -> T, + builder: HttpRequestBuilder.() -> Unit ): T = http.prepareGet(builder).execute { response -> if (!response.status.isSuccess()) throw HttpException(response.status) diff --git a/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt b/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt new file mode 100644 index 0000000000..7312da152e --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt @@ -0,0 +1,39 @@ +package app.revanced.manager.ui.component + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import app.revanced.manager.util.FilePickRequest +import app.revanced.manager.util.UiRequest +import app.revanced.manager.util.uiRequestChannel + +// The request currently being fulfilled. Lives outside the composition so that a result +// delivered after the activity is recreated (e.g. a rotation while the file picker +// is open) still completes the request instead of dropping it. +private var currentRequest: UiRequest<*>? = null + +// Fulfills UI requests sent by background code. Mounted once at the root of the UI, +// unconditionally, so that pending activity results are always delivered. +@Composable +fun UiRequestHost() { + val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + (currentRequest as? FilePickRequest)?.result?.complete(uri) + currentRequest = null + } + + LaunchedEffect(Unit) { + // A request may still be in flight from before a configuration change. + currentRequest?.result?.await() + + for (request in uiRequestChannel) { + currentRequest = request + when (request) { + is FilePickRequest -> filePicker.launch("*/*") + } + + // One request at a time. + request.result.await() + } + } +} diff --git a/app/src/main/java/app/revanced/manager/util/UiRequest.kt b/app/src/main/java/app/revanced/manager/util/UiRequest.kt new file mode 100644 index 0000000000..7aeb7a2f08 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/UiRequest.kt @@ -0,0 +1,36 @@ +package app.revanced.manager.util + +import android.net.Uri +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel + +// Communication layer for background code that needs the UI to fulfill a request, +// such as showing a system dialog. UI hosts receive requests from [uiRequestChannel] +// and complete [UiRequest.result] with the outcome. +sealed class UiRequest { + // The response slot. The sender suspends on it until the UI completes it. + val result = CompletableDeferred() +} + +// A request to let the user pick a file, answered with a content:// URI +// or null if the picker was cancelled. +class FilePickRequest : UiRequest() + +val uiRequestChannel = Channel>() + +// Sends this request to the UI and suspends until it is fulfilled. +suspend fun UiRequest.send(): T { + uiRequestChannel.send(this) + return result.await() +} + +// Lets background code ask the user to pick a file without dealing with +// the request machinery. Answers with a content:// URI, or null if the +// picker was cancelled. +interface FilePicker { + suspend fun pickFile(): Uri? +} + +class UiFilePicker : FilePicker { + override suspend fun pickFile() = FilePickRequest().send() +} From 3c664f350e2e4453d6271602057a527a555bc168 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 06:15:42 +0200 Subject: [PATCH 04/21] specialize the request layer to file picking --- .../java/app/revanced/manager/MainActivity.kt | 4 +- .../app/revanced/manager/di/ServiceModule.kt | 3 +- .../ui/component/FilePickerRequestHost.kt | 32 +++++++++++++++ .../manager/ui/component/UiRequestHost.kt | 39 ------------------- .../app/revanced/manager/util/FilePicker.kt | 34 ++++++++++++++++ .../app/revanced/manager/util/UiRequest.kt | 36 ----------------- 6 files changed, 70 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt create mode 100644 app/src/main/java/app/revanced/manager/util/FilePicker.kt delete mode 100644 app/src/main/java/app/revanced/manager/util/UiRequest.kt diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 1aa15f5718..2fbc82a5d1 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -71,7 +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.UiRequestHost +import app.revanced.manager.ui.component.FilePickerRequestHost import app.revanced.manager.util.resetListItemColorsCached import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @@ -104,7 +104,7 @@ class MainActivity : AppCompatActivity() { dynamicColor = dynamicColor, pureBlackTheme = pureBlackTheme ) { - UiRequestHost() + FilePickerRequestHost() ReVancedManager(vm) } } diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index daeeee3335..578362a637 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -7,12 +7,13 @@ 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) - single { UiFilePicker() } + singleOf(::UiFilePicker) { bind() } singleOf(::HttpProtocolHandler) single { ContentProtocolHandler(androidContext().contentResolver) } singleOf(::FileProtocolHandler) diff --git a/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt b/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt new file mode 100644 index 0000000000..46477cabc1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/FilePickerRequestHost.kt @@ -0,0 +1,32 @@ +package app.revanced.manager.ui.component + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import app.revanced.manager.util.UiFilePicker +import app.revanced.manager.util.filePickerRequestChannel +import org.koin.compose.koinInject + +// Fulfills file pick requests sent by background code. Mounted once at the root +// of the UI, unconditionally, so pending activity results are always delivered. +@Composable +fun FilePickerRequestHost(picker: UiFilePicker = koinInject()) { + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + picker.currentRequest?.result?.complete(uri) + picker.currentRequest = null + } + + LaunchedEffect(Unit) { + // A request may still be in flight from before a configuration change. + picker.currentRequest?.result?.await() + + for (request in filePickerRequestChannel) { + picker.currentRequest = request + launcher.launch("*/*") + + // One request at a time. + request.result.await() + } + } +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt b/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt deleted file mode 100644 index 7312da152e..0000000000 --- a/app/src/main/java/app/revanced/manager/ui/component/UiRequestHost.kt +++ /dev/null @@ -1,39 +0,0 @@ -package app.revanced.manager.ui.component - -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import app.revanced.manager.util.FilePickRequest -import app.revanced.manager.util.UiRequest -import app.revanced.manager.util.uiRequestChannel - -// The request currently being fulfilled. Lives outside the composition so that a result -// delivered after the activity is recreated (e.g. a rotation while the file picker -// is open) still completes the request instead of dropping it. -private var currentRequest: UiRequest<*>? = null - -// Fulfills UI requests sent by background code. Mounted once at the root of the UI, -// unconditionally, so that pending activity results are always delivered. -@Composable -fun UiRequestHost() { - val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - (currentRequest as? FilePickRequest)?.result?.complete(uri) - currentRequest = null - } - - LaunchedEffect(Unit) { - // A request may still be in flight from before a configuration change. - currentRequest?.result?.await() - - for (request in uiRequestChannel) { - currentRequest = request - when (request) { - is FilePickRequest -> filePicker.launch("*/*") - } - - // One request at a time. - request.result.await() - } - } -} diff --git a/app/src/main/java/app/revanced/manager/util/FilePicker.kt b/app/src/main/java/app/revanced/manager/util/FilePicker.kt new file mode 100644 index 0000000000..eed93c26ec --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/FilePicker.kt @@ -0,0 +1,34 @@ +package app.revanced.manager.util + +import android.net.Uri +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel + +// A request to let the user pick a file, answered with a content:// URI +// or null if the picker was cancelled. The sender suspends on [result] +// until the UI completes it. +class FilePickerRequest { + val result = CompletableDeferred() +} + +val filePickerRequestChannel = Channel() + +// Lets background code ask the user to pick a file without dealing with +// the request machinery. +interface FilePicker { + suspend fun pickFile(): Uri? +} + +class UiFilePicker : FilePicker { + // The request currently shown to the user. Lives here rather than in the + // composition so a result delivered after activity recreation still + // completes it. + var currentRequest: FilePickerRequest? = null + + override suspend fun pickFile(): Uri? { + val request = FilePickerRequest() + filePickerRequestChannel.send(request) + + return request.result.await() + } +} diff --git a/app/src/main/java/app/revanced/manager/util/UiRequest.kt b/app/src/main/java/app/revanced/manager/util/UiRequest.kt deleted file mode 100644 index 7aeb7a2f08..0000000000 --- a/app/src/main/java/app/revanced/manager/util/UiRequest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package app.revanced.manager.util - -import android.net.Uri -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.channels.Channel - -// Communication layer for background code that needs the UI to fulfill a request, -// such as showing a system dialog. UI hosts receive requests from [uiRequestChannel] -// and complete [UiRequest.result] with the outcome. -sealed class UiRequest { - // The response slot. The sender suspends on it until the UI completes it. - val result = CompletableDeferred() -} - -// A request to let the user pick a file, answered with a content:// URI -// or null if the picker was cancelled. -class FilePickRequest : UiRequest() - -val uiRequestChannel = Channel>() - -// Sends this request to the UI and suspends until it is fulfilled. -suspend fun UiRequest.send(): T { - uiRequestChannel.send(this) - return result.await() -} - -// Lets background code ask the user to pick a file without dealing with -// the request machinery. Answers with a content:// URI, or null if the -// picker was cancelled. -interface FilePicker { - suspend fun pickFile(): Uri? -} - -class UiFilePicker : FilePicker { - override suspend fun pickFile() = FilePickRequest().send() -} From 783c7030be9c2a58eea3481f778609cd0a5f7198 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 06:36:00 +0200 Subject: [PATCH 05/21] parse sources as URIs and route remote transport through protocol handlers --- .../manager/data/room/sources/Source.kt | 14 ++++-- .../manager/domain/manager/SourceManager.kt | 14 ++++++ .../domain/protocol/ProtocolHandler.kt | 9 ++++ .../domain/repository/DownloaderRepository.kt | 7 ++- .../repository/PatchBundleRepository.kt | 7 ++- .../manager/domain/sources/RemoteSource.kt | 47 +++++++++++-------- 6 files changed, 71 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt index 060ed92674..afe5539eab 100644 --- a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt @@ -1,5 +1,6 @@ package app.revanced.manager.data.room.sources +import android.net.Uri import androidx.room.ColumnInfo import io.ktor.http.Url @@ -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 + // "local" and "api" values without a scheme. + null -> if (value == API.SENTINEL) API else Local + else -> Remote(Url(value)) + } } } } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index c956aa8d64..b603929706 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -11,6 +11,10 @@ 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.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.APISource import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.LocalSource @@ -37,7 +41,9 @@ 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 @@ -56,6 +62,14 @@ abstract class SourceManager( 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 = mapOf( + "http" to get(), + "https" to get(), + "content" to get(), + "file" to get(), + ) protected abstract suspend fun dbGetAll(): List protected abstract suspend fun dbGetProps(uid: Int): SourceProperties? diff --git a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt index cc5bddf94a..0d9eacdf86 100644 --- a/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt +++ b/app/src/main/java/app/revanced/manager/domain/protocol/ProtocolHandler.kt @@ -45,3 +45,12 @@ class FileProtocolHandler( return contentProtocolHandler.getStream(picked, block) } } + +// Opens a stream to [uri] with the handler registered for its scheme. +suspend fun Map.getStream( + uri: Uri, + block: suspend (InputStream) -> T +): T { + val handler = this[uri.scheme] ?: throw IOException("No handler for $uri") + return handler.getStream(uri, block) +} diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 1c39aee3f2..cdc4a99688 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -81,7 +81,8 @@ class DownloaderRepository( file, SourceInfo.API.SENTINEL, autoUpdate, - loader + loader, + protocolHandlers ) { getDownloaderUpdate() } is SourceInfo.Remote -> JsonSource( @@ -93,7 +94,9 @@ class DownloaderRepository( file, source.url.toString(), autoUpdate, - loader + loader, + protocolHandlers, + json ) } } 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 3b6ad8a7bb..bea9b88c8c 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 @@ -78,7 +78,8 @@ class PatchBundleRepository( file, SourceInfo.API.SENTINEL, autoUpdate, - PatchBundleLoader + PatchBundleLoader, + protocolHandlers ) { getPatchesUpdate() } is SourceInfo.Remote -> JsonPatchBundle( @@ -90,7 +91,9 @@ class PatchBundleRepository( file, source.url.toString(), autoUpdate, - PatchBundleLoader + PatchBundleLoader, + protocolHandlers, + json ) } } diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt index c6125bf2ac..f8045663ba 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt @@ -1,20 +1,22 @@ package app.revanced.manager.domain.sources +import android.net.Uri import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.domain.protocol.ProtocolHandler +import app.revanced.manager.domain.protocol.getStream import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset -import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.utils.APIFailure import app.revanced.manager.network.utils.APIResponse import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.patcher.patch.PatchBundle -import io.ktor.client.request.url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File -import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerializationException typealias RemotePatchBundle = RemoteSource @@ -45,12 +47,11 @@ sealed class RemoteSource( file: File, val endpoint: String, val autoUpdate: Boolean, - loader: Loader -) : Source(name, uid, error, file, loader), KoinComponent { + loader: Loader, + protected val handlers: Map +) : Source(name, uid, error, file, loader) { data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) - protected val http: HttpService by inject() - protected abstract suspend fun getLatestInfo(): ReVancedAsset abstract fun copy( error: Throwable? = this.error, @@ -65,10 +66,8 @@ sealed class RemoteSource( copy(error, name, this.endpoint, this.autoUpdate, this.versionHash, this.releasedAt) private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { - outputStream().use { - http.streamTo(it) { - url(info.downloadUrl) - } + handlers.getStream(Uri.parse(info.downloadUrl)) { stream -> + outputStream().use { stream.copyTo(it) } } UpdateResult(info.version, info.createdAt) @@ -99,13 +98,17 @@ class JsonSource( file: File, endpoint: String, autoUpdate: Boolean, - loader: Loader -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { + loader: Loader, + handlers: Map, + private val json: Json +) : RemoteSource( + name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader, handlers +) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { runCatching { - http.request { - url(endpoint) - }.getOrThrow() + handlers.getStream(Uri.parse(endpoint)) { stream -> + json.decodeFromString(stream.reader().readText()) + } }.getOrElse { throw it.asRemoteSourceException() } } @@ -125,7 +128,9 @@ class JsonSource( file, endpoint, autoUpdate, - loader + loader, + handlers, + json ) } @@ -139,8 +144,11 @@ class APISource( endpoint: String, autoUpdate: Boolean, loader: Loader, + handlers: Map, private val getUpdate: suspend ReVancedAPI.() -> APIResponse -) : RemoteSource(name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader) { +) : RemoteSource( + name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader, handlers +), KoinComponent { private val api: ReVancedAPI by inject() override suspend fun getLatestInfo() = api.getUpdate().getOrThrow() @@ -161,6 +169,7 @@ class APISource( endpoint, autoUpdate, loader, + handlers, getUpdate ) -} \ No newline at end of file +} From caa7808ba8a2b8b06b690db5e57d31c643eef04d Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 15:36:19 +0200 Subject: [PATCH 06/21] turn the default source into a plain URL --- .../manager/domain/manager/SourceManager.kt | 5 +- .../domain/repository/DownloaderRepository.kt | 16 +++++-- .../repository/PatchBundleRepository.kt | 16 +++++-- .../manager/domain/sources/RemoteSource.kt | 46 ------------------- .../manager/network/api/ReVancedAPI.kt | 4 -- .../viewmodel/BundleInformationViewModel.kt | 3 ++ .../ui/viewmodel/DownloadsViewModel.kt | 3 ++ 7 files changed, 31 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index b603929706..bd308fcde9 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -15,7 +15,6 @@ 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.APISource import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.RemoteSource @@ -268,7 +267,9 @@ abstract class SourceManager( } suspend fun reloadApiSources() = dispatchAction("Reload API sources") { state -> - this@SourceManager.store.state.value.sources.values.filterIsInstance>() + this@SourceManager.store.state.value.sources.values + .filterIsInstance>() + .filter { it.isDefault } .forEach { src -> with(src) { deleteLocalFile() } updateDb(src.uid) { it.copy(versionHash = null, releasedAt = null) } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index cdc4a99688..2da1dbbb51 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -16,7 +16,6 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.downloader.DownloaderEntity import app.revanced.manager.data.room.sources.SourceProperties import app.revanced.manager.domain.manager.SourceManager -import app.revanced.manager.domain.sources.APISource import app.revanced.manager.domain.sources.JsonSource import app.revanced.manager.domain.sources.Loader import app.revanced.manager.domain.sources.LocalSource @@ -72,18 +71,19 @@ class DownloaderRepository( return when (source) { is SourceInfo.Local -> LocalSource(actualName, uid, null, file, loader) - is SourceInfo.API -> APISource( + is SourceInfo.API -> JsonSource( actualName, uid, versionHash, releasedAt, null, file, - SourceInfo.API.SENTINEL, + defaultSourceUrl(), autoUpdate, loader, - protocolHandlers - ) { getDownloaderUpdate() } + protocolHandlers, + json + ) is SourceInfo.Remote -> JsonSource( actualName, @@ -113,6 +113,12 @@ class DownloaderRepository( releasedAt = props.releasedAt ) + // The default source is a plain URL to the ReVanced API, built from preferences + // so URL and prerelease changes take effect on the next reload. + private fun defaultSourceUrl() = + "${prefs.api.getBlocking()}/v5/manager/downloaders" + + if (prefs.useDownloaderPrerelease.getBlocking()) "/prerelease" else "" + override fun realNameOf(loaded: DownloaderPackage) = loaded.name override val updateFailed = R.string.downloader_update_failed 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 bea9b88c8c..96b8e88df6 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 @@ -9,7 +9,6 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.sources.SourceProperties import app.revanced.manager.data.room.sources.Source as SourceInfo -import app.revanced.manager.domain.sources.APIPatchBundle import app.revanced.manager.domain.sources.JsonPatchBundle import app.revanced.manager.domain.sources.LocalPatchBundle import app.revanced.manager.domain.sources.PatchBundleSource @@ -69,18 +68,19 @@ class PatchBundleRepository( return when (source) { is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, file, PatchBundleLoader) - is SourceInfo.API -> APIPatchBundle( + is SourceInfo.API -> JsonPatchBundle( actualName, uid, versionHash, releasedAt, null, file, - SourceInfo.API.SENTINEL, + defaultSourceUrl(), autoUpdate, PatchBundleLoader, - protocolHandlers - ) { getPatchesUpdate() } + protocolHandlers, + json + ) is SourceInfo.Remote -> JsonPatchBundle( actualName, @@ -110,6 +110,12 @@ class PatchBundleRepository( releasedAt = props.releasedAt ) + // The default source is a plain URL to the ReVanced API, built from preferences + // so URL and prerelease changes take effect on the next reload. + private fun defaultSourceUrl() = + "${prefs.api.getBlocking()}/v5/patches" + + if (prefs.usePatchesPrereleases.getBlocking()) "/prerelease" else "" + override fun realNameOf(loaded: PatchBundle) = loaded.manifestAttributes?.name override suspend fun loadDataFromSources(sources: MutableMap>) = loadMetadata(sources).toPersistentMap() diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt index f8045663ba..efb01c6d35 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt @@ -4,24 +4,18 @@ import android.net.Uri import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.domain.protocol.ProtocolHandler import app.revanced.manager.domain.protocol.getStream -import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.utils.APIFailure -import app.revanced.manager.network.utils.APIResponse -import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.patcher.patch.PatchBundle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime import kotlinx.serialization.json.Json -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import java.io.File import kotlinx.serialization.SerializationException typealias RemotePatchBundle = RemoteSource typealias JsonPatchBundle = JsonSource -typealias APIPatchBundle = APISource class UnsupportedRemoteSourceException(cause: Throwable? = null) : Exception(cause) @@ -133,43 +127,3 @@ class JsonSource( json ) } - -class APISource( - name: String, - uid: Int, - versionHash: String?, - releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - endpoint: String, - autoUpdate: Boolean, - loader: Loader, - handlers: Map, - private val getUpdate: suspend ReVancedAPI.() -> APIResponse -) : RemoteSource( - name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader, handlers -), KoinComponent { - private val api: ReVancedAPI by inject() - - override suspend fun getLatestInfo() = api.getUpdate().getOrThrow() - override fun copy( - error: Throwable?, - name: String, - endpoint: String, - autoUpdate: Boolean, - versionHash: String?, - releasedAt: LocalDateTime? - ) = APISource( - name, - uid, - versionHash, - releasedAt, - error, - file, - endpoint, - autoUpdate, - loader, - handlers, - getUpdate - ) -} diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index adcddcc3b8..d0ed9cd7ae 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -46,13 +46,9 @@ class ReVancedAPI( suspend fun getAppHistory() = request>("manager/history${prefs.useManagerPrereleases.prereleaseString()}") - suspend fun getPatchesUpdate() = request("patches${prefs.usePatchesPrereleases.prereleaseString()}") - suspend fun getPatchesHistory(apiUrl: String, prerelease: Boolean) = request>(apiUrl, defaultApiVersion, "patches/history${prerelease.prereleaseString()}") - suspend fun getDownloaderUpdate() = request("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}") - suspend fun getContributors() = request>("contributors") suspend fun getInfo() = request("about") diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt index 46b5c10b88..95eeb67a3b 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt @@ -50,6 +50,9 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { prefs.usePatchesPrereleases.update(value) + + // Rebuilds the default source with the URL of the new release channel. + patchBundleRepository.reload() refresh() } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index cad37e9a15..329e18a8fa 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -57,6 +57,9 @@ class DownloadsViewModel( fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { usePrereleases.update(value) + + // Rebuilds the default source with the URL of the new release channel. + downloaderRepository.reload() val apiSource = downloaderRepository.downloaderSources.first()[0]?.asRemoteOrNull ?: return@launch updateDownloader(apiSource) } From 844dce762b2af928c1fc7f132c95b2738b7c7d27 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 16:07:10 +0200 Subject: [PATCH 07/21] route local imports through the protocol handlers --- .../manager/domain/manager/SourceManager.kt | 6 +++--- .../domain/repository/DownloaderRepository.kt | 9 ++++++++- .../domain/repository/PatchBundleRepository.kt | 9 ++++++++- .../manager/domain/sources/LocalSource.kt | 16 ++++++++++------ .../manager/ui/viewmodel/DashboardViewModel.kt | 6 +----- .../manager/ui/viewmodel/DownloadsViewModel.kt | 7 +------ 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index bd308fcde9..d6399b5cb8 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -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 @@ -45,7 +46,6 @@ 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 @@ -238,12 +238,12 @@ abstract class SourceManager( ) } - suspend fun createLocal(createStream: suspend () -> InputStream) = + suspend fun createLocal(uri: Uri) = dispatchAction("Add local") { state -> val entity = createEntity("", SourceInfo.Local) with(loadEntity(entity) as LocalSource) { 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) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 2da1dbbb51..1771c0cb61 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -70,7 +70,14 @@ class DownloaderRepository( } return when (source) { - is SourceInfo.Local -> LocalSource(actualName, uid, null, file, loader) + is SourceInfo.Local -> LocalSource( + actualName, + uid, + null, + file, + loader, + protocolHandlers + ) is SourceInfo.API -> JsonSource( actualName, uid, 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 96b8e88df6..7e760e5839 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 @@ -67,7 +67,14 @@ class PatchBundleRepository( } return when (source) { - is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, file, PatchBundleLoader) + is SourceInfo.Local -> LocalPatchBundle( + actualName, + uid, + null, + file, + PatchBundleLoader, + protocolHandlers + ) is SourceInfo.API -> JsonPatchBundle( actualName, uid, diff --git a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt index e38a25f7d8..10646d8fb4 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt @@ -1,11 +1,13 @@ package app.revanced.manager.domain.sources +import android.net.Uri import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.domain.protocol.ProtocolHandler +import app.revanced.manager.domain.protocol.getStream import app.revanced.manager.patcher.patch.PatchBundle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File -import java.io.InputStream typealias LocalPatchBundle = LocalSource @@ -14,12 +16,13 @@ class LocalSource( uid: Int, error: Throwable?, file: File, - loader: Loader + loader: Loader, + private val handlers: Map ) : Source(name, uid, error, file, loader) { - suspend fun ActionContext.replace(inputStream: InputStream) { + suspend fun ActionContext.replace(uri: Uri) { withContext(Dispatchers.IO) { - outputStream().use { outputStream -> - inputStream.copyTo(outputStream) + handlers.getStream(uri) { stream -> + outputStream().use { stream.copyTo(it) } } } } @@ -29,6 +32,7 @@ class LocalSource( uid, error, file, - loader + loader, + handlers ) } 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 e599961442..d56644b5c1 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,8 +1,6 @@ package app.revanced.manager.ui.viewmodel -import android.annotation.SuppressLint import android.app.Application -import android.content.ContentResolver import android.net.Uri import android.os.Build import androidx.compose.runtime.getValue @@ -38,7 +36,6 @@ class DashboardViewModel( val availablePatches = patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } } val bundleDownloadError = patchBundleRepository.apiOutageError - private val contentResolver: ContentResolver = app.contentResolver val hasUpdate = managerUpdateRepository.hasUpdate val updateVersion = managerUpdateRepository.version @@ -124,9 +121,8 @@ class DashboardViewModel( patchBundleRepository.remove(source) } - @SuppressLint("Recycle") fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { - patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! } + patchBundleRepository.createLocal(patchBundle) } fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 329e18a8fa..6706fb9954 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -1,7 +1,5 @@ package app.revanced.manager.ui.viewmodel -import android.annotation.SuppressLint -import android.app.Application import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,14 +25,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DownloadsViewModel( - app: Application, private val downloadedAppRepository: DownloadedAppRepository, private val downloaderRepository: DownloaderRepository, prefs: PreferencesManager, val pm: PM, val networkInfo: NetworkInfo, ) : ViewModel() { - private val contentResolver = app.contentResolver val usePrereleases = prefs.useDownloaderPrerelease val downloaderSources = downloaderRepository.downloaderSources val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps -> @@ -64,9 +60,8 @@ class DownloadsViewModel( updateDownloader(apiSource) } - @SuppressLint("Recycle") fun createLocalSource(downloaderUri: Uri) = viewModelScope.launch { - downloaderRepository.createLocal { contentResolver.openInputStream(downloaderUri)!! } + downloaderRepository.createLocal(downloaderUri) } fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { From 85ef81b064f94e90859d0c3a8651f137d8d0075f Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 16:24:58 +0200 Subject: [PATCH 08/21] merge local and remote sources into one --- .../manager/domain/manager/SourceManager.kt | 28 ++-- .../domain/repository/DownloaderRepository.kt | 58 +++----- .../repository/PatchBundleRepository.kt | 61 +++------ .../manager/domain/sources/LocalSource.kt | 38 ------ .../manager/domain/sources/RemoteSource.kt | 129 ------------------ .../revanced/manager/domain/sources/Source.kt | 110 +++++++++++++-- .../ui/screen/BundleInformationScreen.kt | 5 +- .../screen/settings/DownloadersInfoScreen.kt | 2 +- .../viewmodel/BundleInformationViewModel.kt | 2 +- .../ui/viewmodel/BundleListViewModel.kt | 5 +- .../ui/viewmodel/DeveloperOptionsViewModel.kt | 4 +- .../ui/viewmodel/DownloadsViewModel.kt | 9 +- 12 files changed, 167 insertions(+), 284 deletions(-) delete mode 100644 app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt delete mode 100644 app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index d6399b5cb8..4db4273f25 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -17,8 +17,6 @@ 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.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.LocalSource -import app.revanced.manager.domain.sources.RemoteSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.sources.UnsupportedRemoteSourceException import app.revanced.manager.domain.sources.asRemoteSourceException @@ -142,7 +140,7 @@ abstract class SourceManager( updateDb(uid) { it.copy( name = newName, - releasedAt = (src as? RemoteSource)?.releasedAt?.toEpochMillis() + releasedAt = src.releasedAt?.toEpochMillis() ) } sources[uid] = src.copy(name = newName) @@ -241,7 +239,7 @@ abstract class SourceManager( suspend fun createLocal(uri: Uri) = dispatchAction("Add local") { state -> val entity = createEntity("", SourceInfo.Local) - with(loadEntity(entity) as LocalSource) { + with(loadEntity(entity)) { try { replace(uri) } catch (e: Exception) { @@ -261,14 +259,13 @@ abstract class SourceManager( suspend fun createRemote(url: String, autoUpdate: Boolean) = dispatchAction("Add remote ($url)") { state -> val entity = createEntity("", SourceInfo.from(url), autoUpdate) - val src = loadEntity(entity) as RemoteSource + 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>() .filter { it.isDefault } .forEach { src -> with(src) { deleteLocalFile() } @@ -278,19 +275,19 @@ abstract class SourceManager( doReload(state) } - suspend fun RemoteSource.setAutoUpdate(value: Boolean) = + suspend fun Source.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.setEndpoint(value: String) = + suspend fun Source.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 + if (current.uri.toString() == value) return@dispatchAction state updateDb(uid) { props -> if (props.source !is SourceInfo.Remote) return@updateDb props @@ -305,7 +302,7 @@ abstract class SourceManager( val newSources = state.sources.toMutableMap() newSources[uid] = current.copy( error = null, - endpoint = value, + uri = Uri.parse(value), versionHash = null, releasedAt = null ) @@ -319,7 +316,7 @@ abstract class SourceManager( } suspend fun update( - vararg sources: RemoteSource, + vararg sources: Source, showToast: Boolean = false, force: Boolean = true ) { @@ -366,7 +363,7 @@ abstract class SourceManager( private val force: Boolean = false, private val redownload: Boolean = false, private val showToast: Boolean = false, - private val predicate: (source: RemoteSource) -> Boolean = { true }, + private val predicate: (source: Source) -> Boolean = { true }, ) : Action> { private suspend fun toast(@StringRes id: Int, vararg args: Any?) = withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) } @@ -382,8 +379,7 @@ abstract class SourceManager( val outdated = current.outdatedSources.toMutableSet() val results = current.sources.values - .filterIsInstance>() - .filter { predicate(it) } + .filter { it.isUpdatable && predicate(it) } .also { targets -> // Clear errors for sources we are updating. targets.forEach { src -> @@ -398,7 +394,7 @@ abstract class SourceManager( 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 } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 1771c0cb61..64c8c3d75b 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -15,10 +15,9 @@ import app.revanced.manager.R import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.downloader.DownloaderEntity import app.revanced.manager.data.room.sources.SourceProperties +import android.net.Uri import app.revanced.manager.domain.manager.SourceManager -import app.revanced.manager.domain.sources.JsonSource import app.revanced.manager.domain.sources.Loader -import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.network.downloader.LoadedDownloader import app.revanced.manager.network.downloader.ParceledDownloaderData @@ -69,43 +68,26 @@ class DownloaderRepository( .toLocalDateTime(kotlinx.datetime.TimeZone.UTC) } - return when (source) { - is SourceInfo.Local -> LocalSource( - actualName, - uid, - null, - file, - loader, - protocolHandlers - ) - is SourceInfo.API -> JsonSource( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - defaultSourceUrl(), - autoUpdate, - loader, - protocolHandlers, - json - ) - - is SourceInfo.Remote -> JsonSource( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - source.url.toString(), - autoUpdate, - loader, - protocolHandlers, - json - ) + val uri = when (source) { + // Imported files are copied into app storage and their origin is not recorded. + is SourceInfo.Local -> Uri.parse("rvp:${file.absolutePath}") + is SourceInfo.API -> Uri.parse(defaultSourceUrl()) + is SourceInfo.Remote -> Uri.parse(source.url.toString()) } + + return Source( + actualName, + uid, + uri, + versionHash, + releasedAt, + autoUpdate, + null, + file, + loader, + protocolHandlers, + json + ) } override fun entityFromProps( 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 7e760e5839..7d34c77163 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 @@ -9,12 +9,10 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.sources.SourceProperties import app.revanced.manager.data.room.sources.Source as SourceInfo -import app.revanced.manager.domain.sources.JsonPatchBundle -import app.revanced.manager.domain.sources.LocalPatchBundle +import android.net.Uri import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.manager.SourceManager import app.revanced.manager.domain.sources.Loader -import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.sources.Source import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchBundle @@ -66,43 +64,26 @@ class PatchBundleRepository( .toLocalDateTime(TimeZone.UTC) } - return when (source) { - is SourceInfo.Local -> LocalPatchBundle( - actualName, - uid, - null, - file, - PatchBundleLoader, - protocolHandlers - ) - is SourceInfo.API -> JsonPatchBundle( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - defaultSourceUrl(), - autoUpdate, - PatchBundleLoader, - protocolHandlers, - json - ) - - is SourceInfo.Remote -> JsonPatchBundle( - actualName, - uid, - versionHash, - releasedAt, - null, - file, - source.url.toString(), - autoUpdate, - PatchBundleLoader, - protocolHandlers, - json - ) + val uri = when (source) { + // Imported files are copied into app storage and their origin is not recorded. + is SourceInfo.Local -> Uri.parse("rvp:${file.absolutePath}") + is SourceInfo.API -> Uri.parse(defaultSourceUrl()) + is SourceInfo.Remote -> Uri.parse(source.url.toString()) } + + return Source( + actualName, + uid, + uri, + versionHash, + releasedAt, + autoUpdate, + null, + file, + PatchBundleLoader, + protocolHandlers, + json + ) } override fun entityFromProps( @@ -201,7 +182,7 @@ class PatchBundleRepository( this[src.uid] = PatchBundleInfo.Global( src.name, bundle.manifestAttributes?.version, - (src as? RemotePatchBundle)?.releasedAt, + src.releasedAt, src.uid, result.getOrThrow().toList() ) diff --git a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt deleted file mode 100644 index 10646d8fb4..0000000000 --- a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt +++ /dev/null @@ -1,38 +0,0 @@ -package app.revanced.manager.domain.sources - -import android.net.Uri -import app.revanced.manager.data.redux.ActionContext -import app.revanced.manager.domain.protocol.ProtocolHandler -import app.revanced.manager.domain.protocol.getStream -import app.revanced.manager.patcher.patch.PatchBundle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -typealias LocalPatchBundle = LocalSource - -class LocalSource( - name: String, - uid: Int, - error: Throwable?, - file: File, - loader: Loader, - private val handlers: Map -) : Source(name, uid, error, file, loader) { - suspend fun ActionContext.replace(uri: Uri) { - withContext(Dispatchers.IO) { - handlers.getStream(uri) { stream -> - outputStream().use { stream.copyTo(it) } - } - } - } - - override fun copy(error: Throwable?, name: String) = LocalSource( - name, - uid, - error, - file, - loader, - handlers - ) -} diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt deleted file mode 100644 index efb01c6d35..0000000000 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ /dev/null @@ -1,129 +0,0 @@ -package app.revanced.manager.domain.sources - -import android.net.Uri -import app.revanced.manager.data.redux.ActionContext -import app.revanced.manager.domain.protocol.ProtocolHandler -import app.revanced.manager.domain.protocol.getStream -import app.revanced.manager.network.dto.ReVancedAsset -import app.revanced.manager.network.utils.APIFailure -import app.revanced.manager.patcher.patch.PatchBundle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.json.Json -import java.io.File -import kotlinx.serialization.SerializationException - -typealias RemotePatchBundle = RemoteSource -typealias JsonPatchBundle = JsonSource - -class UnsupportedRemoteSourceException(cause: Throwable? = null) : Exception(cause) - -internal fun Throwable.asRemoteSourceException(): Throwable { - if (this is UnsupportedRemoteSourceException) return this - - val hasSerializationFailure = generateSequence(this) { it.cause } - .any { it is SerializationException } - if (!hasSerializationFailure) return this - - return when (this) { - is APIFailure -> UnsupportedRemoteSourceException(this) - else -> UnsupportedRemoteSourceException(this) - } -} - -sealed class RemoteSource( - name: String, - uid: Int, - protected val versionHash: String?, - val releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - val endpoint: String, - val autoUpdate: Boolean, - loader: Loader, - protected val handlers: Map -) : Source(name, uid, error, file, loader) { - data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) - - protected abstract suspend fun getLatestInfo(): ReVancedAsset - abstract fun copy( - error: Throwable? = this.error, - name: String = this.name, - endpoint: String = this.endpoint, - autoUpdate: Boolean = this.autoUpdate, - versionHash: String? = this.versionHash, - releasedAt: LocalDateTime? = this.releasedAt - ): RemoteSource - - override fun copy(error: Throwable?, name: String): RemoteSource = - copy(error, name, this.endpoint, this.autoUpdate, this.versionHash, this.releasedAt) - - private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { - handlers.getStream(Uri.parse(info.downloadUrl)) { stream -> - outputStream().use { stream.copyTo(it) } - } - - UpdateResult(info.version, info.createdAt) - } - - /** - * Downloads the latest version regardless if there is a new update available. - */ - suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) - suspend fun ActionContext.getUpdateInfo() = - getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } - - suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { - getUpdateInfo()?.let { download(it) } - } - - companion object { - const val updateFailMsg = "Failed to update" - } -} - -class JsonSource( - name: String, - uid: Int, - versionHash: String?, - releasedAt: LocalDateTime?, - error: Throwable?, - file: File, - endpoint: String, - autoUpdate: Boolean, - loader: Loader, - handlers: Map, - private val json: Json -) : RemoteSource( - name, uid, versionHash, releasedAt, error, file, endpoint, autoUpdate, loader, handlers -) { - override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { - runCatching { - handlers.getStream(Uri.parse(endpoint)) { stream -> - json.decodeFromString(stream.reader().readText()) - } - }.getOrElse { throw it.asRemoteSourceException() } - } - - override fun copy( - error: Throwable?, - name: String, - endpoint: String, - autoUpdate: Boolean, - versionHash: String?, - releasedAt: LocalDateTime? - ) = JsonSource( - name, - uid, - versionHash, - releasedAt, - error, - file, - endpoint, - autoUpdate, - loader, - handlers, - json - ) -} diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index a25a22921a..6efc8f1976 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -1,10 +1,18 @@ package app.revanced.manager.domain.sources +import android.net.Uri import androidx.compose.runtime.Stable import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.domain.protocol.ProtocolHandler +import app.revanced.manager.domain.protocol.getStream +import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.utils.APIFailure import app.revanced.manager.patcher.patch.PatchBundle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json import java.io.File import java.io.OutputStream @@ -14,16 +22,37 @@ fun interface Loader { typealias PatchBundleSource = Source +class UnsupportedRemoteSourceException(cause: Throwable? = null) : Exception(cause) + +internal fun Throwable.asRemoteSourceException(): Throwable { + if (this is UnsupportedRemoteSourceException) return this + + val hasSerializationFailure = generateSequence(this) { it.cause } + .any { it is SerializationException } + if (!hasSerializationFailure) return this + + return when (this) { + is APIFailure -> UnsupportedRemoteSourceException(this) + else -> UnsupportedRemoteSourceException(this) + } +} + /** * A [PatchBundle] or [app.revanced.manager.downloader.Downloader] source. */ @Stable -sealed class Source( +class Source( val name: String, val uid: Int, + val uri: Uri, + val versionHash: String?, + val releasedAt: LocalDateTime?, + val autoUpdate: Boolean, error: Throwable?, - protected val file: File, - protected val loader: Loader + private val file: File, + private val loader: Loader, + private val handlers: Map, + private val json: Json ) { val state = when { error != null -> State.Failed(error) @@ -36,18 +65,43 @@ sealed class Source( } val isDefault inline get() = uid == 0 + + // A source can update itself when a handler knows how to reach its origin. + val isUpdatable get() = uri.scheme in handlers + val loaded get() = @Suppress("UNCHECKED_CAST") (state as? State.Available)?.obj val error get() = (state as? State.Failed)?.throwable + data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) + suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) { file.delete() } - abstract fun copy(error: Throwable? = this.error, name: String = this.name): Source + fun copy( + error: Throwable? = this.error, + name: String = this.name, + uri: Uri = this.uri, + autoUpdate: Boolean = this.autoUpdate, + versionHash: String? = this.versionHash, + releasedAt: LocalDateTime? = this.releasedAt + ) = Source( + name, + uid, + uri, + versionHash, + releasedAt, + autoUpdate, + error, + file, + loader, + handlers, + json + ) - protected fun hasInstalled() = file.exists() + private fun hasInstalled() = file.exists() - protected fun outputStream(): OutputStream = with(file) { + private fun outputStream(): OutputStream = with(file) { // Android 14+ requires dex containers to be readonly. try { setWritable(true, true) @@ -57,14 +111,54 @@ sealed class Source( } } + private suspend fun getLatestInfo(): ReVancedAsset = withContext(Dispatchers.IO) { + runCatching { + handlers.getStream(uri) { stream -> + json.decodeFromString(stream.reader().readText()) + } + }.getOrElse { throw it.asRemoteSourceException() } + } + + private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { + handlers.getStream(Uri.parse(info.downloadUrl)) { stream -> + outputStream().use { stream.copyTo(it) } + } + + UpdateResult(info.version, info.createdAt) + } + + /** + * Downloads the latest version regardless if there is a new update available. + */ + suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) + suspend fun ActionContext.getUpdateInfo() = + getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } + + suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { + getUpdateInfo()?.let { download(it) } + } + + // Replaces the content with the resource behind [uri], e.g. an imported file. + suspend fun ActionContext.replace(uri: Uri) { + withContext(Dispatchers.IO) { + handlers.getStream(uri) { stream -> + outputStream().use { stream.copyTo(it) } + } + } + } + sealed interface State { data object Missing : State data class Failed(val throwable: Throwable) : State data class Available(val obj: T) : State } + + companion object { + const val updateFailMsg = "Failed to update" + } } object Extensions { - val Source.asRemoteOrNull inline get() = this as? RemoteSource + val Source.asRemoteOrNull inline get() = takeIf { it.isUpdatable } val PatchBundleSource.version get() = loaded?.manifestAttributes?.version -} \ No newline at end of file +} diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index a024c67410..17d14c3033 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -59,7 +59,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog @@ -86,9 +85,9 @@ fun BundleInformationScreen( val patchCount by viewModel.patchCount.collectAsStateWithLifecycle(0) var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } - val isLocal = src is LocalSource<*> + val isLocal = !src.isUpdatable val bundleManifestAttributes = src.loaded?.manifestAttributes - val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } + val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.uri.toString() } ?: (null to null) val subtitleAuthor = bundleManifestAttributes?.author?.let { diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt index 593358d326..2e47eb9bde 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt @@ -215,7 +215,7 @@ fun DownloaderInfoScreen( ) } - remote?.endpoint?.takeUnless { source.isDefault }?.let { url -> + remote?.uri?.toString()?.takeUnless { source.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt index 95eeb67a3b..269278c71a 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt @@ -37,7 +37,7 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { fun setEndpoint(value: String) = viewModelScope.launch { val endpoint = value.trim() bundle.first()?.asRemoteOrNull?.let { current -> - if (current.endpoint == endpoint) return@launch + if (current.uri.toString() == endpoint) return@launch patchBundleRepository.run { current.setEndpoint(endpoint) } bundle.first()?.asRemoteOrNull?.let { updated -> 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 index f1276b6b48..ae634fc01e 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.sources.PatchBundleSource -import app.revanced.manager.domain.sources.RemotePatchBundle import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.util.mutableStateSetOf import kotlinx.coroutines.flow.combine @@ -52,7 +51,7 @@ class BundleListViewModel : ViewModel(), KoinComponent { Event.UPDATE_SELECTED -> viewModelScope.launch { patchBundleRepository.update( - *getSelectedSources().filterIsInstance().toTypedArray(), + *getSelectedSources().filter { it.isUpdatable }.toTypedArray(), showToast = true ) } @@ -63,7 +62,7 @@ class BundleListViewModel : ViewModel(), KoinComponent { viewModelScope.launch { patchBundleRepository.remove(src) } fun update(src: PatchBundleSource) = viewModelScope.launch { - if (src !is RemotePatchBundle) return@launch + if (!src.isUpdatable) return@launch patchBundleRepository.update(src, showToast = true) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt index f58c129580..1a1abfc326 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt @@ -4,7 +4,7 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R -import app.revanced.manager.domain.sources.RemotePatchBundle +import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -20,7 +20,7 @@ class DeveloperOptionsViewModel( private val downloaderRepository: DownloaderRepository ) : ViewModel() { fun redownloadBundles() = viewModelScope.launch { - uiSafe(app, R.string.patches_download_fail, RemotePatchBundle.updateFailMsg) { + uiSafe(app, R.string.patches_download_fail, Source.updateFailMsg) { patchBundleRepository.redownloadRemote() } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 6706fb9954..cc7f8a5b83 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -12,7 +12,6 @@ import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull -import app.revanced.manager.domain.sources.RemoteSource import app.revanced.manager.domain.sources.Source import app.revanced.manager.network.downloader.DownloaderPackage import app.revanced.manager.util.PM @@ -102,7 +101,7 @@ class DownloadsViewModel( } } - fun updateDownloader(src: RemoteSource) = viewModelScope.launch { + fun updateDownloader(src: Source) = viewModelScope.launch { try { isUpdatingDownloader = true downloaderRepository.update(src, showToast = true) @@ -111,15 +110,15 @@ class DownloadsViewModel( } } - fun setAutoUpdate(src: RemoteSource, value: Boolean) = viewModelScope.launch { + fun setAutoUpdate(src: Source, value: Boolean) = viewModelScope.launch { with(downloaderRepository) { src.setAutoUpdate(value) } } - fun setEndpoint(src: RemoteSource, value: String) = viewModelScope.launch { + fun setEndpoint(src: Source, value: String) = viewModelScope.launch { val endpoint = value.trim() - if (src.endpoint == endpoint) return@launch + if (src.uri.toString() == endpoint) return@launch with(downloaderRepository) { src.setEndpoint(endpoint) From 9ea6088125fa968eb470c17611b6b720bdf66cac Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 16 Jul 2026 20:38:08 +0200 Subject: [PATCH 09/21] provide the content resolver through the koin graph --- app/src/main/java/app/revanced/manager/di/ServiceModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index 578362a637..866df6db96 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -14,7 +14,8 @@ import org.koin.dsl.module val serviceModule = module { singleOf(::HttpService) singleOf(::UiFilePicker) { bind() } + single { androidContext().contentResolver } singleOf(::HttpProtocolHandler) - single { ContentProtocolHandler(androidContext().contentResolver) } + singleOf(::ContentProtocolHandler) singleOf(::FileProtocolHandler) } From 9d5de81aefab80e144703c6ee2e3d68ef71da677 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Fri, 17 Jul 2026 23:01:19 +0200 Subject: [PATCH 10/21] remove the local and remote distinction outside the handlers --- .../manager/domain/manager/SourceManager.kt | 41 ++++--- .../domain/repository/DownloaderRepository.kt | 4 +- .../repository/PatchBundleRepository.kt | 4 +- .../revanced/manager/domain/sources/Source.kt | 22 ++-- .../ui/screen/BundleInformationScreen.kt | 106 +++++++++--------- .../manager/ui/screen/DashboardScreen.kt | 6 +- .../screen/settings/DownloadersInfoScreen.kt | 87 +++++++------- .../settings/DownloadsSettingsScreen.kt | 9 +- .../viewmodel/BundleInformationViewModel.kt | 11 +- .../ui/viewmodel/BundleListViewModel.kt | 4 +- .../ui/viewmodel/DashboardViewModel.kt | 10 +- .../ui/viewmodel/DeveloperOptionsViewModel.kt | 2 +- .../ui/viewmodel/DownloadsViewModel.kt | 15 ++- .../ui/viewmodel/OnboardingViewModel.kt | 7 +- .../ui/viewmodel/PatchesSelectorViewModel.kt | 3 +- 15 files changed, 151 insertions(+), 180 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index 4db4273f25..ab3981d6f3 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -16,10 +16,9 @@ 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.Extensions.asRemoteOrNull 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 @@ -236,28 +235,28 @@ abstract class SourceManager( ) } - suspend fun createLocal(uri: Uri) = - dispatchAction("Add local") { state -> + suspend fun importFrom(uri: Uri) = + dispatchAction("Import ($uri)") { state -> val entity = createEntity("", SourceInfo.Local) with(loadEntity(entity)) { try { 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) update(src) @@ -268,7 +267,7 @@ abstract class SourceManager( this@SourceManager.store.state.value.sources.values .filter { it.isDefault } .forEach { src -> - with(src) { deleteLocalFile() } + with(src) { deleteFile() } updateDb(src.uid) { it.copy(versionHash = null, releasedAt = null) } } @@ -286,7 +285,7 @@ abstract class SourceManager( suspend fun Source.setEndpoint(value: String) = dispatchAction("Set endpoint ($name, $value)") { state -> - val current = state.sources[uid]?.asRemoteOrNull ?: return@dispatchAction state + val current = state.sources[uid] ?: return@dispatchAction state if (current.uri.toString() == value) return@dispatchAction state updateDb(uid) { props -> @@ -297,7 +296,7 @@ abstract class SourceManager( releasedAt = null ) } - with(current) { deleteLocalFile() } + with(current) { deleteFile() } val newSources = state.sources.toMutableMap() newSources[uid] = current.copy( @@ -324,7 +323,7 @@ abstract class SourceManager( store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids }) } - suspend fun redownloadRemote() = + suspend fun redownload() = store.dispatch(Update(force = true, redownload = true)) /** @@ -337,25 +336,25 @@ abstract class SourceManager( ) { it.autoUpdate } ) - suspend fun validateRemoteUrl(url: String): String? = withContext(Dispatchers.IO) { + suspend fun validateUrl(url: String): String? = withContext(Dispatchers.IO) { runCatching { http.request { 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) // 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() } @@ -379,7 +378,7 @@ abstract class SourceManager( val outdated = current.outdatedSources.toMutableSet() val results = current.sources.values - .filter { it.isUpdatable && predicate(it) } + .filter { predicate(it) } .also { targets -> // Clear errors for sources we are updating. targets.forEach { src -> @@ -440,7 +439,7 @@ abstract class SourceManager( when { !showToast -> {} hasErrors -> { - val error = errors.values.first().toRemoteUpdateMessage() + val error = errors.values.first().toUpdateMessage() toast(updateFailed, error) } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 64c8c3d75b..5c5391d4d7 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -69,8 +69,8 @@ class DownloaderRepository( } val uri = when (source) { - // Imported files are copied into app storage and their origin is not recorded. - is SourceInfo.Local -> Uri.parse("rvp:${file.absolutePath}") + // Imported files are copied into app storage; the source points at that copy. + is SourceInfo.Local -> Uri.fromFile(file) is SourceInfo.API -> Uri.parse(defaultSourceUrl()) is SourceInfo.Remote -> Uri.parse(source.url.toString()) } 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 7d34c77163..2f09c9c979 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 @@ -65,8 +65,8 @@ class PatchBundleRepository( } val uri = when (source) { - // Imported files are copied into app storage and their origin is not recorded. - is SourceInfo.Local -> Uri.parse("rvp:${file.absolutePath}") + // Imported files are copied into app storage, the source points at that copy. + is SourceInfo.Local -> Uri.fromFile(file) is SourceInfo.API -> Uri.parse(defaultSourceUrl()) is SourceInfo.Remote -> Uri.parse(source.url.toString()) } diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index 6efc8f1976..30c380ed52 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -22,24 +22,22 @@ fun interface Loader { typealias PatchBundleSource = Source -class UnsupportedRemoteSourceException(cause: Throwable? = null) : Exception(cause) +class UnsupportedSourceException(cause: Throwable? = null) : Exception(cause) -internal fun Throwable.asRemoteSourceException(): Throwable { - if (this is UnsupportedRemoteSourceException) return this +internal fun Throwable.asSourceException(): Throwable { + if (this is UnsupportedSourceException) return this val hasSerializationFailure = generateSequence(this) { it.cause } .any { it is SerializationException } if (!hasSerializationFailure) return this return when (this) { - is APIFailure -> UnsupportedRemoteSourceException(this) - else -> UnsupportedRemoteSourceException(this) + is APIFailure -> UnsupportedSourceException(this) + else -> UnsupportedSourceException(this) } } -/** - * A [PatchBundle] or [app.revanced.manager.downloader.Downloader] source. - */ +// A resource and the URL it is retrieved from. @Stable class Source( val name: String, @@ -66,15 +64,12 @@ class Source( val isDefault inline get() = uid == 0 - // A source can update itself when a handler knows how to reach its origin. - val isUpdatable get() = uri.scheme in handlers - val loaded get() = @Suppress("UNCHECKED_CAST") (state as? State.Available)?.obj val error get() = (state as? State.Failed)?.throwable data class UpdateResult(val versionHash: String, val releasedAt: LocalDateTime) - suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) { + suspend fun ActionContext.deleteFile() = withContext(Dispatchers.IO) { file.delete() } @@ -116,7 +111,7 @@ class Source( handlers.getStream(uri) { stream -> json.decodeFromString(stream.reader().readText()) } - }.getOrElse { throw it.asRemoteSourceException() } + }.getOrElse { throw it.asSourceException() } } private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { @@ -159,6 +154,5 @@ class Source( } object Extensions { - val Source.asRemoteOrNull inline get() = takeIf { it.isUpdatable } val PatchBundleSource.version get() = loaded?.manifestAttributes?.version } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index 17d14c3033..b9a5e901b4 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.repository.ChangelogSource -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Source import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog @@ -85,10 +84,9 @@ fun BundleInformationScreen( val patchCount by viewModel.patchCount.collectAsStateWithLifecycle(0) var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } - val isLocal = !src.isUpdatable val bundleManifestAttributes = src.loaded?.manifestAttributes - val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.uri.toString() } - ?: (null to null) + val autoUpdate = src.autoUpdate + val endpoint = src.uri.toString() val subtitleAuthor = bundleManifestAttributes?.author?.let { stringResource(R.string.bundle_information_by_author, it) @@ -130,7 +128,7 @@ fun BundleInformationScreen( val separator = "$emSpace$dot$emSpace" Text(text = buildAnnotatedString { append("$subtitleAuthor$separator$subtitleVersion") - src.asRemoteOrNull?.releasedAt?.let { + src.releasedAt?.let { val releaseDate = it.relativeTime( LocalContext.current ) @@ -164,7 +162,7 @@ fun BundleInformationScreen( contentDescription ) } - if (!isLocal) TooltipIconButton( + TooltipIconButton( onClick = viewModel::refresh, tooltip = stringResource(R.string.refresh), ) { contentDescription -> @@ -240,36 +238,34 @@ fun BundleInformationScreen( } ListSection { - if (autoUpdate != null) { - SettingsListItem( - headlineContent = stringResource(R.string.auto_update), - supportingContent = stringResource(R.string.auto_update_description), - trailingContent = { - HapticSwitch( - checked = autoUpdate, - onCheckedChange = viewModel::setAutoUpdate, - thumbContent = if (autoUpdate) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } + SettingsListItem( + headlineContent = stringResource(R.string.auto_update), + supportingContent = stringResource(R.string.auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = viewModel::setAutoUpdate, + thumbContent = if (autoUpdate) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) } - ) - }, - onClick = { viewModel.setAutoUpdate(!autoUpdate) } - ) - } + } else { + { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + } + ) + }, + onClick = { viewModel.setAutoUpdate(!autoUpdate) } + ) if (src.isDefault) { SafeguardBooleanItem( @@ -285,7 +281,7 @@ fun BundleInformationScreen( ) } - endpoint?.takeUnless { src.isDefault }?.let { url -> + endpoint.takeUnless { src.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { @@ -326,25 +322,23 @@ fun BundleInformationScreen( trailingContent = null ) - endpoint?.let { - SettingsListItem( - headlineContent = stringResource(R.string.changelog), - onClick = { - val source = if (src.isDefault) { - ChangelogSource.Patches( - url = viewModel.prefs.api.getBlocking(), - prerelease = viewModel.prefs.usePatchesPrereleases.getBlocking() - ) - } else { - ChangelogSource.Patches( - url = endpoint, - prerelease = false - ) - } - onChangelogClick(source) - }, - ) - } + SettingsListItem( + headlineContent = stringResource(R.string.changelog), + onClick = { + val source = if (src.isDefault) { + ChangelogSource.Patches( + url = viewModel.prefs.api.getBlocking(), + prerelease = viewModel.prefs.usePatchesPrereleases.getBlocking() + ) + } else { + ChangelogSource.Patches( + url = endpoint, + prerelease = false + ) + } + onChangelogClick(source) + }, + ) src.error?.let { var showDialog by rememberSaveable { mutableStateOf(false) } @@ -369,7 +363,7 @@ fun BundleInformationScreen( ) } - if (src.state is Source.State.Missing && !isLocal) { + if (src.state is Source.State.Missing) { SettingsListItem( headlineContent = stringResource(R.string.patches_error), supportingContent = stringResource(R.string.patches_not_downloaded), 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 c9c8a44497..54e04a6ffd 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 @@ -200,16 +200,16 @@ fun DashboardScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.PATCHES, onDismiss = { showAddBundleDialog = false }, - validateRemote = vm::validateRemoteSourceUrl, + validateRemote = vm::validateSourceUrl, onLocalSubmit = { patches -> showAddBundleDialog = false patchesSourceEditMode = false - vm.createLocalSource(patches) + vm.importSource(patches) }, onRemoteSubmit = { url, autoUpdate -> showAddBundleDialog = false patchesSourceEditMode = false - vm.createRemoteSource(url, autoUpdate) + vm.createSource(url, autoUpdate) } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt index 2e47eb9bde..6bc2ef721b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.EmptyState @@ -69,8 +68,6 @@ fun DownloaderInfoScreen( ) { val downloaderStates by viewModel.downloaderSources.collectAsStateWithLifecycle(emptyMap()) val source = downloaderStates[uid] ?: return - val remote = source.asRemoteOrNull - val appName = source.name val displayNames = remember(source) { @@ -112,8 +109,8 @@ fun DownloaderInfoScreen( Text( text = buildAnnotatedString { append("v$it") - if (remote?.releasedAt != null) { - val releaseDate = remote.releasedAt.relativeTime( + source.releasedAt?.let { releasedAt -> + val releaseDate = releasedAt.relativeTime( LocalContext.current ).lowercase(getDefault()) @@ -143,14 +140,12 @@ fun DownloaderInfoScreen( Icon(Icons.Filled.Delete, stringResource(R.string.delete)) } - remote?.let { - TooltipIconButton( - onClick = { viewModel.updateDownloader(it) }, - enabled = !isDeleting, - tooltip = stringResource(R.string.update) - ) { - Icon(Icons.Filled.Refresh, stringResource(R.string.update)) - } + TooltipIconButton( + onClick = { viewModel.updateDownloader(source) }, + enabled = !isDeleting, + tooltip = stringResource(R.string.update) + ) { + Icon(Icons.Filled.Refresh, stringResource(R.string.update)) } }, scrollBehavior = scrollBehavior @@ -169,37 +164,35 @@ fun DownloaderInfoScreen( ListSection( modifier = Modifier.padding(top = 16.dp) ) { - remote?.let { remoteSource -> - val autoUpdate = remoteSource.autoUpdate - SettingsListItem( - headlineContent = stringResource(R.string.auto_update), - supportingContent = stringResource(R.string.auto_update_description), - trailingContent = { - HapticSwitch( - checked = autoUpdate, - onCheckedChange = { viewModel.setAutoUpdate(remoteSource, it) }, - thumbContent = if (autoUpdate) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - } else { - { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } + val autoUpdate = source.autoUpdate + SettingsListItem( + headlineContent = stringResource(R.string.auto_update), + supportingContent = stringResource(R.string.auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = { viewModel.setAutoUpdate(source, it) }, + thumbContent = if (autoUpdate) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) } - ) - }, - onClick = { viewModel.setAutoUpdate(remoteSource, !autoUpdate) } - ) - } + } else { + { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + } + ) + }, + onClick = { viewModel.setAutoUpdate(source, !autoUpdate) } + ) if (source.isDefault) { SafeguardBooleanItem( @@ -215,7 +208,7 @@ fun DownloaderInfoScreen( ) } - remote?.uri?.toString()?.takeUnless { source.isDefault }?.let { url -> + source.uri.toString().takeUnless { source.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { @@ -223,10 +216,10 @@ fun DownloaderInfoScreen( initial = url, title = stringResource(R.string.downloader_url), onDismissRequest = { showUrlInputDialog = false }, - confirmValidator = viewModel::validateRemoteSourceUrl, + confirmValidator = viewModel::validateSourceUrl, onConfirm = { showUrlInputDialog = false - viewModel.setEndpoint(remote, it.trim()) + viewModel.setEndpoint(source, it.trim()) }, validator = { val value = it.trim() @@ -281,7 +274,7 @@ fun DownloaderInfoScreen( EmptyState( icon = Icons.Outlined.SignalWifiOff, title = R.string.downloader_sources_unavailable_title, - description = if (remote != null) R.string.downloader_sources_unavailable_with_remote_description else R.string.downloader_sources_unavailable_without_remote_description + description = R.string.downloader_sources_unavailable_with_remote_description ) } } 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 b31159db8e..f5ae36d2d7 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 @@ -52,7 +52,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.room.apps.downloaded.DownloadedApp -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.sources.Source.State import app.revanced.manager.network.downloader.DownloaderPackage @@ -128,14 +127,14 @@ fun DownloadsSettingsScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.DOWNLOADERS, onDismiss = { showImportDialog = false }, - validateRemote = viewModel::validateRemoteSourceUrl, + validateRemote = viewModel::validateSourceUrl, onLocalSubmit = { uri -> showImportDialog = false - viewModel.createLocalSource(uri) + viewModel.importSource(uri) }, onRemoteSubmit = { url, autoUpdate -> showImportDialog = false - viewModel.createRemoteSource(url, autoUpdate) + viewModel.createSource(url, autoUpdate) } ) } @@ -333,7 +332,7 @@ private fun DownloaderItem( val version = source.loaded?.version val relativeTime = - (source.asRemoteOrNull)?.releasedAt?.relativeTime(LocalContext.current) + source.releasedAt?.relativeTime(LocalContext.current) Text( text = buildAnnotatedString { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt index 269278c71a..c3d7bdce5f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -23,30 +22,30 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { } fun refresh() = viewModelScope.launch { - bundle.first()?.asRemoteOrNull?.let { + bundle.first()?.let { patchBundleRepository.update(it, showToast = true) } } fun setAutoUpdate(value: Boolean) = viewModelScope.launch { - bundle.first()?.asRemoteOrNull?.let { + bundle.first()?.let { patchBundleRepository.run { it.setAutoUpdate(value) } } } fun setEndpoint(value: String) = viewModelScope.launch { val endpoint = value.trim() - bundle.first()?.asRemoteOrNull?.let { current -> + bundle.first()?.let { current -> if (current.uri.toString() == endpoint) return@launch patchBundleRepository.run { current.setEndpoint(endpoint) } - bundle.first()?.asRemoteOrNull?.let { updated -> + bundle.first()?.let { updated -> patchBundleRepository.update(updated, showToast = true) } } } - suspend fun validateEndpoint(value: String) = patchBundleRepository.validateRemoteUrl(value.trim()) + suspend fun validateEndpoint(value: String) = patchBundleRepository.validateUrl(value.trim()) fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { prefs.usePatchesPrereleases.update(value) 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 index ae634fc01e..c972f1f425 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt @@ -51,7 +51,7 @@ class BundleListViewModel : ViewModel(), KoinComponent { Event.UPDATE_SELECTED -> viewModelScope.launch { patchBundleRepository.update( - *getSelectedSources().filter { it.isUpdatable }.toTypedArray(), + *getSelectedSources().toTypedArray(), showToast = true ) } @@ -62,8 +62,6 @@ class BundleListViewModel : ViewModel(), KoinComponent { viewModelScope.launch { patchBundleRepository.remove(src) } fun update(src: PatchBundleSource) = viewModelScope.launch { - if (!src.isUpdatable) return@launch - patchBundleRepository.update(src, showToast = true) } 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 d56644b5c1..a1dbe406d4 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 @@ -121,13 +121,13 @@ class DashboardViewModel( patchBundleRepository.remove(source) } - fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { - patchBundleRepository.createLocal(patchBundle) + fun importSource(patchBundle: Uri) = viewModelScope.launch { + patchBundleRepository.importFrom(patchBundle) } - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { - patchBundleRepository.createRemote(apiUrl, autoUpdate) + fun createSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + patchBundleRepository.create(apiUrl, autoUpdate) } - suspend fun validateRemoteSourceUrl(apiUrl: String) = patchBundleRepository.validateRemoteUrl(apiUrl) + suspend fun validateSourceUrl(apiUrl: String) = patchBundleRepository.validateUrl(apiUrl) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt index 1a1abfc326..286cc7e1d2 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt @@ -21,7 +21,7 @@ class DeveloperOptionsViewModel( ) : ViewModel() { fun redownloadBundles() = viewModelScope.launch { uiSafe(app, R.string.patches_download_fail, Source.updateFailMsg) { - patchBundleRepository.redownloadRemote() + patchBundleRepository.redownload() } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index cc7f8a5b83..a3fcdf131f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -11,7 +11,6 @@ import app.revanced.manager.data.room.apps.downloaded.DownloadedApp import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloaderRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Source import app.revanced.manager.network.downloader.DownloaderPackage import app.revanced.manager.util.PM @@ -55,19 +54,19 @@ class DownloadsViewModel( // Rebuilds the default source with the URL of the new release channel. downloaderRepository.reload() - val apiSource = downloaderRepository.downloaderSources.first()[0]?.asRemoteOrNull ?: return@launch + val apiSource = downloaderRepository.downloaderSources.first()[0] ?: return@launch updateDownloader(apiSource) } - fun createLocalSource(downloaderUri: Uri) = viewModelScope.launch { - downloaderRepository.createLocal(downloaderUri) + fun importSource(downloaderUri: Uri) = viewModelScope.launch { + downloaderRepository.importFrom(downloaderUri) } - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { - downloaderRepository.createRemote(apiUrl, autoUpdate) + fun createSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + downloaderRepository.create(apiUrl, autoUpdate) } - suspend fun validateRemoteSourceUrl(apiUrl: String) = downloaderRepository.validateRemoteUrl(apiUrl) + suspend fun validateSourceUrl(apiUrl: String) = downloaderRepository.validateUrl(apiUrl) fun toggleApp(downloadedApp: DownloadedApp) { if (appSelection.contains(downloadedApp)) @@ -124,7 +123,7 @@ class DownloadsViewModel( src.setEndpoint(endpoint) } - downloaderSources.first()[src.uid]?.asRemoteOrNull?.let { updated -> + downloaderSources.first()[src.uid]?.let { updated -> updateDownloader(updated) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt index 648e44fd03..cb76bbe176 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/OnboardingViewModel.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.lifecycle.ViewModel -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -88,8 +87,7 @@ class OnboardingViewModel( with(patchBundleRepository) { val src = sources .first() - .find { it.isDefault } - ?.asRemoteOrNull ?: return@with + .find { it.isDefault } ?: return@with src.setAutoUpdate(patchesEnabled) update(src) @@ -97,8 +95,7 @@ class OnboardingViewModel( with(downloaderRepository) { val src = downloaderSources - .first()[0] - ?.asRemoteOrNull ?: return@with + .first()[0] ?: return@with src.setAutoUpdate(downloadersEnabled) update(src) 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 038218727b..1a6f86352f 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 @@ -18,7 +18,6 @@ import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.sources.Source.State import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull import app.revanced.manager.domain.sources.Extensions.version import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.PatchBundleInfo @@ -394,7 +393,7 @@ private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped( private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped( name = name, version = version, - releasedAt = (this.asRemoteOrNull)?.releasedAt, + releasedAt = releasedAt, uid = uid, patches = emptyList(), compatible = emptyList(), From 602a23cf2ff019b439fe92db7f159b65f3c31652 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Fri, 17 Jul 2026 23:34:50 +0200 Subject: [PATCH 11/21] store sources as plain URLs and migrate old rows on boot --- .../revanced/manager/data/room/Converters.kt | 6 +-- .../data/room/bundles/PatchBundleEntity.kt | 4 +- .../data/room/downloader/DownloaderEntity.kt | 4 +- .../manager/data/room/sources/Source.kt | 45 ---------------- .../data/room/sources/SourceProperties.kt | 12 +++++ .../manager/domain/manager/SourceManager.kt | 53 ++++++++++--------- .../domain/repository/DownloaderRepository.kt | 24 +++------ .../repository/PatchBundleRepository.kt | 24 +++------ .../ui/viewmodel/DeveloperOptionsViewModel.kt | 2 +- 9 files changed, 65 insertions(+), 109 deletions(-) delete mode 100644 app/src/main/java/app/revanced/manager/data/room/sources/Source.kt create mode 100644 app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt index eed7ddf901..c22b1927cc 100644 --- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt +++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt @@ -1,16 +1,16 @@ package app.revanced.manager.data.room +import android.net.Uri import androidx.room.TypeConverter import app.revanced.manager.data.room.options.Option.SerializedValue -import app.revanced.manager.data.room.sources.Source import java.io.File class Converters { @TypeConverter - fun sourceFromString(value: String) = Source.from(value) + fun uriFromString(value: String): Uri = Uri.parse(value) @TypeConverter - fun sourceToString(value: Source) = value.toString() + fun uriToString(value: Uri) = value.toString() @TypeConverter fun fileFromString(value: String) = File(value) 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 7c4b01177e..c9bfc58ba1 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 @@ -1,7 +1,7 @@ package app.revanced.manager.data.room.bundles +import android.net.Uri import androidx.room.* -import app.revanced.manager.data.room.sources.Source import app.revanced.manager.domain.manager.SourceManager @Entity(tableName = "patch_bundles") @@ -9,7 +9,7 @@ data class PatchBundleEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Source, + @ColumnInfo(name = "source") val source: Uri, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt index a0d558002d..4dd5454030 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt @@ -1,7 +1,7 @@ package app.revanced.manager.data.room.downloader +import android.net.Uri import androidx.room.* -import app.revanced.manager.data.room.sources.Source import app.revanced.manager.domain.manager.SourceManager @Entity(tableName = "downloaders") @@ -9,7 +9,7 @@ data class DownloaderEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Source, + @ColumnInfo(name = "source") val source: Uri, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt b/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt deleted file mode 100644 index afe5539eab..0000000000 --- a/app/src/main/java/app/revanced/manager/data/room/sources/Source.kt +++ /dev/null @@ -1,45 +0,0 @@ -package app.revanced.manager.data.room.sources - -import android.net.Uri -import androidx.room.ColumnInfo -import io.ktor.http.Url - - -sealed class Source { - object Local : Source() { - const val SENTINEL = "local" - - override fun toString() = SENTINEL - } - - object API : Source() { - const val SENTINEL = "api" - - override fun toString() = SENTINEL - } - - data class Remote(val url: Url) : Source() { - override fun toString() = url.toString() - } - - companion object { - fun from(value: String): Source { - val uri = Uri.parse(value) - - return when (uri.scheme) { - // Rows written before sources were stored as URIs are plain - // "local" and "api" values without a scheme. - null -> if (value == API.SENTINEL) API else Local - else -> Remote(Url(value)) - } - } - } -} - -data class SourceProperties( - @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, - @ColumnInfo(name = "released_at") val releasedAt: Long? = null, -) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt new file mode 100644 index 0000000000..bb7368c783 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt @@ -0,0 +1,12 @@ +package app.revanced.manager.data.room.sources + +import android.net.Uri +import androidx.room.ColumnInfo + +data class SourceProperties( + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "version") val versionHash: String? = null, + @ColumnInfo(name = "source") val source: Uri, + @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, + @ColumnInfo(name = "released_at") val releasedAt: Long? = null, +) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index ab3981d6f3..ec84e2f6d7 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -10,7 +10,6 @@ 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.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.protocol.ContentProtocolHandler import app.revanced.manager.domain.protocol.FileProtocolHandler @@ -26,7 +25,6 @@ import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag import app.revanced.manager.util.toast import io.ktor.client.request.url -import io.ktor.http.Url import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -76,6 +74,12 @@ abstract class SourceManager( protected abstract fun loadEntity(entity: DB): Source protected abstract fun entityFromProps(uid: Int, props: SourceProperties): DB + // The file the downloaded copy of a source lives in. + protected abstract fun fileOf(uid: Int): File + + // The URL of the default source, built from preferences. + protected abstract suspend fun defaultSourceUri(): Uri + protected abstract fun realNameOf(loaded: LOADED): String? @get:StringRes @@ -92,15 +96,6 @@ abstract class SourceManager( protected abstract suspend fun loadDataFromSources(sources: MutableMap>): OUTPUT - protected val defaultSource = entityFromProps( - 0, SourceProperties( - name = "", - versionHash = null, - source = SourceInfo.API, - autoUpdate = false - ) - ) - protected val store = Store( CoroutineScope(Dispatchers.Default), State(data = initial) @@ -154,24 +149,34 @@ abstract class SourceManager( } private suspend fun loadFromDb(): List { - val all = dbGetAll().toMutableList() - val default = defaultSource + if (dbGetAll().none { it.uid == 0 }) { + createEntity(0, "", defaultSourceUri()) + } - if (all.none { it.uid == default.uid }) { - dbUpsert(default) - all += default + // Migrates rows from before sources were stored as URLs and keeps the + // default source pointed at the API configured in settings. + dbGetAll().forEach { entity -> + val props = dbGetProps(entity.uid) ?: return@forEach + val migrated = when { + entity.uid == 0 -> defaultSourceUri().takeIf { it != props.source } + props.source.scheme == null -> Uri.fromFile(fileOf(entity.uid)) + else -> null + } ?: return@forEach + + updateDb(entity.uid) { it.copy(source = migrated) } } - return all + return dbGetAll() } private suspend fun createEntity( + uid: Int, name: String, - source: SourceInfo, + source: Uri, autoUpdate: Boolean = false, ) = entityFromProps( - uid = generateUid(), + uid = uid, SourceProperties( name = name, versionHash = null, @@ -237,7 +242,8 @@ abstract class SourceManager( suspend fun importFrom(uri: Uri) = dispatchAction("Import ($uri)") { state -> - val entity = createEntity("", SourceInfo.Local) + val uid = generateUid() + val entity = createEntity(uid, "", Uri.fromFile(fileOf(uid))) with(loadEntity(entity)) { try { replace(uri) @@ -257,13 +263,13 @@ abstract class SourceManager( suspend fun create(url: String, autoUpdate: Boolean) = dispatchAction("Add ($url)") { state -> - val entity = createEntity("", SourceInfo.from(url), autoUpdate) + val entity = createEntity(generateUid(), "", Uri.parse(url), autoUpdate) 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 -> + suspend fun resetDefaultSource() = dispatchAction("Reset default source") { state -> this@SourceManager.store.state.value.sources.values .filter { it.isDefault } .forEach { src -> @@ -289,9 +295,8 @@ abstract class SourceManager( if (current.uri.toString() == value) return@dispatchAction state updateDb(uid) { props -> - if (props.source !is SourceInfo.Remote) return@updateDb props props.copy( - source = SourceInfo.Remote(Url(value)), + source = Uri.parse(value), versionHash = null, releasedAt = null ) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 5c5391d4d7..fd6cf50e31 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -33,7 +33,6 @@ import java.io.File import java.lang.ref.WeakReference import java.lang.reflect.Modifier import kotlin.time.Instant -import app.revanced.manager.data.room.sources.Source as SourceInfo @OptIn(DownloaderHostApi::class) class DownloaderRepository( @@ -58,8 +57,9 @@ class DownloaderRepository( loadPackage(pkgInfo, dataDir) } + override fun fileOf(uid: Int): File = directoryOf(uid).resolve("downloader.jar") + override fun loadEntity(entity: DownloaderEntity): Source = with(entity) { - val file = directoryOf(uid).resolve("downloader.jar") val actualName = name.ifEmpty { app.getString(if (uid == 0) R.string.auto_updates_dialog_downloaders else R.string.source_name_fallback) } @@ -68,22 +68,15 @@ class DownloaderRepository( .toLocalDateTime(kotlinx.datetime.TimeZone.UTC) } - val uri = when (source) { - // Imported files are copied into app storage; the source points at that copy. - is SourceInfo.Local -> Uri.fromFile(file) - is SourceInfo.API -> Uri.parse(defaultSourceUrl()) - is SourceInfo.Remote -> Uri.parse(source.url.toString()) - } - return Source( actualName, uid, - uri, + source, versionHash, releasedAt, autoUpdate, null, - file, + fileOf(uid), loader, protocolHandlers, json @@ -102,11 +95,10 @@ class DownloaderRepository( releasedAt = props.releasedAt ) - // The default source is a plain URL to the ReVanced API, built from preferences - // so URL and prerelease changes take effect on the next reload. - private fun defaultSourceUrl() = - "${prefs.api.getBlocking()}/v5/manager/downloaders" + - if (prefs.useDownloaderPrerelease.getBlocking()) "/prerelease" else "" + override suspend fun defaultSourceUri(): Uri = Uri.parse( + "${prefs.api.get()}/v5/manager/downloaders" + + if (prefs.useDownloaderPrerelease.get()) "/prerelease" else "" + ) override fun realNameOf(loaded: DownloaderPackage) = loaded.name 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 2f09c9c979..fbe6a7e72f 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 @@ -8,7 +8,6 @@ import app.revanced.manager.R import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.sources.SourceProperties -import app.revanced.manager.data.room.sources.Source as SourceInfo import android.net.Uri import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.manager.SourceManager @@ -54,8 +53,9 @@ class PatchBundleRepository( override suspend fun dbRemove(uid: Int) = dao.remove(uid) override suspend fun dbReset() = dao.reset() + override fun fileOf(uid: Int): File = directoryOf(uid).resolve("patches.jar") + override fun loadEntity(entity: PatchBundleEntity): PatchBundleSource = with(entity) { - val file = directoryOf(uid).resolve("patches.jar") val actualName = entity.name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.source_name_fallback) } @@ -64,22 +64,15 @@ class PatchBundleRepository( .toLocalDateTime(TimeZone.UTC) } - val uri = when (source) { - // Imported files are copied into app storage, the source points at that copy. - is SourceInfo.Local -> Uri.fromFile(file) - is SourceInfo.API -> Uri.parse(defaultSourceUrl()) - is SourceInfo.Remote -> Uri.parse(source.url.toString()) - } - return Source( actualName, uid, - uri, + source, versionHash, releasedAt, autoUpdate, null, - file, + fileOf(uid), PatchBundleLoader, protocolHandlers, json @@ -98,11 +91,10 @@ class PatchBundleRepository( releasedAt = props.releasedAt ) - // The default source is a plain URL to the ReVanced API, built from preferences - // so URL and prerelease changes take effect on the next reload. - private fun defaultSourceUrl() = - "${prefs.api.getBlocking()}/v5/patches" + - if (prefs.usePatchesPrereleases.getBlocking()) "/prerelease" else "" + override suspend fun defaultSourceUri(): Uri = Uri.parse( + "${prefs.api.get()}/v5/patches" + + if (prefs.usePatchesPrereleases.get()) "/prerelease" else "" + ) override fun realNameOf(loaded: PatchBundle) = loaded.manifestAttributes?.name override suspend fun loadDataFromSources(sources: MutableMap>) = loadMetadata(sources).toPersistentMap() diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt index 286cc7e1d2..cdcb965dde 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt @@ -31,7 +31,7 @@ class DeveloperOptionsViewModel( prefs.api.update(value) arrayOf(patchBundleRepository, downloaderRepository).forEach { - it.reloadApiSources() + it.resetDefaultSource() it.updateCheck() } } From 1168d7047a8267b87db5466bdda643c63e0171f4 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Mon, 20 Jul 2026 00:27:48 +0200 Subject: [PATCH 12/21] remove the remaining remote references outside the handlers --- .../manager/domain/manager/SourceManager.kt | 23 ++++++++----------- .../manager/patcher/patch/PatchBundleInfo.kt | 2 +- .../component/sources/ImportSourceDialog.kt | 12 +++++----- .../ui/screen/BundleInformationScreen.kt | 10 ++++---- .../manager/ui/screen/DashboardScreen.kt | 6 ++--- .../screen/settings/DownloadersInfoScreen.kt | 4 ++-- .../settings/DownloadsSettingsScreen.kt | 6 ++--- .../viewmodel/BundleInformationViewModel.kt | 10 ++++---- .../ui/viewmodel/DownloadsViewModel.kt | 8 +++---- app/src/main/res/values/strings.xml | 7 +++--- 10 files changed, 42 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index ec84e2f6d7..ca417e8134 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -15,16 +15,14 @@ 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.protocol.getStream import app.revanced.manager.domain.sources.Source 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 import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag import app.revanced.manager.util.toast -import io.ktor.client.request.url import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -55,7 +53,6 @@ abstract class SourceManager( protected val app: Application by inject() 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 = mapOf( @@ -289,8 +286,8 @@ abstract class SourceManager( state.copy(sources = state.sources.toMutableMap().also { it[uid] = newSrc }) } - suspend fun Source.setEndpoint(value: String) = - dispatchAction("Set endpoint ($name, $value)") { state -> + suspend fun Source.setUrl(value: String) = + dispatchAction("Set URL ($name, $value)") { state -> val current = state.sources[uid] ?: return@dispatchAction state if (current.uri.toString() == value) return@dispatchAction state @@ -343,23 +340,23 @@ abstract class SourceManager( suspend fun validateUrl(url: String): String? = withContext(Dispatchers.IO) { runCatching { - http.request { - url(url) - }.getOrThrow() + protocolHandlers.getStream(Uri.parse(url)) { stream -> + json.decodeFromString(stream.reader().readText()) + } }.exceptionOrNull()?.toValidationMessage() } private fun Throwable.toValidationMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedSourceException -> app.getString(R.string.remote_source_url_unsupported) + is UnsupportedSourceException -> app.getString(R.string.source_url_unsupported) // 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) + else -> app.getString(R.string.source_url_validation_failed) } private fun Throwable.toUpdateMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedSourceException -> app.getString(R.string.remote_source_url_unsupported) + is UnsupportedSourceException -> app.getString(R.string.source_url_unsupported) else -> simpleMessage() } @@ -372,7 +369,7 @@ abstract class SourceManager( private suspend fun toast(@StringRes id: Int, vararg args: Any?) = withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) } - override fun toString() = if (redownload) "Redownload remote sources" else "Update check" + override fun toString() = if (redownload) "Redownload sources" else "Update check" override suspend fun ActionContext.execute( current: State 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 index 632e5584ee..dc31ca3d7d 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt @@ -18,7 +18,7 @@ sealed class PatchBundleInfo { abstract val version: String? /** - * When this bundle was released. Only applicable for remote bundles. + * When this bundle was released, if known. */ abstract val releasedAt: LocalDateTime? diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index 84d6229cbc..8d1be65682 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -67,9 +67,9 @@ enum class ImportSourceDialogStrings( fun ImportSourceDialog( strings: ImportSourceDialogStrings, onDismiss: () -> Unit, - validateRemote: suspend (String) -> String?, - onRemoteSubmit: (String, Boolean) -> Unit, - onLocalSubmit: (Uri) -> Unit + validateUrl: suspend (String) -> String?, + onUrlSubmit: (String, Boolean) -> Unit, + onFileSubmit: (Uri) -> Unit ) { var currentStep by rememberSaveable { mutableIntStateOf(0) } var sourceType by rememberSaveable { mutableStateOf(SourceType.Remote) } @@ -138,16 +138,16 @@ fun ImportSourceDialog( enabled = inputsAreValid && !isSubmittingRemote, onClick = { when (sourceType) { - SourceType.Local -> local?.let(onLocalSubmit) + SourceType.Local -> local?.let(onFileSubmit) SourceType.Remote -> { val trimmedUrl = remoteUrl.trim() coroutineScope.launch { isSubmittingRemote = true - val validationError = validateRemote(trimmedUrl) + val validationError = validateUrl(trimmedUrl) isSubmittingRemote = false if (validationError == null) { - onRemoteSubmit(trimmedUrl, autoUpdate) + onUrlSubmit(trimmedUrl, autoUpdate) } else { remoteValidationError = validationError } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index b9a5e901b4..cb21dae05f 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -86,7 +86,7 @@ fun BundleInformationScreen( var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } val bundleManifestAttributes = src.loaded?.manifestAttributes val autoUpdate = src.autoUpdate - val endpoint = src.uri.toString() + val sourceUrl = src.uri.toString() val subtitleAuthor = bundleManifestAttributes?.author?.let { stringResource(R.string.bundle_information_by_author, it) @@ -281,7 +281,7 @@ fun BundleInformationScreen( ) } - endpoint.takeUnless { src.isDefault }?.let { url -> + sourceUrl.takeUnless { src.isDefault }?.let { url -> var showUrlInputDialog by rememberSaveable { mutableStateOf(false) } if (showUrlInputDialog) { @@ -289,10 +289,10 @@ fun BundleInformationScreen( initial = url, title = stringResource(R.string.patches_url), onDismissRequest = { showUrlInputDialog = false }, - confirmValidator = viewModel::validateEndpoint, + confirmValidator = viewModel::validateUrl, onConfirm = { showUrlInputDialog = false - viewModel.setEndpoint(it.trim()) + viewModel.setUrl(it.trim()) }, validator = { val value = it.trim() @@ -332,7 +332,7 @@ fun BundleInformationScreen( ) } else { ChangelogSource.Patches( - url = endpoint, + url = sourceUrl, prerelease = false ) } 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 54e04a6ffd..7a94d0316f 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 @@ -200,13 +200,13 @@ fun DashboardScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.PATCHES, onDismiss = { showAddBundleDialog = false }, - validateRemote = vm::validateSourceUrl, - onLocalSubmit = { patches -> + validateUrl = vm::validateSourceUrl, + onFileSubmit = { patches -> showAddBundleDialog = false patchesSourceEditMode = false vm.importSource(patches) }, - onRemoteSubmit = { url, autoUpdate -> + onUrlSubmit = { url, autoUpdate -> showAddBundleDialog = false patchesSourceEditMode = false vm.createSource(url, autoUpdate) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt index 6bc2ef721b..c408e6dd9c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadersInfoScreen.kt @@ -219,7 +219,7 @@ fun DownloaderInfoScreen( confirmValidator = viewModel::validateSourceUrl, onConfirm = { showUrlInputDialog = false - viewModel.setEndpoint(source, it.trim()) + viewModel.setUrl(source, it.trim()) }, validator = { val value = it.trim() @@ -274,7 +274,7 @@ fun DownloaderInfoScreen( EmptyState( icon = Icons.Outlined.SignalWifiOff, title = R.string.downloader_sources_unavailable_title, - description = R.string.downloader_sources_unavailable_with_remote_description + description = R.string.downloader_sources_unavailable_description ) } } 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 f5ae36d2d7..08c44b6515 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 @@ -127,12 +127,12 @@ fun DownloadsSettingsScreen( ImportSourceDialog( strings = ImportSourceDialogStrings.DOWNLOADERS, onDismiss = { showImportDialog = false }, - validateRemote = viewModel::validateSourceUrl, - onLocalSubmit = { uri -> + validateUrl = viewModel::validateSourceUrl, + onFileSubmit = { uri -> showImportDialog = false viewModel.importSource(uri) }, - onRemoteSubmit = { url, autoUpdate -> + onUrlSubmit = { url, autoUpdate -> showImportDialog = false viewModel.createSource(url, autoUpdate) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt index c3d7bdce5f..dbb1a20cd3 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleInformationViewModel.kt @@ -33,19 +33,19 @@ class BundleInformationViewModel(uid: Int) : ViewModel(), KoinComponent { } } - fun setEndpoint(value: String) = viewModelScope.launch { - val endpoint = value.trim() + fun setUrl(value: String) = viewModelScope.launch { + val url = value.trim() bundle.first()?.let { current -> - if (current.uri.toString() == endpoint) return@launch + if (current.uri.toString() == url) return@launch - patchBundleRepository.run { current.setEndpoint(endpoint) } + patchBundleRepository.run { current.setUrl(url) } bundle.first()?.let { updated -> patchBundleRepository.update(updated, showToast = true) } } } - suspend fun validateEndpoint(value: String) = patchBundleRepository.validateUrl(value.trim()) + suspend fun validateUrl(value: String) = patchBundleRepository.validateUrl(value.trim()) fun updateUsePrereleases(value: Boolean) = viewModelScope.launch { prefs.usePatchesPrereleases.update(value) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index a3fcdf131f..6aacd39734 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -115,12 +115,12 @@ class DownloadsViewModel( } } - fun setEndpoint(src: Source, value: String) = viewModelScope.launch { - val endpoint = value.trim() - if (src.uri.toString() == endpoint) return@launch + fun setUrl(src: Source, value: String) = viewModelScope.launch { + val url = value.trim() + if (src.uri.toString() == url) return@launch with(downloaderRepository) { - src.setEndpoint(endpoint) + src.setUrl(url) } downloaderSources.first()[src.uid]?.let { updated -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b251ce8869..9e25f106c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,8 +219,7 @@ You won’t be able to update apps that were signed with the previous keystore.< Add downloader from local storage Downloader can receive updates These downloaders are missing - Tap Update to fix this issue - Make sure that you imported the correct downloader + Tap Update to fix this issue Failed to update downloader: %s Failed to import downloader: %s @@ -545,8 +544,8 @@ It’s only compatible with these versions: %2$s %sd ago Invalid date Invalid value - This URL is pointing to an unsupported source. - Couldn’t verify this URL. Make sure it’s correct and try again. + This URL is pointing to an unsupported source. + Couldn’t verify this URL. Make sure it’s correct and try again. Required options Couldn’t check for updates: %s From ece3dbc7b1adbc7c3323109a9dd5541418a61c2e Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Mon, 20 Jul 2026 00:29:56 +0200 Subject: [PATCH 13/21] enable auto update for the default source --- .../java/app/revanced/manager/domain/manager/SourceManager.kt | 2 +- .../main/java/app/revanced/manager/domain/sources/Source.kt | 4 ---- .../manager/ui/viewmodel/DeveloperOptionsViewModel.kt | 3 +-- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index ca417e8134..ffdd0edc8a 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -147,7 +147,7 @@ abstract class SourceManager( private suspend fun loadFromDb(): List { if (dbGetAll().none { it.uid == 0 }) { - createEntity(0, "", defaultSourceUri()) + createEntity(0, "", defaultSourceUri(), autoUpdate = true) } // Migrates rows from before sources were stored as URLs and keeps the diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index 30c380ed52..ad5b6c2175 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -147,10 +147,6 @@ class Source( data class Failed(val throwable: Throwable) : State data class Available(val obj: T) : State } - - companion object { - const val updateFailMsg = "Failed to update" - } } object Extensions { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt index cdcb965dde..b8a4536c70 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DeveloperOptionsViewModel.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R -import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -20,7 +19,7 @@ class DeveloperOptionsViewModel( private val downloaderRepository: DownloaderRepository ) : ViewModel() { fun redownloadBundles() = viewModelScope.launch { - uiSafe(app, R.string.patches_download_fail, Source.updateFailMsg) { + uiSafe(app, R.string.patches_download_fail, "Failed to update") { patchBundleRepository.redownload() } } From fdfa64cf4e5ece0ce7541d8c4c760f12906a882c Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Thu, 23 Jul 2026 18:12:42 +0200 Subject: [PATCH 14/21] rebuild the import dialog around a method dropdown --- .../component/sources/ImportSourceDialog.kt | 456 +++++++++--------- app/src/main/res/values/strings.xml | 6 + 2 files changed, 227 insertions(+), 235 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index 8d1be65682..edb50708e7 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -1,23 +1,30 @@ package app.revanced.manager.ui.component.sources +import android.content.Context import android.net.Uri +import android.provider.OpenableColumns +import android.text.format.Formatter import android.webkit.URLUtil import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.KeyboardType @@ -28,41 +35,31 @@ import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.TooltipIconButton import app.revanced.manager.ui.component.haptics.HapticCheckbox -import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.transparentListItemColors import kotlinx.coroutines.launch -private enum class SourceType { - Local, - Remote +private enum class ImportMethod(val label: Int) { + // Free text: any protocol, the scheme decides how the source is resolved. + Auto(R.string.import_source_method_auto), + + // A web address, the most common kind of source. + Http(R.string.import_source_method_http), + + // A local file, picked through the system file picker. + File(R.string.import_source_method_file) } enum class ImportSourceDialogStrings( val title: Int, - val type_remote_description: Int, - val type_local_description: Int, - val import_local: Int, - val import_remote: Int + val urlLabel: Int ) { - PATCHES( - R.string.add_patches, - R.string.remote_patches_description, - R.string.local_patches_description, - R.string.patches, - R.string.patches_url - ), - DOWNLOADERS( - R.string.downloader_add, - R.string.remote_downloaders_description, - R.string.local_downloaders_description, - R.string.downloaders, - R.string.downloader_url - ), + PATCHES(R.string.add_patches, R.string.patches_url), + DOWNLOADERS(R.string.downloader_add, R.string.downloader_url), } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImportSourceDialog( strings: ImportSourceDialogStrings, @@ -71,109 +68,176 @@ fun ImportSourceDialog( onUrlSubmit: (String, Boolean) -> Unit, onFileSubmit: (Uri) -> Unit ) { - var currentStep by rememberSaveable { mutableIntStateOf(0) } - var sourceType by rememberSaveable { mutableStateOf(SourceType.Remote) } - var local by rememberSaveable { mutableStateOf(null) } - var remoteUrl by rememberSaveable { mutableStateOf("") } + var method by rememberSaveable { mutableStateOf(ImportMethod.Auto) } + var url by rememberSaveable { mutableStateOf("") } + var file by rememberSaveable { mutableStateOf(null) } var autoUpdate by rememberSaveable { mutableStateOf(true) } - var remoteValidationError by rememberSaveable { mutableStateOf(null) } - var isSubmittingRemote by rememberSaveable { mutableStateOf(false) } + var urlValidationError by rememberSaveable { mutableStateOf(null) } + var isSubmitting by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val fileActivityLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { local = it } + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { picked -> + picked?.let { file = it } } - fun launchFileActivity() { - when(strings) { - ImportSourceDialogStrings.PATCHES -> fileActivityLauncher.launch(BIN_MIMETYPE) - ImportSourceDialogStrings.DOWNLOADERS -> fileActivityLauncher.launch(APK_MIMETYPE) - } - } - - val steps = listOf<@Composable () -> Unit>( - { - SelectSourceTypeStep(strings, sourceType) { selectedType -> - sourceType = selectedType - } - }, - { - ImportSourceStep( - strings, - sourceType, - local, - remoteUrl, - autoUpdate, - remoteValidationError, - ::launchFileActivity, - { - remoteUrl = it - remoteValidationError = null - }, - { autoUpdate = it } - ) + fun launchFileActivity() = fileActivityLauncher.launch( + when (strings) { + ImportSourceDialogStrings.PATCHES -> BIN_MIMETYPE + ImportSourceDialogStrings.DOWNLOADERS -> APK_MIMETYPE } ) + // Auto accepts any text and defers the scheme to the handlers, URL expects a + // well-formed web address before the network is even touched. + val isValidUrl = url.trim().let { URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) } val inputsAreValid by remember { derivedStateOf { - (sourceType == SourceType.Local && local != null) || - (sourceType == SourceType.Remote && remoteUrl.isNotEmpty() && - (URLUtil.isHttpUrl(remoteUrl) || URLUtil.isHttpsUrl(remoteUrl))) + when (method) { + ImportMethod.Auto -> url.isNotBlank() + ImportMethod.Http -> isValidUrl + ImportMethod.File -> file != null + } } } AlertDialogExtended( onDismissRequest = onDismiss, - title = { - Text(stringResource(strings.title)) - }, + title = { Text(stringResource(strings.title)) }, text = { - steps[currentStep]() - }, - confirmButton = { - if (currentStep == steps.lastIndex) { - TextButton( - enabled = inputsAreValid && !isSubmittingRemote, - onClick = { - when (sourceType) { - SourceType.Local -> local?.let(onFileSubmit) - SourceType.Remote -> { - val trimmedUrl = remoteUrl.trim() - coroutineScope.launch { - isSubmittingRemote = true - val validationError = validateUrl(trimmedUrl) - isSubmittingRemote = false + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Column(modifier = Modifier.padding(TextHorizontalPadding)) { + var expanded by remember { mutableStateOf(false) } - if (validationError == null) { - onUrlSubmit(trimmedUrl, autoUpdate) - } else { - remoteValidationError = validationError - } - } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = stringResource(method.label), + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.import_source_method)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + ImportMethod.entries.forEach { entry -> + DropdownMenuItem( + text = { Text(stringResource(entry.label)) }, + onClick = { + expanded = false + method = entry + urlValidationError = null + }, + shape = MaterialTheme.shapes.medium + ) } } - }, - shapes = ButtonDefaults.shapes() - ) { - Text(stringResource(R.string.add)) + } } - } else { - TextButton(onClick = { currentStep++ }, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.next)) + + Spacer(modifier = Modifier.height(16.dp)) + + when (method) { + ImportMethod.Auto, ImportMethod.Http -> { + val showValidator = method == ImportMethod.Http && url.isNotEmpty() && !isValidUrl + Column(modifier = Modifier.padding(TextHorizontalPadding)) { + OutlinedTextField( + value = url, + onValueChange = { + url = it + urlValidationError = null + }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false + ), + singleLine = true, + label = { + Text( + stringResource( + if (method == ImportMethod.Auto) R.string.import_source_input + else strings.urlLabel + ) + ) + }, + placeholder = if (method == ImportMethod.Http) { + { Text("https://") } + } else null, + isError = showValidator || urlValidationError != null, + supportingText = { + when { + urlValidationError != null -> Text(urlValidationError!!) + showValidator -> Text(stringResource(R.string.input_dialog_value_invalid)) + } + } + ) + } + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp)) { + ListItem( + modifier = Modifier.clickable( + role = Role.Checkbox, + onClick = { autoUpdate = !autoUpdate } + ), + headlineContent = { Text(stringResource(R.string.auto_update)) }, + leadingContent = { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + HapticCheckbox( + checked = autoUpdate, + onCheckedChange = { autoUpdate = !autoUpdate } + ) + } + }, + colors = transparentListItemColors + ) + } + } + + ImportMethod.File -> FilePickerCard( + file = file, + onClick = ::launchFileActivity + ) } } }, + confirmButton = { + TextButton( + enabled = inputsAreValid && !isSubmitting, + onClick = { + when (method) { + ImportMethod.File -> file?.let(onFileSubmit) + ImportMethod.Auto, ImportMethod.Http -> { + val trimmedUrl = url.trim() + coroutineScope.launch { + isSubmitting = true + val validationError = validateUrl(trimmedUrl) + isSubmitting = false + + if (validationError == null) onUrlSubmit(trimmedUrl, autoUpdate) + else urlValidationError = validationError + } + } + } + }, + shapes = ButtonDefaults.shapes() + ) { + Text(stringResource(R.string.add)) + } + }, dismissButton = { - if (currentStep > 0) { - TextButton(onClick = { currentStep-- }, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.back)) - } - } else { - TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) { - Text(stringResource(R.string.cancel)) - } + TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) { + Text(stringResource(R.string.cancel)) } }, textHorizontalPadding = PaddingValues(0.dp) @@ -181,142 +245,64 @@ fun ImportSourceDialog( } @Composable -private fun SelectSourceTypeStep( - strings: ImportSourceDialogStrings, - sourceType: SourceType, - onSourceTypeSelected: (SourceType) -> Unit -) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Column { - ListItem( - modifier = Modifier.clickable( - role = Role.RadioButton, - onClick = { onSourceTypeSelected(SourceType.Remote) } - ), - headlineContent = { Text(stringResource(R.string.enter_url)) }, - overlineContent = { Text(stringResource(R.string.recommended)) }, - supportingContent = { Text(stringResource(strings.type_remote_description)) }, - leadingContent = { - HapticRadioButton( - selected = sourceType == SourceType.Remote, - onClick = null - ) - }, - colors = transparentListItemColors - ) - HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) - ListItem( - modifier = Modifier.clickable( - role = Role.RadioButton, - onClick = { onSourceTypeSelected(SourceType.Local) } - ), - headlineContent = { Text(stringResource(R.string.select_from_storage)) }, - supportingContent = { Text(stringResource(strings.type_local_description)) }, - overlineContent = { }, - leadingContent = { - HapticRadioButton( - selected = sourceType == SourceType.Local, - onClick = null - ) - }, - colors = transparentListItemColors - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun ImportSourceStep( - strings: ImportSourceDialogStrings, - sourceType: SourceType, - local: Uri?, - remoteUrl: String, - autoUpdate: Boolean, - remoteValidationError: String?, - launchFileActivity: () -> Unit, - onRemoteUrlChange: (String) -> Unit, - onAutoUpdateChange: (Boolean) -> Unit +private fun FilePickerCard( + file: Uri?, + onClick: () -> Unit ) { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - when (sourceType) { - SourceType.Local -> { - Column( - modifier = Modifier.padding(horizontal = 8.dp) - ) { - ListItem( - headlineContent = { - Text(stringResource(strings.import_local)) - }, - supportingContent = { Text(stringResource(if (local != null) R.string.file_field_set else R.string.file_field_not_set)) }, - trailingContent = { - TooltipIconButton( - onClick = launchFileActivity, - tooltip = stringResource(strings.import_local) - ) { - Icon(imageVector = Icons.Default.Topic, contentDescription = null) - } - }, - modifier = Modifier.clickable { launchFileActivity() }, - colors = transparentListItemColors - ) - } - } + val context = LocalContext.current + val info = remember(file) { file?.let { context.readFileInfo(it) } } - SourceType.Remote -> { - val isUrl = remoteUrl.trim().let { - URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) - } - // We check if the value is empty to avoid showing an error state when the user - // hasn't entered anything yet to avoid bashing user with bad UX of blaming the - // user immediately when everything is intended. - val validator = remoteUrl.isNotEmpty() && !isUrl - Column( - modifier = Modifier.padding(TextHorizontalPadding) - ) { - OutlinedTextField( - value = remoteUrl, - onValueChange = onRemoteUrlChange, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - autoCorrectEnabled = false - ), - label = { Text(stringResource(strings.import_remote)) }, - isError = validator || remoteValidationError != null, - supportingText = { - when { - remoteValidationError != null -> Text(remoteValidationError) - validator -> Text(stringResource(R.string.input_dialog_value_invalid)) - } - }, + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text(info?.name ?: stringResource(R.string.file_field_not_set)) + }, + supportingContent = { + Text( + info?.size?.let { Formatter.formatShortFileSize(context, it) } + ?: stringResource(R.string.import_source_press_to_select) + ) + }, + leadingContent = if (file != null) { + { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary ) } - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp) + } else null, + trailingContent = { + TooltipIconButton( + onClick = onClick, + tooltip = stringResource(R.string.select_from_storage) ) { - ListItem( - modifier = Modifier.clickable( - role = Role.Checkbox, - onClick = { onAutoUpdateChange(!autoUpdate) } - ), - headlineContent = { Text(stringResource(R.string.auto_update)) }, - leadingContent = { - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - HapticCheckbox( - checked = autoUpdate, - onCheckedChange = { - onAutoUpdateChange(!autoUpdate) - } - ) - } - }, - colors = transparentListItemColors - ) + Icon(imageVector = Icons.Outlined.Edit, contentDescription = null) } - } - } + }, + colors = transparentListItemColors + ) + } +} + +private class FileInfo(val name: String?, val size: Long?) + +private fun Context.readFileInfo(uri: Uri): FileInfo = runCatching { + contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + + fun column(name: String) = cursor.getColumnIndex(name).takeIf { it >= 0 && !cursor.isNull(it) } + + FileInfo( + name = column(OpenableColumns.DISPLAY_NAME)?.let(cursor::getString), + size = column(OpenableColumns.SIZE)?.let(cursor::getLong) + ) } -} \ No newline at end of file +}.getOrNull() ?: FileInfo(uri.lastPathSegment, null) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e25f106c9..04c7aaa18c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,6 +67,12 @@ Second \"item\" text" Import Import patches + Method + Auto + HTTP + File + Input + Press to import Selected Not selected From f521703a8aa5cebdd182176d2660a584efff98cb Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Mon, 27 Jul 2026 11:30:55 +0200 Subject: [PATCH 15/21] offer the file picker from the auto method --- .../component/sources/ImportSourceDialog.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index edb50708e7..fb4a82d5b4 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Folder import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -78,7 +79,11 @@ fun ImportSourceDialog( val fileActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { picked -> - picked?.let { file = it } + picked?.let { + file = it + // Picking a file continues in the file flow no matter which method started it. + method = ImportMethod.File + } } fun launchFileActivity() = fileActivityLauncher.launch( @@ -175,6 +180,20 @@ fun ImportSourceDialog( placeholder = if (method == ImportMethod.Http) { { Text("https://") } } else null, + // Auto accepts a file too so offer the picker without making the user switch methods first. + trailingIcon = if (method == ImportMethod.Auto) { + { + TooltipIconButton( + onClick = ::launchFileActivity, + tooltip = stringResource(R.string.select_from_storage) + ) { + Icon( + imageVector = Icons.Outlined.Folder, + contentDescription = null + ) + } + } + } else null, isError = showValidator || urlValidationError != null, supportingText = { when { From 7238979700fae3bcffea003b71a47e22266cf006 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Mon, 27 Jul 2026 11:39:22 +0200 Subject: [PATCH 16/21] check for updates through the version resource --- .../manager/domain/manager/SourceManager.kt | 16 ++++++++++ .../domain/repository/DownloaderRepository.kt | 3 +- .../repository/PatchBundleRepository.kt | 3 +- .../revanced/manager/domain/sources/Source.kt | 31 ++++++++++++++++--- .../manager/network/dto/ReVancedAsset.kt | 5 +++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index ffdd0edc8a..55f40012c4 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -77,6 +77,19 @@ abstract class SourceManager( // The URL of the default source, built from preferences. protected abstract suspend fun defaultSourceUri(): Uri + // The version resource is nested under the source, with the prerelease variant staying + // last: /v5/patches/prerelease has its version at /v5/patches/version/prerelease. + protected fun versionUriOf(uri: Uri): Uri { + val segments = uri.pathSegments + val isPrerelease = segments.lastOrNull() == PRERELEASE_PATH + + return uri.buildUpon().path(null).apply { + segments.dropLast(if (isPrerelease) 1 else 0).forEach(::appendPath) + appendPath(VERSION_PATH) + if (isPrerelease) appendPath(PRERELEASE_PATH) + }.build() + } + protected abstract fun realNameOf(loaded: LOADED): String? @get:StringRes @@ -468,4 +481,7 @@ abstract class SourceManager( } } +private const val VERSION_PATH = "version" +private const val PRERELEASE_PATH = "prerelease" + private fun LocalDateTime.toEpochMillis() = toInstant(TimeZone.UTC).toEpochMilliseconds() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index fd6cf50e31..752370524a 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -79,7 +79,8 @@ class DownloaderRepository( fileOf(uid), loader, protocolHandlers, - json + json, + ::versionUriOf ) } 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 fbe6a7e72f..1901d9675f 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 @@ -75,7 +75,8 @@ class PatchBundleRepository( fileOf(uid), PatchBundleLoader, protocolHandlers, - json + json, + ::versionUriOf ) } diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index ad5b6c2175..69049da647 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -6,8 +6,10 @@ import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.domain.protocol.ProtocolHandler import app.revanced.manager.domain.protocol.getStream import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.dto.ReVancedAssetVersion import app.revanced.manager.network.utils.APIFailure import app.revanced.manager.patcher.patch.PatchBundle +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.LocalDateTime @@ -50,7 +52,9 @@ class Source( private val file: File, private val loader: Loader, private val handlers: Map, - private val json: Json + private val json: Json, + // Where to read the version of this source, derived from its URL. + private val versionUriOf: (Uri) -> Uri ) { val state = when { error != null -> State.Failed(error) @@ -91,7 +95,8 @@ class Source( file, loader, handlers, - json + json, + versionUriOf ) private fun hasInstalled() = file.exists() @@ -122,12 +127,30 @@ class Source( UpdateResult(info.version, info.createdAt) } + // The version resource of the source, returns null when the source does not serve one, which is not an error. + private suspend fun getLatestVersion(): String? = withContext(Dispatchers.IO) { + try { + handlers.getStream(versionUriOf(uri)) { stream -> + json.decodeFromString(stream.reader().readText()).version + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + /** * Downloads the latest version regardless if there is a new update available. */ suspend fun ActionContext.downloadLatest() = download(getLatestInfo()) - suspend fun ActionContext.getUpdateInfo() = - getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } + + suspend fun ActionContext.getUpdateInfo(): ReVancedAsset? { + // Reading the version alone is cheaper than the whole descriptor so use it to rule out an update first. Sources without a version resource fall through to the descriptor. + if (hasInstalled() && versionHash != null && getLatestVersion() == versionHash) return null + + return getLatestInfo().takeUnless { hasInstalled() && it.version == versionHash } + } suspend fun ActionContext.update(): UpdateResult? = withContext(Dispatchers.IO) { getUpdateInfo()?.let { download(it) } diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt index 27ea5b4bca..97fb7e1ed3 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt @@ -16,6 +16,11 @@ data class ReVancedAsset ( val version: String, ) +@Serializable +data class ReVancedAssetVersion( + val version: String, +) + @Serializable data class ReVancedAssetHistory( val version: String, From 2d81ce2a5de5c97964ef11454bb77dbb5e664b54 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Mon, 27 Jul 2026 13:55:13 +0200 Subject: [PATCH 17/21] fill the input with the picked file --- .../ui/component/sources/ImportSourceDialog.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index fb4a82d5b4..6c14433aee 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.component.sources +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns @@ -81,8 +82,8 @@ fun ImportSourceDialog( rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { picked -> picked?.let { file = it - // Picking a file continues in the file flow no matter which method started it. - method = ImportMethod.File + // Selecting a file is an action of the input field, so show what was picked. + url = it.toString() } } @@ -168,7 +169,6 @@ fun ImportSourceDialog( keyboardType = KeyboardType.Uri, autoCorrectEnabled = false ), - singleLine = true, label = { Text( stringResource( @@ -238,6 +238,11 @@ fun ImportSourceDialog( ImportMethod.File -> file?.let(onFileSubmit) ImportMethod.Auto, ImportMethod.Http -> { val trimmedUrl = url.trim() + val picked = Uri.parse(trimmedUrl) + .takeIf { it.scheme == ContentResolver.SCHEME_CONTENT } + + if (picked != null) return@TextButton onFileSubmit(picked) + coroutineScope.launch { isSubmitting = true val validationError = validateUrl(trimmedUrl) From 5b2e42557a833725921fa215fd1d496792aca209 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Sun, 2 Aug 2026 22:09:54 +0200 Subject: [PATCH 18/21] stored sources as urls and renamed the column --- .../6.json | 471 ++++++++++++++++++ .../revanced/manager/data/room/AppDatabase.kt | 23 +- .../revanced/manager/data/room/Converters.kt | 6 +- .../data/room/bundles/PatchBundleDao.kt | 9 +- .../data/room/bundles/PatchBundleEntity.kt | 4 +- .../data/room/downloader/DownloaderDao.kt | 9 +- .../data/room/downloader/DownloaderEntity.kt | 4 +- .../data/room/sources/SourceProperties.kt | 11 +- .../manager/domain/manager/SourceManager.kt | 55 +- .../domain/repository/DownloaderRepository.kt | 9 +- .../repository/PatchBundleRepository.kt | 9 +- 11 files changed, 572 insertions(+), 38 deletions(-) create mode 100644 app/schemas/app.revanced.manager.data.room.AppDatabase/6.json diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json new file mode 100644 index 0000000000..67b7f12686 --- /dev/null +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/6.json @@ -0,0 +1,471 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "da3efac0c33785267e2b14b0b27c93f8", + "entities": [ + { + "tableName": "patch_bundles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `url` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionHash", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + }, + { + "tableName": "patch_selections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchBundle", + "columnName": "patch_bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_patch_selections_patch_bundle_package_name", + "unique": true, + "columnNames": [ + "patch_bundle", + "package_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)" + } + ], + "foreignKeys": [ + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patch_bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "selected_patches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "selection", + "columnName": "selection", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "selection", + "patch_name" + ] + }, + "foreignKeys": [ + { + "table": "patch_selections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "selection" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "downloaded_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "last_used", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "version" + ] + } + }, + { + "tableName": "installed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))", + "fields": [ + { + "fieldPath": "currentPackageName", + "columnName": "current_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalPackageName", + "columnName": "original_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installType", + "columnName": "install_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "current_package_name" + ] + } + }, + { + "tableName": "applied_patch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundle", + "columnName": "bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "bundle", + "patch_name" + ] + }, + "foreignKeys": [ + { + "table": "installed_app", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "package_name" + ], + "referencedColumns": [ + "current_package_name" + ] + } + ] + }, + { + "tableName": "installed_patch_bundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle_uid` INTEGER NOT NULL, `bundle_name` TEXT NOT NULL, `bundle_version` TEXT, PRIMARY KEY(`package_name`, `bundle_uid`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundleUid", + "columnName": "bundle_uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bundleName", + "columnName": "bundle_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundleVersion", + "columnName": "bundle_version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "bundle_uid" + ] + }, + "foreignKeys": [ + { + "table": "installed_app", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "package_name" + ], + "referencedColumns": [ + "current_package_name" + ] + } + ] + }, + { + "tableName": "option_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchBundle", + "columnName": "patch_bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_option_groups_patch_bundle_package_name", + "unique": true, + "columnNames": [ + "patch_bundle", + "package_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)" + } + ], + "foreignKeys": [ + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patch_bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "options", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group", + "patch_name", + "key" + ] + }, + "foreignKeys": [ + { + "table": "option_groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "downloaders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `url` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionHash", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "auto_update", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releasedAt", + "columnName": "released_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da3efac0c33785267e2b14b0b27c93f8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 093646f01b..6eba3137a9 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -3,6 +3,7 @@ package app.revanced.manager.data.room import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteTable +import androidx.room.RenameColumn import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec @@ -26,7 +27,7 @@ import kotlin.random.Random @Database( entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class], - version = 5, + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -36,7 +37,12 @@ import kotlin.random.Random spec = AppDatabase.DeleteTrustedDownloaders::class ), AutoMigration(from = 3, to = 4), - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + AutoMigration( + from = 5, + to = 6, + spec = AppDatabase.RenameSourceToUrl::class + ) ] ) @TypeConverters(Converters::class) @@ -51,6 +57,19 @@ abstract class AppDatabase : RoomDatabase() { @DeleteTable(tableName = "trusted_downloaders") class DeleteTrustedDownloaders : AutoMigrationSpec + // Sources are stored as the URL they are retrieved from. + @RenameColumn( + tableName = "patch_bundles", + fromColumnName = "source", + toColumnName = "url" + ) + @RenameColumn( + tableName = "downloaders", + fromColumnName = "source", + toColumnName = "url" + ) + class RenameSourceToUrl : AutoMigrationSpec + companion object { fun generateUid() = Random.nextInt() } diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt index c22b1927cc..08980dd6c5 100644 --- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt +++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt @@ -1,16 +1,16 @@ package app.revanced.manager.data.room -import android.net.Uri import androidx.room.TypeConverter import app.revanced.manager.data.room.options.Option.SerializedValue +import io.ktor.http.Url import java.io.File class Converters { @TypeConverter - fun uriFromString(value: String): Uri = Uri.parse(value) + fun urlFromString(value: String) = Url(value) @TypeConverter - fun uriToString(value: Uri) = value.toString() + fun urlToString(value: Url) = value.toString() @TypeConverter fun fileFromString(value: String) = File(value) 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 85dd7ffc3f..0a71f55427 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 @@ -2,6 +2,7 @@ package app.revanced.manager.data.room.bundles import androidx.room.* import app.revanced.manager.data.room.sources.SourceProperties +import app.revanced.manager.data.room.sources.SourceUrl @Dao interface PatchBundleDao { @@ -23,9 +24,15 @@ interface PatchBundleDao { @Query("DELETE FROM patch_bundles WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source, released_at FROM patch_bundles WHERE uid = :uid") + @Query("SELECT name, version, auto_update, url, released_at FROM patch_bundles WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? + @Query("SELECT uid, url FROM patch_bundles") + suspend fun allUrls(): List + + @Query("UPDATE patch_bundles SET url = :url WHERE uid = :uid") + suspend fun setUrl(uid: Int, url: String) + @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 c9bfc58ba1..1917e8510c 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 @@ -1,15 +1,15 @@ package app.revanced.manager.data.room.bundles -import android.net.Uri import androidx.room.* import app.revanced.manager.domain.manager.SourceManager +import io.ktor.http.Url @Entity(tableName = "patch_bundles") data class PatchBundleEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Uri, + @ColumnInfo(name = "url") val url: Url, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt index 344273d729..898d733540 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderDao.kt @@ -5,6 +5,7 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import app.revanced.manager.data.room.sources.SourceProperties +import app.revanced.manager.data.room.sources.SourceUrl @Dao interface DownloaderDao { @@ -26,9 +27,15 @@ interface DownloaderDao { @Query("DELETE FROM downloaders WHERE uid = :uid") suspend fun remove(uid: Int) - @Query("SELECT name, version, auto_update, source, released_at FROM downloaders WHERE uid = :uid") + @Query("SELECT name, version, auto_update, url, released_at FROM downloaders WHERE uid = :uid") suspend fun getProps(uid: Int): SourceProperties? + @Query("SELECT uid, url FROM downloaders") + suspend fun allUrls(): List + + @Query("UPDATE downloaders SET url = :url WHERE uid = :uid") + suspend fun setUrl(uid: Int, url: String) + @Upsert suspend fun upsert(source: DownloaderEntity) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt index 4dd5454030..85c07107e6 100644 --- a/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/downloader/DownloaderEntity.kt @@ -1,15 +1,15 @@ package app.revanced.manager.data.room.downloader -import android.net.Uri import androidx.room.* import app.revanced.manager.domain.manager.SourceManager +import io.ktor.http.Url @Entity(tableName = "downloaders") data class DownloaderEntity( @PrimaryKey override val uid: Int, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Uri, + @ColumnInfo(name = "url") val url: Url, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null ) : SourceManager.DatabaseEntity \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt index bb7368c783..33095c8696 100644 --- a/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt +++ b/app/src/main/java/app/revanced/manager/data/room/sources/SourceProperties.kt @@ -1,12 +1,19 @@ package app.revanced.manager.data.room.sources -import android.net.Uri import androidx.room.ColumnInfo +import io.ktor.http.Url +// The URL of a source as it is stored, used to migrate rows written before sources were URLs. +data class SourceUrl( + @ColumnInfo(name = "uid") val uid: Int, + @ColumnInfo(name = "url") val url: String, +) + +// The columns of a source row that can be updated after it was created. data class SourceProperties( @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "version") val versionHash: String? = null, - @ColumnInfo(name = "source") val source: Uri, + @ColumnInfo(name = "url") val url: Url, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean, @ColumnInfo(name = "released_at") val releasedAt: Long? = null, ) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index 55f40012c4..8201fdf7ba 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -2,6 +2,7 @@ package app.revanced.manager.domain.manager import android.app.Application import android.net.Uri +import io.ktor.http.Url import android.util.Log import androidx.annotation.StringRes import app.revanced.manager.R @@ -11,6 +12,7 @@ import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.data.redux.Store import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.sources.SourceProperties +import app.revanced.manager.data.room.sources.SourceUrl import app.revanced.manager.domain.protocol.ContentProtocolHandler import app.revanced.manager.domain.protocol.FileProtocolHandler import app.revanced.manager.domain.protocol.HttpProtocolHandler @@ -64,6 +66,8 @@ abstract class SourceManager( protected abstract suspend fun dbGetAll(): List protected abstract suspend fun dbGetProps(uid: Int): SourceProperties? + protected abstract suspend fun dbGetUrls(): List + protected abstract suspend fun dbSetUrl(uid: Int, url: String) protected abstract suspend fun dbUpsert(entity: DB) protected abstract suspend fun dbRemove(uid: Int) protected abstract suspend fun dbReset() @@ -75,7 +79,7 @@ abstract class SourceManager( protected abstract fun fileOf(uid: Int): File // The URL of the default source, built from preferences. - protected abstract suspend fun defaultSourceUri(): Uri + protected abstract suspend fun defaultUrl(): Url // The version resource is nested under the source, with the prerelease variant staying // last: /v5/patches/prerelease has its version at /v5/patches/version/prerelease. @@ -159,21 +163,16 @@ abstract class SourceManager( } private suspend fun loadFromDb(): List { + migrateLegacyUrls() + if (dbGetAll().none { it.uid == 0 }) { - createEntity(0, "", defaultSourceUri(), autoUpdate = true) + createEntity(0, "", defaultUrl(), autoUpdate = true) } - // Migrates rows from before sources were stored as URLs and keeps the - // default source pointed at the API configured in settings. - dbGetAll().forEach { entity -> - val props = dbGetProps(entity.uid) ?: return@forEach - val migrated = when { - entity.uid == 0 -> defaultSourceUri().takeIf { it != props.source } - props.source.scheme == null -> Uri.fromFile(fileOf(entity.uid)) - else -> null - } ?: return@forEach - - updateDb(entity.uid) { it.copy(source = migrated) } + // Keeps the default source pointed at the API configured in settings. + dbGetProps(0)?.let { props -> + val url = defaultUrl() + if (url != props.url) updateDb(0) { it.copy(url = url) } } return dbGetAll() @@ -182,7 +181,7 @@ abstract class SourceManager( private suspend fun createEntity( uid: Int, name: String, - source: Uri, + url: Url, autoUpdate: Boolean = false, ) = entityFromProps( @@ -190,7 +189,7 @@ abstract class SourceManager( SourceProperties( name = name, versionHash = null, - source = source, + url = url, autoUpdate = autoUpdate, releasedAt = null, ) @@ -213,7 +212,7 @@ abstract class SourceManager( SourceProperties( name = new.name, versionHash = new.versionHash, - source = new.source, + url = new.url, autoUpdate = new.autoUpdate, releasedAt = new.releasedAt, ) @@ -223,6 +222,22 @@ abstract class SourceManager( protected fun directoryOf(uid: Int) = sourceDir.resolve(uid.toString()).also { it.mkdirs() } + // The URL of the copy an imported source was stored in. + private fun fileUrlOf(uid: Int) = Url(Uri.fromFile(fileOf(uid)).toString()) + + // Rows written before sources were URLs hold a sentinel instead of one. + // They have to be rewritten before anything reads them because + // parsing a sentinel silently yields a valid but meaningless URL rather than failing. + private suspend fun migrateLegacyUrls() = dbGetUrls().forEach { (uid, url) -> + val migrated = when (url) { + LEGACY_LOCAL -> fileUrlOf(uid) + LEGACY_API -> defaultUrl() + else -> return@forEach + } + + dbSetUrl(uid, migrated.toString()) + } + suspend fun reset() = dispatchAction("Reset") { state -> dbReset() state.sources.keys.forEach { directoryOf(it).deleteRecursively() } @@ -253,7 +268,7 @@ abstract class SourceManager( suspend fun importFrom(uri: Uri) = dispatchAction("Import ($uri)") { state -> val uid = generateUid() - val entity = createEntity(uid, "", Uri.fromFile(fileOf(uid))) + val entity = createEntity(uid, "", fileUrlOf(uid)) with(loadEntity(entity)) { try { replace(uri) @@ -273,7 +288,7 @@ abstract class SourceManager( suspend fun create(url: String, autoUpdate: Boolean) = dispatchAction("Add ($url)") { state -> - val entity = createEntity(generateUid(), "", Uri.parse(url), autoUpdate) + val entity = createEntity(generateUid(), "", Url(url), autoUpdate) val src = loadEntity(entity) update(src) state.copy(sources = state.sources.toMutableMap().also { it[src.uid] = src }) @@ -306,7 +321,7 @@ abstract class SourceManager( updateDb(uid) { props -> props.copy( - source = Uri.parse(value), + url = Url(value), versionHash = null, releasedAt = null ) @@ -481,6 +496,8 @@ abstract class SourceManager( } } +private const val LEGACY_LOCAL = "local" +private const val LEGACY_API = "api" private const val VERSION_PATH = "version" private const val PRERELEASE_PATH = "prerelease" diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 752370524a..157a07cdcd 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -16,6 +16,7 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.downloader.DownloaderEntity import app.revanced.manager.data.room.sources.SourceProperties import android.net.Uri +import io.ktor.http.Url import app.revanced.manager.domain.manager.SourceManager import app.revanced.manager.domain.sources.Loader import app.revanced.manager.domain.sources.Source @@ -47,6 +48,8 @@ class DownloaderRepository( override suspend fun dbGetAll() = dao.all() override suspend fun dbGetProps(uid: Int) = dao.getProps(uid) + override suspend fun dbGetUrls() = dao.allUrls() + override suspend fun dbSetUrl(uid: Int, url: String) = dao.setUrl(uid, url) override suspend fun dbUpsert(entity: DownloaderEntity) = dao.upsert(entity) override suspend fun dbRemove(uid: Int) = dao.remove(uid) override suspend fun dbReset() = dao.reset() @@ -71,7 +74,7 @@ class DownloaderRepository( return Source( actualName, uid, - source, + Uri.parse(url.toString()), versionHash, releasedAt, autoUpdate, @@ -91,12 +94,12 @@ class DownloaderRepository( uid, name = props.name, versionHash = props.versionHash, - source = props.source, + url = props.url, autoUpdate = props.autoUpdate, releasedAt = props.releasedAt ) - override suspend fun defaultSourceUri(): Uri = Uri.parse( + override suspend fun defaultUrl() = Url( "${prefs.api.get()}/v5/manager/downloaders" + if (prefs.useDownloaderPrerelease.get()) "/prerelease" else "" ) 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 1901d9675f..7aa7a3e8b9 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 @@ -9,6 +9,7 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.sources.SourceProperties import android.net.Uri +import io.ktor.http.Url import app.revanced.manager.domain.sources.PatchBundleSource import app.revanced.manager.domain.manager.SourceManager import app.revanced.manager.domain.sources.Loader @@ -49,6 +50,8 @@ class PatchBundleRepository( override suspend fun dbGetAll() = dao.all() override suspend fun dbGetProps(uid: Int) = dao.getProps(uid) + override suspend fun dbGetUrls() = dao.allUrls() + override suspend fun dbSetUrl(uid: Int, url: String) = dao.setUrl(uid, url) override suspend fun dbUpsert(entity: PatchBundleEntity) = dao.upsert(entity) override suspend fun dbRemove(uid: Int) = dao.remove(uid) override suspend fun dbReset() = dao.reset() @@ -67,7 +70,7 @@ class PatchBundleRepository( return Source( actualName, uid, - source, + Uri.parse(url.toString()), versionHash, releasedAt, autoUpdate, @@ -87,12 +90,12 @@ class PatchBundleRepository( uid, name = props.name, versionHash = props.versionHash, - source = props.source, + url = props.url, autoUpdate = props.autoUpdate, releasedAt = props.releasedAt ) - override suspend fun defaultSourceUri(): Uri = Uri.parse( + override suspend fun defaultUrl() = Url( "${prefs.api.get()}/v5/patches" + if (prefs.usePatchesPrereleases.get()) "/prerelease" else "" ) From 9019fc3da5be061650759b5069ddf7c5b54cffce Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Sun, 2 Aug 2026 22:11:37 +0200 Subject: [PATCH 19/21] name the patches and downloader url errors --- .../app/revanced/manager/domain/manager/SourceManager.kt | 8 ++++++-- .../manager/domain/repository/DownloaderRepository.kt | 1 + .../manager/domain/repository/PatchBundleRepository.kt | 1 + app/src/main/res/values/strings.xml | 3 ++- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index 8201fdf7ba..f457671fff 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -108,6 +108,10 @@ abstract class SourceManager( @get:StringRes protected abstract val replaceFail: Int + // Shown when a URL does not lead to what this manager expects, e.g. patches. + @get:StringRes + protected abstract val urlUnsupported: Int + protected abstract suspend fun loadDataFromSources(sources: MutableMap>): OUTPUT protected val store = Store( @@ -376,7 +380,7 @@ abstract class SourceManager( private fun Throwable.toValidationMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedSourceException -> app.getString(R.string.source_url_unsupported) + is UnsupportedSourceException -> app.getString(urlUnsupported) // wtf is this? this is not a data at all and more like a webpage or something else! else -> app.getString(R.string.source_url_validation_failed) @@ -384,7 +388,7 @@ abstract class SourceManager( private fun Throwable.toUpdateMessage() = when (asSourceException()) { // wtf is this? this data is not a bundle, at least something! - is UnsupportedSourceException -> app.getString(R.string.source_url_unsupported) + is UnsupportedSourceException -> app.getString(urlUnsupported) else -> simpleMessage() } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt index 157a07cdcd..ec562a1959 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderRepository.kt @@ -109,6 +109,7 @@ class DownloaderRepository( override val updateFailed = R.string.downloader_update_failed override val updateSuccess = R.string.patches_update_success override val updateUnavailable = R.string.patches_update_unavailable + override val urlUnsupported = R.string.downloader_url_unsupported override val replaceFail = R.string.downloader_replace_fail override suspend fun loadDataFromSources(sources: MutableMap>) = 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 7aa7a3e8b9..0545ca881e 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 @@ -46,6 +46,7 @@ class PatchBundleRepository( override val updateFailed = R.string.patches_download_fail override val updateSuccess = R.string.patches_update_success override val updateUnavailable = R.string.patches_update_unavailable + override val urlUnsupported = R.string.patches_url_unsupported override val replaceFail = R.string.patches_replace_fail override suspend fun dbGetAll() = dao.all() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04c7aaa18c..3acc8ab6f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -550,7 +550,8 @@ It’s only compatible with these versions: %2$s %sd ago Invalid date Invalid value - This URL is pointing to an unsupported source. + This URL does not point to valid patches. + This URL does not point to a valid downloader. Couldn’t verify this URL. Make sure it’s correct and try again. Required options From 897ff3f89bccf17c19fba7b1bc0f07e0b0978871 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Sun, 2 Aug 2026 22:57:57 +0200 Subject: [PATCH 20/21] fix the add button never enabling for http --- .../ui/component/sources/ImportSourceDialog.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt index 6c14433aee..0931aa0a58 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/ImportSourceDialog.kt @@ -97,14 +97,10 @@ fun ImportSourceDialog( // Auto accepts any text and defers the scheme to the handlers, URL expects a // well-formed web address before the network is even touched. val isValidUrl = url.trim().let { URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) } - val inputsAreValid by remember { - derivedStateOf { - when (method) { - ImportMethod.Auto -> url.isNotBlank() - ImportMethod.Http -> isValidUrl - ImportMethod.File -> file != null - } - } + val inputsAreValid = when (method) { + ImportMethod.Auto -> url.isNotBlank() + ImportMethod.Http -> isValidUrl + ImportMethod.File -> file != null } AlertDialogExtended( From c4d27a5afc4d418d4d3829145ca1e2ea2ecde5a6 Mon Sep 17 00:00:00 2001 From: mostafanazari702 Date: Sun, 2 Aug 2026 23:34:09 +0200 Subject: [PATCH 21/21] read the changelog from the source url --- .../manager/domain/manager/SourceManager.kt | 19 +++++++++++++++---- .../domain/repository/ChangelogsRepository.kt | 9 +++++---- .../manager/network/api/ReVancedAPI.kt | 4 ---- .../ui/screen/BundleInformationScreen.kt | 15 +-------------- .../ui/viewmodel/ChangelogsViewModel.kt | 4 +++- .../manager/ui/viewmodel/UpdateViewModel.kt | 4 +++- 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt index f457671fff..17c9960700 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/SourceManager.kt @@ -22,6 +22,7 @@ import app.revanced.manager.domain.sources.Source 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.dto.ReVancedAssetHistory import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag import app.revanced.manager.util.toast @@ -81,19 +82,28 @@ abstract class SourceManager( // The URL of the default source, built from preferences. protected abstract suspend fun defaultUrl(): Url - // The version resource is nested under the source, with the prerelease variant staying - // last: /v5/patches/prerelease has its version at /v5/patches/version/prerelease. - protected fun versionUriOf(uri: Uri): Uri { + // Resources of a source are nested under it, with the prerelease variant staying last: + // /v5/patches/prerelease has its version at /v5/patches/version/prerelease. + private fun resourceUriOf(uri: Uri, name: String): Uri { val segments = uri.pathSegments val isPrerelease = segments.lastOrNull() == PRERELEASE_PATH return uri.buildUpon().path(null).apply { segments.dropLast(if (isPrerelease) 1 else 0).forEach(::appendPath) - appendPath(VERSION_PATH) + appendPath(name) if (isPrerelease) appendPath(PRERELEASE_PATH) }.build() } + protected fun versionUriOf(uri: Uri) = resourceUriOf(uri, VERSION_PATH) + + // The releases a source has published. + suspend fun getHistory(uri: Uri): List = withContext(Dispatchers.IO) { + protocolHandlers.getStream(resourceUriOf(uri, HISTORY_PATH)) { stream -> + json.decodeFromString(stream.reader().readText()) + } + } + protected abstract fun realNameOf(loaded: LOADED): String? @get:StringRes @@ -503,6 +513,7 @@ abstract class SourceManager( private const val LEGACY_LOCAL = "local" private const val LEGACY_API = "api" private const val VERSION_PATH = "version" +private const val HISTORY_PATH = "history" private const val PRERELEASE_PATH = "prerelease" private fun LocalDateTime.toEpochMillis() = toInstant(TimeZone.UTC).toEpochMilliseconds() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt index 77d59e665b..3ea38221ad 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/ChangelogsRepository.kt @@ -14,13 +14,14 @@ import kotlinx.serialization.Serializable @Serializable sealed interface ChangelogSource : Parcelable { data object Manager : ChangelogSource - data class Patches(val url: String, val prerelease: Boolean) : ChangelogSource { - val baseUrl by lazy { url.toUri().let { "${it.scheme}://${it.host}" } } - } + + // The URL of the source itself, the changelog is a resource of it. + data class Patches(val url: String) : ChangelogSource } class ChangelogsRepository( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, ) : PagingSource() { @@ -31,7 +32,7 @@ class ChangelogsRepository( api.getAppHistory().getOrThrow() is ChangelogSource.Patches -> - api.getPatchesHistory(source.baseUrl, source.prerelease).getOrThrow() + patchBundleRepository.getHistory(source.url.toUri()) } LoadResult.Page( diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index d0ed9cd7ae..650b1b5e01 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -46,15 +46,11 @@ class ReVancedAPI( suspend fun getAppHistory() = request>("manager/history${prefs.useManagerPrereleases.prereleaseString()}") - suspend fun getPatchesHistory(apiUrl: String, prerelease: Boolean) = - request>(apiUrl, defaultApiVersion, "patches/history${prerelease.prereleaseString()}") - suspend fun getContributors() = request>("contributors") suspend fun getInfo() = request("about") private companion object { suspend fun Preference.prereleaseString() = if (get()) "/prerelease" else "" - fun Boolean.prereleaseString() = if (this) "/prerelease" else "" } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt index cb21dae05f..c107b8f1b1 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleInformationScreen.kt @@ -324,20 +324,7 @@ fun BundleInformationScreen( SettingsListItem( headlineContent = stringResource(R.string.changelog), - onClick = { - val source = if (src.isDefault) { - ChangelogSource.Patches( - url = viewModel.prefs.api.getBlocking(), - prerelease = viewModel.prefs.usePatchesPrereleases.getBlocking() - ) - } else { - ChangelogSource.Patches( - url = sourceUrl, - prerelease = false - ) - } - onChangelogClick(source) - }, + onClick = { onChangelogClick(ChangelogSource.Patches(sourceUrl)) }, ) src.error?.let { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt index 064650464f..f64820e72f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt @@ -8,12 +8,14 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.repository.ChangelogsRepository +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAssetHistory import kotlinx.coroutines.flow.Flow class ChangelogsViewModel( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, ) : ViewModel() { val changelogs: Flow> = Pager( @@ -21,6 +23,6 @@ class ChangelogsViewModel( pageSize = 10, enablePlaceholders = false ), - pagingSourceFactory = { ChangelogsRepository(api, source) } + pagingSourceFactory = { ChangelogsRepository(api, patchBundleRepository, source) } ).flow.cachedIn(viewModelScope) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index 5a5f1e15d8..7fada7f591 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -21,6 +21,7 @@ import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.repository.ChangelogSource import app.revanced.manager.domain.repository.ChangelogsRepository +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.ManagerUpdateRepository import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset @@ -45,6 +46,7 @@ import ru.solrudev.ackpine.session.parameters.Confirmation class UpdateViewModel( private val api: ReVancedAPI, + private val patchBundleRepository: PatchBundleRepository, private val source: ChangelogSource, private val downloadOnScreenEntry: Boolean, private val app: Application, @@ -83,7 +85,7 @@ class UpdateViewModel( pageSize = 10, enablePlaceholders = false ), - pagingSourceFactory = { ChangelogsRepository(api, source) } + pagingSourceFactory = { ChangelogsRepository(api, patchBundleRepository, source) } ).flow.cachedIn(viewModelScope) private val location = fs.uiTempDir.resolve("updater.apk")