Compare commits

..

7 Commits

Author SHA1 Message Date
semantic-release-bot
c53617d92d chore: Release API v1.0.0-dev.3 [skip ci]
# api [1.0.0-dev.3](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.2...api@1.0.0-dev.3) (2026-03-12)

### Features

* Add support for writing downloader UI with fragments ([#2998](https://github.com/ReVanced/revanced-manager/issues/2998)) ([7471710](7471710f28))
2026-03-12 20:48:48 +00:00
Ax333l
7471710f28 feat: Add support for writing downloader UI with fragments (#2998) 2026-03-12 21:41:34 +01:00
Ushie
65babf5190 chore: Merge plurals.xml into strings.xml 2026-03-12 13:59:46 +03:00
Pun Butrach
d937e686f8 ci: Bump CI dependencies before Node 20 deprecation 2026-03-10 20:26:22 +07:00
semantic-release-bot
b0016f7c05 chore: Release v1.26.0-dev.34 [skip ci]
# app [1.26.0-dev.34](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.33...v1.26.0-dev.34) (2026-03-10)

### Features

* Improve root installation ([#2895](https://github.com/ReVanced/revanced-manager/issues/2895)) ([763483b](763483b65f))
2026-03-10 01:08:55 +00:00
Robert
763483b65f feat: Improve root installation (#2895)
Co-authored-by: Ushie <ushiekane@gmail.com>
2026-03-10 04:01:49 +03:00
Ushie
c59b3e4377 build: Enable build parallelism 2026-03-10 04:00:59 +03:00
34 changed files with 335 additions and 154 deletions

View File

@@ -12,10 +12,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Cache Gradle - name: Cache Gradle
uses: burrunan/gradle-cache-action@v1 uses: burrunan/gradle-cache-action@v3
- name: Build - name: Build
env: env:
@@ -24,7 +24,7 @@ jobs:
run: ./gradlew assembleRelease --no-daemon run: ./gradlew assembleRelease --no-daemon
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: revanced-manager name: revanced-manager
path: | path: |

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Open pull request - name: Open pull request
uses: repo-sync/pull-request@v2 uses: repo-sync/pull-request@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
ref: dev ref: dev
clean: true clean: true

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v6
- name: Push strings - name: Push strings
uses: crowdin/github-action@v2 uses: crowdin/github-action@v2

View File

@@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
@@ -37,7 +37,7 @@ jobs:
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: "lts/*" node-version: "lts/*"
cache: 'npm' cache: 'npm'
@@ -46,7 +46,7 @@ jobs:
run: npm ci run: npm ci
- name: Import GPG key - name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6 uses: crazy-max/ghaction-import-gpg@v7
with: with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }} passphrase: ${{ secrets.GPG_PASSPHRASE }}

View File

@@ -11,7 +11,7 @@ jobs:
name: Dispatch event to documentation repository name: Dispatch event to documentation repository
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
- uses: peter-evans/repository-dispatch@v3 - uses: peter-evans/repository-dispatch@v4
with: with:
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }} token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
repository: revanced/revanced-documentation repository: revanced/revanced-documentation

View File

@@ -1,3 +1,10 @@
# api [1.0.0-dev.3](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.2...api@1.0.0-dev.3) (2026-03-12)
### Features
* Add support for writing downloader UI with fragments ([#2998](https://github.com/ReVanced/revanced-manager/issues/2998)) ([7471710](https://github.com/ReVanced/revanced-manager/commit/7471710f287b4febb9546a7d7cd59d654b2754e9))
# api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27) # api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27)

View File

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

View File

@@ -1 +1 @@
version = 1.0.0-dev.2 version = 1.0.0-dev.3

View File

@@ -6,13 +6,17 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.IBinder import android.os.IBinder
import android.app.Activity import android.app.Activity
import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.jvm.java
@RequiresOptIn( @RequiresOptIn(
level = RequiresOptIn.Level.ERROR, level = RequiresOptIn.Level.ERROR,
@@ -34,6 +38,11 @@ interface Scope {
* The package name of the downloader. * The package name of the downloader.
*/ */
val downloaderPackageName: String 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. * @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code.
*/ */
suspend fun requestStartActivity(intent: Intent): Intent? 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 interface BaseDownloadScope : Scope

View File

@@ -3,8 +3,10 @@ package app.revanced.manager.downloader
import android.app.Activity import android.app.Activity
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.os.Parcelable import android.os.Parcelable
import androidx.fragment.app.Fragment
import java.io.OutputStream import java.io.OutputStream
/** /**
@@ -31,6 +33,15 @@ suspend inline fun <reified ACTIVITY : Activity> GetScope.requestStartActivity()
Intent().apply { setClassName(downloaderPackageName, ACTIVITY::class.qualifiedName!!) } 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]. * Performs [DownloaderScope.useService] with an [Intent] created using the type information of [SERVICE].
* @see [DownloaderScope.useService] * @see [DownloaderScope.useService]

View File

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

View File

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

@@ -1,3 +1,10 @@
# app [1.26.0-dev.34](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.33...v1.26.0-dev.34) (2026-03-10)
### Features
* Improve root installation ([#2895](https://github.com/ReVanced/revanced-manager/issues/2895)) ([763483b](https://github.com/ReVanced/revanced-manager/commit/763483b65f755201db087eecc6f3b02b65921177))
# app [1.26.0-dev.33](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.32...v1.26.0-dev.33) (2026-03-10) # app [1.26.0-dev.33](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.32...v1.26.0-dev.33) (2026-03-10)

View File

@@ -1 +1 @@
version = 1.26.0-dev.33 version = 1.26.0-dev.34

View File

@@ -49,7 +49,7 @@
</intent-filter> </intent-filter>
</activity> </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 <service
android:name="androidx.work.impl.foreground.SystemForegroundService" 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

@@ -54,7 +54,11 @@ class RootInstaller(
await() await()
} }
suspend fun execute(vararg commands: String) = getShell().newJob().add(*commands).exec() suspend fun execute(vararg commands: String): Shell.Result {
val stdout = mutableListOf<String>()
val stderr = mutableListOf<String>()
return getShell().newJob().add(*commands).to(stdout, stderr).exec()
}
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
@@ -108,20 +112,18 @@ class RootInstaller(
unmount(packageName) unmount(packageName)
stockAPK?.let { stockApp -> stockAPK?.let { stockApp ->
pm.getPackageInfo(packageName)?.let { packageInfo -> // TODO: get user id programmatically
// TODO: get user id programmatically execute("pm uninstall -k --user 0 $packageName")
if (pm.getVersionCode(packageInfo) <= pm.getVersionCode(
pm.getPackageInfo(patchedAPK)
?: error("Failed to get package info for patched app")
)
)
execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
}
execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app") execute("pm install -r -d --user 0 \"${stockApp.absolutePath}\"")
.assertSuccess("Failed to install stock app")
} }
remoteFS.getFile(modulePath).mkdirs() remoteFS.getFile(modulePath).apply {
if (!mkdirs() && !exists()) {
throw Exception("Failed to create module directory")
}
}
listOf( listOf(
"service.sh", "service.sh",
@@ -142,7 +144,6 @@ class RootInstaller(
} }
"$modulePath/$packageName.apk".let { apkPath -> "$modulePath/$packageName.apk".let { apkPath ->
remoteFS.getFile(patchedAPK.absolutePath) remoteFS.getFile(patchedAPK.absolutePath)
.also { if (!it.exists()) throw Exception("File doesn't exist") } .also { if (!it.exists()) throw Exception("File doesn't exist") }
.newInputStream().use { inputStream -> .newInputStream().use { inputStream ->
@@ -173,9 +174,48 @@ class RootInstaller(
const val modulesPath = "/data/adb/modules" const val modulesPath = "/data/adb/modules"
private fun Shell.Result.assertSuccess(errorMessage: String) { private fun Shell.Result.assertSuccess(errorMessage: String) {
if (!isSuccess) throw Exception(errorMessage) if (!isSuccess) {
throw ShellCommandException(
errorMessage,
code,
out,
err
)
}
} }
} }
} }
class ShellCommandException(
val userMessage: String,
val exitCode: Int,
val stdout: List<String>,
val stderr: List<String>
) : Exception(format(userMessage, exitCode, stdout, stderr)) {
companion object {
private fun format(
message: String,
exitCode: Int,
stdout: List<String>,
stderr: List<String>
): String =
buildString {
appendLine(message)
appendLine("Exit code: $exitCode")
val output = stdout.filter { it.isNotBlank() }
val errors = stderr.filter { it.isNotBlank() }
if (output.isNotEmpty()) {
appendLine("stdout:")
output.forEach(::appendLine)
}
if (errors.isNotEmpty()) {
appendLine("stderr:")
errors.forEach(::appendLine)
}
}
}
}
class RootServiceException : Exception("Root not available") class RootServiceException : Exception("Root not available")

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,14 @@
package app.revanced.manager.network.downloader package app.revanced.manager.network.downloader
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.downloader.OutputDownloadScope import app.revanced.manager.downloader.Downloader
import app.revanced.manager.downloader.GetScope import app.revanced.manager.downloader.Scope
import java.io.OutputStream
class LoadedDownloader( class LoadedDownloader(
val packageName: String, val packageName: String,
val className: String, val className: String,
val name: String, val name: String,
val version: String, val version: String,
val get: suspend GetScope.(packageName: String, version: String?) -> Pair<Parcelable, String?>?, val scopeImpl: Scope,
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit 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.patcher.toRemoteError
import app.revanced.manager.downloader.GetScope import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
@@ -185,11 +186,7 @@ class PatcherWorker(
downloaderRepository.loadedDownloadersFlow.first() downloaderRepository.loadedDownloadersFlow.first()
.firstNotNullOfOrNull { downloader -> .firstNotNullOfOrNull { downloader ->
try { try {
val getScope = object : GetScope { val getScope = object : GetScope, Scope by downloader.scopeImpl {
override val downloaderPackageName = downloader.packageName
override val hostPackageName =
applicationContext.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? { override suspend fun requestStartActivity(intent: Intent): Intent? {
val result = val result =
args.handleStartActivityRequest(downloader, intent) args.handleStartActivityRequest(downloader, intent)
@@ -204,7 +201,7 @@ class PatcherWorker(
} }
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
downloader.get( downloader.impl.get(
getScope, getScope,
selectedApp.packageName, selectedApp.packageName,
selectedApp.version selectedApp.version

View File

@@ -3,7 +3,6 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller as AndroidPackageInstaller
import android.net.Uri import android.net.Uri
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
@@ -30,18 +29,18 @@ import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.patcher.ProgressEvent import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.InstallerModel import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.Step import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.withState import app.revanced.manager.ui.model.withState
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
@@ -80,6 +79,7 @@ import ru.solrudev.ackpine.uninstaller.UninstallFailure
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.time.Duration import java.time.Duration
import android.content.pm.PackageInstaller as AndroidPackageInstaller
@OptIn(SavedStateHandleSaveableApi::class, DownloaderHostApi::class) @OptIn(SavedStateHandleSaveableApi::class, DownloaderHostApi::class)
class PatcherViewModel( class PatcherViewModel(
@@ -404,24 +404,26 @@ class PatcherViewModel(
withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile) } withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile) }
?: throw Exception("Failed to load application info") ?: throw Exception("Failed to load application info")
// If the app is currently installed
val existingPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(currentPackageInfo.packageName) }
if (existingPackageInfo != null) {
// Check if the app version is less than the installed version
if (
pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(
existingPackageInfo
)
) {
// Exit if the selected app version is less than the installed version
packageInstallerStatus = AndroidPackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
}
}
when (installType) { when (installType) {
InstallType.DEFAULT -> { InstallType.DEFAULT -> {
// If the app is currently installed
val existingPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(currentPackageInfo.packageName) }
if (existingPackageInfo != null) {
// Check if the app version is less than the installed version
if (
pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(
existingPackageInfo
)
) {
// Exit if the selected app version is less than the installed version
packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
}
}
// Check if the app is mounted as root // Check if the app is mounted as root
// If it is, unmount it first, silently // If it is, unmount it first, silently
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) { if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) {
@@ -437,17 +439,6 @@ class PatcherViewModel(
currentPackageInfo.label() currentPackageInfo.label()
} }
// Check for base APK, first check if the app is already installed
if (existingPackageInfo == null) {
// If the app is not installed, check if the output file is a base apk
if (currentPackageInfo.splitNames.isNotEmpty()) {
// Exit if there is no base APK package
packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_INVALID
return@launch
}
}
val inputVersion = input.selectedApp.version val inputVersion = input.selectedApp.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName } ?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version") ?: throw Exception("Failed to determine input APK 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.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.downloader.GetScope import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.Patcher
@@ -192,9 +193,7 @@ class SelectedAppInfoViewModel(
cancelDownloaderAction() cancelDownloaderAction()
downloaderAction = downloader to viewModelScope.launch { downloaderAction = downloader to viewModelScope.launch {
try { try {
val scope = object : GetScope { val scope = object : GetScope, Scope by downloader.scopeImpl {
override val hostPackageName = app.packageName
override val downloaderPackageName = downloader.packageName
override suspend fun requestStartActivity(intent: Intent) = override suspend fun requestStartActivity(intent: Intent) =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (launchedActivity != null) error("Previous activity has not finished") if (launchedActivity != null) error("Previous activity has not finished")
@@ -219,7 +218,7 @@ class SelectedAppInfoViewModel(
} }
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
downloader.get(scope, packageName, desiredVersion) downloader.impl.get(scope, packageName, desiredVersion)
}?.let { (data, version) -> }?.let { (data, version) ->
if (desiredVersion != null && version != desiredVersion) { if (desiredVersion != null && version != desiredVersion) {
app.toast(app.getString(R.string.downloader_invalid_version)) 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

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="patch_count">
<item quantity="one">%d patch</item>
<item quantity="other">%d patches</item>
</plurals>
<plurals name="patches_executed">
<item quantity="one">Execute %d patch</item>
<item quantity="other">Execute %d patches</item>
</plurals>
<plurals name="selected_count">
<item quantity="other">%d selected</item>
</plurals>
</resources>

View File

@@ -516,4 +516,16 @@ Click on the patches to see more details.</string>
<string name="export">Export</string> <string name="export">Export</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>
<string name="new_announcement">New announcement:\n%s</string> <string name="new_announcement">New announcement:\n%s</string>
<plurals name="patch_count">
<item quantity="one">%d patch</item>
<item quantity="other">%d patches</item>
</plurals>
<plurals name="patches_executed">
<item quantity="one">Execute %d patch</item>
<item quantity="other">Execute %d patches</item>
</plurals>
<plurals name="selected_count">
<item quantity="other">%d selected</item>
</plurals>
</resources> </resources>

View File

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

View File

@@ -3,4 +3,5 @@ android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=false android.nonFinalResIds=false
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true

View File

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