Compare commits

..

29 Commits

Author SHA1 Message Date
Robert
b28e9a15be Add local APK to version and source selector 2026-01-08 22:50:50 +01:00
Robert
9fc2b4fdef Show selected source in overview 2026-01-07 20:51:39 +01:00
Robert
08662b2132 Use selected source in patcher 2026-01-07 00:42:21 +01:00
Robert
0169fd2109 Merge branch 'dev' into feat/improve-user-flow 2026-01-06 19:33:12 +01:00
Robert
c53d0462d6 Show selected source in overview 2026-01-03 20:56:45 +01:00
Robert
af8f2afa36 Implement basic source selector UI 2025-12-31 00:14:54 +01:00
Robert
9cdb8eafb3 Update selected app info screen and version selector screen 2025-12-30 21:19:22 +01:00
Robert
fda0e1697b feat: replace SelectedApp with packageName in selector 2025-12-30 17:58:54 +01:00
Robert
2d98923f50 feat: Separate version and source selection 2025-12-30 16:15:16 +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
semantic-release-bot
35fb59b31d chore: Release v1.26.0-dev.15 [skip ci]
# 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](18a4df9af9))
2025-12-29 22:49:24 +00:00
Ax333l
18a4df9af9 fix: install dialog getting stuck (#2900) 2025-12-29 23:42:14 +01:00
semantic-release-bot
bd69b45a69 chore: Release v1.26.0-dev.14 [skip ci]
# 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](0d26df03f4))
2025-12-28 18:20:22 +00:00
Robert
0d26df03f4 fix: Update selected patch count when SelectionState changes (#2896) 2025-12-28 19:13:00 +01:00
semantic-release-bot
c436a7a100 chore: Release v1.26.0-dev.13 [skip ci]
# 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)

### Features

* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](dbb6c01e89))
2025-12-17 20:05:47 +00:00
Ushie
dbb6c01e89 feat: Make patcher screen design more consistent with inspiration (#2805) 2025-12-17 22:58:02 +03:00
semantic-release-bot
e0d529c2df chore: Release v1.26.0-dev.12 [skip ci]
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)

### Features

* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](0300da9eac))
2025-12-17 16:04:25 +00:00
Ushie
0300da9eac feat: Improve trust plugin dialog design (#2420) 2025-12-17 18:54:04 +03:00
LisoUseInAIKyrios
4e5057ecad chore: Use raw string encoding to prevent AI translation errors (#2794)
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-10-25 20:17:49 +07:00
Pun Butrach
1ef5c1c5c5 docs: Adjust feature request template to match other repository 2025-10-25 19:40:31 +07:00
Coby
39f52c1242 chore: Correct grammar mistakes (#2791)
Co-authored-by: Kobe <kobew5050@gmail.com>
2025-10-25 19:14:34 +07:00
semantic-release-bot
0c62837454 chore: Release v1.26.0-dev.11 [skip ci]
# app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25)

### Features

* Add pure black theme ([#2824](https://github.com/ReVanced/revanced-manager/issues/2824)) ([3d75ffe](3d75ffe6a7))
2025-10-25 03:55:05 +00:00
alieRN
3d75ffe6a7 feat: Add pure black theme (#2824) 2025-10-25 10:46:50 +07:00
Coby
d188d5a410 docs: Fix typos and settings order (#2785)
Co-authored-by: Kobe <kobew5050@gmail.com>
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-10-18 00:34:59 +07:00
semantic-release-bot
b43ac8a1af chore: Release v1.26.0-dev.10 [skip ci]
# app [1.26.0-dev.10](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.9...v1.26.0-dev.10) (2025-10-06)

### Bug Fixes

* prevent back presses during installation ([2ff7072](2ff70728b4))
2025-10-06 17:48:23 +00:00
Ax333l
2ff70728b4 fix: prevent back presses during installation 2025-10-06 19:40:21 +02:00
semantic-release-bot
e5097b5ecd chore: Release v1.26.0-dev.9 [skip ci]
# app [1.26.0-dev.9](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.8...v1.26.0-dev.9) (2025-10-06)

### Bug Fixes

* Instantly re-fetch patch bundle on pre-release preference update ([d5671db](d5671db3a7)), closes [#2784](https://github.com/ReVanced/revanced-manager/issues/2784)
2025-10-06 00:44:24 +00:00
Ushie
d5671db3a7 fix: Instantly re-fetch patch bundle on pre-release preference update
Closes #2784
2025-10-06 03:36:14 +03:00
62 changed files with 2101 additions and 1385 deletions

View File

@@ -1,3 +1,7 @@
name: ⭐ Feature request
description: Create a detailed request for a new feature.
title: 'feat: '
labels: ['Feature request']
body:
- type: markdown
attributes:

View File

@@ -1,3 +1,59 @@
# 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)
### Features
* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](https://github.com/ReVanced/revanced-manager/commit/dbb6c01e89a5e710185ff4304de0ac9e19bed053))
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)
### Features
* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](https://github.com/ReVanced/revanced-manager/commit/0300da9eac6c0fc29dbbb66622c0d52f4cf68934))
# app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25)
### Features
* Add pure black theme ([#2824](https://github.com/ReVanced/revanced-manager/issues/2824)) ([3d75ffe](https://github.com/ReVanced/revanced-manager/commit/3d75ffe6a7a39efdebe13dbd07c937c1de409ead))
# app [1.26.0-dev.10](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.9...v1.26.0-dev.10) (2025-10-06)
### Bug Fixes
* prevent back presses during installation ([2ff7072](https://github.com/ReVanced/revanced-manager/commit/2ff70728b490b92f212a82dcf599bc0c23f589e7))
# app [1.26.0-dev.9](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.8...v1.26.0-dev.9) (2025-10-06)
### Bug Fixes
* Instantly re-fetch patch bundle on pre-release preference update ([d5671db](https://github.com/ReVanced/revanced-manager/commit/d5671db3a77541c07bbbb4c3baca02f3ba0703f2)), closes [#2784](https://github.com/ReVanced/revanced-manager/issues/2784)
# app [1.26.0-dev.8](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.7...v1.26.0-dev.8) (2025-10-06)

View File

@@ -108,6 +108,10 @@ 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.8
version = 1.26.0-dev.16

View File

@@ -51,9 +51,6 @@
<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"
@@ -75,5 +72,15 @@
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

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

@@ -30,7 +30,7 @@ import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
import app.revanced.manager.ui.model.navigation.InstalledApplicationInfo
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AppSelectorScreen
@@ -41,7 +41,9 @@ import app.revanced.manager.ui.screen.PatchesSelectorScreen
import app.revanced.manager.ui.screen.RequiredOptionsScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.SourceSelectorScreen
import app.revanced.manager.ui.screen.UpdateScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.screen.settings.AboutSettingsScreen
import app.revanced.manager.ui.screen.settings.AdvancedSettingsScreen
import app.revanced.manager.ui.screen.settings.ContributorSettingsScreen
@@ -81,6 +83,7 @@ class MainActivity : ComponentActivity() {
)
val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState()
val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState()
EventEffect(vm.legacyImportActivityFlow) {
try {
@@ -91,25 +94,19 @@ class MainActivity : ComponentActivity() {
ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
dynamicColor = dynamicColor
dynamicColor = dynamicColor,
pureBlackTheme = pureBlackTheme
) {
ReVancedManager(vm)
ReVancedManager()
}
}
}
}
@Composable
private fun ReVancedManager(vm: MainViewModel) {
private fun ReVancedManager() {
val navController = rememberNavController()
EventEffect(vm.appSelectFlow) { app ->
navController.navigateComplex(
SelectedApplicationInfo,
SelectedApplicationInfo.ViewModelParams(app)
)
}
NavHost(
navController = navController,
startDestination = Dashboard,
@@ -140,7 +137,12 @@ private fun ReVancedManager(vm: MainViewModel) {
val data = it.toRoute<InstalledApplicationInfo>()
InstalledAppInfoScreen(
onPatchClick = vm::selectApp,
onPatchClick = { packageName ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(packageName)
)
},
onBackClick = navController::popBackStack,
viewModel = koinViewModel { parametersOf(data.packageName) }
)
@@ -148,8 +150,20 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<AppSelector> {
AppSelectorScreen(
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onSelect = { packageName ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(packageName)
)
},
onStorageSelect = { packageName, localPath ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(
packageName, localPath
)
)
},
onBackClick = navController::popBackStack
)
}
@@ -177,11 +191,11 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
navigation<SelectedAppInfo>(startDestination = SelectedAppInfo.Main) {
composable<SelectedAppInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
parentBackStackEntry.getComplexArg<SelectedAppInfo.ViewModelParams>()
val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
@@ -197,23 +211,47 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
},
onPatchSelectorClick = { app, patches, options ->
onPatchSelectorClick = { packageName, version, patchSelection, options ->
navController.navigateComplex(
SelectedApplicationInfo.PatchesSelector,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
SelectedAppInfo.PatchesSelector,
SelectedAppInfo.PatchesSelector.ViewModelParams(
packageName,
version,
patchSelection,
options,
)
)
},
onRequiredOptions = { app, patches, options ->
onRequiredOptions = { packageName, version, patchSelection, options ->
navController.navigateComplex(
SelectedApplicationInfo.RequiredOptions,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
SelectedAppInfo.RequiredOptions,
SelectedAppInfo.PatchesSelector.ViewModelParams(
packageName,
version,
patchSelection,
options,
)
)
},
onVersionClick = { packageName, patchSelection, selectedVersion, local ->
navController.navigateComplex(
SelectedAppInfo.VersionSelector,
SelectedAppInfo.VersionSelector.ViewModelParams(
packageName,
patchSelection,
selectedVersion,
local,
)
)
},
onSourceClick = { packageName, version, selectedSource, local ->
navController.navigateComplex(
SelectedAppInfo.SourceSelector,
SelectedAppInfo.SourceSelector.ViewModelParams(
packageName,
version,
selectedSource,
local,
)
)
},
@@ -221,9 +259,9 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<SelectedApplicationInfo.PatchesSelector> {
composable<SelectedAppInfo.PatchesSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedAppInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
@@ -238,9 +276,43 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<SelectedApplicationInfo.RequiredOptions> {
composable<SelectedAppInfo.VersionSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedAppInfo.VersionSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
VersionSelectorScreen(
onBackClick = navController::popBackStack,
onSave = { version ->
selectedAppInfoVm.updateVersion(version)
navController.popBackStack()
},
viewModel = koinViewModel { parametersOf(data) }
)
}
composable<SelectedAppInfo.SourceSelector> {
val data =
it.getComplexArg<SelectedAppInfo.SourceSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
SourceSelectorScreen(
onBackClick = navController::popBackStack,
onSave = { source ->
selectedAppInfoVm.updateSource(source)
navController.popBackStack()
},
viewModel = koinViewModel { parametersOf(data) }
)
}
composable<SelectedAppInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedAppInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)

View File

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

View File

@@ -2,7 +2,6 @@ package app.revanced.manager.data.room.apps.downloaded
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@@ -12,6 +11,9 @@ interface DownloadedAppDao {
@Query("SELECT * FROM downloaded_app")
fun getAllApps(): Flow<List<DownloadedApp>>
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName")
fun get(packageName: String): Flow<List<DownloadedApp>>
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
suspend fun get(packageName: String, version: String): DownloadedApp?

View File

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

@@ -24,4 +24,6 @@ val viewModelModule = module {
viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::SourceSelectorViewModel)
}

View File

@@ -9,6 +9,7 @@ class PreferencesManager(
context: Context
) : BasePreferencesManager(context, "settings") {
val dynamicColor = booleanPreference("dynamic_color", true)
val pureBlackTheme = booleanPreference("pure_black_theme", false)
val theme = enumPreference("theme", Theme.SYSTEM)
val api = stringPreference("api_url", "https://api.revanced.app")

View File

@@ -30,6 +30,8 @@ class DownloadedAppRepository(
fun getAll() = dao.getAllApps().distinctUntilChanged()
fun get(packageName: String) = dao.get(packageName)
fun getApkFileForApp(app: DownloadedApp): File =
getApkFileForDir(dir.resolve(app.directory))

View File

@@ -26,6 +26,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
@@ -74,6 +75,17 @@ class PatchBundleRepository(
val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } }
fun suggestedVersions(packageName: String, patchSelection: PatchSelection) =
bundleInfoFlow.map {
val allPatches = patchSelection.flatMap { (uid, patches) ->
val bundle = it[uid] ?: return@flatMap emptyList()
bundle.patches.filter { patch -> patches.contains(patch.name) }
.map(PatchInfo::toPatcherPatch)
}.toSet()
allPatches.mostCommonCompatibleVersions(countUnusedPatches = true)[packageName]
}
val suggestedVersions = bundleInfoFlow.map {
val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()

View File

@@ -16,7 +16,7 @@ class PatchSelectionRepository(db: AppDatabase) {
packageName = packageName
).also { dao.createSelection(it) }.uid
suspend fun getSelection(packageName: String): Map<Int, Set<String>> =
suspend fun getSelection(packageName: String): app.revanced.manager.util.PatchSelection =
dao.getSelectedPatches(packageName).mapValues { it.value.toSet() }
suspend fun updateSelection(packageName: String, selection: Map<Int, Set<String>>) =

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

@@ -40,7 +40,7 @@ data class PatchInfo(
if (pkg.packageName != packageName) return@any false
if (pkg.versions == null) return@any true
versionName != null && versionName in pkg.versions
versionName == null || versionName in pkg.versions
}
}

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.ui.model.SelectedSource
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,
@@ -66,19 +67,17 @@ class PatcherWorker(
private val rootInstaller: RootInstaller by inject()
class Args(
val input: SelectedApp,
val packageName: String,
val version: String?,
val source: SelectedSource,
val output: String,
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 packageName get() = input.packageName
}
val onEvent: (ProgressEvent) -> Unit,
)
override suspend fun getForegroundInfo() =
ForegroundInfo(
@@ -140,14 +139,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 {
if (args.input is SelectedApp.Installed) {
if (args.source is SelectedSource.Installed) {
installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.MOUNT) {
rootInstaller.unmount(args.packageName)
@@ -160,58 +155,65 @@ class PatcherWorker(
plugin,
data,
args.packageName,
args.input.version,
args.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
) { 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)
val inputFile = when (val source = args.source) {
is SelectedSource.Auto -> throw Exception("Auto source is not supported in worker.")
download(plugin, data)
}
is SelectedSource.Plugin -> {
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
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
)
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,
args.packageName,
args.version
)
}?.takeIf { (_, version) -> args.version == null || version == args.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) }
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
is SelectedSource.Downloaded -> File(source.path)
is SelectedSource.Local -> File(source.path)
is SelectedSource.Installed -> File(pm.getPackageInfo(args.packageName)!!.applicationInfo!!.sourceDir)
}
val runtime = if (prefs.useProcessRuntime.get()) {
@@ -227,12 +229,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,17 +243,15 @@ 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()
if (args.input is SelectedApp.Local && args.input.temporary) {
args.input.file.delete()
}
if (args.source is SelectedSource.Local) File(args.source.path).delete()
}
}

View File

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

@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import app.revanced.manager.R
import io.github.fornewid.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers
@@ -47,6 +48,8 @@ fun AppLabel(
shape = RoundedCornerShape(100)
)
.then(modifier),
style = style
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

View File

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

@@ -190,6 +190,7 @@ fun BundleInformationDialog(
prefs.usePatchesPrereleases.update(
it
)
onUpdate()
}
}
)
@@ -197,6 +198,7 @@ fun BundleInformationDialog(
modifier = Modifier.clickable {
composableScope.launch {
prefs.usePatchesPrereleases.update(!useBundlePrerelease)
onUpdate()
}
}
)

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.ui.component.patcher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
@@ -21,6 +20,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -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,21 +50,10 @@ 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
) {
var expanded by rememberSaveable { mutableStateOf(true) }
val categoryColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent,
label = "category"
)
val cardColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent,
label = "card"
)
val state = remember(steps) {
when {
steps.all { it.state == State.COMPLETED } -> State.COMPLETED
@@ -76,62 +63,69 @@ 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)
onExpand()
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(MaterialTheme.shapes.large)
.fillMaxWidth()
.background(cardColor)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { expanded = !expanded }
.background(categoryColor)
.clickable(true, onClick = onClick)
.fillMaxWidth()
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(16.dp)
) {
StepIcon(state = state, size = 24.dp)
StepIcon(state = state, size = 24.dp)
Text(stringResource(category.displayName))
Text(stringResource(category.displayName))
Spacer(modifier = Modifier.weight(1f))
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}",
style = MaterialTheme.typography.labelSmall
)
Text(
text = stepProgress,
style = MaterialTheme.typography.labelSmall
)
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
}
ArrowButton(modifier = Modifier.size(24.dp), expanded = isExpanded, onClick = null)
}
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(top = 10.dp)
) {
steps.forEach { 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
progressText = progressText,
isFirst = index == 0,
isLast = index == filteredSteps.lastIndex,
)
}
}
@@ -145,7 +139,9 @@ fun SubStep(
state: State,
message: String? = null,
progress: Float? = null,
progressText: String? = null
progressText: String? = null,
isFirst: Boolean = false,
isLast: Boolean = false,
) {
var messageExpanded by rememberSaveable { mutableStateOf(true) }
@@ -156,22 +152,22 @@ fun SubStep(
clickable { messageExpanded = !messageExpanded }
else this
}
.padding(top = if (isFirst) 10.dp else 8.dp, bottom = if (isLast) 20.dp else 8.dp)
.padding(horizontal = 20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
StepIcon(state, progress, size = 20.dp)
}
StepIcon(
size = 18.dp,
state = state,
progress = progress,
)
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
@@ -201,7 +197,7 @@ fun SubStep(
text = message.orEmpty(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp)
modifier = Modifier.padding(horizontal = 36.dp, vertical = 8.dp)
)
}
}
@@ -211,40 +207,44 @@ fun SubStep(
fun StepIcon(state: State, progress: Float? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) {
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.step_completed),
tint = MaterialTheme.colorScheme.surfaceTint,
modifier = Modifier.size(size)
)
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING -> Icon(
Icons.Outlined.Circle,
contentDescription = stringResource(R.string.step_waiting),
tint = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(size)
)
State.RUNNING ->
LoadingIndicator(
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
progress = { progress },
strokeWidth = strokeWidth
Crossfade(targetState = state, label = "State CrossFade") { state ->
when (state) {
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.step_completed),
tint = Color(0xFF59B463),
modifier = Modifier.size(size)
)
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING -> Icon(
Icons.Outlined.Circle,
contentDescription = stringResource(R.string.step_waiting),
tint = MaterialTheme.colorScheme.onSurface.copy(.2f),
modifier = Modifier.size(size)
)
State.RUNNING -> {
LoadingIndicator(
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
progress = { progress },
strokeWidth = strokeWidth
)
}
}
}
}

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

@@ -1,35 +0,0 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import app.revanced.manager.network.downloader.ParceledDownloaderData
import kotlinx.parcelize.Parcelize
import java.io.File
sealed interface SelectedApp : Parcelable {
val packageName: String
val version: String?
@Parcelize
data class Download(
override val packageName: String,
override val version: String?,
val data: ParceledDownloaderData
) : SelectedApp
@Parcelize
data class Search(override val packageName: String, override val version: String?) : SelectedApp
@Parcelize
data class Local(
override val packageName: String,
override val version: String,
val file: File,
val temporary: Boolean
) : SelectedApp
@Parcelize
data class Installed(
override val packageName: String,
override val version: String
) : SelectedApp
}

View File

@@ -0,0 +1,13 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class SelectedSource : Parcelable {
data object Auto : SelectedSource()
data object Installed : SelectedSource()
data class Downloaded(val path: String, val version: String) : SelectedSource()
data class Local(val path: String) : SelectedSource()
data class Plugin(val packageName: String?) : SelectedSource()
}

View File

@@ -0,0 +1,11 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class SelectedVersion : Parcelable {
data object Auto : SelectedVersion()
data object Any : SelectedVersion()
data class Specific(val version: String) : SelectedVersion()
}

View File

@@ -1,7 +1,8 @@
package app.revanced.manager.ui.model.navigation
import android.os.Parcelable
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
@@ -23,10 +24,11 @@ data class InstalledApplicationInfo(val packageName: String)
data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val packageName: String,
val localPath: String? = null,
val patches: PatchSelection? = null
) : Parcelable
@@ -37,12 +39,35 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
data object PatchesSelector : ComplexParameter<PatchesSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val currentSelection: PatchSelection?,
val packageName: String,
val version: String?,
val patchSelection: PatchSelection?,
val options: @RawValue Options,
) : Parcelable
}
@Serializable
data object VersionSelector : ComplexParameter<VersionSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val patchSelection: PatchSelection,
val selectedVersion: SelectedVersion,
val localPath: String? = null,
) : Parcelable
}
@Serializable
data object SourceSelector : ComplexParameter<SourceSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val localPath: String? = null,
) : Parcelable
}
@Serializable
data object RequiredOptions : ComplexParameter<PatchesSelector.ViewModelParams>
}
@@ -51,7 +76,9 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
data object Patcher : ComplexParameter<Patcher.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val selectedApp: SelectedApp,
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val selectedPatches: PatchSelection,
val options: @RawValue Options
) : Parcelable

View File

@@ -44,7 +44,6 @@ import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
@@ -54,13 +53,13 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onSelect: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
onSelect: (packageName: String) -> Unit,
onStorageSelect: (packageName: String, path: String) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {
EventEffect(flow = vm.storageSelectionFlow) {
onStorageSelect(it)
onStorageSelect(it.first, it.second)
}
val pickApkLauncher =
@@ -83,12 +82,12 @@ fun AppSelectorScreen(
}
}
vm.nonSuggestedVersionDialogSubject?.let {
NonSuggestedVersionDialog(
suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
onDismiss = vm::dismissNonSuggestedVersionDialog
)
}
// vm.nonSuggestedVersionDialogSubject?.let {
// NonSuggestedVersionDialog(
// suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
// onDismiss = vm::dismissNonSuggestedVersionDialog
// )
// }
if (search)
SearchView(
@@ -115,8 +114,7 @@ fun AppSelectorScreen(
)
},
headlineContent = { AppLabel(app.packageInfo) },
supportingContent = { Text(app.packageName) },
trailingContent = app.patches?.let {
supportingContent = app.patches?.let {
{
Text(
pluralStringResource(
@@ -214,12 +212,7 @@ fun AppSelectorScreen(
defaultText = app.packageName
)
},
supportingContent = {
suggestedVersions[app.packageName]?.let {
Text(stringResource(R.string.suggested_version_info, it))
}
},
trailingContent = app.patches?.let {
supportingContent = app.patches?.let {
{
Text(
pluralStringResource(

View File

@@ -55,6 +55,7 @@ import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -76,18 +77,17 @@ fun PatcherScreen(
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
var showDismissConfirmationDialog by rememberSaveable { mutableStateOf(false) }
fun onPageBack() {
if(patcherSucceeded == null)
showDismissConfirmationDialog = true
else
onLeave()
fun onPageBack() = when {
patcherSucceeded == null -> showDismissConfirmationDialog = true
viewModel.isInstalling -> context.toast(context.getString(R.string.patcher_install_in_progress))
else -> onLeave()
}
BackHandler(onBack = ::onPageBack)
val steps by remember {
derivedStateOf {
viewModel.steps.groupBy { it.category }
viewModel.steps.groupBy { it.category }.toList()
}
}
@@ -213,6 +213,12 @@ fun PatcherScreen(
.padding(paddingValues)
.fillMaxSize()
) {
var expandedCategory by rememberSaveable { mutableStateOf<StepCategory?>(null) }
val expandCategory: (StepCategory?) -> Unit = { category ->
expandedCategory = category
}
LinearProgressIndicator(
progress = { viewModel.progress },
modifier = Modifier.fillMaxWidth()
@@ -224,14 +230,17 @@ 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 = {
expandCategory(if (expandedCategory == category) null else category)
}
)
}
}

View File

@@ -498,7 +498,7 @@ private fun PatchItem(
leadingContent = {
HapticCheckbox(
checked = selected,
onCheckedChange = { onToggle() },
onCheckedChange = null,
enabled = compatible
)
},

View File

@@ -1,16 +1,12 @@
package app.revanced.manager.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh
@@ -21,7 +17,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -36,33 +31,30 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.enabled
import app.revanced.manager.util.patchCount
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectedAppInfoScreen(
onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit,
onRequiredOptions: (SelectedApp, PatchSelection?, Options) -> Unit,
onPatchSelectorClick: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onRequiredOptions: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onPatchClick: () -> Unit,
onVersionClick: (packageName: String, patchSelection: PatchSelection, selectedVersion: SelectedVersion, localPath: String?) -> Unit,
onSourceClick: (packageName: String, version: String?, SelectedSource, localPath: String?) -> Unit,
onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel
) {
@@ -71,29 +63,23 @@ fun SelectedAppInfoScreen(
val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() }
val packageName = vm.selectedApp.packageName
val version = vm.selectedApp.version
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches = remember(bundles, allowIncompatiblePatches) {
vm.getPatches(bundles, allowIncompatiblePatches)
}
val selectedPatchCount = remember(patches) {
patches.values.sumOf { it.size }
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val packageName = vm.packageName
val composableScope = rememberCoroutineScope()
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
val selectedVersion by vm.selectedVersion.collectAsStateWithLifecycle()
val resolvedVersion by vm.resolvedVersion.collectAsStateWithLifecycle(null)
val selectedSource by vm.selectedSource.collectAsStateWithLifecycle()
val resolvedSource by vm.resolvedSource.collectAsStateWithLifecycle(null)
val customSelection by vm.customSelection.collectAsStateWithLifecycle(null)
val fullPatchSelection by vm.patchSelection.collectAsStateWithLifecycle(emptyMap())
val patchCount = fullPatchSelection.patchCount
val incompatibleCount by vm.incompatiblePatchCount.collectAsStateWithLifecycle(0)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -116,18 +102,18 @@ fun SelectedAppInfoScreen(
)
},
onClick = patchClick@{
if (selectedPatchCount == 0) {
if (patchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
composableScope.launch {
if (!vm.hasSetRequiredOptions(patches)) {
if (!vm.hasSetRequiredOptions(fullPatchSelection)) {
onRequiredOptions(
vm.selectedApp,
vm.getCustomPatches(bundles, allowIncompatiblePatches),
vm.options
vm.packageName,
resolvedVersion,
customSelection,
vm.options,
)
return@launch
}
@@ -139,94 +125,96 @@ fun SelectedAppInfoScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
if (vm.showSourceSelector) {
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
AppSourceSelectorDialog(
plugins = plugins,
installedApp = vm.installedAppData,
searchApp = SelectedApp.Search(
vm.packageName,
vm.desiredVersion
),
activeSearchJob = vm.activePluginAction,
hasRoot = vm.hasRoot,
onDismissRequest = vm::dismissSourceSelector,
onSelectPlugin = vm::searchUsingPlugin,
requiredVersion = requiredVersion,
onSelect = {
vm.selectedApp = it
vm.dismissSourceSelector()
}
)
}
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
Text(
version ?: stringResource(R.string.selected_app_meta_any_version),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
vm.selectedAppInfo?.let {
Text(
it.packageName,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}
PageItem(
R.string.patch_selector_item,
stringResource(
R.string.patch_selector_item_description,
selectedPatchCount
),
stringResource(R.string.patch_selector_item_description, patchCount),
onClick = {
onPatchSelectorClick(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.packageName,
resolvedVersion,
customSelection,
vm.options
)
}
},
extraDescription = if (incompatibleCount > 0) { {
Text(
"$incompatibleCount incompatible",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
} } else null,
)
val versionText = resolvedVersion ?: "Any available version"
val versionDescription = if (selectedVersion is SelectedVersion.Auto)
"Auto ($versionText)" // stringResource(R.string.selected_app_meta_auto_version, actualVersion)
else versionText
PageItem(
R.string.version_selector_item,
versionDescription,
onClick = {
onVersionClick(
packageName,
fullPatchSelection,
selectedVersion,
vm.localPath,
)
},
)
val sourceText = when (val source = resolvedSource) {
is SelectedSource.Installed -> "Installed APK"
is SelectedSource.Downloaded -> "Downloaded APK"
is SelectedSource.Local -> "Local APK"
is SelectedSource.Plugin -> {
source.packageName ?: "Any available downloader"
}
else -> "Auto"
}
val sourceDescription = if (selectedSource is SelectedSource.Auto)
"Auto ($sourceText)" // stringResource(R.string.selected_app_meta_auto_version, actualVersion)
else sourceText
PageItem(
R.string.apk_source_selector_item,
when (val app = vm.selectedApp) {
is SelectedApp.Search -> stringResource(R.string.apk_source_auto)
is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
is SelectedApp.Download -> stringResource(
R.string.apk_source_downloader,
plugins.find { it.packageName == app.data.pluginPackageName }?.name
?: app.data.pluginPackageName
)
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
},
onClick = {
vm.showSourceSelector()
}
sourceDescription,
onClick = { onSourceClick(
packageName,
resolvedVersion,
selectedSource,
vm.localPath,
) },
)
error?.let {
Text(
stringResource(it.resourceId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp)
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Column(
modifier = Modifier.padding(horizontal = 24.dp),
if (resolvedSource is SelectedSource.Plugin) Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val needsInternet =
vm.selectedApp.let { it is SelectedApp.Search || it is SelectedApp.Download }
when {
!needsInternet -> {}
!networkConnected -> {
NotificationCard(
isWarning = true,
@@ -235,7 +223,6 @@ fun SelectedAppInfoScreen(
onDismiss = null
)
}
networkMetered -> {
NotificationCard(
isWarning = true,
@@ -251,11 +238,17 @@ fun SelectedAppInfoScreen(
}
@Composable
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
private fun PageItem(
@StringRes title: Int,
description: String,
onClick: () -> Unit,
enabled: Boolean = true,
extraDescription: @Composable (ColumnScope.() -> Unit)? = null,
) {
ListItem(
modifier = Modifier
.clickable(onClick = onClick)
.padding(start = 8.dp),
.clickable(enabled, onClick = onClick)
.enabled(enabled),
headlineContent = {
Text(
stringResource(title),
@@ -264,99 +257,17 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
)
},
supportingContent = {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
Column {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
extraDescription?.invoke(this)
}
},
trailingContent = {
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
}
)
}
@Composable
private fun AppSourceSelectorDialog(
plugins: List<LoadedDownloaderPlugin>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: String?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
onSelect: (SelectedApp) -> Unit,
) {
val canSelect = activeSearchJob == null
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(R.string.app_source_dialog_title)) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
LazyColumn {
item(key = "auto") {
val hasPlugins = plugins.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
.enabled(hasPlugins),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasPlugins)
stringResource(R.string.app_source_dialog_option_auto_description)
else
stringResource(R.string.app_source_dialog_option_auto_unavailable)
)
},
colors = transparentListItemColors
)
}
installedApp?.let { (app, meta) ->
item(key = "installed") {
val (usable, text) = when {
// Mounted apps must be unpatched before patching, which cannot be done without root access.
meta?.installType == InstallType.MOUNT && !hasRoot -> false to stringResource(
R.string.app_source_dialog_option_installed_no_root
)
// Patching already patched apps is not allowed because patches expect unpatched apps.
meta?.installType == InstallType.DEFAULT -> false to stringResource(R.string.already_patched)
// Version does not match suggested version.
requiredVersion != null && app.version != requiredVersion -> false to stringResource(
R.string.app_source_dialog_option_installed_version_not_suggested,
app.version
)
else -> true to app.version
}
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && usable) { onSelect(app) }
.enabled(usable),
headlineContent = { Text(stringResource(R.string.installed)) },
supportingContent = { Text(text) },
colors = transparentListItemColors
)
}
}
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
headlineContent = { Text(plugin.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
colors = transparentListItemColors
)
}
}
}
)
}

View File

@@ -0,0 +1,154 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.viewmodel.SourceSelectorViewModel
import app.revanced.manager.util.enabled
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SourceSelectorScreen(
onBackClick: () -> Unit,
onSave: (source: SelectedSource) -> Unit,
viewModel: SourceSelectorViewModel,
) {
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val plugins by viewModel.plugins.collectAsStateWithLifecycle(emptyList())
Scaffold(
topBar = {
AppTopBar(
title = { Text("Select source") },
onBackClick = onBackClick,
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) },
onClick = { onSave(viewModel.selectedSource) },
)
}
) { paddingValues ->
LazyColumnWithScrollbar (
contentPadding = paddingValues,
) {
item {
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Auto,
onSelect = { viewModel.selectSource(SelectedSource.Auto) },
headlineContent = { Text("Auto (Recommended)") },
supportingContent = { Text("Automatically select the best available source") }
)
}
item {
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Plugin(null),
onSelect = { viewModel.selectSource(SelectedSource.Plugin(null)) },
headlineContent = { Text("Any available downloader") },
)
}
viewModel.localApp?.let { option ->
item {
HorizontalDivider()
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
viewModel.installedSource?.let { option ->
item {
HorizontalDivider()
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
if (downloadedApps.isNotEmpty()) item { HorizontalDivider() }
items(downloadedApps, key = { it.key }) { option ->
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
if (plugins.isNotEmpty()) item { HorizontalDivider() }
items(plugins, key = { it.key }) { option ->
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
}
}
@Composable
private fun SourceOption(
sourceOption: SourceSelectorViewModel.SourceOption,
isSelected: Boolean,
onSelect: (SelectedSource) -> Unit,
) = SourceOption(
isSelected = isSelected,
onSelect = { onSelect(sourceOption.source) },
overlineContent = sourceOption.category?.let {{ Text(it) }},
headlineContent = { Text(sourceOption.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = sourceOption.disableReason?.let {{ Text(it.message) }},
enabled = sourceOption.disableReason == null,
)
@Composable
private fun SourceOption(
isSelected: Boolean,
onSelect: () -> Unit,
headlineContent: @Composable (() -> Unit),
supportingContent: @Composable (() -> Unit)? = null,
overlineContent: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
) {
ListItem(
modifier = Modifier
.clickable(enabled) { onSelect() }
.enabled(enabled),
leadingContent = {
RadioButton(
selected = isSelected,
onClick = null
)
},
headlineContent = headlineContent,
supportingContent = supportingContent,
overlineContent = overlineContent,
colors = transparentListItemColors
)
}

View File

@@ -0,0 +1,139 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VersionSelectorScreen(
onBackClick: () -> Unit,
onSave: (version: SelectedVersion) -> Unit,
viewModel: VersionSelectorViewModel,
) {
val versions by viewModel.availableVersions.collectAsStateWithLifecycle(emptyList())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val localVersion by viewModel.localVersion.collectAsStateWithLifecycle(null)
Scaffold(
topBar = {
AppTopBar(
title = { Text("Select version") },
onBackClick = onBackClick,
actions = {
IconButton({}) {
Icon(Icons.Outlined.MoreVert, contentDescription = null)
}
}
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, contentDescription = null) },
onClick = { onSave(viewModel.selectedVersion) }
)
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues)
) {
VersionOption(
version = SelectedVersion.Auto,
isSelected = viewModel.selectedVersion is SelectedVersion.Auto,
onSelect = viewModel::selectVersion,
headlineContent = { Text("Auto (Recommended)") },
supportingContent = { Text("Automatically select the best available version") }
)
HorizontalDivider()
if (versions.isNotEmpty()) {
LazyColumn {
items(versions, key = { it.first.version }) { version ->
val isDownloaded = downloadedVersions.contains(version.first.version)
val isInstalled = viewModel.installedAppVersion == version.first.version
val isLocal = localVersion == version.first.version
val overlineText = when {
isLocal -> "Local"
isDownloaded && isInstalled -> "Downloaded, Installed"
isDownloaded -> "Downloaded"
isInstalled -> "Installed"
else -> null
}
VersionOption(
version = version.first,
isSelected = viewModel.selectedVersion == version.first,
onSelect = viewModel::selectVersion,
headlineContent = { Text(version.first.version) },
supportingContent = {
Text(
"${version.second.let { if (it == 0) "No" else it }} incompatible patches"
)
},
overlineContent = overlineText?.let { { Text(it) } }
)
}
}
} else {
VersionOption(
version = SelectedVersion.Any,
isSelected = viewModel.selectedVersion is SelectedVersion.Any,
onSelect = viewModel::selectVersion,
headlineContent = { Text("Any available version") },
supportingContent = { Text("Use any available version regardless of compatibility") }
)
}
}
}
}
@Composable
private fun VersionOption(
version: SelectedVersion,
isSelected: Boolean,
onSelect: (SelectedVersion) -> Unit,
headlineContent: @Composable (() -> Unit),
supportingContent: @Composable (() -> Unit)? = null,
overlineContent: @Composable (() -> Unit)? = null,
) {
ListItem(
modifier = Modifier
.clickable { onSelect(version) },
leadingContent = {
RadioButton(
selected = isSelected,
onClick = null
)
},
headlineContent = headlineContent,
supportingContent = supportingContent,
trailingContent = overlineContent,
colors = transparentListItemColors
)
}

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -10,10 +12,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -28,6 +33,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -36,10 +42,10 @@ import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
@@ -52,6 +58,7 @@ fun DownloadsSettingsScreen(
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val context = LocalContext.current
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -75,7 +82,7 @@ fun DownloadsSettingsScreen(
onBackClick = onBackClick,
actions = {
if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { showDeleteConfirmationDialog = true }) {
IconButton(onClick = { viewModel.deleteApps() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete))
}
}
@@ -121,6 +128,11 @@ fun DownloadsSettingsScreen(
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
val appName = remember {
packageInfo.applicationInfo?.loadLabel(context.packageManager)
?.toString()
?: packageName
}
when (state) {
is DownloaderPluginState.Loaded -> TrustDialog(
@@ -130,6 +142,8 @@ fun DownloadsSettingsScreen(
packageName,
signature
),
pluginName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokePluginTrust(packageName)
@@ -147,10 +161,10 @@ fun DownloadsSettingsScreen(
is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
R.string.downloader_plugin_trust_dialog_body
),
pluginName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustPlugin(packageName)
@@ -226,6 +240,8 @@ fun DownloadsSettingsScreen(
private fun TrustDialog(
@StringRes title: Int,
body: String,
pluginName: String,
signature: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
@@ -238,10 +254,39 @@ private fun TrustDialog(
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(title)) },
text = { Text(body) }
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(body)
Card {
Column(
Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.downloader_plugin_trust_dialog_plugin,
pluginName
),
)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
stringResource(
R.string.downloader_plugin_trust_dialog_signature,
signature.chunked(2).joinToString(" ")
), modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
)
}

View File

@@ -1,6 +1,7 @@
package app.revanced.manager.ui.screen.settings
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -96,6 +97,14 @@ fun GeneralSettingsScreen(
description = R.string.dynamic_color_description
)
}
AnimatedVisibility(theme != Theme.LIGHT) {
BooleanItem(
preference = prefs.pureBlackTheme,
coroutineScope = coroutineScope,
headline = R.string.pure_black_theme,
description = R.string.pure_black_theme_description
)
}
}
}
}

View File

@@ -80,6 +80,7 @@ private val LightColorScheme = lightColorScheme(
fun ReVancedManagerTheme(
darkTheme: Boolean,
dynamicColor: Boolean,
pureBlackTheme: Boolean,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -93,6 +94,10 @@ fun ReVancedManagerTheme(
darkTheme -> DarkColorScheme
else -> LightColorScheme
}.let {
if (darkTheme && pureBlackTheme)
it.copy(background = Color.Black, surface = Color.Black)
else it
}
val view = LocalView.current

View File

@@ -3,9 +3,6 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.pm.PackageInfo
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -14,7 +11,6 @@ import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
@@ -31,7 +27,7 @@ class AppSelectorViewModel(
private val app: Application,
private val pm: PM,
fs: Filesystem,
private val patchBundleRepository: PatchBundleRepository,
patchBundleRepository: PatchBundleRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
@@ -42,19 +38,19 @@ class AppSelectorViewModel(
}
val appList = pm.appList
private val storageSelectionChannel = Channel<SelectedApp.Local>()
private val storageSelectionChannel = Channel<Pair<String, String>>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
private set
// var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
// private set
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
fun dismissNonSuggestedVersionDialog() {
nonSuggestedVersionDialogSubject = null
}
// fun dismissNonSuggestedVersionDialog() {
// nonSuggestedVersionDialogSubject = null
// }
fun handleStorageResult(uri: Uri) = viewModelScope.launch {
val selectedApp = withContext(Dispatchers.IO) {
@@ -66,11 +62,8 @@ class AppSelectorViewModel(
return@launch
}
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
storageSelectionChannel.send(selectedApp)
} else {
nonSuggestedVersionDialogSubject = selectedApp
}
// TODO: Disallow if 0 patches are compatible
storageSelectionChannel.send(selectedApp)
}
private fun loadSelectedFile(uri: Uri) =
@@ -80,12 +73,7 @@ class AppSelectorViewModel(
Files.copy(stream, toPath())
pm.getPackageInfo(this)?.let { packageInfo ->
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName!!,
file = this,
temporary = true
)
Pair(packageInfo.packageName, path)
}
}
}

View File

@@ -1,17 +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.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
@@ -19,7 +13,6 @@ 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
@@ -30,6 +23,8 @@ 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
@@ -87,51 +82,28 @@ class InstalledAppInfoViewModel(
fun uninstall() {
val app = installedApp ?: return
when (app.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName)
InstallType.MOUNT -> viewModelScope.launch {
rootInstaller.uninstall(app.currentPackageName)
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()
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
}
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) {
this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage))
Session.State.Succeeded -> {}
}
}
}
}
}.also {
ContextCompat.registerReceiver(
context,
it,
IntentFilter(UninstallService.APP_UNINSTALL_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onCleared() {
super.onCleared()
context.unregisterReceiver(uninstallBroadcastReceiver)
InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName)
}
installedAppRepository.delete(app)
onBackClick()
}
}
}

View File

@@ -12,11 +12,9 @@ import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
@@ -31,44 +29,14 @@ import kotlinx.serialization.json.Json
class MainViewModel(
private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository,
private val keystoreManager: KeystoreManager,
private val app: Application,
val prefs: PreferencesManager,
private val json: Json
) : ViewModel() {
private val appSelectChannel = Channel<SelectedApp>()
val appSelectFlow = appSelectChannel.receiveAsFlow()
private val legacyImportActivityChannel = Channel<Intent>()
val legacyImportActivityFlow = legacyImportActivityChannel.receiveAsFlow()
private suspend fun suggestedVersion(packageName: String) =
patchBundleRepository.suggestedVersions.first()[packageName]
private suspend fun findDownloadedApp(app: SelectedApp): SelectedApp.Local? {
if (app !is SelectedApp.Search) return null
val suggestedVersion = suggestedVersion(app.packageName) ?: return null
val downloadedApp =
downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true)
?: return null
return SelectedApp.Local(
downloadedApp.packageName,
downloadedApp.version,
downloadedAppRepository.getApkFileForApp(downloadedApp),
false
)
}
fun selectApp(app: SelectedApp) = viewModelScope.launch {
appSelectChannel.send(findDownloadedApp(app) ?: app)
}
fun selectApp(packageName: String) = viewModelScope.launch {
selectApp(SelectedApp.Search(packageName, suggestedVersion(packageName)))
}
init {
viewModelScope.launch {
if (!prefs.firstLaunch.get()) return@launch

View File

@@ -1,11 +1,9 @@
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.PackageInstaller
import android.content.pm.PackageInstaller as AndroidPackageInstaller
import android.net.Uri
import android.os.ParcelUuid
import android.util.Log
@@ -16,7 +14,6 @@ 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
@@ -32,32 +29,35 @@ 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.SelectedSource
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
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,6 +66,15 @@ 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
@@ -73,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()
@@ -81,11 +90,11 @@ 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
val packageName = selectedApp.packageName
val version = selectedApp.version
val packageName = input.packageName
val version = input.version
var installedPackageName by savedStateHandle.saveable(
key = "installedPackageName",
@@ -95,7 +104,6 @@ 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()
@@ -104,7 +112,7 @@ class PatcherViewModel(
}
private set
var isInstalling by mutableStateOf(ongoingPmSession)
var isInstalling by mutableStateOf(false)
private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(
@@ -123,6 +131,18 @@ 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")
@@ -138,35 +158,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.selectedSource, 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()
}
@@ -174,67 +174,48 @@ 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,
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.
ParcelUuid(
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.packageName,
input.version,
input.selectedSource,
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 {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
// 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()
}
} finally {
launchedActivity = null
}
} finally {
launchedActivity = null
currentActivityRequest = null
}
} finally {
currentActivityRequest = null
}
}
},
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 =
@@ -246,64 +227,26 @@ class PatcherViewModel(
}
}
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
)
init {
// TODO: detect system-initiated process death during the patching process.
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
)
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)
}
} else packageInstallerStatus = pmStatus
}
} finally {
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)
@@ -313,10 +256,9 @@ 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) {
if (input.selectedSource is SelectedSource.Installed && installedApp?.installType == InstallType.MOUNT) {
GlobalScope.launch(Dispatchers.Main) {
uiSafe(app, R.string.failed_to_mount, "Failed to mount") {
withTimeout(Duration.ofMinutes(1L)) {
@@ -327,7 +269,37 @@ 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()
}
@@ -372,44 +344,93 @@ class PatcherViewModel(
fun open() = installedPackageName?.let(pm::launch)
fun install(installType: InstallType) = viewModelScope.launch {
var pmInstallStarted = false
try {
isInstalling = true
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)
}
val currentPackageInfo = pm.getPackageInfo(outputFile)
?: throw Exception("Failed to load application info")
// 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
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()
}
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)
}
Session.State.Succeeded -> {
app.toast(app.getString(R.string.install_app_success))
installedPackageName = installerPkgName
installedAppRepository.addOrUpdate(
installerPkgName,
packageName,
input.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches
)
}
}
}
// Install regularly
pm.installApp(listOf(outputFile))
pmInstallStarted = true
fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
var needsRootUninstall = false
try {
uiSafe(app, R.string.install_app_fail, "Failed to install") {
val currentPackageInfo =
withContext(Dispatchers.IO) { 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
}
}
InstallType.MOUNT -> {
try {
val packageInfo = pm.getPackageInfo(outputFile)
?: throw Exception("Failed to load application info")
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 -> {
val label = with(pm) {
packageInfo.label()
currentPackageInfo.label()
}
// Check for base APK, first check if the app is already installed
@@ -417,15 +438,17 @@ class PatcherViewModel(
// 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 = PackageInstaller.STATUS_FAILURE_INVALID
packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_INVALID
return@launch
}
}
val inputVersion = input.selectedApp.version
?: inputFile?.let(pm::getPackageInfo)?.versionName
val inputVersion = input.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version")
needsRootUninstall = true
// Install as root
rootInstaller.install(
outputFile,
@@ -436,7 +459,7 @@ class PatcherViewModel(
)
installedAppRepository.addOrUpdate(
packageInfo.packageName,
currentPackageInfo.packageName,
packageName,
inputVersion,
InstallType.MOUNT,
@@ -448,21 +471,20 @@ class PatcherViewModel(
installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success))
} 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) {
}
needsRootUninstall = false
}
}
}
} catch (e: Exception) {
Log.e(tag, "Failed to install", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
} finally {
if (!pmInstallStarted) isInstalling = false
isInstalling = false
if (needsRootUninstall) {
try {
withContext(NonCancellable) {
rootInstaller.uninstall(packageName)
}
} catch (_: Exception) {
}
}
}
}
@@ -473,12 +495,27 @@ class PatcherViewModel(
override fun reinstall() {
viewModelScope.launch {
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))
try {
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
}
}
}
@@ -497,34 +534,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,
selectedSource: SelectedSource,
selectedPatches: PatchSelection
): List<Step> = buildList {
if (selectedSource is SelectedSource.Plugin)
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

@@ -20,7 +20,7 @@ import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.saver.Nullable
@@ -45,14 +45,14 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
class PatchesSelectorViewModel(input: SelectedAppInfo.PatchesSelector.ViewModelParams) :
ViewModel(), KoinComponent {
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val packageName = input.app.packageName
val appVersion = input.app.version
private val packageName = input.packageName
val appVersion = input.version
var selectionWarningEnabled by mutableStateOf(true)
private set
@@ -62,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.app.version)
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.version)
init {
viewModelScope.launch {
@@ -88,7 +88,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
key = "selection",
stateSaver = selectionSaver,
) {
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
mutableStateOf(input.patchSelection?.toPersistentPatchSelection())
}
private val patchOptions: PersistentOptions by savedStateHandle.saveable(

View File

@@ -1,91 +1,223 @@
package app.revanced.manager.ui.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
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
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
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.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import app.revanced.manager.util.patchCount
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class SelectedAppInfoViewModel(
input: SelectedApplicationInfo.ViewModelParams
private val input: SelectedAppInfo.ViewModelParams
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val bundleRepository: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get()
private val optionsRepository: PatchOptionsRepository = get()
private val pluginsRepository: DownloaderPluginRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val rootInstaller: RootInstaller = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val pm: PM = get()
private val savedStateHandle: SavedStateHandle = get()
val prefs: PreferencesManager = get()
private val prefs: PreferencesManager = get()
val plugins = pluginsRepository.loadedPluginsFlow
val desiredVersion = input.app.version
val packageName = input.app.packageName
val packageName = input.packageName
val localPath = input.localPath
private val persistConfiguration = input.patches == null
val hasRoot = rootInstaller.hasRootAccess()
var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
// User selection
private var selectionFlow = MutableStateFlow(
input.patches?.let { selection ->
SelectionState.Customized(selection)
} ?: SelectionState.Default
)
private val _selectedVersion = MutableStateFlow<SelectedVersion>(SelectedVersion.Auto)
val selectedVersion: StateFlow<SelectedVersion> = _selectedVersion
private val _selectedSource = MutableStateFlow<SelectedSource>(SelectedSource.Auto)
val selectedSource: StateFlow<SelectedSource> = _selectedSource
fun updateVersion(version: SelectedVersion) {
_selectedVersion.value = version
}
fun updateSource(source: SelectedSource) {
_selectedSource.value = source
}
fun updateConfiguration(
selection: PatchSelection?,
selectedOptions: Options
) = viewModelScope.launch {
selectionFlow.value = selection?.let(SelectionState::Customized) ?: SelectionState.Default
val filteredOptions = selectedOptions.filtered(bundleInfoFlow.first())
options = filteredOptions
if (persistConfiguration) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
}
}
// All patches for package
val bundles = bundleRepository.scopedBundleInfoFlow(packageName, null)
// Selection derived from selectionFlow
val patchSelection = combine(
selectionFlow,
bundles,
) { selection, bundles ->
selection.patches(bundles, allowIncompatible = true)
}
val customSelection = combine(
selectionFlow,
bundles,
) { selection, bundles ->
(selection as? SelectionState.Customized)?.patches(bundles, allowIncompatible = true)
}
// Most compatible versions based on patch selection
@OptIn(ExperimentalCoroutinesApi::class)
val mostCompatibleVersions = patchSelection.flatMapLatest { patchSelection ->
bundleRepository.suggestedVersions(
packageName,
patchSelection
)
}
// Resolve actual version from user selection
val resolvedVersion = combine(
_selectedVersion,
mostCompatibleVersions,
) { selected, mostCompatible ->
when (selected) {
is SelectedVersion.Specific -> selected.version
is SelectedVersion.Any -> null
is SelectedVersion.Auto -> mostCompatible?.maxWithOrNull(
compareBy<Map.Entry<String, Int>> { it.value }
.thenBy { it.key }
)?.key
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val scopedBundles = resolvedVersion.flatMapLatest { version ->
bundleRepository.scopedBundleInfoFlow(packageName, version)
}
val incompatiblePatchCount = scopedBundles.map { bundles ->
bundles.sumOf { bundle ->
bundle.incompatible.size
}
}
// Resolve actual source from user selection
val resolvedSource = combine(
_selectedSource,
resolvedVersion
) { source, version ->
when (source) {
is SelectedSource.Installed -> source
is SelectedSource.Local -> source
is SelectedSource.Downloaded -> source
is SelectedSource.Plugin -> source
is SelectedSource.Auto -> {
val app = version?.let {
downloadedAppRepository.get(packageName, it)
}
val file = app?.let {
downloadedAppRepository.getApkFileForApp(it)
}
file?.let { SelectedSource.Downloaded(it.path, version) }
?: SelectedSource.Plugin(null)
}
}
}
val bundleInfoFlow by derivedStateOf {
bundleRepository.scopedBundleInfoFlow(packageName, null)
}
var options: Options by savedStateHandle.saveable {
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
.associate { it.uid to it.patches.associateBy { patch -> patch.name } }
options = withContext(Dispatchers.Default) {
optionsRepository.getOptions(packageName, bundlePatches)
}
}
mutableStateOf(emptyMap())
}
private set
val errorFlow = combine(
plugins,
resolvedSource,
) { pluginsList, source ->
when {
source is SelectedSource.Plugin && pluginsList.isEmpty() -> Error.NoPlugins
else -> null
}
}
// var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
// private set
private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app)
mutableStateOf(null)
}
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
@@ -98,170 +230,12 @@ class SelectedAppInfoViewModel(
invalidateSelectedAppInfo()
}
init {
invalidateSelectedAppInfo()
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedAppData =
packageInfo.await()?.let {
SelectedApp.Installed(
packageName,
it.versionName!!
) to installedAppDeferred.await()
}
}
}
val requiredVersion = combine(
prefs.suggestedVersionSafeguard.flow,
bundleRepository.suggestedVersions
) { suggestedVersionSafeguard, suggestedVersions ->
if (!suggestedVersionSafeguard) return@combine null
suggestedVersions[input.app.packageName]
}
val bundleInfoFlow by derivedStateOf {
bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version)
}
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()
.associate { it.uid to it.patches.associateBy { patch -> patch.name } }
options = withContext(Dispatchers.Default) {
optionsRepository.getOptions(packageName, bundlePatches)
}
}
state
}
private set
private var selectionState: SelectionState by savedStateHandle.saveable {
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
val previous = selectionRepository.getSelection(packageName)
if (previous.values.sumOf { it.size } == 0) return@launch
selectionState = SelectionState.Customized(previous)
}
selection
}
var showSourceSelector by mutableStateOf(false)
private set
private var pluginAction: Pair<LoadedDownloaderPlugin, Job>? by mutableStateOf(null)
val activePluginAction get() = pluginAction?.first?.packageName
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
val errorFlow = combine(plugins, snapshotFlow { selectedApp }) { pluginsList, app ->
when {
app is SelectedApp.Search && pluginsList.isEmpty() -> Error.NoPlugins
else -> null
}
}
fun showSourceSelector() {
dismissSourceSelector()
showSourceSelector = true
}
private fun cancelPluginAction() {
pluginAction?.second?.cancel()
pluginAction = null
}
fun dismissSourceSelector() {
cancelPluginAction()
showSourceSelector = false
}
fun searchUsingPlugin(plugin: LoadedDownloaderPlugin) {
cancelPluginAction()
pluginAction = plugin to viewModelScope.launch {
try {
val scope = object : GetScope {
override val hostPackageName = app.packageName
override val pluginPackageName = plugin.packageName
override suspend fun requestStartActivity(intent: Intent) =
withContext(Dispatchers.Main) {
if (launchedActivity != null) error("Previous activity has not finished")
try {
val result = with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
} finally {
launchedActivity = null
}
}
}
withContext(Dispatchers.IO) {
plugin.get(scope, packageName, desiredVersion)
}?.let { (data, version) ->
if (desiredVersion != null && version != desiredVersion) {
app.toast(app.getString(R.string.downloader_invalid_version))
return@launch
}
selectedApp = SelectedApp.Download(
packageName,
version,
ParceledDownloaderData(plugin, data)
)
} ?: app.toast(app.getString(R.string.downloader_app_not_found))
} catch (e: UserInteractionException.Activity) {
app.toast(e.message!!)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
app.toast(app.getString(R.string.downloader_error, e.simpleMessage()))
Log.e(tag, "Downloader.get threw an exception", e)
} finally {
pluginAction = null
dismissSourceSelector()
}
}
}
fun handlePluginActivityResult(result: ActivityResult) {
launchedActivity?.complete(result)
}
// TODO: Load from local file or downloaded app
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) {
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
else -> null
}
selectedAppInfo = info
selectedAppInfo = pm.getPackageInfo(packageName)
}
fun getOptionsFiltered(bundles: List<PatchBundleInfo.Scoped>) = options.filtered(bundles)
@@ -277,41 +251,54 @@ class SelectedAppInfoViewModel(
val allowIncompatible = prefs.disablePatchVersionCompatCheck.get()
val bundles = bundleInfoFlow.first()
return Patcher.ViewModelParams(
selectedApp,
getPatches(bundles, allowIncompatible),
input.packageName,
resolvedVersion.first(),
resolvedSource.first(),
patchSelection.first(),
getOptionsFiltered(bundles)
)
}
fun getPatches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible)
init {
invalidateSelectedAppInfo()
fun getCustomPatches(
bundles: List<PatchBundleInfo.Scoped>,
allowIncompatible: Boolean
): PatchSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible)
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
_selectedVersion.value = SelectedVersion.Specific(
packageInfo?.versionName ?: return@launch
)
_selectedSource.value = SelectedSource.Local(local)
}
}
fun updateConfiguration(
selection: PatchSelection?,
options: Options
) = viewModelScope.launch {
selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
// Get the previous selection if customization is enabled.
viewModelScope.launch {
if (prefs.disableSelectionWarning.get()) {
val previous = selectionRepository.getSelection(packageName)
if (previous.patchCount == 0) return@launch
selectionFlow.value = SelectionState.Customized(previous)
}
}
val filteredOptions = options.filtered(bundleInfoFlow.first())
this@SelectedAppInfoViewModel.options = filteredOptions
// Get installed app info
viewModelScope.launch {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
if (!persistConfiguration) return@launch
viewModelScope.launch(Dispatchers.Default) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
// installedAppData =
// packageInfo.await()?.let {
// SelectedApp.Installed(
// packageName,
// it.versionName!!
// ) to installedAppDeferred.await()
// }
}
}
enum class Error(@StringRes val resourceId: Int) {
enum class Error(@param:StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available)
}

View File

@@ -0,0 +1,136 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.PM
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
class SourceSelectorViewModel(
val input: SelectedAppInfo.SourceSelector.ViewModelParams
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val pluginRepository: DownloaderPluginRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val pm: PM = get()
var selectedSource by mutableStateOf(input.selectedSource)
private set
fun selectSource(source: SelectedSource) {
selectedSource = source
}
var localApp by mutableStateOf<SourceOption?>(null)
private set
val downloadedApps = downloadedAppRepository.get(input.packageName)
.map { apps ->
apps.sortedByDescending { app -> app.version }
.map {
SourceOption(
source = SelectedSource.Downloaded(
path = downloadedAppRepository.getApkFileForApp(it).path,
version = it.version
),
title = it.version,
category = "Downloaded",
key = it.version,
disableReason = if (input.version != null && it.version != input.version) {
DisableReason.VERSION_NOT_MATCHING
} else null
)
}
}
val plugins = pluginRepository.pluginStates.map { plugins ->
plugins.toList().sortedByDescending { it.second is DownloaderPluginState.Loaded }
.map {
val packageInfo = pm.getPackageInfo(it.first)
val label = packageInfo?.applicationInfo?.loadLabel(app.packageManager)
?.toString()
SourceOption(
source = SelectedSource.Plugin(it.first),
title = label ?: it.first,
category = "Plugin",
key = it.first,
disableReason = when (it.second) {
is DownloaderPluginState.Loaded -> null
is DownloaderPluginState.Untrusted -> DisableReason.NOT_TRUSTED
is DownloaderPluginState.Failed -> DisableReason.FAILED_TO_LOAD
}
)
}
}
fun getPackageInfo(packageName: String) = pm.getPackageInfo(packageName)
var installedSource by mutableStateOf<SourceOption?>(null)
private set
init {
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(input.packageName) ?: return@launch
val installedApp = installedAppRepository.get(input.packageName)
installedSource = SourceOption(
source = SelectedSource.Installed,
title = packageInfo.versionName.toString(),
category = "Installed",
key = input.packageName,
disableReason = when {
installedApp != null -> DisableReason.ALREADY_PATCHED
input.version != null && packageInfo.versionName != input.version -> DisableReason.VERSION_NOT_MATCHING
else -> null
}
)
}
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
?: return@launch
localApp = SourceOption(
source = SelectedSource.Local(local),
title = packageInfo.versionName.toString(),
category = "Local",
key = "local",
disableReason = if (input.version != null && packageInfo.versionName != input.version) {
DisableReason.VERSION_NOT_MATCHING
} else null
)
}
}
}
enum class DisableReason(val message: String) {
VERSION_NOT_MATCHING("Does not match the selected version"),
ALREADY_PATCHED("Already patched"),
NOT_TRUSTED("Not trusted"),
FAILED_TO_LOAD("Failed to load"),
}
data class SourceOption(
val source: SelectedSource,
val title: String,
val category: String? = null,
val key: String,
val disableReason: DisableReason? = null
)
}

View File

@@ -1,18 +1,13 @@
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.PackageInstaller
import android.net.Uri
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
@@ -21,8 +16,6 @@ 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
@@ -31,7 +24,14 @@ 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,10 +39,11 @@ 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)
@@ -62,14 +63,17 @@ 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")
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
state = State.CAN_DOWNLOAD
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
}
}
}
}
@@ -98,50 +102,36 @@ class UpdateViewModel(
fun installUpdate() = viewModelScope.launch {
state = State.INSTALLING
val result = withContext(Dispatchers.IO) {
ackpineInstaller.createSession(Uri.fromFile(location)) {
confirmation = Confirmation.IMMEDIATE
}.await()
}
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
}
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
}
}
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(@StringRes val title: Int) {
enum class State(@param: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

@@ -0,0 +1,85 @@
package app.revanced.manager.ui.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.PM
import app.revanced.manager.util.patchCount
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
class VersionSelectorViewModel(
val input: SelectedAppInfo.VersionSelector.ViewModelParams
) : ViewModel(), KoinComponent {
private val patchBundleRepository: PatchBundleRepository = get()
private val downloadedAppsRepository: DownloadedAppRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val pm: PM = get()
val patchCount = input.patchSelection.patchCount
val downloadedVersions = downloadedAppsRepository.get(input.packageName)
.map { apps ->
apps.map { it.version }
}
private val _localVersion = MutableStateFlow<String?>(null)
val localVersion: StateFlow<String?> = _localVersion
val availableVersions = combine(
patchBundleRepository.suggestedVersions(input.packageName, input.patchSelection),
_localVersion,
) { versions, local ->
versions.orEmpty()
.let { versions ->
local?.let {
versions.toMutableMap().also { it.putIfAbsent(local, 0) }
} ?: versions
}
.map { (key, value) -> SelectedVersion.Specific(key) to patchCount - value }
.sortedWith(
compareBy<Pair<SelectedVersion.Specific, Int>>{ it.second }
.thenByDescending { it.first.version }
)
}
var installedAppVersion by mutableStateOf<String?>(null)
init {
viewModelScope.launch {
val currentApp = pm.getPackageInfo(input.packageName)
val patchedApp = installedAppRepository.get(input.packageName)
// Skip if installed app is patched
if (patchedApp?.currentPackageName == input.packageName) return@launch
installedAppVersion = currentApp?.versionName
}
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
_localVersion.value = packageInfo?.versionName
}
}
}
var selectedVersion by mutableStateOf(input.selectedVersion)
private set
fun selectVersion(version: SelectedVersion) {
selectedVersion = version
}
}

View File

@@ -0,0 +1,30 @@
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,11 +2,8 @@ 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
@@ -16,8 +13,6 @@ 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
@@ -25,10 +20,13 @@ 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(
@@ -40,7 +38,8 @@ data class AppInfo(
@SuppressLint("QueryPermissionsNeeded")
class PM(
private val app: Application,
patchBundleRepository: PatchBundleRepository
patchBundleRepository: PatchBundleRepository,
private val uninstaller: PackageUninstaller
) {
private val scope = CoroutineScope(Dispatchers.IO)
@@ -145,17 +144,11 @@ class PM(
false
)
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)
suspend fun uninstallPackage(pkg: String, config: UninstallParametersDsl.() -> Unit = {}) = withContext(Dispatchers.IO) {
uninstaller.createSession(pkg) {
confirmation = Confirmation.IMMEDIATE
config()
}.await()
}
fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let {
@@ -164,44 +157,4 @@ 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,6 +33,7 @@ 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
@@ -57,6 +58,9 @@ import kotlin.reflect.KProperty
typealias PatchSelection = Map<Int, Set<String>>
typealias Options = Map<Int, Map<String, Map<String, Any?>>>
val PatchSelection.patchCount
get() = this.values.sumOf { it.size }
val Context.isDebuggable get() = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
fun Context.openUrl(url: String) {
@@ -82,6 +86,8 @@ 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

@@ -5,8 +5,8 @@
<item quantity="other">%d patches</item>
</plurals>
<plurals name="patches_executed">
<item quantity="one">Executed %d patch</item>
<item quantity="other">Executed %d patches</item>
<item quantity="one">Execute %d patch</item>
<item quantity="other">Execute %d patches</item>
</plurals>
<plurals name="selected_count">
<item quantity="other">%d selected</item>

View File

@@ -1,3 +1,17 @@
<!--
Strings with new lines must be raw strings, where the string is wrapped in double quotes and new lines are regular line breaks and not \n
Raw strings still require escaping embedded double quotes, but single quote characters can be escaped or used as-is.
Raw strings are required because Crowdin AI translations regularly gets confused and
replace \n with an encoded new line character.
Bad:
<string name="summary_key">First \'item\' text\nSecond \"item\" text</string>
Good:
<string name="summary_key">"First 'item' text
Second \"item\" text"</string>
-->
<resources>
<string name="app_name">ReVanced Manager</string>
<string name="patcher">Patcher</string>
@@ -47,15 +61,16 @@
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
<string name="patch_item_description">Start patching the application</string>
<string name="patch_selector_item">Select patches</string>
<string name="patch_selector_item_description">%d patches selected</string>
<string name="patch_selector_item">Patches</string>
<string name="patch_selector_item_description">%d selected</string>
<string name="no_patches_selected">No patches selected</string>
<string name="version_selector_item">Version</string>
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
<string name="apk_source_selector_item">Select APK source</string>
<string name="apk_source_auto">Using all APK downloader</string>
<string name="apk_source_selector_item">APK source</string>
<string name="apk_source_auto">Using all APK downloaders</string>
<string name="apk_source_downloader">Using %s</string>
<string name="apk_source_installed">Using installed APK</string>
<string name="apk_source_local">Using a local APK file</string>
@@ -86,21 +101,31 @@
<string name="contributors_description">View the contributors of ReVanced</string>
<string name="dynamic_color">Dynamic color</string>
<string name="dynamic_color_description">Adapt colors to the wallpaper</string>
<string name="pure_black_theme">Pure black theme</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="safeguards">Safeguards</string>
<string name="patch_compat_check">Disable version compatibility check</string>
<string name="patch_compat_check_description">The check restricts patches to compatible app versions</string>
<string name="patch_compat_check_confirmation">Selecting incompatible patches can result in a broken app.\n\nDo you want to proceed anyways?</string>
<string name="patch_compat_check_description">Do not restrict patches to compatible app versions</string>
<string name="patch_compat_check_confirmation">"Selecting incompatible patches can result in a broken app.
Do you want to proceed anyways?"</string>
<string name="suggested_version_safeguard">Require suggested app version</string>
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways?</string>
<string name="suggested_version_safeguard_confirmation">"Selecting an app that is not the suggested version may cause unexpected issues.
Do you want to proceed anyways?"</string>
<string name="patch_selection_safeguard">Allow changing patch selection and options</string>
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string>
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.\n\nEnable anyways?</string>
<string name="patch_selection_safeguard_confirmation">"Changing the selection of patches may cause unexpected issues.
Enable anyways?"</string>
<string name="universal_patches_safeguard">Allow using universal patches</string>
<string name="universal_patches_safeguard_description">Do not prevent using universal patches</string>
<string name="universal_patches_safeguard_confirmation">Universal patches are not as well tested as those that target specific apps.\n\nEnable anyways?</string>
<string name="universal_patches_safeguard_confirmation">"Universal patches are not as well tested as those that target specific apps.
Enable anyways?"</string>
<string name="import_keystore">Import keystore</string>
<string name="import_keystore_description">Import a custom keystore</string>
<string name="import_keystore_dialog_title">Enter keystore credentials</string>
@@ -116,7 +141,9 @@
<string name="export_keystore_success">Exported keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string>
<string name="regenerate_keystore_description">Generate a new keystore</string>
<string name="regenerate_keystore_dialog_description">You are about to regenerate your keystore the manager will use during the patching process.\n\nYou will not be able to update the previously installed apps from this source.</string>
<string name="regenerate_keystore_dialog_description">"You are about to regenerate your keystore the manager will use during the patching process.
You will not be able to update the previously installed apps from this source."</string>
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
<string name="import_patch_selection">Import patch selection</string>
<string name="import_patch_selection_description">Import patch selection from a JSON file</string>
@@ -132,8 +159,8 @@
<string name="reset_patch_options_description">Reset the stored patch options</string>
<string name="reset_patch_selection_success">Patch selection has been reset</string>
<string name="patch_selection_reset_all">Reset patch selection globally</string>
<string name="patch_selection_reset_all_dialog_description">You are about to reset all the patch selections. You will need to manually select each patch again.</string>
<string name="patch_selection_reset_all_description">Resets all the patch selections</string>
<string name="patch_selection_reset_all_dialog_description">You are about to reset all patch selections. You will need to manually select each patch again.</string>
<string name="patch_selection_reset_all_description">Resets all patch selections</string>
<string name="patch_selection_reset_package">Reset patch selection for app</string>
<string name="patch_selection_reset_package_dialog_description">You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again.</string>
<string name="patch_selection_reset_package_description">Resets patch selection for a single app</string>
@@ -147,7 +174,7 @@
<string name="patch_options_reset_patches_dialog_description">You are about to reset the patch options for \"%s\". You will have to reapply each option again.</string>
<string name="patch_options_reset_patches_description">Resets the patch options for a specific collection of patches</string>
<string name="patch_options_reset_all">Reset patch options globally</string>
<string name="patch_options_reset_all_dialog_description">You are about to reset patch options. You will have to reapply each option again.</string>
<string name="patch_options_reset_all_dialog_description">You are about to reset all patch options. You will have to reapply each option again.</string>
<string name="patch_options_reset_all_description">Resets all patch options</string>
<string name="downloader_plugins">Plugins</string>
<string name="downloader_plugin_state_trusted">Trusted</string>
@@ -155,10 +182,12 @@
<string name="downloader_plugin_state_untrusted">Untrusted</string>
<string name="downloader_plugin_trust_dialog_title">Trust plugin?</string>
<string name="downloader_plugin_revoke_trust_dialog_title">Revoke trust?</string>
<string name="downloader_plugin_trust_dialog_body">Package name: %1$s\nSignature (SHA-256): %2$s</string>
<string name="downloader_plugin_trust_dialog_body">Continuing will allow this plugin to run on your system.\n\nOnly enable this plugin if you trust it. Plugins can execute arbitrary code and may compromise your device.</string>
<string name="downloader_plugin_trust_dialog_signature">Signature:\n\n%s</string>
<string name="downloader_plugin_trust_dialog_plugin">Plugin:\n%s</string>
<string name="downloader_plugin_delete_apps_title">Delete selected apps</string>
<string name="downloader_plugin_delete_apps_description">Are you sure you want to delete the selected apps?</string>
<string name="downloader_settings_no_apps">No downloaded apps found</string>
<string name="downloader_settings_no_apps">No downloaded apps found.</string>
<string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string>
@@ -174,7 +203,7 @@
<string name="share">Share</string>
<string name="patch">Patch</string>
<string name="select_from_storage">Select from storage</string>
<string name="select_from_storage_description">Select an APK file from storage using file picker</string>
<string name="select_from_storage_description">Select an APK file from storage</string>
<string name="suggested_version_info">Suggested version: %s</string>
<string name="type_anything">Type anything to continue</string>
<string name="search">Search patches…</string>
@@ -191,7 +220,7 @@
<string name="appearance">Appearance</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>
<string name="process_runtime_description">This is faster and allows Patcher to use more memory</string>
<string name="process_runtime_memory_limit">Patcher process memory limit</string>
<string name="process_runtime_memory_limit_description">The max amount of memory that the Patcher process can use (in megabytes)</string>
<string name="debug_logs_export">Export debug logs</string>
@@ -199,7 +228,7 @@
<string name="debug_logs_export_failed">Failed to export logs</string>
<string name="debug_logs_export_success">Exported logs</string>
<string name="api_url">API URL</string>
<string name="api_url_description">The API used to download necessary files.</string>
<string name="api_url_description">The API used to download necessary files</string>
<string name="api_url_dialog_title">Change API URL</string>
<string name="api_url_dialog_description">Change the API URL of ReVanced Manager. ReVanced Manager uses the API to download patches and updates.</string>
<string name="api_url_dialog_warning">ReVanced Manager connects to the API to download patches and updates. Make sure that you trust it.</string>
@@ -235,14 +264,23 @@
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
<string name="patch_options_reset_toast">Patch options have been reset</string>
<string name="non_suggested_version_warning_title">Non suggested version</string>
<string name="non_suggested_version_warning_description">The version of the app you have selected does not match the suggested version.\nPlease use the suggested version: %s\n\nTo continue anyway, disable \"Require suggested app version\" in the advanced settings.</string>
<string name="non_suggested_version_warning_description">"The version of the app you have selected does not match the suggested version.
Please use the suggested version: %s
To continue anyway, disable \"Require suggested app version\" in the advanced settings."</string>
<string name="selection_warning_title">Stop using defaults?</string>
<string name="selection_warning_description">It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.\n\nYou need to turn on \"Allow changing patch selection\" in the advanced settings before toggling patches.</string>
<string name="universal_patch_warning_description">Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.\n\nYou need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches.</string>
<string name="selection_warning_description">"It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches."</string>
<string name="universal_patch_warning_description">"Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches."</string>
<string name="this_version">This version</string>
<string name="universal">Any app</string>
<string name="search_patches">Search patches</string>
<string name="app_version_not_compatible">This patch is not compatible with the selected app version (%1$s).\n\nIt is only compatible with the following version(s): %2$s.</string>
<string name="app_version_not_compatible">"This patch is not compatible with the selected app version (%1$s)
It is only compatible with the following version(s): %2$s"</string>
<string name="continue_with_version">Continue with this version?</string>
<string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string>
<string name="download_application">Download application?</string>
@@ -276,7 +314,7 @@
<string name="downloader_app_not_found">Downloader did not find the app</string>
<string name="downloader_error">Downloader error: %s</string>
<string name="downloader_no_plugins_installed">No downloader installed.</string>
<string name="downloader_no_plugins_available">There are downloader installed but none is trusted. Check your settings.</string>
<string name="downloader_no_plugins_available">There are downloaders installed but none are trusted. Check your settings.</string>
<string name="already_patched">Already patched</string>
<string name="patch_selector_sheet_filter_title">Filter</string>
@@ -318,6 +356,7 @@
<string name="patcher_notification_text">Tap to return to the patcher</string>
<string name="patcher_stop_confirm_title">Stop patcher</string>
<string name="patcher_stop_confirm_description">Are you sure you want to stop the patching process?</string>
<string name="patcher_install_in_progress">Installation is in progress. Please wait</string>
<string name="execute_patches">Execute patches</string>
<string name="executing_patch">Execute %s</string>
<string name="failed_to_execute_patch">Failed to execute %s</string>
@@ -383,7 +422,8 @@
<string name="save_with_count">Save (%1$s)</string>
<string name="update">Update</string>
<string name="empty">Empty</string>
<string name="installing_message">Tap on <b>Update</b> when prompted.\nReVanced Manager will close when updating.</string>
<string name="installing_message">"Tap on <b>Update</b> when prompted.
ReVanced Manager will close when updating."</string>
<string name="no_changelogs_found">No changelogs found</string>
<string name="just_now">Just now</string>
<string name="minutes_ago">%sm ago</string>
@@ -402,7 +442,9 @@
<string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
<string name="failed_to_download_update">Failed to download update: %s</string>
<string name="download">Download</string>
<string name="download_confirmation_metered">You are currently on a metered connection, and data charges from your service provider may apply.\n\nDo you still want to continue?</string>
<string name="download_confirmation_metered">"You are currently on a metered connection, and data charges from your service provider may apply.
Do you still want to continue?"</string>
<string name="download_update_confirmation">Download update?</string>
<string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string>
@@ -438,14 +480,16 @@
<string name="add_patches">Add patches</string>
<string name="auto_update_description">Automatically update when a new version is available</string>
<string name="patches_prereleases">Use pre-releases</string>
<string name="patches_prereleases_description">Use pre-release version of %s</string>
<string name="patches_prereleases_description">Use pre-release versions of %s</string>
<string name="patches_url">Patches URL</string>
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
<string name="incompatible_patches_dialog">"These patches are not compatible with the selected app version (%1$s).
Click on the patches to see more details."</string>
<string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string>
<string name="never_show_again">Never show again</string>
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
<string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string>
<string name="show_manager_update_dialog_on_launch_description">Show a popup notification whenever a new update is available on launch</string>
<string name="failed_to_import_keystore">Failed to import keystore</string>
<string name="export">Export</string>
<string name="confirm">Confirm</string>

View File

@@ -13,9 +13,9 @@ Learn how to use ReVanced Manager to patch apps.
7. Tap on the `Install` button to install the patched app[^4]
[^1]: Here you can see all the apps that are supported by ReVanced.
You can also add custom apps by tapping on the `+` button in the top right corner.
You can also add custom apps by tapping on the `Select from storage` button at the top.
[^2]: It is recommended to use the default set of patches by tapping on the `Reset` button in the bottom right corner.
[^3]: By default, all available downloader will be used to download the app.
[^3]: By default, all available downloaders will be used to download the app.
If you want to use a specific downloader, you can change it here.
[^4]: You can export the patched app or the patch logs in the bottom left corner.

View File

@@ -3,7 +3,7 @@
Learn how to manage downloaders.
Refer to the [template](https://github.com/ReVanced/revanced-manager-downloader-template) if you are developer who wants to create a plugin.
Downloaders are Apk files and are installed, updated and uninstalled just like regular Android apps.
Downloaders are APK files and are installed, updated and uninstalled just like regular Android apps.
Downloaders can execute arbitrary code inside ReVanced Manager and must be marked as trusted before use. Manager will show a notification in the dashboard when a new downloader is discovered.
Trust can also be granted and revoked under `Settings` > `Downloads`.

View File

@@ -9,7 +9,7 @@ Learn how to update ReVanced Manager.
3. Configure the update settings accordingly[^1]
[^1]: By default, ReVanced Manager will check for updates automatically on launch
and let you when an update is available.
and let you know when an update is available.
## ⏭️ What's next

View File

@@ -4,29 +4,29 @@ Learn how to configure ReVanced Manager.
## 🔧 Settings
- **Import & export**: Import or export patch selections, patch options and the signing keystore
- **Downloads**: Enable or disable ReVanced Manager downloader and manage past downloaded apps here
- **Import & export**: Import or export patch selections, patch options and the signing keystore
- **Advanced**:
- **API URL**: Set the URL of the ReVanced API, ReVanced Manager will use
- **Disable version compatibility check**: Patching versions of apps the patches are explicitly compatible with is enforced.
Disabling this will allow patching versions of apps the patches are not explicitly compatible with
> ⚠️ Warning
> Patches may fail patching versions they are not explicitly compatible with.
> Unless you know what you are doing, it is recommended to keep this enabled.
- **Allow changing patch selection**: The default selection of patches is enforced.
Enabling this will allow you to change the patch selection
> ⚠️ Warning
> Changing the selection may cause unexpected issues.
> Patches may fail on app versions they are not explicitly compatible with.
> Unless you know what you are doing, it is recommended to keep this disabled.
- **Require suggested app version**: Specific versions of apps is enforced based on the patch selection automatically.
Disabling this will allow you to patch any version of apps
> ⚠️ Warning
> Patches not compatible with the selected version of the app will not be used.
> Unless you know what you are doing, it is recommended to keep this enabled.
- **Allow universal patches**: Patches that do not specify compatibility with an app explicitly are forcibly disabled.
- **Allow changing patch selection and options**: The default selection of patches is enforced.
Enabling this will allow you to change the patch selection
> ⚠️ Warning
> Changing the selection may cause unexpected issues.
> Unless you know what you are doing, it is recommended to keep this disabled.
- **Allow using universal patches**: Patches that do not specify compatibility with an app are forcibly disabled.
Enabling this will allow selecting such patches
> ⚠️ Warning
> Universal patches do not specify compatibility with an app explicitly may not work on all apps regardless.
> Universal patches do not specify compatibility with an app and may not work on all apps regardless.
> Unless you know what you are doing, it is recommended to keep this disabled.
- **About**: View more information and links about ReVanced and ReVanced Manager.

View File

@@ -37,6 +37,7 @@ 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
@@ -133,6 +134,10 @@ 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" }