From 45b1d1868549594159535ef9213fd8459df2cdcf Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 18 Aug 2024 20:12:44 +0200 Subject: [PATCH] new plugin API WIP --- .../repository/DownloadedAppRepository.kt | 62 +++++++++--- .../repository/DownloaderPluginRepository.kt | 3 +- .../downloader/LoadedDownloaderPlugin.kt | 3 +- downloader-plugin/build.gradle.kts | 4 + .../manager/plugin/downloader/Downloader.kt | 17 +--- .../manager/plugin/downloader/Extensions.kt | 96 +++++++++++++++++++ .../downloader/example/ExamplePlugins.kt | 24 +++-- gradle/libs.versions.toml | 2 + 8 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt index dc701cf4..8cfecbc9 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt @@ -8,8 +8,16 @@ import app.revanced.manager.data.room.apps.downloaded.DownloadedApp import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.plugin.downloader.App import app.revanced.manager.plugin.downloader.DownloadScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn import java.io.File +import java.io.FilterInputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.io.path.exists class DownloadedAppRepository(app: Application, db: AppDatabase) { private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE) @@ -17,7 +25,9 @@ class DownloadedAppRepository(app: Application, db: AppDatabase) { fun getAll() = dao.getAllApps().distinctUntilChanged() - private fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory)) + private fun getApkFileForApp(app: DownloadedApp): File = + getApkFileForDir(dir.resolve(app.directory)) + private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first() suspend fun download( @@ -32,21 +42,51 @@ class DownloadedAppRepository(app: Application, db: AppDatabase) { // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here. val relativePath = File(generateUid().toString()) val saveDir = dir.resolve(relativePath).also { it.mkdirs() } - val targetFile = saveDir.resolve("base.apk") + val targetFile = saveDir.resolve("base.apk").toPath() try { - val scope = object : DownloadScope { - override val targetFile = targetFile - override suspend fun reportProgress(bytesReceived: Long, bytesTotal: Long?) { - require(bytesReceived >= 0) { "bytesReceived must not be negative" } - require(bytesTotal == null || bytesTotal >= bytesReceived) { "bytesTotal must be greater than or equal to bytesReceived" } - require(bytesTotal != 0L) { "bytesTotal must not be zero" } + channelFlow { + var fileSize: Long? = null + var downloadedBytes = 0L - onDownload(bytesReceived.megaBytes to bytesTotal?.megaBytes) + val scope = object : DownloadScope { + override suspend fun reportSize(size: Long) { + fileSize = size + send(downloadedBytes to size) + } + /* + override val targetFile = targetFile + override suspend fun reportProgress(bytesReceived: Long, bytesTotal: Long?) { + require(bytesReceived >= 0) { "bytesReceived must not be negative" } + require(bytesTotal == null || bytesTotal >= bytesReceived) { "bytesTotal must be greater than or equal to bytesReceived" } + require(bytesTotal != 0L) { "bytesTotal must not be zero" } + + onDownload(bytesReceived.megaBytes to bytesTotal?.megaBytes) + }*/ + } + + plugin.download(scope, app).use { inputStream -> + Files.copy(object : FilterInputStream(inputStream) { + override fun read(): Int { + val array = ByteArray(1) + if (read(array, 0, 1) != 1) return -1 + return array[0].toInt() + } + + override fun read(b: ByteArray?, off: Int, len: Int) = + super.read(b, off, len).also { result -> + // Report download progress + if (result > 0) { + downloadedBytes += result + fileSize?.let { trySend(downloadedBytes to it).getOrThrow() } + } + } + }, targetFile, StandardCopyOption.REPLACE_EXISTING) } } - - plugin.download(scope, app) + .conflate() + .flowOn(Dispatchers.IO) + .collect { (downloaded, size) -> onDownload(downloaded.megaBytes to size.megaBytes) } if (!targetFile.exists()) throw Exception("Downloader did not download any files") diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt index 20bbfb55..f87a65ec 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import java.io.InputStream import java.lang.reflect.Modifier class DownloaderPluginRepository( @@ -112,7 +113,7 @@ class DownloaderPluginRepository( with(pm) { packageInfo.label() }, packageInfo.versionName, downloader.get, - downloader.download as suspend DownloadScope.(App) -> Unit, + downloader.download as suspend DownloadScope.(App) -> InputStream, classLoader ) ) diff --git a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt index 54174cc3..feab85b7 100644 --- a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt +++ b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt @@ -4,12 +4,13 @@ import android.content.Context import app.revanced.manager.plugin.downloader.App import app.revanced.manager.plugin.downloader.DownloadScope import app.revanced.manager.plugin.downloader.GetScope +import java.io.InputStream class LoadedDownloaderPlugin( val packageName: String, val name: String, val version: String, val get: suspend GetScope.(packageName: String, version: String?) -> App?, - val download: suspend DownloadScope.(app: App) -> Unit, + val download: suspend DownloadScope.(app: App) -> InputStream, val classLoader: ClassLoader ) \ No newline at end of file diff --git a/downloader-plugin/build.gradle.kts b/downloader-plugin/build.gradle.kts index ec553d79..b3871e64 100644 --- a/downloader-plugin/build.gradle.kts +++ b/downloader-plugin/build.gradle.kts @@ -33,6 +33,10 @@ android { } } +dependencies { + implementation(libs.kotlinx.coroutines) +} + publishing { repositories { mavenLocal() diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt index 02b2831c..47dd7b30 100644 --- a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt @@ -2,6 +2,7 @@ package app.revanced.manager.plugin.downloader import android.content.Intent import java.io.File +import java.io.InputStream @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) @DslMarker @@ -18,27 +19,19 @@ fun interface ActivityLaunchPermit { @DownloaderDsl interface DownloadScope { - /** - * The location where the downloaded APK should be saved. - */ - val targetFile: File - - /** - * A callback for reporting download progress - */ - suspend fun reportProgress(bytesReceived: Long, bytesTotal: Long?) + suspend fun reportSize(size: Long) } @DownloaderDsl class DownloaderBuilder { - private var download: (suspend DownloadScope.(A) -> Unit)? = null + private var download: (suspend DownloadScope.(A) -> InputStream)? = null private var get: (suspend GetScope.(String, String?) -> A?)? = null fun get(block: suspend GetScope.(packageName: String, version: String?) -> A?) { get = block } - fun download(block: suspend DownloadScope.(app: A) -> Unit) { + fun download(block: suspend DownloadScope.(app: A) -> InputStream) { download = block } @@ -50,7 +43,7 @@ class DownloaderBuilder { class Downloader internal constructor( val get: suspend GetScope.(packageName: String, version: String?) -> A?, - val download: suspend DownloadScope.(app: A) -> Unit + val download: suspend DownloadScope.(app: A) -> InputStream ) fun downloader(block: DownloaderBuilder.() -> Unit) = diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt new file mode 100644 index 00000000..334b446a --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt @@ -0,0 +1,96 @@ +package app.revanced.manager.plugin.downloader + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.io.FilterInputStream +import java.io.FilterOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream + +// OutputStream-based version of download +fun DownloaderBuilder.download(block: suspend DownloadScope.(A, OutputStream) -> Unit) = + download { app -> + val input = PipedInputStream(1024 * 1024) + var currentThrowable: Throwable? = null + + val coroutineScope = + CoroutineScope(Dispatchers.IO + Job() + CoroutineExceptionHandler { _, throwable -> + currentThrowable?.let { + it.addSuppressed(throwable) + return@CoroutineExceptionHandler + } + + currentThrowable = throwable + }) + var started = false + + fun rethrowException() { + currentThrowable?.let { + currentThrowable = null + throw it + } + } + + fun start() { + started = true + coroutineScope.launch { + PipedOutputStream(input).use { + block(app, object : FilterOutputStream(it) { + var closed = false + + private fun assertIsOpen() { + if (closed) throw IOException("Stream is closed.") + } + + override fun write(b: ByteArray?, off: Int, len: Int) { + assertIsOpen() + super.write(b, off, len) + } + + override fun write(b: Int) { + assertIsOpen() + super.write(b) + } + + override fun close() { + closed = true + } + }) + } + } + } + + object : FilterInputStream(input) { + override fun read(): Int { + val array = ByteArray(1) + if (read(array, 0, 1) != 1) return -1 + return array[0].toInt() + } + + override fun read(b: ByteArray?, off: Int, len: Int): Int { + if (!started) start() + rethrowException() + return super.read(b, off, len) + } + + override fun close() { + super.close() + coroutineScope.cancel() + rethrowException() + } + } + } + +fun DownloaderBuilder.download(block: suspend DownloadScope.(A, (InputStream) -> Unit) -> Unit) = + download { app, outputStream: OutputStream -> + block(app) { inputStream -> + inputStream.use { it.copyTo(outputStream) } + } + } \ No newline at end of file diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugins.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugins.kt index d706b60d..3f2b33b8 100644 --- a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugins.kt +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugins.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.content.pm.PackageManager import app.revanced.manager.plugin.downloader.App import app.revanced.manager.plugin.downloader.DownloaderContext +import app.revanced.manager.plugin.downloader.download import app.revanced.manager.plugin.downloader.downloader import app.revanced.manager.plugin.downloader.example.BuildConfig.PLUGIN_PACKAGE_NAME import kotlinx.coroutines.delay @@ -13,6 +14,8 @@ import kotlinx.parcelize.Parcelize import java.nio.file.Files import java.nio.file.StandardCopyOption import kotlin.io.path.Path +import kotlin.io.path.fileSize +import kotlin.io.path.inputStream // TODO: document API, change dispatcher. @@ -47,15 +50,16 @@ fun installedAppDownloader(context: DownloaderContext) = downloader + Path(app.apkPath).also { + reportSize(it.fileSize()) + }.inputStream() + }*/ - Files.copy(Path(it.apkPath), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + download { app, outputStream -> + val path = Path(app.apkPath) + reportSize(path.fileSize()) + Files.copy(path, outputStream) } -} - -private val Int.megaBytes get() = times(1_000_000) \ No newline at end of file +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b0ecfd1..10030fba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ accompanist = "0.34.0" placeholder = "1.1.2" reorderable = "1.5.2" serialization = "1.6.3" +coroutines = "1.8.1" collection = "0.3.7" room-version = "2.6.1" revanced-patcher = "19.3.1" @@ -68,6 +69,7 @@ placeholder-material3 = { group = "io.github.fornewid", name = "placeholder-mate # Kotlinx kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-collection-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collection" } +kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } # Room room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room-version" }