add webview api

This commit is contained in:
Ax333l 2024-11-30 21:20:16 +01:00
parent 3029a61e99
commit 66c06a6fe5
No known key found for this signature in database
GPG Key ID: D2B4D85271127D23
15 changed files with 316 additions and 40 deletions

View File

@ -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))

View File

@ -48,6 +48,8 @@
</intent-filter>
</activity>
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" />

View File

@ -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()

View File

@ -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()

View File

@ -4,5 +4,7 @@
<style name="Theme.ReVancedManager" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.AppCompat.NoActionBar</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

View File

@ -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 {

View File

@ -0,0 +1,7 @@
// IWebView.aidl
package app.revanced.manager.plugin.downloader.webview;
oneway interface IWebView {
void load(String url);
void finish();
}

View File

@ -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);
}

View File

@ -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<T> = suspend WebViewCallbackScope<T>.(url: String) -> Unit
internal typealias DownloadCallback<T> = suspend WebViewCallbackScope<T>.(url: String, mimeType: String, userAgent: String) -> Unit
internal typealias ReadyCallback<T> = suspend WebViewCallbackScope<T>.() -> 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<T : Parcelable> {
suspend fun finish(result: GetResult<T>?)
suspend fun load(url: String)
}
class WebViewScope<T : Parcelable> internal constructor(
coroutineScope: CoroutineScope,
setResult: (GetResult<T>?) -> Unit
) {
private var onPageLoadCallback: PageLoadCallback<T> = {}
private var onDownloadCallback: DownloadCallback<T> = { _, _, _ -> }
private var onReadyCallback: ReadyCallback<T> =
{ 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<T> {
override suspend fun finish(result: GetResult<T>?) {
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<T>) {
onDownloadCallback = block
}
fun onPageLoad(block: PageLoadCallback<T>) {
onPageLoadCallback = block
}
fun onReady(block: ReadyCallback<T>) {
onReadyCallback = block
}
}
fun <T : Parcelable> DownloaderScope<T>.webView(block: WebViewScope<T>.(packageName: String, version: String?) -> Unit) =
get { pkgName, version ->
var result: GetResult<T>? = 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
}

View File

@ -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<WebViewModel>()
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<WebView>(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<WebView>(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<Command>()
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
}
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
@ -11,7 +11,6 @@
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:rotationX="25"
tools:layout_editor_absoluteX="1dp"
tools:layout_editor_absoluteY="1dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WebViewActivity" parent="Theme.AppCompat.DayNight">
<item name="android:windowActionBar">true</item>
<item name="android:windowNoTitle">false</item>
</style>
</resources>

View File

@ -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)

View File

@ -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<InstalledApp> {
val installedAppDownloader = downloader<DownloadUrl> {
val pm = application.packageManager
/*
get { packageName, version ->
val packageInfo = try {
pm.getPackageInfo(packageName, 0)
@ -40,8 +37,37 @@ val installedAppDownloader = downloader<InstalledApp> {
requestStartActivity<InteractionActivity>()
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<InstalledApp> {
val path = Path(app.path)
reportSize(path.fileSize())
Files.copy(path, outputStream)
}
}*/
}

View File

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