diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cb80b2ba..d7ec3623 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -113,9 +113,10 @@ dependencies {
implementation(libs.runtime.ktx)
implementation(libs.runtime.compose)
implementation(libs.splash.screen)
- implementation(libs.compose.activity)
+ implementation(libs.activity.compose)
implementation(libs.work.runtime.ktx)
implementation(libs.preferences.datastore)
+ implementation(libs.appcompat)
// Compose
implementation(platform(libs.compose.bom))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 883755b9..0404d045 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,8 @@
+
+
diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt
index c3c6daf5..a0dd1e62 100644
--- a/app/src/main/java/app/revanced/manager/MainActivity.kt
+++ b/app/src/main/java/app/revanced/manager/MainActivity.kt
@@ -3,6 +3,7 @@ package app.revanced.manager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
@@ -36,7 +37,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
-
+ enableEdgeToEdge()
installSplashScreen()
val vm: MainViewModel = getAndroidViewModel()
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
index dfa9662e..5328d018 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
@@ -215,6 +215,8 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
ParceledDownloaderData(plugin, data)
)
} ?: app.toast("App was not found")
+ } catch (e: UserInteractionException.Activity) {
+ app.toast(e.message!!)
} finally {
pluginAction = null
dismissSourceSelector()
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 25bde821..59eb8c34 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -4,5 +4,7 @@
\ No newline at end of file
diff --git a/downloader-plugin/build.gradle.kts b/downloader-plugin/build.gradle.kts
index cd6a1e0a..dfa6beee 100644
--- a/downloader-plugin/build.gradle.kts
+++ b/downloader-plugin/build.gradle.kts
@@ -31,12 +31,15 @@ android {
kotlinOptions {
jvmTarget = "17"
}
+ buildFeatures {
+ aidl = true
+ }
}
dependencies {
- implementation(libs.androidx.appcompat)
- implementation(libs.material)
- implementation(libs.androidx.activity)
- implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.ktx)
+ implementation(libs.activity.ktx)
+ implementation(libs.runtime.ktx)
+ implementation(libs.appcompat)
}
publishing {
diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl
new file mode 100644
index 00000000..3f8b85dc
--- /dev/null
+++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl
@@ -0,0 +1,7 @@
+// IWebView.aidl
+package app.revanced.manager.plugin.downloader.webview;
+
+oneway interface IWebView {
+ void load(String url);
+ void finish();
+}
\ No newline at end of file
diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl
new file mode 100644
index 00000000..6bba2d8d
--- /dev/null
+++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl
@@ -0,0 +1,10 @@
+// IWebViewEvents.aidl
+package app.revanced.manager.plugin.downloader.webview;
+
+import app.revanced.manager.plugin.downloader.webview.IWebView;
+
+oneway interface IWebViewEvents {
+ void ready(IWebView iface);
+ void pageLoad(String url);
+ void download(String url, String mimetype, String userAgent);
+}
\ No newline at end of file
diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt
new file mode 100644
index 00000000..4e0df78c
--- /dev/null
+++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt
@@ -0,0 +1,126 @@
+package app.revanced.manager.plugin.downloader.webview
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Parcelable
+import app.revanced.manager.plugin.downloader.DownloaderScope
+import app.revanced.manager.plugin.downloader.GetResult
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.parcelize.Parcelize
+import java.net.HttpURLConnection
+import java.net.URI
+
+internal typealias PageLoadCallback = suspend WebViewCallbackScope.(url: String) -> Unit
+internal typealias DownloadCallback = suspend WebViewCallbackScope.(url: String, mimeType: String, userAgent: String) -> Unit
+internal typealias ReadyCallback = suspend WebViewCallbackScope.() -> Unit
+
+@Parcelize
+data class DownloadUrl(val url: String, val mimeType: String, val userAgent: String) : Parcelable {
+ fun toResult() = with(URI.create(url).toURL().openConnection() as HttpURLConnection) {
+ useCaches = false
+ allowUserInteraction = false
+ setRequestProperty("User-Agent", userAgent)
+ connectTimeout = 10_000
+ connect()
+ inputStream to getHeaderField("Content-Length").toLong()
+ }
+}
+
+interface WebViewCallbackScope {
+ suspend fun finish(result: GetResult?)
+ suspend fun load(url: String)
+}
+
+class WebViewScope internal constructor(
+ coroutineScope: CoroutineScope,
+ setResult: (GetResult?) -> Unit
+) {
+ private var onPageLoadCallback: PageLoadCallback = {}
+ private var onDownloadCallback: DownloadCallback = { _, _, _ -> }
+ private var onReadyCallback: ReadyCallback =
+ { throw Exception("Ready callback not set") }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val dispatcher = Dispatchers.Default.limitedParallelism(1)
+ private var current: IWebView? = null
+ private val webView: IWebView
+ inline get() = current ?: throw Exception("WebView interface unavailable")
+
+ internal val binder = object : IWebViewEvents.Stub() {
+ override fun ready(iface: IWebView?) {
+ coroutineScope.launch(dispatcher) {
+ val wasNull = current == null
+ current = iface
+ if (wasNull) onReadyCallback(callbackScope)
+ }
+ }
+
+ override fun pageLoad(url: String?) {
+ coroutineScope.launch(dispatcher) { onPageLoadCallback(callbackScope, url!!) }
+ }
+
+ override fun download(url: String?, mimetype: String?, userAgent: String?) {
+ coroutineScope.launch(dispatcher) {
+ onDownloadCallback(
+ callbackScope,
+ url!!,
+ mimetype!!,
+ userAgent!!
+ )
+ }
+ }
+ }
+
+ private val callbackScope = object : WebViewCallbackScope {
+ override suspend fun finish(result: GetResult?) {
+ setResult(result)
+ // Tell the WebViewActivity to finish
+ webView.let { withContext(Dispatchers.IO) { it.finish() } }
+ }
+
+ override suspend fun load(url: String) {
+ webView.let { withContext(Dispatchers.IO) { it.load(url) } }
+ }
+
+ }
+
+ fun onDownload(block: DownloadCallback) {
+ onDownloadCallback = block
+ }
+
+ fun onPageLoad(block: PageLoadCallback) {
+ onPageLoadCallback = block
+ }
+
+ fun onReady(block: ReadyCallback) {
+ onReadyCallback = block
+ }
+}
+
+fun DownloaderScope.webView(block: WebViewScope.(packageName: String, version: String?) -> Unit) =
+ get { pkgName, version ->
+ var result: GetResult? = null
+
+ coroutineScope {
+ val scope = WebViewScope(this) { result = it }
+ scope.block(pkgName, version)
+ requestStartActivity(Intent().apply {
+ putExtras(Bundle().apply {
+ putBinder(WebViewActivity.BINDER_KEY, scope.binder)
+ val pm = context.packageManager
+ val label = pm.getPackageInfo(pluginPackageName, 0).applicationInfo.loadLabel(pm).toString()
+ putString(WebViewActivity.TITLE_KEY, label)
+ })
+ setClassName(
+ hostPackageName,
+ WebViewActivity::class.qualifiedName!!
+ )
+ })
+ }
+ result
+ }
\ No newline at end of file
diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt
index f79a51fd..b548318c 100644
--- a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt
+++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt
@@ -2,20 +2,33 @@ package app.revanced.manager.plugin.downloader.webview
import android.annotation.SuppressLint
import android.os.Bundle
+import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
+import androidx.activity.viewModels
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.lifecycle.viewModelScope
import app.revanced.manager.plugin.downloader.R
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
-// TODO: use ComponentActivity instead.
-class WebViewActivity : AppCompatActivity() {
+class WebViewActivity : ComponentActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ val vm by viewModels()
+
enableEdgeToEdge()
setContentView(R.layout.activity_webview)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
@@ -23,10 +36,16 @@ class WebViewActivity : AppCompatActivity() {
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
- val cookieManager = CookieManager.getInstance()
- findViewById(R.id.content).apply {
- cookieManager.setAcceptCookie(true)
- // TODO: murder cookies if this is the first time setting it up.
+ actionBar?.apply {
+ title = intent.getStringExtra(TITLE_KEY)
+ setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
+ setDisplayHomeAsUpEnabled(true)
+ }
+
+ val events = IWebViewEvents.Stub.asInterface(intent.extras!!.getBinder(BINDER_KEY))!!
+ vm.setup(events)
+
+ val webView = findViewById(R.id.content).apply {
settings.apply {
cacheMode = WebSettings.LOAD_NO_CACHE
databaseEnabled = false
@@ -34,6 +53,86 @@ class WebViewActivity : AppCompatActivity() {
domStorageEnabled = false
javaScriptEnabled = true
}
+
+ webViewClient = vm.webViewClient
+ setDownloadListener { url, userAgent, _, mimetype, _ ->
+ vm.onDownload(url, mimetype, userAgent)
+ }
+ }
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ vm.commands.collect {
+ when (it) {
+ is WebViewModel.Command.Finish -> {
+ setResult(RESULT_OK)
+ finish()
+ }
+
+ is WebViewModel.Command.Load -> webView.loadUrl(it.url)
+ }
+ }
+ }
}
}
+
+ override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
+ setResult(RESULT_CANCELED)
+ finish()
+ true
+ } else super.onOptionsItemSelected(item)
+
+ internal companion object {
+ const val BINDER_KEY = "EVENTS"
+ const val TITLE_KEY = "TITLE"
+ }
+}
+
+internal class WebViewModel : ViewModel() {
+ init {
+ CookieManager.getInstance().apply {
+ removeAllCookies(null)
+ setAcceptCookie(true)
+ }
+ }
+
+ private val commandChannel = Channel()
+ val commands = commandChannel.receiveAsFlow()
+
+ private var eventBinder: IWebViewEvents? = null
+ private val ctrlBinder = object : IWebView.Stub() {
+ override fun load(url: String?) {
+ viewModelScope.launch {
+ commandChannel.send(Command.Load(url!!))
+ }
+ }
+
+ override fun finish() {
+ viewModelScope.launch {
+ commandChannel.send(Command.Finish)
+ }
+ }
+ }
+
+ val webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ eventBinder!!.pageLoad(url)
+ }
+ }
+
+ fun onDownload(url: String, mimeType: String, userAgent: String) {
+ eventBinder!!.download(url, mimeType, userAgent)
+ }
+
+ fun setup(binder: IWebViewEvents) {
+ if (eventBinder != null) return
+ eventBinder = binder
+ binder.ready(ctrlBinder)
+ }
+
+ sealed interface Command {
+ data class Load(val url: String) : Command
+ data object Finish : Command
+ }
}
\ No newline at end of file
diff --git a/downloader-plugin/src/main/res/layout/activity_webview.xml b/downloader-plugin/src/main/res/layout/activity_webview.xml
index f07432bb..466721cc 100644
--- a/downloader-plugin/src/main/res/layout/activity_webview.xml
+++ b/downloader-plugin/src/main/res/layout/activity_webview.xml
@@ -1,5 +1,5 @@
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/downloader-plugin/src/main/res/values/themes.xml b/downloader-plugin/src/main/res/values/themes.xml
new file mode 100644
index 00000000..495cde8e
--- /dev/null
+++ b/downloader-plugin/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/example-downloader-plugin/build.gradle.kts b/example-downloader-plugin/build.gradle.kts
index da792645..ec81e0e6 100644
--- a/example-downloader-plugin/build.gradle.kts
+++ b/example-downloader-plugin/build.gradle.kts
@@ -43,7 +43,7 @@ android {
}
dependencies {
- implementation(libs.compose.activity)
+ implementation(libs.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling)
diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt
index d0de82cb..5bf58725 100644
--- a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt
+++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt
@@ -3,16 +3,12 @@
package app.revanced.manager.plugin.downloader.example
import android.app.Application
-import android.content.pm.PackageManager
+import android.net.Uri
import android.os.Parcelable
-import app.revanced.manager.plugin.downloader.download
import app.revanced.manager.plugin.downloader.downloader
-import app.revanced.manager.plugin.downloader.requestStartActivity
+import app.revanced.manager.plugin.downloader.webview.DownloadUrl
+import app.revanced.manager.plugin.downloader.webview.webView
import kotlinx.parcelize.Parcelize
-import java.nio.file.Files
-import kotlin.io.path.Path
-import kotlin.io.path.fileSize
-import kotlin.io.path.inputStream
// TODO: document API, update UI error presentation and strings
@@ -26,9 +22,10 @@ private val application by lazy {
clazz.getMethod("getApplication")(activityThread) as Application
}
-val installedAppDownloader = downloader {
+val installedAppDownloader = downloader {
val pm = application.packageManager
+ /*
get { packageName, version ->
val packageInfo = try {
pm.getPackageInfo(packageName, 0)
@@ -40,8 +37,37 @@ val installedAppDownloader = downloader {
requestStartActivity()
InstalledApp(packageInfo.applicationInfo.sourceDir) to packageInfo.versionName
+ }*/
+ webView { packageName, version ->
+ val startUrl = with(Uri.Builder()) {
+ scheme("https")
+ authority("www.apkmirror.com")
+ mapOf(
+ "post_type" to "app_release",
+ "searchtype" to "apk",
+ "s" to (version?.let { "$packageName $it" } ?: packageName),
+ "bundles%5B%5D" to "apk_files" // bundles[]
+ ).forEach { (key, value) ->
+ appendQueryParameter(key, value)
+ }
+
+ build().toString()
+ }
+
+ onDownload { url, mimeType, userAgent ->
+ finish(DownloadUrl(url, mimeType, userAgent) to version)
+ }
+
+ onReady {
+ load(startUrl)
+ }
}
+ download { downloadable ->
+ downloadable.toResult()
+ }
+
+ /*
download { app ->
with(Path(app.path)) { inputStream() to fileSize() }
}
@@ -50,5 +76,5 @@ val installedAppDownloader = downloader {
val path = Path(app.path)
reportSize(path.fileSize())
Files.copy(path, outputStream)
- }
+ }*/
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 532fc54b..6e468332 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,8 @@ material3 = "1.3.1"
ui-tooling = "1.7.5"
viewmodel-lifecycle = "2.8.7"
splash-screen = "1.0.1"
-compose-activity = "1.9.3"
+activity = "1.9.3"
+appcompat = "1.7.0"
preferences-datastore = "1.1.1"
work-runtime = "2.10.0"
compose-bom = "2024.11.00"
@@ -37,21 +38,17 @@ compose-icons = "1.2.4"
kotlin-process = "1.4.1"
hidden-api-stub = "4.3.3"
-# TODO: get rid of these.
-appcompat = "1.7.0"
-material = "1.12.0"
-activity = "1.9.1"
-constraintlayout = "2.1.4"
-
[libraries]
# AndroidX Core
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "viewmodel-lifecycle" }
runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" }
splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" }
-compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
+activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" }
preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" }
+appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@@ -138,12 +135,6 @@ reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reo
# switch to br.com.devsrsouza.compose.icons after DevSrSouza/compose-icons#30 is merged
compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", name = "font-awesome", version.ref = "compose-icons" }
-
-androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
-androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
-androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
-
[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }