Compare commits

..

1 Commits

Author SHA1 Message Date
Robert
ff70a77afb feat: Improve root installation 2025-12-26 01:07:58 +01:00
36 changed files with 747 additions and 842 deletions

View File

@@ -1,31 +1,3 @@
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)
### Bug Fixes
* allow updating patches on metered networks ([9d9a0e8](https://github.com/ReVanced/revanced-manager/commit/9d9a0e81dbc9e73e6e3181f6bea9cabb69e49ea8))
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)
### Features
* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](https://github.com/ReVanced/revanced-manager/commit/11dd6e4064099427a8c9bc6f225a19412e5c70e2))
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)
### Bug Fixes
* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6))
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)
### Bug Fixes
* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](https://github.com/ReVanced/revanced-manager/commit/0d26df03f463195dae550240c7f652680763079c))
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)

View File

@@ -108,10 +108,6 @@ dependencies {
// Compose Icons
implementation(libs.compose.icons.fontawesome)
// Ackpine
implementation(libs.ackpine.core)
implementation(libs.ackpine.ktx)
}
buildscript {

View File

@@ -1 +1 @@
version = 1.26.0-dev.17
version = 1.26.0-dev.13

View File

@@ -51,6 +51,9 @@
<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" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="specialUse"
@@ -72,15 +75,5 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="ru.solrudev.ackpine.AckpineInitializer"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@@ -1,4 +0,0 @@
// ProgressEventParcel.aidl
package app.revanced.manager.patcher;
parcelable ProgressEventParcel;

View File

@@ -1,12 +1,11 @@
// IPatcherEvents.aidl
package app.revanced.manager.patcher.runtime.process;
import app.revanced.manager.patcher.ProgressEventParcel;
// Interface for sending events back to the main app process.
oneway interface IPatcherEvents {
void log(String level, String msg);
void event(in ProgressEventParcel event);
void patchSucceeded();
void progress(String name, String state, String msg);
// The patching process has ended. The exceptionStackTrace is null if it finished successfully.
void finished(String exceptionStackTrace);
}

View File

@@ -48,8 +48,7 @@ class ManagerApplication : Application() {
workerModule,
viewModelModule,
databaseModule,
rootModule,
ackpineModule
rootModule
)
}

View File

@@ -15,5 +15,5 @@ class NetworkInfo(app: Application) {
/**
* Returns true if it is safe to download large files.
*/
fun isSafe(ignoreMetered: Boolean) = isConnected() && (ignoreMetered || isUnmetered())
fun isSafe() = isConnected() && isUnmetered()
}

View File

@@ -1,19 +0,0 @@
package app.revanced.manager.di
import android.content.Context
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.uninstaller.PackageUninstaller
val ackpineModule = module {
fun provideInstaller(context: Context) = PackageInstaller.getInstance(context)
fun provideUninstaller(context: Context) = PackageUninstaller.getInstance(context)
single {
provideInstaller(androidContext())
}
single {
provideUninstaller(androidContext())
}
}

View File

@@ -54,7 +54,11 @@ class RootInstaller(
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
@@ -108,20 +112,15 @@ class RootInstaller(
unmount(packageName)
stockAPK?.let { stockApp ->
pm.getPackageInfo(packageName)?.let { packageInfo ->
// TODO: get user id programmatically
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")
}
// TODO: get user id programmatically
execute("pm uninstall -k --user 0 $packageName")
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).mkdir()
remoteFS.getFile(modulePath).mkdirs()
.also { if (!it) throw Exception("Failed to create module directory") }
listOf(
"service.sh",
@@ -142,7 +141,6 @@ class RootInstaller(
}
"$modulePath/$packageName.apk".let { apkPath ->
remoteFS.getFile(patchedAPK.absolutePath)
.also { if (!it.exists()) throw Exception("File doesn't exist") }
.newInputStream().use { inputStream ->
@@ -173,9 +171,43 @@ class RootInstaller(
const val modulesPath = "/data/adb/modules"
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")

View File

@@ -34,6 +34,4 @@ class PreferencesManager(
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)
val allowMeteredNetworks = booleanPreference("allow_metered_networks", false)
}

View File

@@ -286,29 +286,28 @@ class PatchBundleRepository(
State(sources.toPersistentMap(), info.toPersistentMap())
}
suspend fun createLocal(createStream: suspend () -> InputStream) =
dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
deleteLocalFile()
suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
}
doReload()
deleteLocalFile()
}
}
doReload()
}
suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add bundle ($url)") { state ->
val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle
update(src, force = true)
update(src)
state.copy(sources = state.sources.put(src.uid, src))
}
@@ -330,38 +329,32 @@ class PatchBundleRepository(
state.copy(sources = state.sources.put(uid, newSrc))
}
suspend fun update(
vararg sources: RemotePatchBundle,
showToast: Boolean = false,
force: Boolean = false
) {
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
store.dispatch(Update(showToast = showToast) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true, redownload = true))
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() =
store.dispatch(Update(force = prefs.allowMeteredNetworks.get()) { it.autoUpdate })
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
private inner class Update(
private val force: Boolean = false,
private val redownload: Boolean = false,
private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (redownload) "Redownload remote bundles" else "Update check"
override fun toString() = if (force) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe(force)) {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
@@ -374,7 +367,7 @@ class PatchBundleRepository(
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (redownload) downloadLatest() else update()
if (force) downloadLatest() else update()
} ?: return@async null
it to newVersion

View File

@@ -1,78 +0,0 @@
package app.revanced.manager.patcher
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class ProgressEvent : Parcelable {
abstract val stepId: StepId?
data class Started(override val stepId: StepId) : ProgressEvent()
data class Progress(
override val stepId: StepId,
val current: Long? = null,
val total: Long? = null,
val message: String? = null,
) : ProgressEvent()
data class Completed(
override val stepId: StepId,
) : ProgressEvent()
data class Failed(
override val stepId: StepId?,
val error: RemoteError,
) : ProgressEvent()
}
/**
* Parcelable wrapper for [ProgressEvent].
*
* Required because AIDL does not support sealed classes.
*/
@Parcelize
data class ProgressEventParcel(val event: ProgressEvent) : Parcelable
fun ProgressEventParcel.toEvent(): ProgressEvent = event
fun ProgressEvent.toParcel(): ProgressEventParcel = ProgressEventParcel(this)
@Parcelize
sealed class StepId : Parcelable {
data object DownloadAPK : StepId()
data object LoadPatches : StepId()
data object ReadAPK : StepId()
data object ExecutePatches : StepId()
data class ExecutePatch(val index: Int) : StepId()
data object WriteAPK : StepId()
data object SignAPK : StepId()
}
@Parcelize
data class RemoteError(
val type: String,
val message: String?,
val stackTrace: String,
) : Parcelable
fun Exception.toRemoteError() = RemoteError(
type = this::class.java.name,
message = this.message,
stackTrace = this.stackTraceToString(),
)
inline fun <T> runStep(
stepId: StepId,
onEvent: (ProgressEvent) -> Unit,
block: () -> T,
): T = try {
onEvent(ProgressEvent.Started(stepId))
val value = block()
onEvent(ProgressEvent.Completed(stepId))
value
} catch (error: Exception) {
onEvent(ProgressEvent.Failed(stepId, error.toRemoteError()))
throw error
}

View File

@@ -1,9 +1,10 @@
package app.revanced.manager.patcher
import android.content.Context
import app.revanced.library.ApkUtils.applyTo
import app.revanced.manager.patcher.Session.Companion.component1
import app.revanced.manager.patcher.Session.Companion.component2
import app.revanced.manager.R
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.ui.model.State
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch
@@ -21,10 +22,15 @@ class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
private val androidContext: Context,
private val logger: Logger,
private val input: File,
private val onEvent: (ProgressEvent) -> Unit,
private val onPatchCompleted: suspend () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
onProgress(name, state, message)
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher(
PatcherConfig(
@@ -36,68 +42,86 @@ class Session(
)
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
var nextPatchIndex = 0
updateProgress(
name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]),
state = State.RUNNING
)
this().collect { (patch, exception) ->
val index = selectedPatches.indexOf(patch)
if (index == -1) return@collect
if (patch !in selectedPatches) return@collect
if (exception != null) {
onEvent(
ProgressEvent.Failed(
StepId.ExecutePatch(index),
exception.toRemoteError(),
)
updateProgress(
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
state = State.FAILED,
message = exception.stackTraceToString()
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
}
onEvent(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
nextPatchIndex++
onPatchCompleted()
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
updateProgress(
name = androidContext.getString(R.string.executing_patch, nextPatch.name)
)
)
}
logger.info("${patch.name} succeeded")
}
updateProgress(
state = State.COMPLETED,
name = androidContext.resources.getQuantityString(
R.plurals.patches_executed,
selectedPatches.size,
selectedPatches.size
)
)
}
suspend fun run(output: File, selectedPatches: PatchList) {
runStep(StepId.ExecutePatches, onEvent) {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
}
updateProgress(state = State.COMPLETED) // Unpacking
addHandler(logger.handler)
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
}
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
}
addHandler(logger.handler)
}
runStep(StepId.WriteAPK, onEvent) {
logger.info("Writing patched files...")
val result = patcher.get()
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
result.applyTo(patched)
logger.info("Patched apk saved to $patched")
withContext(Dispatchers.IO) {
Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
}
logger.info("Writing patched files...")
val result = patcher.get()
val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
result.applyTo(patched)
logger.info("Patched apk saved to $patched")
withContext(Dispatchers.IO) {
Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
updateProgress(state = State.COMPLETED) // Saving
}
override fun close() {

View File

@@ -1,12 +1,11 @@
package app.revanced.manager.patcher.runtime
import android.content.Context
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import java.io.File
@@ -14,7 +13,7 @@ import java.io.File
/**
* Simple [Runtime] implementation that runs the patcher using coroutines.
*/
class CoroutineRuntime(context: Context) : Runtime(context) {
class CoroutineRuntime(private val context: Context) : Runtime(context) {
override suspend fun execute(
inputFile: String,
outputFile: String,
@@ -22,50 +21,47 @@ class CoroutineRuntime(context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onEvent: (ProgressEvent) -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
) {
val patchList = runStep(StepId.LoadPatches, onEvent) {
val selectedBundles = selectedPatches.keys
val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val selectedBundles = selectedPatches.keys
val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles }
val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles }
val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
// Set all patch options.
options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
val patchOptions = patches.single { it.name == patchName }.options
configuredPatchOptions.forEach { (key, value) ->
patchOptions[key] = value
}
// Set all patch options.
options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
val patchOptions = patches.single { it.name == patchName }.options
configuredPatchOptions.forEach { (key, value) ->
patchOptions[key] = value
}
}
patchList
}
val session = runStep(StepId.ReadAPK, onEvent) {
Session(
cacheDir,
frameworkPath,
aaptPath,
logger,
File(inputFile),
onEvent,
)
}
onProgress(null, State.COMPLETED, null) // Loading patches
session.use { s ->
s.run(
Session(
cacheDir,
frameworkPath,
aaptPath,
context,
logger,
File(inputFile),
onPatchCompleted = onPatchCompleted,
onProgress
).use { session ->
session.run(
File(outputFile),
patchList
)

View File

@@ -10,13 +10,12 @@ import app.revanced.manager.BuildConfig
import app.revanced.manager.patcher.runtime.process.IPatcherEvents
import app.revanced.manager.patcher.runtime.process.IPatcherProcess
import app.revanced.manager.patcher.LibraryResolver
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.ProgressEventParcel
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runtime.process.Parameters
import app.revanced.manager.patcher.runtime.process.PatchConfiguration
import app.revanced.manager.patcher.runtime.process.PatcherProcess
import app.revanced.manager.patcher.toEvent
import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
@@ -67,7 +66,8 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onEvent: (ProgressEvent) -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
) = coroutineScope {
// Get the location of our own Apk.
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
@@ -111,6 +111,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
}
val patching = CompletableDeferred<Unit>()
val scope = this
launch(Dispatchers.IO) {
val binder = awaitBinderConnection()
@@ -123,10 +124,13 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
val eventHandler = object : IPatcherEvents.Stub() {
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
override fun event(event: ProgressEventParcel?) {
event?.let { onEvent(it.toEvent()) }
override fun patchSucceeded() {
scope.launch { onPatchCompleted() }
}
override fun progress(name: String?, state: String?, msg: String?) =
onProgress(name, state?.let { enumValueOf<State>(it) }, msg)
override fun finished(exceptionStackTrace: String?) {
binder.exit()

View File

@@ -4,9 +4,9 @@ import android.content.Context
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import kotlinx.coroutines.flow.first
@@ -34,6 +34,7 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onEvent: (ProgressEvent) -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
)
}

View File

@@ -8,15 +8,12 @@ import android.os.Build
import android.os.Bundle
import android.os.Looper
import app.revanced.manager.BuildConfig
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toParcel
import app.revanced.manager.ui.model.State
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -27,7 +24,7 @@ import kotlin.system.exitProcess
/**
* The main class that runs inside the runner process launched by [ProcessRuntime].
*/
class PatcherProcess() : IPatcherProcess.Stub() {
class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
private var eventBinder: IPatcherEvents? = null
private val scope =
@@ -49,8 +46,6 @@ class PatcherProcess() : IPatcherProcess.Stub() {
override fun exit() = exitProcess(0)
override fun start(parameters: Parameters, events: IPatcherEvents) {
fun onEvent(event: ProgressEvent) = events.event(event.toParcel())
eventBinder = events
scope.launch {
@@ -61,42 +56,38 @@ class PatcherProcess() : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
val patchList = runStep(StepId.LoadPatches, ::onEvent) {
val allPatches = PatchBundle.Loader.patches(
parameters.configurations.map { it.bundle },
parameters.packageName
)
parameters.configurations.flatMap { config ->
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName)
val patchList = parameters.configurations.flatMap { config ->
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
.filter { it.name in config.patches }
.associateBy { it.name }
config.options.forEach { (patchName, opts) ->
val patchOptions = patches[patchName]?.options
?: throw Exception("Patch with name $patchName does not exist.")
config.options.forEach { (patchName, opts) ->
val patchOptions = patches[patchName]?.options
?: throw Exception("Patch with name $patchName does not exist.")
opts.forEach { (key, value) ->
patchOptions[key] = value
}
opts.forEach { (key, value) ->
patchOptions[key] = value
}
patches.values
}
patches.values
}
val session = runStep(StepId.ReadAPK, ::onEvent) {
Session(
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
logger = logger,
input = File(parameters.inputFile),
onEvent = ::onEvent,
)
}
events.progress(null, State.COMPLETED.name, null) // Loading patches
session.use {
Session(
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
androidContext = context,
logger = logger,
input = File(parameters.inputFile),
onPatchCompleted = { events.patchSucceeded() },
onProgress = { name, state, message ->
events.progress(name, state?.name, message)
}
).use {
it.run(File(parameters.outputFile), patchList)
}
@@ -128,7 +119,7 @@ class PatcherProcess() : IPatcherProcess.Stub() {
}
}
val ipcInterface = PatcherProcess()
val ipcInterface = PatcherProcess(appContext)
appContext.sendBroadcast(Intent().apply {
action = ProcessRuntime.CONNECT_TO_APP_ACTION

View File

@@ -29,17 +29,14 @@ import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
@@ -51,6 +48,8 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
@OptIn(PluginHostApi::class)
class PatcherWorker(
context: Context,
@@ -72,9 +71,11 @@ class PatcherWorker(
val selectedPatches: PatchSelection,
val options: Options,
val logger: Logger,
val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val onPatchCompleted: suspend () -> Unit,
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onEvent: (ProgressEvent) -> Unit,
val onProgress: ProgressEventHandler
) {
val packageName get() = input.packageName
}
@@ -139,6 +140,10 @@ class PatcherWorker(
}
private suspend fun runPatcher(args: Args): Result {
fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
args.onProgress(name, state, message)
val patchedApk = fs.tempDir.resolve("patched.apk")
return try {
@@ -158,65 +163,51 @@ class PatcherWorker(
args.input.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
)
)
}
).also { args.setInputFile(it) }
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
runStep(StepId.DownloadAPK, args.onEvent) {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(
selectedApp.data
)
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
download(plugin, data)
}
download(plugin, data)
}
is SelectedApp.Search -> {
runStep(StepId.DownloadAPK, args.onEvent) {
downloaderPluginRepository.loadedPluginsFlow.first()
.firstNotNullOfOrNull { plugin ->
try {
val getScope = object : GetScope {
override val pluginPackageName = plugin.packageName
override val hostPackageName =
applicationContext.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? {
val result =
args.handleStartActivityRequest(plugin, intent)
return when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
downloaderPluginRepository.loadedPluginsFlow.first()
.firstNotNullOfOrNull { plugin ->
try {
val getScope = object : GetScope {
override val pluginPackageName = plugin.packageName
override val hostPackageName = applicationContext.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? {
val result = args.handleStartActivityRequest(plugin, intent)
return when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
}
withContext(Dispatchers.IO) {
plugin.get(
getScope,
selectedApp.packageName,
selectedApp.version
)
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
} catch (e: UserInteractionException.Activity.NotCompleted) {
throw e
} catch (_: UserInteractionException) {
null
}?.let { (data, _) -> download(plugin, data) }
} ?: throw Exception("App is not available.")
}
}
withContext(Dispatchers.IO) {
plugin.get(
getScope,
selectedApp.packageName,
selectedApp.version
)
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
} catch (e: UserInteractionException.Activity.NotCompleted) {
throw e
} catch (_: UserInteractionException) {
null
}?.let { (data, _) -> download(plugin, data) }
} ?: throw Exception("App is not available.")
}
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
@@ -236,12 +227,12 @@ class PatcherWorker(
args.selectedPatches,
args.options,
args.logger,
args.onEvent,
args.onPatchCompleted,
args.onProgress
)
runStep(StepId.SignAPK, args.onEvent) {
keystoreManager.sign(patchedApk, File(args.output))
}
keystoreManager.sign(patchedApk, File(args.output))
updateProgress(state = State.COMPLETED) // Signing
Log.i(tag, "Patching succeeded".logFmt())
Result.success()
@@ -250,11 +241,11 @@ class PatcherWorker(
tag,
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
)
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
updateProgress(state = State.FAILED, message = e.originalStackTrace)
Result.failure()
} catch (e: Exception) {
Log.e(tag, "An exception occurred while patching".logFmt(), e)
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
updateProgress(state = State.FAILED, message = e.stackTraceToString())
Result.failure()
} finally {
patchedApk.delete()

View File

@@ -0,0 +1,53 @@
package app.revanced.manager.service
import android.app.Service
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.IBinder
@Suppress("DEPRECATION")
class InstallService : Service() {
override fun onStartCommand(
intent: Intent, flags: Int, startId: Int
): Int {
val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val extraPackageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
when (extraStatus) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
startActivity(if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}.apply {
this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
else -> {
sendBroadcast(Intent().apply {
action = APP_INSTALL_ACTION
`package` = packageName
putExtra(EXTRA_INSTALL_STATUS, extraStatus)
putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage)
putExtra(EXTRA_PACKAGE_NAME, extraPackageName)
})
}
}
stopSelf()
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
const val APP_INSTALL_ACTION = "APP_INSTALL_ACTION"
const val EXTRA_INSTALL_STATUS = "EXTRA_INSTALL_STATUS"
const val EXTRA_INSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE"
const val EXTRA_PACKAGE_NAME = "EXTRA_PACKAGE_NAME"
}
}

View File

@@ -1,6 +1,5 @@
package app.revanced.manager.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
@@ -80,7 +79,7 @@ private fun installerStatusDialogButton(
enum class DialogKind(
val flag: Int,
val title: Int,
@param:StringRes val contentStringResId: Int,
@StringRes val contentStringResId: Int,
val icon: ImageVector = Icons.Outlined.ErrorOutline,
val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
val dismissButton: InstallerStatusDialogButton? = null,
@@ -134,8 +133,10 @@ enum class DialogKind(
title = R.string.installation_storage_issue_dialog_title,
contentStringResId = R.string.installation_storage_issue_description,
),
@RequiresApi(34)
FAILURE_TIMEOUT(
flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT,
flag = PackageInstaller.STATUS_FAILURE_TIMEOUT,
title = R.string.installation_timeout_dialog_title,
contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model ->

View File

@@ -39,9 +39,11 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.ProgressKey
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.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import java.util.Locale
import kotlin.math.floor
@@ -50,6 +52,8 @@ import kotlin.math.floor
fun Steps(
category: StepCategory,
steps: List<Step>,
stepCount: Pair<Int, Int>? = null,
stepProgressProvider: StepProgressProvider,
isExpanded: Boolean = false,
onExpand: () -> Unit,
onClick: () -> Unit
@@ -63,17 +67,8 @@ fun Steps(
}
}
val filteredSteps = remember(steps) {
val failedCount = steps.count { it.state == State.FAILED }
steps.filter { step ->
// Show hidden steps if it's the only failed step.
!step.hide || (step.state == State.FAILED && failedCount == 1)
}
}
LaunchedEffect(state) {
if (state == State.RUNNING || state == State.FAILED)
if (state == State.RUNNING)
onExpand()
}
@@ -97,8 +92,13 @@ fun Steps(
Spacer(modifier = Modifier.weight(1f))
val stepProgress = remember(stepCount, steps) {
stepCount?.let { (current, total) -> "$current/$total" }
?: "${steps.count { it.state == State.COMPLETED }}/${steps.size}"
}
Text(
text = "${filteredSteps.count { it.state == State.COMPLETED }}/${filteredSteps.size}",
text = stepProgress,
style = MaterialTheme.typography.labelSmall
)
@@ -112,20 +112,23 @@ fun Steps(
.fillMaxWidth()
.padding(top = 10.dp)
) {
filteredSteps.forEachIndexed { index, step ->
val (progress, progressText) = step.progress?.let { (current, total) ->
if (total != null) current.toFloat() / total.toFloat() to "${current.megaBytes}/${total.megaBytes} MB"
else null to "${current.megaBytes} MB"
steps.forEachIndexed { index, step ->
val (progress, progressText) = when (step.progressKey) {
null -> null
ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) ->
if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB"
else null to "${downloaded.megaBytes} MB"
}
} ?: (null to null)
SubStep(
name = step.title,
name = step.name,
state = step.state,
message = step.message,
progress = progress,
progressText = progressText,
isFirst = index == 0,
isLast = index == filteredSteps.lastIndex,
isLast = index == steps.lastIndex,
)
}
}

View File

@@ -3,7 +3,6 @@ package app.revanced.manager.ui.model
import android.os.Parcelable
import androidx.annotation.StringRes
import app.revanced.manager.R
import app.revanced.manager.patcher.StepId
import kotlinx.parcelize.Parcelize
enum class StepCategory(@StringRes val displayName: Int) {
@@ -16,20 +15,19 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED
}
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize
data class Step(
val id: StepId,
val title: String,
val name: String,
val category: StepCategory,
val state: State = State.WAITING,
val message: String? = null,
val progress: Pair<Long, Long?>? = null,
val hide: Boolean = false,
) : Parcelable
fun Step.withState(
state: State = this.state,
message: String? = this.message,
progress: Pair<Long, Long?>? = this.progress
) = copy(state = state, message = message, progress = progress)
val progressKey: ProgressKey? = null
) : Parcelable

View File

@@ -87,7 +87,7 @@ fun PatcherScreen(
val steps by remember {
derivedStateOf {
viewModel.steps.groupBy { it.category }.toList()
viewModel.steps.groupBy { it.category }
}
}
@@ -230,12 +230,14 @@ fun PatcherScreen(
contentPadding = PaddingValues(16.dp)
) {
items(
items = steps,
items = steps.toList(),
key = { it.first }
) { (category, steps) ->
Steps(
category = category,
steps = steps,
stepCount = if (category == StepCategory.PATCHING) viewModel.patchesProgress else null,
stepProgressProvider = viewModel,
isExpanded = expandedCategory == category,
onExpand = { expandCategory(category) },
onClick = {

View File

@@ -25,7 +25,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -77,12 +76,12 @@ fun SelectedAppInfoScreen(
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches by remember {
derivedStateOf {
vm.getPatches(bundles, allowIncompatiblePatches)
}
val patches = remember(bundles, allowIncompatiblePatches) {
vm.getPatches(bundles, allowIncompatiblePatches)
}
val selectedPatchCount = remember(patches) {
patches.values.sumOf { it.size }
}
val selectedPatchCount = patches.values.sumOf { it.size }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),

View File

@@ -105,14 +105,6 @@ fun GeneralSettingsScreen(
description = R.string.pure_black_theme_description
)
}
GroupHeader(stringResource(R.string.networking))
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
}
}
}

View File

@@ -54,7 +54,6 @@ class BundleListViewModel : ViewModel(), KoinComponent {
patchBundleRepository.update(
*getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(),
showToast = true,
force = true
)
}
}
@@ -66,7 +65,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
fun update(src: PatchBundleSource) = viewModelScope.launch {
if (src !is RemotePatchBundle) return@launch
patchBundleRepository.update(src, showToast = true, force = true)
patchBundleRepository.update(src, showToast = true)
}
enum class Event {

View File

@@ -1,11 +1,17 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
@@ -13,6 +19,7 @@ import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage
@@ -23,8 +30,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.uninstaller.UninstallFailure
class InstalledAppInfoViewModel(
packageName: String
@@ -82,28 +87,51 @@ class InstalledAppInfoViewModel(
fun uninstall() {
val app = installedApp ?: return
viewModelScope.launch {
when (app.installType) {
InstallType.DEFAULT -> {
when (val result = pm.uninstallPackage(app.currentPackageName)) {
is Session.State.Failed<UninstallFailure> -> {
val msg = result.failure.message.orEmpty()
context.toast(
this@InstalledAppInfoViewModel.context.getString(
R.string.uninstall_app_fail,
msg
)
)
return@launch
}
Session.State.Succeeded -> {}
}
}
when (app.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName)
InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName)
InstallType.MOUNT -> viewModelScope.launch {
rootInstaller.uninstall(app.currentPackageName)
installedAppRepository.delete(app)
onBackClick()
}
installedAppRepository.delete(app)
onBackClick()
}
}
private val uninstallBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
UninstallService.APP_UNINSTALL_ACTION -> {
val extraStatus =
intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999)
val extraStatusMessage =
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch {
installedApp?.let {
installedAppRepository.delete(it)
}
onBackClick()
}
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) {
this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage))
}
}
}
}
}.also {
ContextCompat.registerReceiver(
context,
it,
IntentFilter(UninstallService.APP_UNINSTALL_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onCleared() {
super.onCleared()
context.unregisterReceiver(uninstallBroadcastReceiver)
}
}

View File

@@ -1,9 +1,11 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller as AndroidPackageInstaller
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.ParcelUuid
import android.util.Log
@@ -14,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
@@ -29,35 +32,32 @@ import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.SelectedApp
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.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.withState
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.asCode
import app.revanced.manager.util.saveableVar
import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@@ -66,15 +66,6 @@ import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.installer.createSession
import ru.solrudev.ackpine.installer.getSession
import ru.solrudev.ackpine.session.ProgressSession
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
import ru.solrudev.ackpine.uninstaller.UninstallFailure
import java.io.File
import java.nio.file.Files
import java.time.Duration
@@ -82,7 +73,7 @@ import java.time.Duration
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class PatcherViewModel(
private val input: Patcher.ViewModelParams
) : ViewModel(), KoinComponent, InstallerModel {
) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel {
private val app: Application by inject()
private val fs: Filesystem by inject()
private val pm: PM by inject()
@@ -90,7 +81,6 @@ class PatcherViewModel(
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
private val savedStateHandle: SavedStateHandle = get()
private val ackpineInstaller: PackageInstaller = get()
private var installedApp: InstalledApp? = null
private val selectedApp = input.selectedApp
@@ -105,6 +95,7 @@ class PatcherViewModel(
mutableStateOf<String?>(null)
}
private set
private var ongoingPmSession: Boolean by savedStateHandle.saveableVar { false }
var packageInstallerStatus: Int? by savedStateHandle.saveable(
key = "packageInstallerStatus",
stateSaver = autoSaver()
@@ -113,7 +104,7 @@ class PatcherViewModel(
}
private set
var isInstalling by mutableStateOf(false)
var isInstalling by mutableStateOf(ongoingPmSession)
private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(
@@ -132,18 +123,6 @@ class PatcherViewModel(
}
}
/**
* This coroutine scope is used to await installations.
* It should not be cancelled on system-initiated process death since that would cancel the installation process.
*/
private val installerCoroutineScope = CoroutineScope(Dispatchers.Main)
/**
* Holds the package name of the Apk we are trying to install.
*/
private var installerPkgName: String by savedStateHandle.saveableVar { "" }
private var installerSessionId: ParcelUuid? by savedStateHandle.saveableVar()
private var inputFile: File? by savedStateHandle.saveableVar()
private val outputFile = tempDir.resolve("output.apk")
@@ -159,15 +138,35 @@ class PatcherViewModel(
}
}
val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) {
generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList()
private val patchCount = input.selectedPatches.values.sumOf { it.size }
private var completedPatchCount by savedStateHandle.saveable {
// SavedStateHandle.saveable only supports the boxed version.
@Suppress("AutoboxingStateCreation") mutableStateOf(
0
)
}
val patchesProgress get() = completedPatchCount to patchCount
override var downloadProgress by savedStateHandle.saveable(
key = "downloadProgress",
stateSaver = autoSaver()
) {
mutableStateOf<Pair<Long, Long?>?>(null)
}
private set
val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) {
generateSteps(
app,
input.selectedApp
).toMutableStateList()
}
private var currentStepIndex = 0
val progress by derivedStateOf {
val steps = steps.filter { it.id != StepId.ExecutePatches }
val current = steps.count {
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
} + completedPatchCount
val current = steps.count { it.state == State.COMPLETED }
val total = steps.size
val total = steps.size - 1 + patchCount
current.toFloat() / total.toFloat()
}
@@ -175,46 +174,67 @@ class PatcherViewModel(
private val workManager = WorkManager.getInstance(app)
private val patcherWorkerId by savedStateHandle.saveable<ParcelUuid> {
ParcelUuid(
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.selectedApp,
outputFile.path,
input.selectedPatches,
input.options,
logger,
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
handleStartActivityRequest = { plugin, intent ->
withContext(Dispatchers.Main) {
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
try {
// Wait for the dialog interaction.
val accepted = with(CompletableDeferred<Boolean>()) {
currentActivityRequest = this to plugin.name
ParcelUuid(workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.selectedApp,
outputFile.path,
input.selectedPatches,
input.options,
logger,
onDownloadProgress = {
withContext(Dispatchers.Main) {
downloadProgress = it
}
},
onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } },
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
handleStartActivityRequest = { plugin, intent ->
withContext(Dispatchers.Main) {
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
try {
// Wait for the dialog interaction.
val accepted = with(CompletableDeferred<Boolean>()) {
currentActivityRequest = this to plugin.name
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
} finally {
launchedActivity = null
}
} finally {
currentActivityRequest = null
launchedActivity = null
}
} finally {
currentActivityRequest = null
}
},
onEvent = ::handleProgressEvent,
)
}
},
onProgress = { name, state, message ->
viewModelScope.launch {
steps[currentStepIndex] = steps[currentStepIndex].run {
copy(
name = name ?: this.name,
state = state ?: this.state,
message = message ?: this.message
)
}
if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) {
currentStepIndex++
steps[currentStepIndex] =
steps[currentStepIndex].copy(state = State.RUNNING)
}
}
}
)
)
))
}
val patcherSucceeded =
@@ -226,26 +246,64 @@ class PatcherViewModel(
}
}
init {
// TODO: detect system-initiated process death during the patching process.
private val installerBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
InstallService.APP_INSTALL_ACTION -> {
val pmStatus = intent.getIntExtra(
InstallService.EXTRA_INSTALL_STATUS,
PackageInstaller.STATUS_FAILURE
)
installerSessionId?.uuid?.let { id ->
viewModelScope.launch {
try {
isInstalling = true
uiSafe(app, R.string.install_app_fail, "Failed to install") {
// The process was killed during installation. Await the session again.
withContext(Dispatchers.IO) {
ackpineInstaller.getSession(id)
}?.let {
awaitInstallation(it)
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
?.let(logger::trace)
if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
app.toast(app.getString(R.string.install_app_success))
installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch {
installedAppRepository.addOrUpdate(
installedPackageName!!,
packageName,
input.selectedApp.version
?: pm.getPackageInfo(outputFile)?.versionName!!,
InstallType.DEFAULT,
input.selectedPatches
)
}
}
} finally {
} else packageInstallerStatus = pmStatus
isInstalling = false
}
UninstallService.APP_UNINSTALL_ACTION -> {
val pmStatus = intent.getIntExtra(
UninstallService.EXTRA_UNINSTALL_STATUS,
PackageInstaller.STATUS_FAILURE
)
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
?.let(logger::trace)
if (pmStatus != PackageInstaller.STATUS_SUCCESS)
packageInstallerStatus = pmStatus
}
}
}
}
init {
// TODO: detect system-initiated process death during the patching process.
ContextCompat.registerReceiver(
app,
installerBroadcastReceiver,
IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
},
ContextCompat.RECEIVER_NOT_EXPORTED
)
viewModelScope.launch {
installedApp = installedAppRepository.get(packageName)
@@ -255,6 +313,7 @@ class PatcherViewModel(
@OptIn(DelicateCoroutinesApi::class)
override fun onCleared() {
super.onCleared()
app.unregisterReceiver(installerBroadcastReceiver)
workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
@@ -268,37 +327,7 @@ class PatcherViewModel(
}
}
private fun handleProgressEvent(event: ProgressEvent) = viewModelScope.launch {
val stepIndex = steps.indexOfFirst {
event.stepId?.let { id -> id == it.id }
?: (it.state == State.RUNNING || it.state == State.WAITING)
}
if (stepIndex != -1) steps[stepIndex] = steps[stepIndex].run {
when (event) {
is ProgressEvent.Started -> withState(State.RUNNING)
is ProgressEvent.Progress -> withState(
message = event.message ?: message,
progress = event.current?.let { event.current to event.total } ?: progress
)
is ProgressEvent.Completed -> withState(State.COMPLETED, progress = null)
is ProgressEvent.Failed -> {
if (event.stepId == null && steps.any { it.state == State.FAILED }) return@launch
withState(
State.FAILED,
message = event.error.stackTrace,
progress = null
)
}
}
}
}
fun onBack() {
installerCoroutineScope.cancel()
// tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death.
tempDir.deleteRecursively()
}
@@ -343,111 +372,50 @@ class PatcherViewModel(
fun open() = installedPackageName?.let(pm::launch)
private suspend fun startInstallation(file: File, packageName: String) {
val session = withContext(Dispatchers.IO) {
ackpineInstaller.createSession(Uri.fromFile(file)) {
confirmation = Confirmation.IMMEDIATE
}
}
withContext(Dispatchers.Main) {
installerPkgName = packageName
}
awaitInstallation(session)
}
private suspend fun awaitInstallation(session: ProgressSession<InstallFailure>) = withContext(
Dispatchers.Main
) {
val result = installerCoroutineScope.async {
try {
installerSessionId = ParcelUuid(session.id)
withContext(Dispatchers.IO) {
session.await()
}
} finally {
installerSessionId = null
}
}.await()
when (result) {
is Session.State.Failed<InstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
}
Session.State.Succeeded -> {
app.toast(app.getString(R.string.install_app_success))
installedPackageName = installerPkgName
installedAppRepository.addOrUpdate(
installerPkgName,
packageName,
input.selectedApp.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches
)
}
}
}
fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
var needsRootUninstall = false
var pmInstallStarted = false
try {
uiSafe(app, R.string.install_app_fail, "Failed to install") {
val currentPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile) }
isInstalling = true
when (installType) {
InstallType.DEFAULT -> {
val currentPackageInfo = pm.getPackageInfo(outputFile)
?: 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
// If the app is currently installed
val existingPackageInfo = 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 = PackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
}
}
// Check if the app is mounted as root
// If it is, unmount it first, silently
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) {
rootInstaller.unmount(packageName)
}
// Install regularly
pm.installApp(listOf(outputFile))
pmInstallStarted = true
}
when (installType) {
InstallType.DEFAULT -> {
// Check if the app is mounted as root
// If it is, unmount it first, silently
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) {
rootInstaller.unmount(packageName)
}
// Install regularly
startInstallation(outputFile, currentPackageInfo.packageName)
}
InstallType.MOUNT -> {
InstallType.MOUNT -> {
try {
val packageInfo = pm.getPackageInfo(outputFile)
?: throw Exception("Failed to load application info")
val label = with(pm) {
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
}
packageInfo.label()
}
val inputVersion = input.selectedApp.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: inputFile?.let(pm::getPackageInfo)?.versionName
?: throw Exception("Failed to determine input APK version")
needsRootUninstall = true
// Install as root
rootInstaller.install(
outputFile,
@@ -458,7 +426,7 @@ class PatcherViewModel(
)
installedAppRepository.addOrUpdate(
currentPackageInfo.packageName,
packageInfo.packageName,
packageName,
inputVersion,
InstallType.MOUNT,
@@ -470,20 +438,21 @@ class PatcherViewModel(
installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success))
needsRootUninstall = false
} catch (e: Exception) {
Log.e(tag, "Failed to install as root", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
try {
rootInstaller.uninstall(packageName)
} catch (_: Exception) {
}
}
}
}
} catch (e: Exception) {
Log.e(tag, "Failed to install", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
} finally {
isInstalling = false
if (needsRootUninstall) {
try {
withContext(NonCancellable) {
rootInstaller.uninstall(packageName)
}
} catch (_: Exception) {
}
}
if (!pmInstallStarted) isInstalling = false
}
}
@@ -494,27 +463,12 @@ class PatcherViewModel(
override fun reinstall() {
viewModelScope.launch {
try {
uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") {
pm.getPackageInfo(outputFile)?.packageName?.let { pm.uninstallPackage(it) }
?: throw Exception("Failed to load application info")
pm.installApp(listOf(outputFile))
isInstalling = true
uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") {
val pkgName = withContext(Dispatchers.IO) {
pm.getPackageInfo(outputFile)?.packageName
?: throw Exception("Failed to load application info")
}
when (val result = pm.uninstallPackage(pkgName)) {
is Session.State.Failed<UninstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
return@launch
}
Session.State.Succeeded -> {}
}
startInstallation(outputFile, pkgName)
}
} finally {
isInstalling = false
}
}
}
@@ -533,66 +487,34 @@ class PatcherViewModel(
LogLevel.ERROR -> Log.e(TAG, msg)
}
fun generateSteps(
context: Context,
selectedApp: SelectedApp,
selectedPatches: PatchSelection
): List<Step> = buildList {
if (selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search)
add(
Step(
StepId.DownloadAPK,
context.getString(R.string.download_apk),
StepCategory.PREPARING
)
)
fun generateSteps(context: Context, selectedApp: SelectedApp): List<Step> {
val needsDownload =
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
add(
return listOfNotNull(
Step(
context.getString(R.string.download_apk),
StepCategory.PREPARING,
state = State.RUNNING,
progressKey = ProgressKey.DOWNLOAD,
).takeIf { needsDownload },
Step(
StepId.LoadPatches,
context.getString(R.string.patcher_step_load_patches),
StepCategory.PREPARING
)
)
add(
StepCategory.PREPARING,
state = if (needsDownload) State.WAITING else State.RUNNING,
),
Step(
StepId.ReadAPK,
context.getString(R.string.patcher_step_unpack),
StepCategory.PREPARING
)
)
add(
),
Step(
StepId.ExecutePatches,
context.getString(R.string.execute_patches),
StepCategory.PATCHING,
hide = true
)
)
StepCategory.PATCHING
),
selectedPatches.values.asSequence().flatten().sorted().forEachIndexed { index, name ->
add(
Step(
StepId.ExecutePatch(index),
name,
StepCategory.PATCHING
)
)
}
add(
Step(
StepId.WriteAPK,
context.getString(R.string.patcher_step_write_patched),
StepCategory.SAVING
)
)
add(
Step(
StepId.SignAPK,
context.getString(R.string.patcher_step_sign_apk),
StepCategory.SAVING
)
Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING),
Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING)
)
}
}

View File

@@ -8,6 +8,7 @@ import android.os.Parcelable
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -128,6 +129,8 @@ class SelectedAppInfoViewModel(
}
var options: Options by savedStateHandle.saveable {
val state = mutableStateOf<Options>(emptyMap())
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
@@ -138,7 +141,7 @@ class SelectedAppInfoViewModel(
}
}
mutableStateOf(emptyMap())
state
}
private set
@@ -146,6 +149,8 @@ class SelectedAppInfoViewModel(
if (input.patches != null)
return@saveable mutableStateOf(SelectionState.Customized(input.patches))
val selection: MutableState<SelectionState> = mutableStateOf(SelectionState.Default)
// Try to get the previous selection if customization is enabled.
viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@launch
@@ -155,7 +160,7 @@ class SelectedAppInfoViewModel(
selectionState = SelectionState.Customized(previous)
}
mutableStateOf(SelectionState.Default)
selection
}
var showSourceSelector by mutableStateOf(false)
@@ -306,7 +311,7 @@ class SelectedAppInfoViewModel(
}
}
enum class Error(@param:StringRes val resourceId: Int) {
enum class Error(@StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available)
}

View File

@@ -1,13 +1,18 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.net.Uri
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
@@ -16,6 +21,8 @@ import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.service.InstallService
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import io.ktor.client.plugins.onDownload
@@ -24,14 +31,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.installer.createSession
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
class UpdateViewModel(
private val downloadOnScreenEntry: Boolean
@@ -39,11 +39,10 @@ class UpdateViewModel(
private val app: Application by inject()
private val reVancedAPI: ReVancedAPI by inject()
private val http: HttpService by inject()
private val pm: PM by inject()
private val networkInfo: NetworkInfo by inject()
private val fs: Filesystem by inject()
private val ackpineInstaller: PackageInstaller = get()
// TODO: save state to handle process death.
var downloadedSize by mutableLongStateOf(0L)
private set
var totalSize by mutableLongStateOf(0L)
@@ -63,17 +62,14 @@ class UpdateViewModel(
private set
private val location = fs.tempDir.resolve("updater.apk")
private val job = viewModelScope.launch {
uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
init {
viewModelScope.launch {
uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
state = State.CAN_DOWNLOAD
}
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
state = State.CAN_DOWNLOAD
}
}
}
@@ -82,7 +78,7 @@ class UpdateViewModel(
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
val release = releaseInfo!!
withContext(Dispatchers.IO) {
if (!networkInfo.isSafe(false) && !ignoreInternetCheck) {
if (!networkInfo.isSafe() && !ignoreInternetCheck) {
showInternetCheckDialog = true
} else {
state = State.DOWNLOADING
@@ -102,36 +98,50 @@ class UpdateViewModel(
fun installUpdate() = viewModelScope.launch {
state = State.INSTALLING
val result = withContext(Dispatchers.IO) {
ackpineInstaller.createSession(Uri.fromFile(location)) {
confirmation = Confirmation.IMMEDIATE
}.await()
}
when (result) {
is Session.State.Failed<InstallFailure> -> when (val failure = result.failure) {
is InstallFailure.Aborted -> state = State.CAN_INSTALL
else -> {
val msg = failure.message.orEmpty()
app.toast(app.getString(R.string.install_app_fail, msg))
installError = msg
state = State.FAILED
pm.installApp(listOf(location))
}
private val installBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let {
val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999)
val extra =
intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
when(pmStatus) {
PackageInstaller.STATUS_SUCCESS -> {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
state = State.CAN_INSTALL
}
else -> {
app.toast(app.getString(R.string.install_app_fail, extra))
installError = extra
state = State.FAILED
}
}
}
Session.State.Succeeded -> {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
}
}
}
init {
ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
}, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onCleared() {
super.onCleared()
app.unregisterReceiver(installBroadcastReceiver)
job.cancel()
location.delete()
}
enum class State(@param:StringRes val title: Int) {
enum class State(@StringRes val title: Int) {
CAN_DOWNLOAD(R.string.update_available),
DOWNLOADING(R.string.downloading_manager_update),
CAN_INSTALL(R.string.ready_to_install_update),

View File

@@ -1,30 +0,0 @@
package app.revanced.manager.util
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.uninstaller.UninstallFailure
/**
* Converts an Ackpine installation failure into a PM status code
*/
fun InstallFailure.asCode() = when (this) {
is InstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED
is InstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED
is InstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT
is InstallFailure.Incompatible -> PackageInstaller.STATUS_FAILURE_INCOMPATIBLE
is InstallFailure.Invalid -> PackageInstaller.STATUS_FAILURE_INVALID
is InstallFailure.Storage -> PackageInstaller.STATUS_FAILURE_STORAGE
is InstallFailure.Timeout -> @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT
else -> PackageInstaller.STATUS_FAILURE
}
/**
* Converts an Ackpine uninstallation failure into a PM status code
*/
fun UninstallFailure.asCode() = when (this) {
is UninstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED
is UninstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED
is UninstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT
else -> PackageInstaller.STATUS_FAILURE
}

View File

@@ -2,8 +2,11 @@ package app.revanced.manager.util
import android.annotation.SuppressLint
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PackageInfoFlags
import android.content.pm.PackageManager.NameNotFoundException
@@ -13,6 +16,8 @@ import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -20,13 +25,10 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
import ru.solrudev.ackpine.uninstaller.PackageUninstaller
import ru.solrudev.ackpine.uninstaller.createSession
import ru.solrudev.ackpine.uninstaller.parameters.UninstallParametersDsl
import java.io.File
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
@Immutable
@Parcelize
data class AppInfo(
@@ -38,8 +40,7 @@ data class AppInfo(
@SuppressLint("QueryPermissionsNeeded")
class PM(
private val app: Application,
patchBundleRepository: PatchBundleRepository,
private val uninstaller: PackageUninstaller
patchBundleRepository: PatchBundleRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
@@ -144,11 +145,17 @@ class PM(
false
)
suspend fun uninstallPackage(pkg: String, config: UninstallParametersDsl.() -> Unit = {}) = withContext(Dispatchers.IO) {
uninstaller.createSession(pkg) {
confirmation = Confirmation.IMMEDIATE
config()
}.await()
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
apks.forEach { apk -> session.writeApk(apk) }
session.commit(app.installIntentSender)
}
}
fun uninstallPackage(pkg: String) {
val packageInstaller = app.packageManager.packageInstaller
packageInstaller.uninstall(pkg, app.uninstallIntentSender)
}
fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let {
@@ -157,4 +164,44 @@ class PM(
}
fun canInstallPackages() = app.packageManager.canRequestPackageInstalls()
private fun PackageInstaller.Session.writeApk(apk: File) {
apk.inputStream().use { inputStream ->
openWrite(apk.name, 0, apk.length()).use { outputStream ->
inputStream.copyTo(outputStream, byteArraySize)
fsync(outputStream)
}
}
}
private val intentFlags
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_MUTABLE
else
0
private val sessionParams
get() = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
setRequestUpdateOwnership(true)
setInstallReason(PackageManager.INSTALL_REASON_USER)
}
private val Context.installIntentSender
get() = PendingIntent.getService(
this,
0,
Intent(this, InstallService::class.java),
intentFlags
).intentSender
private val Context.uninstallIntentSender
get() = PendingIntent.getService(
this,
0,
Intent(this, UninstallService::class.java),
intentFlags
).intentSender
}

View File

@@ -33,7 +33,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import app.revanced.manager.R
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -83,8 +82,6 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (error: Exception) {
// You can only toast on the main thread.
GlobalScope.launch(Dispatchers.Main) {

View File

@@ -217,10 +217,6 @@ You will not be able to update the previously installed apps from this source."<
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="appearance">Appearance</string>
<string name="networking">Networking</string>
<string name="allow_metered_networks">Allow metered networks</string>
<string name="allow_metered_networks_description">Permits automatic updates on metered networks.
The application might still warn about metered networks for manual operations.</string>
<string name="downloaded_apps">Downloaded apps</string>
<string name="process_runtime">Run Patcher in another process (experimental)</string>
<string name="process_runtime_description">This is faster and allows Patcher to use more memory</string>

View File

@@ -37,7 +37,6 @@ kotlin-process = "1.5.1"
hidden-api-stub = "4.3.3"
binary-compatibility-validator = "0.17.0"
semver-parser = "3.0.0"
ackpine = "0.18.5"
[libraries]
# AndroidX Core
@@ -134,10 +133,6 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons",
# Semantic versioning parser
semver-parser = { module = "io.github.z4kn4fein:semver", version.ref = "semver-parser" }
# Ackpine
ackpine-core = { module = "ru.solrudev.ackpine:ackpine-core", version.ref = "ackpine" }
ackpine-ktx = { module = "ru.solrudev.ackpine:ackpine-ktx", version.ref = "ackpine" }
[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }