Compare commits

..

11 Commits

Author SHA1 Message Date
semantic-release-bot
1b21f5d4ab chore: Release v1.26.0-dev.19 [skip ci]
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)

### Bug Fixes

* **locales:** use buildconfig instead of generating kt file ([72b1db9](72b1db9a2f))
2026-01-08 22:35:21 +00:00
Ax333l
72b1db9a2f fix(locales): use buildconfig instead of generating kt file 2026-01-08 23:27:02 +01:00
semantic-release-bot
2805ac6540 chore: Release v1.26.0-dev.18 [skip ci]
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)

### Bug Fixes

* Prevent trailing comma when no locales are generated ([b16931c](b16931ca79))

### Features

* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](df31b39cc8))
2026-01-08 21:12:25 +00:00
Robert
b16931ca79 fix: Prevent trailing comma when no locales are generated 2026-01-08 22:04:03 +01:00
Ushie
dfeca09d00 ci: Switch to using crowdin.yml to specify filename 2026-01-07 23:50:57 +03:00
Ushie
44c06e2197 ci: Use a clearer file name for source file to display in Crowdin 2026-01-07 23:43:40 +03:00
Ushie
df31b39cc8 feat: Add language settings (#2913) 2026-01-07 22:54:48 +03:00
semantic-release-bot
25d82e869c chore: Release v1.26.0-dev.17 [skip ci]
# 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](9d9a0e81db))
2026-01-06 21:45:09 +00:00
Ax333l
9d9a0e81db fix: allow updating patches on metered networks 2026-01-06 22:37:25 +01:00
semantic-release-bot
ffa42099e3 chore: Release v1.26.0-dev.16 [skip ci]
# 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](11dd6e4064))
2025-12-30 00:16:11 +00:00
Robert
11dd6e4064 feat: Show patches as individual steps in patcher screen (#2889)
Co-authored-by: Ax333l <main@axelen.xyz>
2025-12-30 01:08:54 +01:00
29 changed files with 720 additions and 337 deletions

43
.github/workflows/pull_strings.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Pull strings
on:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
jobs:
pull:
name: Pull strings
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: dev
clean: true
- name: Pull strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: false
download_translations: true
skip_ref_checkout: true
localization_branch_name: feat/translations
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Open pull request
if: github.event_name == 'workflow_dispatch'
uses: repo-sync/pull-request@v2
with:
source_branch: feat/translations
destination_branch: dev
pr_title: "chore: Sync translations"
pr_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)"

26
.github/workflows/push_strings.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Push strings
on:
workflow_dispatch:
push:
branches:
- dev
paths:
- app/src/main/res/values/strings.xml
jobs:
push:
name: Push strings
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Push strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: true
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,3 +1,36 @@
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)
### Bug Fixes
* **locales:** use buildconfig instead of generating kt file ([72b1db9](https://github.com/ReVanced/revanced-manager/commit/72b1db9a2f33ab5d5fffd8ba83c05901eff19bea))
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)
### Bug Fixes
* Prevent trailing comma when no locales are generated ([b16931c](https://github.com/ReVanced/revanced-manager/commit/b16931ca79d5ce4d17c75f6dd3bf6f976b8ff7be))
### Features
* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](https://github.com/ReVanced/revanced-manager/commit/df31b39cc8c1fbf00bc3301468e8e7e4b283caf2))
# 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)

View File

@@ -143,13 +143,25 @@ android {
(preRelease?.substringAfterLast('.')?.toInt() ?: 99)
}
vectorDrawables.useSupportLibrary = true
val resDir = file("src/main/res")
val locales = resDir.listFiles()
.orEmpty()
//noinspection WrongGradleMethod
.filter { it.isDirectory && it.name.matches(Regex("values-[a-z]{2}(-r[A-Z]{2})?")) }
//noinspection WrongGradleMethod
.map { it.name.removePrefix("values-").replace("-r", "-") }
.sorted()
//noinspection WrongGradleMethod
.joinToString(prefix = "{", separator = ",", postfix = "}") { "\"$it\"" }
buildConfigField("String[]", "SUPPORTED_LOCALES", locales)
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager (Debug)")
isPseudoLocalesEnabled = true
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
}
@@ -231,10 +243,8 @@ android {
buildConfig = true
}
android {
androidResources {
generateLocaleConfig = true
}
androidResources {
generateLocaleConfig = true
}
externalNativeBuild {

View File

@@ -1 +1 @@
version = 1.26.0-dev.15
version = 1.26.0-dev.19

View File

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

View File

@@ -1,11 +1,12 @@
// 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 patchSucceeded();
void progress(String name, String state, String msg);
void event(in ProgressEventParcel event);
// The patching process has ended. The exceptionStackTrace is null if it finished successfully.
void finished(String exceptionStackTrace);
}

View File

@@ -3,11 +3,11 @@ package app.revanced.manager
import android.content.ActivityNotFoundException
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
@@ -63,7 +63,7 @@ import org.koin.androidx.compose.navigation.koinNavViewModel
import org.koin.core.parameter.parametersOf
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

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

View File

@@ -34,4 +34,6 @@ 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,28 +286,29 @@ 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()))
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()
}
deleteLocalFile()
}
}
doReload()
}
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)
update(src, force = true)
state.copy(sources = state.sources.put(src.uid, src))
}
@@ -329,32 +330,38 @@ class PatchBundleRepository(
state.copy(sources = state.sources.put(uid, newSrc))
}
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
suspend fun update(
vararg sources: RemotePatchBundle,
showToast: Boolean = false,
force: Boolean = false
) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true, redownload = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
suspend fun updateCheck() =
store.dispatch(Update(force = prefs.allowMeteredNetworks.get()) { 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 (force) "Redownload remote bundles" else "Update check"
override fun toString() = if (redownload) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) {
if (!networkInfo.isSafe(force)) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
@@ -367,7 +374,7 @@ class PatchBundleRepository(
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
if (redownload) downloadLatest() else update()
} ?: return@async null
it to newVersion

View File

@@ -0,0 +1,78 @@
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,10 +1,9 @@
package app.revanced.manager.patcher
import android.content.Context
import app.revanced.library.ApkUtils.applyTo
import app.revanced.manager.R
import app.revanced.manager.patcher.Session.Companion.component1
import app.revanced.manager.patcher.Session.Companion.component2
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
@@ -22,15 +21,10 @@ class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
private val androidContext: Context,
private val logger: Logger,
private val input: File,
private val onPatchCompleted: suspend () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
private val onEvent: (ProgressEvent) -> 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(
@@ -42,86 +36,68 @@ 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) ->
if (patch !in selectedPatches) return@collect
val index = selectedPatches.indexOf(patch)
if (index == -1) return@collect
if (exception != null) {
updateProgress(
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
state = State.FAILED,
message = exception.stackTraceToString()
onEvent(
ProgressEvent.Failed(
StepId.ExecutePatch(index),
exception.toRemoteError(),
)
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
}
nextPatchIndex++
onPatchCompleted()
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
updateProgress(
name = androidContext.getString(R.string.executing_patch, nextPatch.name)
onEvent(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
)
}
)
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) {
updateProgress(state = State.COMPLETED) // Unpacking
runStep(StepId.ExecutePatches, onEvent) {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
}
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
addHandler(logger.handler)
}
addHandler(logger.handler)
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
}
}
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
runStep(StepId.WriteAPK, onEvent) {
logger.info("Writing patched files...")
val result = patcher.get()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
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("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,11 +1,12 @@
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.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State
import app.revanced.manager.patcher.runStep
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import java.io.File
@@ -13,7 +14,7 @@ import java.io.File
/**
* Simple [Runtime] implementation that runs the patcher using coroutines.
*/
class CoroutineRuntime(private val context: Context) : Runtime(context) {
class CoroutineRuntime(context: Context) : Runtime(context) {
override suspend fun execute(
inputFile: String,
outputFile: String,
@@ -21,47 +22,50 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
) {
val selectedBundles = selectedPatches.keys
val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val patchList = runStep(StepId.LoadPatches, onEvent) {
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
}
onProgress(null, State.COMPLETED, null) // Loading patches
val session = runStep(StepId.ReadAPK, onEvent) {
Session(
cacheDir,
frameworkPath,
aaptPath,
logger,
File(inputFile),
onEvent,
)
}
Session(
cacheDir,
frameworkPath,
aaptPath,
context,
logger,
File(inputFile),
onPatchCompleted = onPatchCompleted,
onProgress
).use { session ->
session.run(
session.use { s ->
s.run(
File(outputFile),
patchList
)

View File

@@ -10,12 +10,13 @@ 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.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State
import app.revanced.manager.patcher.toEvent
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
@@ -66,8 +67,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
) = coroutineScope {
// Get the location of our own Apk.
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
@@ -111,7 +111,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
}
val patching = CompletableDeferred<Unit>()
val scope = this
launch(Dispatchers.IO) {
val binder = awaitBinderConnection()
@@ -124,13 +123,10 @@ 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 patchSucceeded() {
scope.launch { onPatchCompleted() }
override fun event(event: ProgressEventParcel?) {
event?.let { onEvent(it.toEvent()) }
}
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,7 +34,6 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
)
}

View File

@@ -8,12 +8,15 @@ 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.ui.model.State
import app.revanced.manager.patcher.toParcel
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,7 +27,7 @@ import kotlin.system.exitProcess
/**
* The main class that runs inside the runner process launched by [ProcessRuntime].
*/
class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
class PatcherProcess() : IPatcherProcess.Stub() {
private var eventBinder: IPatcherEvents? = null
private val scope =
@@ -46,6 +49,8 @@ class PatcherProcess(private val context: Context) : 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 {
@@ -56,38 +61,42 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
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())
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())
.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
}
}
events.progress(null, State.COMPLETED.name, null) // Loading patches
val session = runStep(StepId.ReadAPK, ::onEvent) {
Session(
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
logger = logger,
input = File(parameters.inputFile),
onEvent = ::onEvent,
)
}
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 {
session.use {
it.run(File(parameters.outputFile), patchList)
}
@@ -119,7 +128,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
}
}
val ipcInterface = PatcherProcess(appContext)
val ipcInterface = PatcherProcess()
appContext.sendBroadcast(Intent().apply {
action = ProcessRuntime.CONNECT_TO_APP_ACTION

View File

@@ -29,14 +29,17 @@ 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
@@ -48,8 +51,6 @@ 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,
@@ -71,11 +72,9 @@ 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 onProgress: ProgressEventHandler
val onEvent: (ProgressEvent) -> Unit,
) {
val packageName get() = input.packageName
}
@@ -140,10 +139,6 @@ 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 {
@@ -163,51 +158,65 @@ class PatcherWorker(
args.input.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
onDownload = { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
)
)
}
).also { args.setInputFile(it) }
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
runStep(StepId.DownloadAPK, args.onEvent) {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(
selectedApp.data
)
download(plugin, data)
download(plugin, data)
}
}
is SelectedApp.Search -> {
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
)
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
)
}
}
}
}
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) }
@@ -227,12 +236,12 @@ class PatcherWorker(
args.selectedPatches,
args.options,
args.logger,
args.onPatchCompleted,
args.onProgress
args.onEvent,
)
keystoreManager.sign(patchedApk, File(args.output))
updateProgress(state = State.COMPLETED) // Signing
runStep(StepId.SignAPK, args.onEvent) {
keystoreManager.sign(patchedApk, File(args.output))
}
Log.i(tag, "Patching succeeded".logFmt())
Result.success()
@@ -241,11 +250,11 @@ class PatcherWorker(
tag,
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
)
updateProgress(state = State.FAILED, message = e.originalStackTrace)
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
Result.failure()
} catch (e: Exception) {
Log.e(tag, "An exception occurred while patching".logFmt(), e)
updateProgress(state = State.FAILED, message = e.stackTraceToString())
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
Result.failure()
} finally {
patchedApk.delete()

View File

@@ -39,11 +39,9 @@ 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.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import app.revanced.manager.ui.model.Step
import java.util.Locale
import kotlin.math.floor
@@ -52,8 +50,6 @@ 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
@@ -67,8 +63,17 @@ 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)
if (state == State.RUNNING || state == State.FAILED)
onExpand()
}
@@ -92,13 +97,8 @@ 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 = stepProgress,
text = "${filteredSteps.count { it.state == State.COMPLETED }}/${filteredSteps.size}",
style = MaterialTheme.typography.labelSmall
)
@@ -112,23 +112,20 @@ fun Steps(
.fillMaxWidth()
.padding(top = 10.dp)
) {
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"
}
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"
} ?: (null to null)
SubStep(
name = step.name,
name = step.title,
state = step.state,
message = step.message,
progress = progress,
progressText = progressText,
isFirst = index == 0,
isLast = index == steps.lastIndex,
isLast = index == filteredSteps.lastIndex,
)
}
}

View File

@@ -3,6 +3,7 @@ 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) {
@@ -15,19 +16,20 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED
}
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize
data class Step(
val name: String,
val id: StepId,
val title: String,
val category: StepCategory,
val state: State = State.WAITING,
val message: String? = null,
val progressKey: ProgressKey? = null
) : Parcelable
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)

View File

@@ -87,7 +87,7 @@ fun PatcherScreen(
val steps by remember {
derivedStateOf {
viewModel.steps.groupBy { it.category }
viewModel.steps.groupBy { it.category }.toList()
}
}
@@ -230,14 +230,12 @@ fun PatcherScreen(
contentPadding = PaddingValues(16.dp)
) {
items(
items = steps.toList(),
items = steps,
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

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
@@ -19,6 +21,7 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -38,6 +41,7 @@ import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -48,6 +52,7 @@ fun GeneralSettingsScreen(
val prefs = viewModel.prefs
val coroutineScope = viewModel.viewModelScope
var showThemePicker by rememberSaveable { mutableStateOf(false) }
var showLanguagePicker by rememberSaveable { mutableStateOf(false) }
if (showThemePicker) {
ThemePicker(
@@ -55,6 +60,17 @@ fun GeneralSettingsScreen(
onConfirm = { viewModel.setTheme(it) }
)
}
if (showLanguagePicker) {
LanguagePicker(
supportedLocales = viewModel.getSupportedLocales(),
currentLocale = viewModel.getCurrentLocale(),
onDismiss = { showLanguagePicker = false },
onConfirm = { viewModel.setLocale(it) },
getDisplayName = { viewModel.getLocaleDisplayName(it) }
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -74,6 +90,24 @@ fun GeneralSettingsScreen(
) {
GroupHeader(stringResource(R.string.appearance))
val currentLocale = viewModel.getCurrentLocale()
val currentLanguageDisplay = remember(currentLocale) {
currentLocale?.let { viewModel.getLocaleDisplayName(it) }
}
SettingsListItem(
modifier = Modifier.clickable { showLanguagePicker = true },
headlineContent = stringResource(R.string.language),
supportingContent = stringResource(R.string.language_description),
trailingContent = {
FilledTonalButton(onClick = { showLanguagePicker = true }) {
Text(
currentLanguageDisplay
?: stringResource(R.string.language_system_default)
)
}
}
)
val theme by prefs.theme.getAsState()
SettingsListItem(
modifier = Modifier.clickable { showThemePicker = true },
@@ -105,6 +139,14 @@ 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
)
}
}
}
@@ -148,4 +190,64 @@ private fun ThemePicker(
}
}
)
}
@Composable
private fun LanguagePicker(
supportedLocales: List<Locale>,
currentLocale: Locale?,
onDismiss: () -> Unit,
onConfirm: (Locale?) -> Unit,
getDisplayName: (Locale) -> String
) {
var selectedLocale by remember { mutableStateOf(currentLocale) }
val systemDefaultString = stringResource(R.string.language_system_default)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = null },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == null,
onClick = { selectedLocale = null }
)
Text(systemDefaultString)
}
supportedLocales.forEach { locale ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = locale },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == locale,
onClick = { selectedLocale = locale }
)
Text(getDisplayName(locale))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(selectedLocale)
onDismiss()
}
) {
Text(stringResource(R.string.apply))
}
}
)
}

View File

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

View File

@@ -1,17 +1,26 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.SupportedLocales
import app.revanced.manager.util.resetListItemColorsCached
import kotlinx.coroutines.launch
import java.util.Locale
class GeneralSettingsViewModel(
private val app: Application,
val prefs: PreferencesManager
) : ViewModel() {
fun setTheme(theme: Theme) = viewModelScope.launch {
prefs.theme.update(theme)
resetListItemColorsCached()
}
fun getSupportedLocales() = SupportedLocales.getSupportedLocales(app)
fun getCurrentLocale() = SupportedLocales.getCurrentLocale()
fun setLocale(locale: Locale?) = SupportedLocales.setLocale(locale)
fun getLocaleDisplayName(locale: Locale) = SupportedLocales.getDisplayName(locale)
}

View File

@@ -29,20 +29,22 @@ 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.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.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import app.revanced.manager.ui.model.Step
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
@@ -80,7 +82,7 @@ import java.time.Duration
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class PatcherViewModel(
private val input: Patcher.ViewModelParams
) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel {
) : ViewModel(), KoinComponent, InstallerModel {
private val app: Application by inject()
private val fs: Filesystem by inject()
private val pm: PM by inject()
@@ -157,35 +159,15 @@ class PatcherViewModel(
}
}
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()
generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList()
}
private var currentStepIndex = 0
val progress by derivedStateOf {
val current = steps.count {
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
} + completedPatchCount
val steps = steps.filter { it.id != StepId.ExecutePatches }
val total = steps.size - 1 + patchCount
val current = steps.count { it.state == State.COMPLETED }
val total = steps.size
current.toFloat() / total.toFloat()
}
@@ -201,12 +183,6 @@ class PatcherViewModel(
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) {
@@ -235,26 +211,10 @@ class PatcherViewModel(
}
}
},
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)
}
}
}
onEvent = ::handleProgressEvent,
)
))
)
)
}
val patcherSucceeded =
@@ -308,6 +268,35 @@ 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.
@@ -544,34 +533,66 @@ class PatcherViewModel(
LogLevel.ERROR -> Log.e(TAG, msg)
}
fun generateSteps(context: Context, selectedApp: SelectedApp): List<Step> {
val needsDownload =
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
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
)
)
return listOfNotNull(
Step(
context.getString(R.string.download_apk),
StepCategory.PREPARING,
state = State.RUNNING,
progressKey = ProgressKey.DOWNLOAD,
).takeIf { needsDownload },
add(
Step(
StepId.LoadPatches,
context.getString(R.string.patcher_step_load_patches),
StepCategory.PREPARING,
state = if (needsDownload) State.WAITING else State.RUNNING,
),
StepCategory.PREPARING
)
)
add(
Step(
StepId.ReadAPK,
context.getString(R.string.patcher_step_unpack),
StepCategory.PREPARING
),
)
)
add(
Step(
StepId.ExecutePatches,
context.getString(R.string.execute_patches),
StepCategory.PATCHING
),
StepCategory.PATCHING,
hide = true
)
)
Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING),
Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING)
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
)
)
}
}

View File

@@ -82,7 +82,7 @@ class UpdateViewModel(
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
val release = releaseInfo!!
withContext(Dispatchers.IO) {
if (!networkInfo.isSafe() && !ignoreInternetCheck) {
if (!networkInfo.isSafe(false) && !ignoreInternetCheck) {
showInternetCheckDialog = true
} else {
state = State.DOWNLOADING

View File

@@ -0,0 +1,40 @@
package app.revanced.manager.util
import android.app.LocaleConfig
import android.content.Context
import android.os.Build
import android.os.LocaleList
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import app.revanced.manager.BuildConfig
import java.util.Locale
object SupportedLocales {
fun getSupportedLocales(context: Context): List<Locale> {
var result: List<Locale>? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) result = runCatching {
LocaleConfig(context).supportedLocales?.toList()
}.getOrNull()
return result ?: generated
}
fun getCurrentLocale(): Locale? =
AppCompatDelegate.getApplicationLocales().takeIf { !it.isEmpty }?.get(0)
fun setLocale(locale: Locale?) = AppCompatDelegate.setApplicationLocales(
locale?.let { LocaleListCompat.create(it) } ?: LocaleListCompat.getEmptyLocaleList()
)
fun getDisplayName(locale: Locale) =
locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
private fun LocaleList.toList() = (0 until size()).map { get(it) }
private val generated by lazy {
listOf(
Locale.ENGLISH,
*BuildConfig.SUPPORTED_LOCALES.map(Locale::forLanguageTag).toTypedArray()
)
}
}

View File

@@ -14,7 +14,7 @@ Second \"item\" text"</string>
-->
<resources>
<string name="app_name">ReVanced Manager</string>
<string name="patcher">Patcher</string>
<string name="patcher">Patcher test</string>
<string name="patches">Patches</string>
<string name="cli">CLI</string>
<string name="manager">Manager</string>
@@ -83,7 +83,7 @@ Second \"item\" text"</string>
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
<string name="general">General</string>
<string name="general_description">Theme, dynamic color</string>
<string name="general_description">Language, theme, dynamic color</string>
<string name="updates">Updates</string>
<string name="updates_description">Check for updates and view changelogs</string>
<string name="downloads">Downloads</string>
@@ -104,6 +104,9 @@ Second \"item\" text"</string>
<string name="pure_black_theme_description">Use pure black backgrounds for dark theme</string>
<string name="theme">Theme</string>
<string name="theme_description">Choose between light or dark theme</string>
<string name="language">Language</string>
<string name="language_description">Choose the app display language</string>
<string name="language_system_default">System default</string>
<string name="safeguards">Safeguards</string>
<string name="patch_compat_check">Disable version compatibility check</string>
<string name="patch_compat_check_description">Do not restrict patches to compatible app versions</string>
@@ -217,6 +220,10 @@ 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>

9
crowdin.yml Normal file
View File

@@ -0,0 +1,9 @@
project_id_env: "CROWDIN_PROJECT_ID"
api_token_env: "CROWDIN_PERSONAL_TOKEN"
preserve_hierarchy: true
files:
- source: app/src/main/res/values/strings.xml
dest: manager.xml
translation: app/src/main/res/values-%android_code%/strings.xml
skip_untranslated_strings: true