fix: process death resilience and account for android 11 bug (#2355)

This commit is contained in:
Ax333l 2024-12-22 22:28:54 +01:00 committed by GitHub
parent 9916e4da4d
commit 49f75f9edd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 377 additions and 171 deletions

View File

@ -1,10 +1,15 @@
package app.revanced.manager
import android.app.Activity
import android.app.Application
import android.os.Bundle
import android.util.Log
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import coil.Coil
import coil.ImageLoader
@ -25,6 +30,7 @@ class ManagerApplication : Application() {
private val prefs: PreferencesManager by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
private val fs: Filesystem by inject()
override fun onCreate() {
super.onCreate()
@ -71,5 +77,34 @@ class ManagerApplication : Application() {
updateCheck()
}
}
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var firstActivityCreated = false
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (firstActivityCreated) return
firstActivityCreated = true
// We do not want to call onFreshProcessStart() if there is state to restore.
// This can happen on system-initiated process death.
if (savedInstanceState == null) {
Log.d(tag, "Fresh process created")
onFreshProcessStart()
} else Log.d(tag, "System-initiated process death detected")
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
}
private fun onFreshProcessStart() {
fs.uiTempDir.apply {
deleteRecursively()
mkdirs()
}
}
}

View File

@ -9,6 +9,8 @@ import android.os.Environment
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import app.revanced.manager.util.RequestManageStorageContract
import java.io.File
import java.nio.file.Path
class Filesystem(private val app: Application) {
val contentResolver = app.contentResolver // TODO: move Content Resolver operations to here.
@ -17,21 +19,33 @@ class Filesystem(private val app: Application) {
* A directory that gets cleared when the app restarts.
* Do not store paths to this directory in a parcel.
*/
val tempDir = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
val tempDir: File = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
deleteRecursively()
mkdirs()
}
fun externalFilesDir() = Environment.getExternalStorageDirectory().toPath()
/**
* A directory for storing temporary files related to UI.
* This is the same as [tempDir], but does not get cleared on system-initiated process death.
* Paths to this directory can be safely stored in parcels.
*/
val uiTempDir: File = app.getDir("ui_ephemeral", Context.MODE_PRIVATE)
fun externalFilesDir(): Path = Environment.getExternalStorageDirectory().toPath()
private fun usesManagePermission() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
private val storagePermissionName = if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE
private val storagePermissionName =
if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE
fun permissionContract(): Pair<ActivityResultContract<String, Boolean>, String> {
val contract = if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission()
val contract =
if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission()
return contract to storagePermissionName
}
fun hasStoragePermission() = if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(storagePermissionName) == PackageManager.PERMISSION_GRANTED
fun hasStoragePermission() =
if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(
storagePermissionName
) == PackageManager.PERMISSION_GRANTED
}

View File

@ -25,7 +25,7 @@ class Session(
private val androidContext: Context,
private val logger: Logger,
private val input: File,
private val onPatchCompleted: () -> Unit,
private val onPatchCompleted: suspend () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =

View File

@ -20,7 +20,7 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: () -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
) {
val bundles = bundles()

View File

@ -66,7 +66,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: () -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
) = coroutineScope {
// Get the location of our own Apk.
@ -123,7 +123,9 @@ 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() = onPatchCompleted()
override fun patchSucceeded() {
launch { onPatchCompleted() }
}
override fun progress(name: String?, state: String?, msg: String?) =
onProgress(name, state?.let { enumValueOf<State>(it) }, msg)

View File

@ -34,7 +34,7 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: () -> Unit,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
)
}

View File

@ -42,9 +42,7 @@ import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -73,10 +71,10 @@ class PatcherWorker(
val selectedPatches: PatchSelection,
val options: Options,
val logger: Logger,
val downloadProgress: MutableStateFlow<Pair<Long, Long?>?>,
val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val onPatchCompleted: suspend () -> Unit,
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: (File) -> Unit,
val setInputFile: suspend (File) -> Unit,
val onProgress: ProgressEventHandler
) {
val packageName get() = input.packageName
@ -160,7 +158,7 @@ class PatcherWorker(
data,
args.packageName,
args.input.version,
onDownload = args.downloadProgress::emit
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
@ -224,11 +222,7 @@ class PatcherWorker(
args.selectedPatches,
args.options,
args.logger,
onPatchCompleted = {
args.patchesProgress.update { (completed, total) ->
completed + 1 to total
}
},
args.onPatchCompleted,
args.onProgress
)

View File

@ -6,7 +6,6 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
@ -21,35 +20,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.model.InstallerModel
import com.github.materiiapps.enumutil.FromValue
private typealias InstallerStatusDialogButtonHandler = ((model: InstallerModel) -> Unit)
private typealias InstallerStatusDialogButton = @Composable (model: InstallerStatusDialogModel) -> Unit
interface InstallerModel {
fun reinstall()
fun install()
}
interface InstallerStatusDialogModel : InstallerModel {
var packageInstallerStatus: Int?
}
private typealias InstallerStatusDialogButton = @Composable (model: InstallerModel, dismiss: () -> Unit) -> Unit
@Composable
fun InstallerStatusDialog(model: InstallerStatusDialogModel) {
fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss: () -> Unit) {
val dialogKind = remember {
DialogKind.fromValue(model.packageInstallerStatus!!) ?: DialogKind.FAILURE
DialogKind.fromValue(installerStatus) ?: DialogKind.FAILURE
}
AlertDialog(
onDismissRequest = {
model.packageInstallerStatus = null
},
onDismissRequest = onDismiss,
confirmButton = {
dialogKind.confirmButton(model)
dialogKind.confirmButton(model, onDismiss)
},
dismissButton = {
dialogKind.dismissButton?.invoke(model)
dialogKind.dismissButton?.invoke(model, onDismiss)
},
icon = {
Icon(dialogKind.icon, null)
@ -75,10 +64,10 @@ fun InstallerStatusDialog(model: InstallerStatusDialogModel) {
private fun installerStatusDialogButton(
@StringRes buttonStringResId: Int,
buttonHandler: InstallerStatusDialogButtonHandler = { },
): InstallerStatusDialogButton = { model ->
): InstallerStatusDialogButton = { model, dismiss ->
TextButton(
onClick = {
model.packageInstallerStatus = null
dismiss()
buttonHandler(model)
}
) {
@ -154,6 +143,7 @@ enum class DialogKind(
model.install()
},
);
// Needed due to the @FromValue annotation.
companion object
}

View File

@ -36,13 +36,14 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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 java.util.Locale
import kotlin.math.floor
@ -52,6 +53,7 @@ fun Steps(
category: StepCategory,
steps: List<Step>,
stepCount: Pair<Int, Int>? = null,
stepProgressProvider: StepProgressProvider
) {
var expanded by rememberSaveable { mutableStateOf(true) }
@ -116,13 +118,20 @@ fun Steps(
modifier = Modifier.fillMaxWidth()
) {
steps.forEach { step ->
val downloadProgress = step.downloadProgress?.collectAsStateWithLifecycle()
val (progress, progressText) = when (step.progressKey) {
null -> null
ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) ->
if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB"
else null to "${downloaded.megaBytes} MB"
}
} ?: (null to null)
SubStep(
name = step.name,
state = step.state,
message = step.message,
downloadProgress = downloadProgress?.value
progress = progress,
progressText = progressText
)
}
}
@ -135,7 +144,8 @@ fun SubStep(
name: String,
state: State,
message: String? = null,
downloadProgress: Pair<Long, Long?>? = null
progress: Float? = null,
progressText: String? = null
) {
var messageExpanded by rememberSaveable { mutableStateOf(true) }
@ -156,7 +166,7 @@ fun SubStep(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
StepIcon(state, downloadProgress, size = 20.dp)
StepIcon(state, progress, size = 20.dp)
}
Text(
@ -167,8 +177,8 @@ fun SubStep(
modifier = Modifier.weight(1f, true),
)
if (message != null) {
Box(
when {
message != null -> Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
@ -178,15 +188,13 @@ fun SubStep(
onClick = null
)
}
} else {
downloadProgress?.let { (current, total) ->
Text(
if (total != null) "${current.megaBytes}/${total.megaBytes} MB" else "${current.megaBytes} MB",
progressText != null -> Text(
progressText,
style = MaterialTheme.typography.labelSmall
)
}
}
}
AnimatedVisibility(visible = messageExpanded && message != null) {
Text(
@ -200,7 +208,7 @@ fun SubStep(
}
@Composable
fun StepIcon(state: State, progress: Pair<Long, Long?>? = null, size: Dp) {
fun StepIcon(state: State, progress: Float? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) {
@ -234,12 +242,7 @@ fun StepIcon(state: State, progress: Pair<Long, Long?>? = null, size: Dp) {
contentDescription = description
}
},
progress = {
progress?.let { (current, total) ->
if (total == null) return@let null
current / total
}?.toFloat()
},
progress = { progress },
strokeWidth = strokeWidth
)
}

View File

@ -0,0 +1,6 @@
package app.revanced.manager.ui.model
interface InstallerModel {
fun reinstall()
fun install()
}

View File

@ -1,8 +1,9 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import androidx.annotation.StringRes
import app.revanced.manager.R
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
enum class StepCategory(@StringRes val displayName: Int) {
PREPARING(R.string.patcher_step_group_preparing),
@ -14,10 +15,19 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED
}
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize
data class Step(
val name: String,
val category: StepCategory,
val state: State = State.WAITING,
val message: String? = null,
val downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
)
val progressKey: ProgressKey? = null
) : Parcelable

View File

@ -5,6 +5,7 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
@ -27,6 +28,7 @@ import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog
@ -36,6 +38,7 @@ import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.util.RequestInstallAppsContract
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@ -93,20 +96,36 @@ fun DashboardScreen(
)
}
var showDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
var showUpdateDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
val availableUpdate by remember {
derivedStateOf { vm.updatedManagerVersion.takeIf { showDialog } }
derivedStateOf { vm.updatedManagerVersion.takeIf { showUpdateDialog } }
}
availableUpdate?.let { version ->
AvailableUpdateDialog(
onDismiss = { showDialog = false },
onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick,
newVersion = version
)
}
val context = LocalContext.current
var showAndroid11Dialog by rememberSaveable { mutableStateOf(false) }
val installAppsPermissionLauncher =
rememberLauncherForActivityResult(RequestInstallAppsContract) { granted ->
showAndroid11Dialog = false
if (granted) onAppSelectorClick()
}
if (showAndroid11Dialog) Android11Dialog(
onDismissRequest = {
showAndroid11Dialog = false
},
onContinue = {
installAppsPermissionLauncher.launch(context.packageName)
}
)
Scaffold(
topBar = {
if (bundlesSelectable) {
@ -188,6 +207,10 @@ fun DashboardScreen(
}
return@HapticFloatingActionButton
}
if (vm.android11BugActive) {
showAndroid11Dialog = true
return@HapticFloatingActionButton
}
onAppSelectorClick()
}
@ -317,3 +340,24 @@ fun Notifications(
}
}
}
@Composable
fun Android11Dialog(onDismissRequest: () -> Unit, onContinue: () -> Unit) {
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onContinue) {
Text(stringResource(R.string.continue_))
}
},
title = {
Text(stringResource(R.string.android_11_bug_dialog_title))
},
icon = {
Icon(Icons.Outlined.BugReport, null)
},
text = {
Text(stringResource(R.string.android_11_bug_dialog_description))
}
)
}

View File

@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppScaffold
@ -38,7 +37,6 @@ import app.revanced.manager.ui.component.InstallerStatusDialog
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.patcher.InstallPickerDialog
import app.revanced.manager.ui.component.patcher.Steps
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE
@ -50,7 +48,11 @@ fun PatcherScreen(
onBackClick: () -> Unit,
vm: PatcherViewModel
) {
BackHandler(onBack = onBackClick)
fun leaveScreen() {
vm.onBack()
onBackClick()
}
BackHandler(onBack = ::leaveScreen)
val context = LocalContext.current
val exportApkLauncher =
@ -66,22 +68,6 @@ fun PatcherScreen(
}
}
val patchesProgress by vm.patchesProgress.collectAsStateWithLifecycle()
val progress by remember {
derivedStateOf {
val (patchesCompleted, patchesTotal) = patchesProgress
val current = vm.steps.count {
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
} + patchesCompleted
val total = vm.steps.size - 1 + patchesTotal
current.toFloat() / total.toFloat()
}
}
if (patcherSucceeded == null) {
DisposableEffect(Unit) {
val window = (context as Activity).window
@ -98,8 +84,9 @@ fun PatcherScreen(
onConfirm = vm::install
)
if (vm.installerStatusDialogModel.packageInstallerStatus != null)
InstallerStatusDialog(vm.installerStatusDialogModel)
vm.packageInstallerStatus?.let {
InstallerStatusDialog(it, vm, vm::dismissPackageInstallerDialog)
}
val activityLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
@ -137,7 +124,7 @@ fun PatcherScreen(
topBar = {
AppTopBar(
title = stringResource(R.string.patcher),
onBackClick = onBackClick
onBackClick = ::leaveScreen
)
},
bottomBar = {
@ -193,7 +180,7 @@ fun PatcherScreen(
.fillMaxSize()
) {
LinearProgressIndicator(
progress = { progress },
progress = { vm.progress },
modifier = Modifier.fillMaxWidth()
)
@ -209,7 +196,8 @@ fun PatcherScreen(
Steps(
category = category,
steps = steps,
stepCount = if (category == StepCategory.PATCHING) patchesProgress else null
stepCount = if (category == StepCategory.PATCHING) vm.patchesProgress else null,
stepProgressProvider = vm
)
}
}

View File

@ -6,9 +6,13 @@ 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
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
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
@ -22,13 +26,19 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
@OptIn(SavedStateHandleSaveableApi::class)
class AppSelectorViewModel(
private val app: Application,
private val pm: PM,
private val patchBundleRepository: PatchBundleRepository
fs: Filesystem,
private val patchBundleRepository: PatchBundleRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = File(app.filesDir, "input.apk").also {
it.delete()
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
File(
fs.uiTempDir,
"input.apk"
).also(File::delete)
}
val appList = pm.appList

View File

@ -3,6 +3,7 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.ContentResolver
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@ -20,6 +21,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.flow.first
@ -32,7 +34,8 @@ class DashboardViewModel(
private val downloaderPluginRepository: DownloaderPluginRepository,
private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo,
val prefs: PreferencesManager
val prefs: PreferencesManager,
private val pm: PM,
) : ViewModel() {
val availablePatches =
patchBundleRepository.bundles.map { it.values.sumOf { bundle -> bundle.patches.size } }
@ -43,6 +46,14 @@ class DashboardViewModel(
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
/**
* Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen.
* This value is true when the conditions that trigger the bug are met.
*
* See: https://github.com/ReVanced/revanced-manager/issues/2138
*/
val android11BugActive get() = Build.VERSION.SDK_INT == Build.VERSION_CODES.R && !pm.canInstallPackages()
var updatedManagerVersion: String? by mutableStateOf(null)
private set
var showBatteryOptimizationsWarning by mutableStateOf(false)

View File

@ -7,18 +7,22 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.ParcelUuid
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import androidx.work.WorkInfo
import androidx.work.WorkManager
import app.revanced.manager.R
@ -35,13 +39,17 @@ 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.component.InstallerStatusDialogModel
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import app.revanced.manager.util.PM
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
@ -51,68 +59,72 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.io.File
import java.nio.file.Files
import java.time.Duration
import java.util.UUID
@Stable
@OptIn(PluginHostApi::class)
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class PatcherViewModel(
private val input: Destination.Patcher
) : ViewModel(), KoinComponent {
) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel {
private val app: Application by inject()
private val fs: Filesystem by inject()
private val pm: PM by inject()
private val workerRepository: WorkerRepository by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
val installerStatusDialogModel: InstallerStatusDialogModel =
object : InstallerStatusDialogModel {
override var packageInstallerStatus: Int? by mutableStateOf(null)
override fun reinstall() {
this@PatcherViewModel.reinstall()
}
override fun install() {
// Since this is a package installer status dialog,
// InstallType.MOUNT is never used here.
install(InstallType.DEFAULT)
}
}
private val savedStateHandle: SavedStateHandle = get()
private var installedApp: InstalledApp? = null
val packageName: String = input.selectedApp.packageName
var installedPackageName by mutableStateOf<String?>(null)
val packageName = input.selectedApp.packageName
var installedPackageName by savedStateHandle.saveable(
key = "installedPackageName",
// Force Kotlin to select the correct overload.
stateSaver = autoSaver()
) {
mutableStateOf<String?>(null)
}
private set
var isInstalling by mutableStateOf(false)
private var ongoingPmSession: Boolean by savedStateHandle.saveableVar { false }
var packageInstallerStatus: Int? by savedStateHandle.saveable(
key = "packageInstallerStatus",
stateSaver = autoSaver()
) {
mutableStateOf(null)
}
private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(null)
var isInstalling by mutableStateOf(ongoingPmSession)
private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(
null
)
val activityPromptDialog by derivedStateOf { currentActivityRequest?.second }
private var launchedActivity: CompletableDeferred<ActivityResult>? = null
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
private val tempDir = fs.tempDir.resolve("installer").also {
private val tempDir = savedStateHandle.saveable(key = "tempDir") {
fs.uiTempDir.resolve("installer").also {
it.deleteRecursively()
it.mkdirs()
}
private var inputFile: File? = null
}
private var inputFile: File? by savedStateHandle.saveableVar()
private val outputFile = tempDir.resolve("output.apk")
private val logs = mutableListOf<Pair<LogLevel, String>>()
private val logs by savedStateHandle.saveable<MutableList<Pair<LogLevel, String>>> { mutableListOf() }
private val logger = object : Logger() {
override fun log(level: LogLevel, message: String) {
level.androidLog(message)
@ -124,28 +136,56 @@ class PatcherViewModel(
}
}
val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size }))
private val downloadProgress = MutableStateFlow<Pair<Long, Long?>?>(null)
val steps = generateSteps(
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,
downloadProgress
input.selectedApp
).toMutableStateList()
}
private var currentStepIndex = 0
val progress by derivedStateOf {
val current = steps.count {
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
} + completedPatchCount
val total = steps.size - 1 + patchCount
current.toFloat() / total.toFloat()
}
private val workManager = WorkManager.getInstance(app)
private val patcherWorkerId: UUID =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
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,
downloadProgress,
patchesProgress,
setInputFile = { inputFile = it },
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.")
@ -192,10 +232,11 @@ class PatcherViewModel(
}
}
)
)
))
}
val patcherSucceeded =
workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo? ->
workManager.getWorkInfoByIdLiveData(patcherWorkerId.uuid).map { workInfo: WorkInfo? ->
when (workInfo?.state) {
WorkInfo.State.SUCCEEDED -> true
WorkInfo.State.FAILED -> false
@ -229,9 +270,7 @@ class PatcherViewModel(
input.selectedPatches
)
}
}
installerStatusDialogModel.packageInstallerStatus = pmStatus
} else packageInstallerStatus = pmStatus
isInstalling = false
}
@ -245,15 +284,15 @@ class PatcherViewModel(
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE)
?.let(logger::trace)
if (pmStatus != PackageInstaller.STATUS_SUCCESS) {
installerStatusDialogModel.packageInstallerStatus = pmStatus
}
if (pmStatus != PackageInstaller.STATUS_SUCCESS)
packageInstallerStatus = pmStatus
}
}
}
}
init { // TODO: navigate away when system-initiated process death is detected because it is not possible to recover from it.
init {
// TODO: detect system-initiated process death during the patching process.
ContextCompat.registerReceiver(
app,
installerBroadcastReceiver,
@ -273,7 +312,7 @@ class PatcherViewModel(
override fun onCleared() {
super.onCleared()
app.unregisterReceiver(installerBroadcastReceiver)
workManager.cancelWorkById(patcherWorkerId)
workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
GlobalScope.launch(Dispatchers.Main) {
@ -284,7 +323,10 @@ class PatcherViewModel(
}
}
}
}
fun onBack() {
// tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death.
tempDir.deleteRecursively()
}
@ -342,8 +384,7 @@ class PatcherViewModel(
// 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
installerStatusDialogModel.packageInstallerStatus =
PackageInstaller.STATUS_FAILURE_CONFLICT
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
}
}
@ -368,13 +409,13 @@ class PatcherViewModel(
val label = with(pm) {
packageInfo.label()
}
// Check for base APK, first check if the app is already installed
if (existingPackageInfo == null) {
// If the app is not installed, check if the output file is a base apk
if (currentPackageInfo.splitNames.isNotEmpty()) {
// Exit if there is no base APK package
installerStatusDialogModel.packageInstallerStatus =
PackageInstaller.STATUS_FAILURE_INVALID
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID
return@launch
}
}
@ -419,12 +460,17 @@ class PatcherViewModel(
Log.e(tag, "Failed to install", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
} finally {
if (!pmInstallStarted)
isInstalling = false
if (!pmInstallStarted) isInstalling = false
}
}
fun reinstall() = viewModelScope.launch {
override fun install() {
// InstallType.MOUNT is never used here since this overload is for the package installer status dialog.
install(InstallType.DEFAULT)
}
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")
@ -433,9 +479,14 @@ class PatcherViewModel(
isInstalling = true
}
}
}
companion object {
private const val TAG = "ReVanced Patcher"
fun dismissPackageInstallerDialog() {
packageInstallerStatus = null
}
private companion object {
const val TAG = "ReVanced Patcher"
fun LogLevel.androidLog(msg: String) = when (this) {
LogLevel.TRACE -> Log.v(TAG, msg)
@ -444,11 +495,7 @@ class PatcherViewModel(
LogLevel.ERROR -> Log.e(TAG, msg)
}
fun generateSteps(
context: Context,
selectedApp: SelectedApp,
downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
): List<Step> {
fun generateSteps(context: Context, selectedApp: SelectedApp): List<Step> {
val needsDownload =
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
@ -457,7 +504,7 @@ class PatcherViewModel(
context.getString(R.string.download_apk),
StepCategory.PREPARING,
state = State.RUNNING,
downloadProgress = downloadProgress,
progressKey = ProgressKey.DOWNLOAD,
).takeIf { needsDownload },
Step(
context.getString(R.string.patcher_step_load_patches),

View File

@ -163,6 +163,8 @@ class PM(
app.startActivity(it)
}
fun canInstallPackages() = app.packageManager.canRequestPackageInstalls()
private fun PackageInstaller.Session.writeApk(apk: File) {
apk.inputStream().use { inputStream ->
openWrite(apk.name, 0, apk.length()).use { outputStream ->

View File

@ -0,0 +1,18 @@
package app.revanced.manager.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContract
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
object RequestInstallAppsContract : ActivityResultContract<String, Boolean>(), KoinComponent {
private val pm: PM by inject()
override fun createIntent(context: Context, input: String) = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.fromParts("package", input, null))
override fun parseResult(resultCode: Int, intent: Intent?): Boolean {
return pm.canInstallPackages()
}
}

View File

@ -3,8 +3,14 @@ package app.revanced.manager.util
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.icu.number.Notation
import android.icu.number.NumberFormatter
import android.icu.number.Precision
import android.icu.text.CompactDecimalFormat
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
@ -28,6 +34,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import app.revanced.manager.R
@ -48,6 +55,9 @@ import kotlinx.datetime.format.char
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
typealias PatchSelection = Map<Int, Set<String>>
typealias Options = Map<Int, Map<String, Map<String, Any?>>>
@ -261,3 +271,22 @@ fun <T, R> ((T) -> R).withHapticFeedback(constant: Int): (T) -> R {
}
fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f)
@MainThread
fun <T : Any> SavedStateHandle.saveableVar(init: () -> T): PropertyDelegateProvider<Any?, ReadWriteProperty<Any?, T>> =
PropertyDelegateProvider { _: Any?, property ->
val name = property.name
if (name !in this) this[name] = init()
object : ReadWriteProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T = get(name)!!
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) =
set(name, value)
}
}
fun <T : Any> SavedStateHandle.saveableVar(): ReadWriteProperty<Any?, T?> =
object : ReadWriteProperty<Any?, T?> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T? = get(property.name)
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) =
set(property.name, value)
}

View File

@ -35,6 +35,9 @@
<string name="bundle_name_default">Default</string>
<string name="bundle_name_fallback">Unnamed</string>
<string name="android_11_bug_dialog_title">Android 11 bug</string>
<string name="android_11_bug_dialog_description">The app installation permission must be granted ahead of time to avoid a bug in the Android 11 system that will negatively affect the user experience.</string>
<string name="selected_app_meta_any_version">Any available version</string>
<string name="app_source_dialog_title">Select source</string>
<string name="app_source_dialog_option_auto">Auto</string>