feat: Add support for writing downloader UI with fragments (#2998)

This commit is contained in:
Ax333l
2026-03-12 21:41:34 +01:00
committed by GitHub
parent 65babf5190
commit 7471710f28
19 changed files with 219 additions and 82 deletions

View File

@@ -4,7 +4,6 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.binary.compatibility.validator)
`maven-publish`
signing
@@ -15,8 +14,7 @@ group = "app.revanced"
dependencies {
implementation(libs.androidx.ktx)
implementation(libs.runtime.ktx)
implementation(libs.activity.compose)
implementation(libs.appcompat)
implementation(libs.fragment.ktx)
}
kotlin {

View File

@@ -6,13 +6,17 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.app.Activity
import android.os.Bundle
import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import kotlinx.coroutines.withTimeout
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.jvm.java
@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
@@ -34,6 +38,11 @@ interface Scope {
* The package name of the downloader.
*/
val downloaderPackageName: String
/**
* A data directory for this downloader package.
*/
val dataDir: File
}
/**
@@ -49,6 +58,25 @@ interface GetScope : Scope {
* @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code.
*/
suspend fun requestStartActivity(intent: Intent): Intent?
/**
* Starts an [Activity] using [requestStartActivity] which loads the specified [Fragment].
* The fragment may reside in the downloader package.
*
* @param clazz The class of the fragment to launch.
* @param args The fragment arguments.
*/
suspend fun requestStartFragment(clazz: Class<out Fragment>, args: Bundle?) =
requestStartActivity(Intent().apply {
setClassName(hostPackageName, "app.revanced.manager.DownloaderActivity")
// We shouldn't use the downloader's resources if it is launching a fragment that resides in manager itself.
if (clazz.classLoader != GetScope::class.java.classLoader) putExtra(
"DOWNLOADER_NAME",
downloaderPackageName
)
putExtra("FRAGMENT_CLASS_NAME", clazz.name)
putExtra("FRAGMENT_ARGS", args)
})
}
interface BaseDownloadScope : Scope

View File

@@ -3,8 +3,10 @@ package app.revanced.manager.downloader
import android.app.Activity
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import android.os.Parcelable
import androidx.fragment.app.Fragment
import java.io.OutputStream
/**
@@ -31,6 +33,15 @@ suspend inline fun <reified ACTIVITY : Activity> GetScope.requestStartActivity()
Intent().apply { setClassName(downloaderPackageName, ACTIVITY::class.qualifiedName!!) }
)
/**
* Starts an [Activity] using [GetScope.requestStartActivity] which loads the specified [Fragment].
* The fragment may reside in the downloader package.
*
* @param args The fragment arguments.
*/
suspend inline fun <reified T : Fragment> GetScope.requestStartFragment(args: Bundle?) =
requestStartFragment(T::class.java, args)
/**
* Performs [DownloaderScope.useService] with an [Intent] created using the type information of [SERVICE].
* @see [DownloaderScope.useService]

View File

@@ -1,6 +1,7 @@
package app.revanced.manager.downloader.webview
import android.content.Intent
import android.os.Bundle
import androidx.annotation.StringRes
import app.revanced.manager.downloader.DownloadUrl
import app.revanced.manager.downloader.DownloaderScope
@@ -8,6 +9,7 @@ import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.Downloader
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.requestStartFragment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -121,15 +123,11 @@ suspend fun <T> GetScope.runWebView(
val scope = WebViewScope<T>(this@supervisorScope, this@runWebView) { result = Container(it) }
scope.initialUrl = scope.block()
// Start the webview activity and wait until it finishes.
requestStartActivity(Intent().apply {
putExtra(
WebViewActivity.KEY,
WebViewActivity.Parameters(title, scope.binder)
)
setClassName(
hostPackageName,
WebViewActivity::class.qualifiedName!!
// Start the webview and wait until it finishes.
requestStartFragment<WebViewFragment>(Bundle().apply {
putParcelable(
WebViewFragment.KEY,
WebViewFragment.Parameters(title, scope.binder)
)
})

View File

@@ -1,20 +1,26 @@
package app.revanced.manager.downloader.webview
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.os.Bundle
import android.os.IBinder
import android.os.Parcelable
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
@@ -29,34 +35,59 @@ import kotlinx.parcelize.Parcelize
@OptIn(DownloaderHostApi::class)
@DownloaderHostApi
class WebViewActivity : ComponentActivity() {
class WebViewFragment : Fragment(R.layout.webview_fragment) {
private val vm by viewModels<WebViewModel>()
lateinit var webView: WebView
private val args by lazy {
arguments?.getParcelable<Parameters>(KEY)!!
}
override fun onAttach(context: Context) {
super.onAttach(context)
requireActivity().apply {
enableEdgeToEdge()
onBackPressedDispatcher.addCallback {
if (webView.canGoBack()) webView.goBack()
else cancelActivity()
}
actionBar?.apply {
title = args.title
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
setDisplayHomeAsUpEnabled(true)
}
addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(
menu: Menu,
menuInflater: MenuInflater
) {
}
override fun onMenuItemSelected(menuItem: MenuItem) =
if (menuItem.itemId == android.R.id.home) {
cancelActivity()
true
} else false
},
this
)
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val vm by viewModels<WebViewModel>()
enableEdgeToEdge()
setContentView(R.layout.activity_webview)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
ViewCompat.setOnApplyWindowInsetsListener(view.findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
val webView = findViewById<WebView>(R.id.webview)
onBackPressedDispatcher.addCallback {
if (webView.canGoBack()) webView.goBack()
else cancelActivity()
}
webView = view.findViewById<WebView>(R.id.webview)
val params = intent.getParcelableExtra<Parameters>(KEY)!!
actionBar?.apply {
title = params.title
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
setDisplayHomeAsUpEnabled(true)
}
val events = IWebViewEvents.Stub.asInterface(params.events)!!
val events = IWebViewEvents.Stub.asInterface(args.events)!!
vm.setup(events)
webView.apply {
@@ -73,13 +104,14 @@ class WebViewActivity : ComponentActivity() {
}
}
val activity = requireActivity()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.commands.collect {
when (it) {
is WebViewModel.Command.Finish -> {
setResult(RESULT_OK)
finish()
activity.setResult(Activity.RESULT_OK)
activity.finish()
}
is WebViewModel.Command.Load -> webView.loadUrl(it.url)
@@ -90,16 +122,11 @@ class WebViewActivity : ComponentActivity() {
}
private fun cancelActivity() {
setResult(RESULT_CANCELED)
finish()
val activity = requireActivity()
activity.setResult(Activity.RESULT_CANCELED)
activity.finish()
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
cancelActivity()
true
} else super.onOptionsItemSelected(item)
@Parcelize
internal class Parameters(
val title: String, val events: IBinder

View File

@@ -1 +0,0 @@
<resources></resources>

View File

@@ -1,7 +0,0 @@
<?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

@@ -49,7 +49,7 @@
</intent-filter>
</activity>
<activity android:name=".downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<activity android:name=".DownloaderActivity" android:exported="false" android:theme="@style/Theme.DownloaderActivity" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -0,0 +1,62 @@
package app.revanced.manager
import android.content.res.Resources
import android.os.Bundle
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.commit
import app.revanced.manager.domain.repository.DownloaderRepository
import app.revanced.manager.network.downloader.DownloaderPackageState
import org.koin.android.ext.android.inject
class DownloaderActivity : FragmentActivity() {
private val downloaderRepository: DownloaderRepository by inject()
var downloaderPackageName = ""
private val downloaderPkgState
get() = downloaderRepository
.downloaderPackageStates
.value[downloaderPackageName]
?.let { it as? DownloaderPackageState.Loaded }
private var res: Resources? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val view = FragmentContainerView(this).apply {
// The fragment manager requires an ID to work.
id = R.id.fragment_container
}
setContentView(
view,
ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
)
downloaderPackageName = intent.getStringExtra("DOWNLOADER_NAME").orEmpty()
val fragmentClassName = intent.getStringExtra("FRAGMENT_CLASS_NAME")!!
val args = intent.getBundleExtra("FRAGMENT_ARGS")
res =
downloaderPkgState?.context?.createConfigurationContext(super.resources.configuration)?.resources
if (savedInstanceState == null) {
@Suppress("UNCHECKED_CAST")
val fragmentClass = classLoader!!.loadClass(fragmentClassName) as Class<Fragment>
supportFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, fragmentClass, args)
}
}
}
override fun getClassLoader(): ClassLoader? =
downloaderPkgState?.classLoader ?: super.classLoader
override fun getResources(): Resources? = res ?: super.resources
}

View File

@@ -6,8 +6,10 @@ import android.os.Parcelable
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.downloader.OutputDownloadScope
import app.revanced.manager.downloader.Scope
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
@@ -21,7 +23,7 @@ import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.outputStream
class DownloadedAppRepository(
private val app: Application,
app: Application,
db: AppDatabase,
private val pm: PM
) {
@@ -35,6 +37,7 @@ class DownloadedAppRepository(
private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
@OptIn(DownloaderHostApi::class)
suspend fun download(
downloader: LoadedDownloader,
data: Parcelable,
@@ -54,9 +57,7 @@ class DownloadedAppRepository(
val downloadedBytes = AtomicLong(0)
channelFlow {
val scope = object : OutputDownloadScope {
override val downloaderPackageName = downloader.packageName
override val hostPackageName = app.packageName
val scope = object : OutputDownloadScope, Scope by downloader.scopeImpl {
override suspend fun reportSize(size: Long) {
require(size > 0) { "Size must be greater than zero" }
require(
@@ -87,7 +88,7 @@ class DownloadedAppRepository(
)
}
}
downloader.download(scope, data, stream)
downloader.impl.download(scope, data, stream)
}
}
.conflate()

View File

@@ -2,6 +2,7 @@ package app.revanced.manager.domain.repository
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.pm.PackageInfo
import android.os.Parcelable
import android.util.Log
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.lang.reflect.Modifier
@OptIn(DownloaderHostApi::class)
@@ -50,6 +52,8 @@ class DownloaderRepository(
installed subtract acknowledged
}
private val downloadersDir = app.getDir("downloaders", Context.MODE_PRIVATE)
suspend fun reload() {
val downloaderPackages =
withContext(Dispatchers.IO) {
@@ -65,8 +69,12 @@ class DownloaderRepository(
acknowledgedDownloader subtract installedDownloaderPackageNames.value
if (uninstalledDownloader.isNotEmpty()) {
Log.d(tag, "Uninstalled downloader: ${uninstalledDownloader.joinToString(", ")}")
this@DownloaderRepository.acknowledgedPackageNames.update(acknowledgedDownloader subtract uninstalledDownloader)
trustDao.removeAll(uninstalledDownloader)
withContext(Dispatchers.IO) {
uninstalledDownloader.forEach { downloadersDir.resolve(it).deleteRecursively() }
}
}
}
@@ -109,6 +117,8 @@ class DownloaderRepository(
val scopeImpl = object : Scope {
override val hostPackageName = app.packageName
override val downloaderPackageName = downloaderContext.packageName
override val dataDir =
downloadersDir.resolve(downloaderPackageName).also(File::mkdirs)
}
DownloaderPackageState.Loaded(
@@ -126,11 +136,12 @@ class DownloaderRepository(
className,
downloaderContext.getString(downloader.name),
packageInfo.versionName!!,
downloader.get,
downloader.download
scopeImpl,
downloader
)
},
classLoader,
downloaderContext,
with(pm) { packageInfo.label() }
)
} catch (e: CancellationException) {

View File

@@ -1,11 +1,14 @@
package app.revanced.manager.network.downloader
import android.content.Context
sealed interface DownloaderPackageState {
data object Untrusted : DownloaderPackageState
data class Loaded(
val downloaders: List<LoadedDownloader>,
val classLoader: ClassLoader,
val context: Context,
val name: String
) : DownloaderPackageState

View File

@@ -1,15 +1,14 @@
package app.revanced.manager.network.downloader
import android.os.Parcelable
import app.revanced.manager.downloader.OutputDownloadScope
import app.revanced.manager.downloader.GetScope
import java.io.OutputStream
import app.revanced.manager.downloader.Downloader
import app.revanced.manager.downloader.Scope
class LoadedDownloader(
val packageName: String,
val className: String,
val name: String,
val version: String,
val get: suspend GetScope.(packageName: String, version: String?) -> Pair<Parcelable, String?>?,
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit
val scopeImpl: Scope,
val impl: Downloader<Parcelable>
)

View File

@@ -38,6 +38,7 @@ import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
@@ -185,11 +186,7 @@ class PatcherWorker(
downloaderRepository.loadedDownloadersFlow.first()
.firstNotNullOfOrNull { downloader ->
try {
val getScope = object : GetScope {
override val downloaderPackageName = downloader.packageName
override val hostPackageName =
applicationContext.packageName
val getScope = object : GetScope, Scope by downloader.scopeImpl {
override suspend fun requestStartActivity(intent: Intent): Intent? {
val result =
args.handleStartActivityRequest(downloader, intent)
@@ -204,7 +201,7 @@ class PatcherWorker(
}
}
withContext(Dispatchers.IO) {
downloader.get(
downloader.impl.get(
getScope,
selectedApp.packageName,
selectedApp.version

View File

@@ -34,6 +34,7 @@ import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.Patcher
@@ -192,9 +193,7 @@ class SelectedAppInfoViewModel(
cancelDownloaderAction()
downloaderAction = downloader to viewModelScope.launch {
try {
val scope = object : GetScope {
override val hostPackageName = app.packageName
override val downloaderPackageName = downloader.packageName
val scope = object : GetScope, Scope by downloader.scopeImpl {
override suspend fun requestStartActivity(intent: Intent) =
withContext(Dispatchers.Main) {
if (launchedActivity != null) error("Previous activity has not finished")
@@ -219,7 +218,7 @@ class SelectedAppInfoViewModel(
}
withContext(Dispatchers.IO) {
downloader.get(scope, packageName, desiredVersion)
downloader.impl.get(scope, packageName, desiredVersion)
}?.let { (data, version) ->
if (desiredVersion != null && version != desiredVersion) {
app.toast(app.getString(R.string.downloader_invalid_version))

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="fragment_container" />
</resources>

View File

@@ -6,4 +6,9 @@
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
<style name="Theme.DownloaderActivity" parent="Theme.AppCompat.DayNight">
<item name="android:windowActionBar">true</item>
<item name="android:windowNoTitle">false</item>
</style>
</resources>

View File

@@ -1,11 +1,12 @@
[versions]
ktx = "1.17.0"
ktx = "1.18.0"
material3 = "1.5.0-alpha14"
ui-tooling = "1.10.0"
viewmodel-lifecycle = "2.10.0"
splash-screen = "1.2.0"
activity = "1.12.2"
appcompat = "1.7.1"
fragment = "1.8.9"
preferences-datastore = "1.2.0"
work-runtime = "2.11.0"
compose-bom = "2025.12.01"
@@ -13,15 +14,15 @@ navigation = "2.9.7"
accompanist = "0.37.3"
placeholder = "1.0.12"
reorderable = "3.0.0"
serialization = "1.9.0"
serialization = "1.10.0"
collection = "0.4.0"
datetime = "0.7.1"
room-version = "2.8.4"
revanced-patcher = "22.0.0"
revanced-library = "4.0.0"
koin = "4.1.1"
ktor = "3.3.3"
markdown-renderer = "0.39.0"
ktor = "3.4.1"
markdown-renderer = "0.39.2"
fading-edges = "1.0.4"
kotlin = "2.3.10"
android-gradle-plugin = "8.13.2"
@@ -37,7 +38,7 @@ kotlin-process = "1.5.1"
hidden-api-stub = "4.4.0"
binary-compatibility-validator = "0.18.1"
semver-parser = "3.0.0"
ackpine = "0.19.1"
ackpine = "0.20.6"
[libraries]
# AndroidX Core
@@ -46,6 +47,7 @@ runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", ve
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" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
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" }