diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 2060e60205..1d17e5ef61 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -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() + } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt index 3afbe6e8e5..7bad2debc3 100644 --- a/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt +++ b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt @@ -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, 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 } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/Session.kt b/app/src/main/java/app/revanced/manager/patcher/Session.kt index d1368f2408..dd5e7dc4b3 100644 --- a/app/src/main/java/app/revanced/manager/patcher/Session.kt +++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt @@ -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) = diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt index 3780e8997e..eb50bd35b9 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt index ada1d9437b..d7e9d342fe 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt @@ -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(it) }, msg) diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt index 434c97c660..7f4616bcd5 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt @@ -34,7 +34,7 @@ sealed class Runtime(context: Context) : KoinComponent { selectedPatches: PatchSelection, options: Options, logger: Logger, - onPatchCompleted: () -> Unit, + onPatchCompleted: suspend () -> Unit, onProgress: ProgressEventHandler, ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index 4bc0dfd98a..5096170caa 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -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?>, - val patchesProgress: MutableStateFlow>, + val onDownloadProgress: suspend (Pair?) -> 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 ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt index a31a813eab..2ae48ce6ff 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt @@ -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 } diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt index 280635cee6..d6c78263c2 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt @@ -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, stepCount: Pair? = 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? = 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,13 +188,11 @@ fun SubStep( onClick = null ) } - } else { - downloadProgress?.let { (current, total) -> - Text( - if (total != null) "${current.megaBytes}/${total.megaBytes} MB" else "${current.megaBytes} MB", - style = MaterialTheme.typography.labelSmall - ) - } + + progressText != null -> Text( + progressText, + style = MaterialTheme.typography.labelSmall + ) } } @@ -200,7 +208,7 @@ fun SubStep( } @Composable -fun StepIcon(state: State, progress: Pair? = 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? = null, size: Dp) { contentDescription = description } }, - progress = { - progress?.let { (current, total) -> - if (total == null) return@let null - current / total - }?.toFloat() - }, + progress = { progress }, strokeWidth = strokeWidth ) } diff --git a/app/src/main/java/app/revanced/manager/ui/model/InstallerModel.kt b/app/src/main/java/app/revanced/manager/ui/model/InstallerModel.kt new file mode 100644 index 0000000000..410b64c1bd --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/model/InstallerModel.kt @@ -0,0 +1,6 @@ +package app.revanced.manager.ui.model + +interface InstallerModel { + fun reinstall() + fun install() +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt index c08c823e4c..3dbb390e1d 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt @@ -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? +} + +@Parcelize data class Step( val name: String, val category: StepCategory, val state: State = State.WAITING, val message: String? = null, - val downloadProgress: StateFlow?>? = null -) \ No newline at end of file + val progressKey: ProgressKey? = null +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 6d3168999c..55fc771bc7 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -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() } @@ -316,4 +339,25 @@ 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)) + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt index 8d4b3bbc3b..d2195e9683 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt @@ -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 ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt index eaa66f47fd..538b85e33a 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index 99be81ecfa..303bd06a04 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -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) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt index 5ab42a89e3..c8577295c9 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt @@ -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(null) + val packageName = input.selectedApp.packageName + + var installedPackageName by savedStateHandle.saveable( + key = "installedPackageName", + // Force Kotlin to select the correct overload. + stateSaver = autoSaver() + ) { + mutableStateOf(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 + + var isInstalling by mutableStateOf(ongoingPmSession) private set - private var currentActivityRequest: Pair, String>? by mutableStateOf(null) + private var currentActivityRequest: Pair, String>? by mutableStateOf( + null + ) val activityPromptDialog by derivedStateOf { currentActivityRequest?.second } private var launchedActivity: CompletableDeferred? = null private val launchActivityChannel = Channel() val launchActivityFlow = launchActivityChannel.receiveAsFlow() - private val tempDir = fs.tempDir.resolve("installer").also { - it.deleteRecursively() - it.mkdirs() + 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>() + private val logs by savedStateHandle.saveable>> { 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?>(null) - val steps = generateSteps( - app, - input.selectedApp, - downloadProgress - ).toMutableStateList() + private val patchCount = input.selectedPatches.values.sumOf { it.size } + private var completedPatchCount by savedStateHandle.saveable { + // SavedStateHandle.saveable only supports the boxed version. + @Suppress("AutoboxingStateCreation") mutableStateOf( + 0 + ) + } + val patchesProgress get() = completedPatchCount to patchCount + override var downloadProgress by savedStateHandle.saveable( + key = "downloadProgress", + stateSaver = autoSaver() + ) { + mutableStateOf?>(null) + } + private set + val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) { + generateSteps( + app, + 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( + private val patcherWorkerId by savedStateHandle.saveable { + ParcelUuid(workerRepository.launchExpedited( "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,23 +460,33 @@ 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 { - 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") + override fun install() { + // InstallType.MOUNT is never used here since this overload is for the package installer status dialog. + install(InstallType.DEFAULT) + } - pm.installApp(listOf(outputFile)) - isInstalling = true + 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)) + 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?>? = null - ): List { + fun generateSteps(context: Context, selectedApp: SelectedApp): List { 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), diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index f137e69969..b484fc50f4 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -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 -> diff --git a/app/src/main/java/app/revanced/manager/util/RequestInstallAppsContract.kt b/app/src/main/java/app/revanced/manager/util/RequestInstallAppsContract.kt new file mode 100644 index 0000000000..b5060999a1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/RequestInstallAppsContract.kt @@ -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(), 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index ac0046df02..1359ae2401 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -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> typealias Options = Map>> @@ -260,4 +270,23 @@ fun ((T) -> R).withHapticFeedback(constant: Int): (T) -> R { } } -fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f) \ No newline at end of file +fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f) + +@MainThread +fun SavedStateHandle.saveableVar(init: () -> T): PropertyDelegateProvider> = + PropertyDelegateProvider { _: Any?, property -> + val name = property.name + if (name !in this) this[name] = init() + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = get(name)!! + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = + set(name, value) + } + } + +fun SavedStateHandle.saveableVar(): ReadWriteProperty = + object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T? = get(property.name) + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) = + set(property.name, value) + } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9ba4305c0..f88f1b1cf6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,9 @@ Default Unnamed + Android 11 bug + 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. + Any available version Select source Auto