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" }