From b4c37e6ddc396dd15731d861448dba6beff65ced Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Wed, 6 Nov 2024 12:48:40 -0800 Subject: [PATCH 01/21] feat: Add haptic feedback (#1457) Co-authored-by: Ushie --- .../manager/ui/component/AutoUpdatesDialog.kt | 6 +-- .../ui/component/AvailableUpdateDialog.kt | 3 +- .../ui/component/bundle/BaseBundleDialog.kt | 3 +- .../manager/ui/component/bundle/BundleItem.kt | 4 +- .../ui/component/bundle/ImportBundleDialog.kt | 29 +++-------- .../ui/component/haptics/HapticCheckbox.kt | 40 ++++++++++++++++ .../HapticExtendedFloatingActionButton.kt | 48 +++++++++++++++++++ .../haptics/HapticFloatingActionButton.kt | 44 +++++++++++++++++ .../ui/component/haptics/HapticRadioButton.kt | 42 ++++++++++++++++ .../ui/component/haptics/HapticSwitch.kt | 46 ++++++++++++++++++ .../manager/ui/component/haptics/HapticTab.kt | 43 +++++++++++++++++ .../component/patcher/InstallPickerDialog.kt | 10 ++-- .../ui/component/patches/OptionFields.kt | 47 +++++------------- .../ui/component/settings/BooleanItem.kt | 4 +- .../manager/ui/screen/DashboardScreen.kt | 8 ++-- .../manager/ui/screen/PatcherScreen.kt | 18 ++----- .../ui/screen/PatchesSelectorScreen.kt | 13 +++-- .../ui/screen/SelectedAppInfoScreen.kt | 17 ++----- .../ui/screen/VersionSelectorScreen.kt | 8 ++-- .../settings/DownloadsSettingsScreen.kt | 4 +- .../screen/settings/GeneralSettingsScreen.kt | 3 +- 21 files changed, 326 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt diff --git a/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt index 9da4f27f..1e2234eb 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt @@ -8,7 +8,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Source import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -24,6 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R +import app.revanced.manager.ui.component.haptics.HapticCheckbox @Composable fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) { @@ -76,6 +76,6 @@ private fun AutoUpdatesItem( ) = ListItem( leadingContent = { Icon(icon, null) }, headlineContent = { Text(stringResource(headline)) }, - trailingContent = { Checkbox(checked = checked, onCheckedChange = null) }, + trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) }, modifier = Modifier.clickable { onCheckedChange(!checked) } -) \ No newline at end of file +) diff --git a/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt index 7059ad0d..4a684c1e 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R +import app.revanced.manager.ui.component.haptics.HapticCheckbox @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,7 +71,7 @@ fun AvailableUpdateDialog( }, leadingContent = { CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { - Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it }) + HapticCheckbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it }) } } ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt index 4450ef5c..dfc63735 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.TextInputDialog +import app.revanced.manager.ui.component.haptics.HapticSwitch @Composable fun BaseBundleDialog( @@ -89,7 +90,7 @@ fun BaseBundleDialog( headlineText = stringResource(R.string.bundle_auto_update), supportingText = stringResource(R.string.bundle_auto_update_description), trailingContent = { - Switch( + HapticSwitch( checked = autoUpdate, onCheckedChange = onAutoUpdateChange ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt index 617c384f..2fdd8f5d 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import kotlinx.coroutines.flow.map @@ -71,7 +71,7 @@ fun BundleItem( ), leadingContent = if (selectable) { { - Checkbox( + HapticCheckbox( checked = isBundleSelected, onCheckedChange = toggleSelection, ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index 2de48a56..2de10053 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -10,26 +10,9 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Topic -import androidx.compose.material3.Checkbox -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.LocalMinimumInteractiveComponentEnforcement -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -37,6 +20,8 @@ import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.TextHorizontalPadding +import app.revanced.manager.ui.component.haptics.HapticCheckbox +import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.model.BundleType import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.JAR_MIMETYPE @@ -170,7 +155,7 @@ fun SelectBundleTypeStep( overlineContent = { Text(stringResource(R.string.recommended)) }, supportingContent = { Text(stringResource(R.string.remote_bundle_description)) }, leadingContent = { - RadioButton( + HapticRadioButton( selected = bundleType == BundleType.Remote, onClick = null ) @@ -186,7 +171,7 @@ fun SelectBundleTypeStep( supportingContent = { Text(stringResource(R.string.local_bundle_description)) }, overlineContent = { }, leadingContent = { - RadioButton( + HapticRadioButton( selected = bundleType == BundleType.Local, onClick = null ) @@ -263,7 +248,7 @@ fun ImportBundleStep( headlineContent = { Text(stringResource(R.string.auto_update)) }, leadingContent = { CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { - Checkbox( + HapticCheckbox( checked = autoUpdate, onCheckedChange = { onAutoUpdateChange(!autoUpdate) diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt new file mode 100644 index 00000000..fb98e40f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt @@ -0,0 +1,40 @@ +package app.revanced.manager.ui.component.haptics + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticCheckbox ( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: CheckboxColors = CheckboxDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + val checkedState = remember { mutableStateOf(checked) } + + // Perform haptic feedback + if (checkedState.value != checked) { + val view = LocalView.current + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + checkedState.value = checked + } + + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt new file mode 100644 index 00000000..f9d91caf --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt @@ -0,0 +1,48 @@ +package app.revanced.manager.ui.component.haptics + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticExtendedFloatingActionButton ( + text: @Composable () -> Unit, + icon: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + expanded: Boolean = true, + shape: Shape = FloatingActionButtonDefaults.extendedFabShape, + containerColor: Color = FloatingActionButtonDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val view = LocalView.current + + ExtendedFloatingActionButton( + text = text, + icon = icon, + onClick = { + // Perform haptic feedback + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + onClick() + }, + modifier = modifier, + expanded = expanded, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + interactionSource = interactionSource + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt new file mode 100644 index 00000000..0268accc --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt @@ -0,0 +1,44 @@ +package app.revanced.manager.ui.component.haptics + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticFloatingActionButton ( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = FloatingActionButtonDefaults.shape, + containerColor: Color = FloatingActionButtonDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + val view = LocalView.current + + FloatingActionButton( + onClick = { + // Perform haptic feedback + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + onClick() + }, + modifier = modifier, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + interactionSource = interactionSource, + content = content + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt new file mode 100644 index 00000000..7ac6a7a8 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt @@ -0,0 +1,42 @@ +package app.revanced.manager.ui.component.haptics + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonColors +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticRadioButton ( + selected: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: RadioButtonColors = RadioButtonDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + val selectedState = remember { mutableStateOf(selected) } + + // Perform haptic feedback + if (selectedState.value != selected) { + if (selected) { + val view = LocalView.current + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + } + selectedState.value = selected + } + + RadioButton( + selected = selected, + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt new file mode 100644 index 00000000..fa3e894b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt @@ -0,0 +1,46 @@ +package app.revanced.manager.ui.component.haptics +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit), + modifier: Modifier = Modifier, + thumbContent: (@Composable () -> Unit)? = null, + enabled: Boolean = true, + colors: SwitchColors = SwitchDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val checkedState = remember { mutableStateOf(checked) } + + // Perform haptic feedback + if (checkedState.value != checked) { + val view = LocalView.current + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + view.performHapticFeedback(if (checked) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF) + } else { + view.performHapticFeedback(if (checked) HapticFeedbackConstants.VIRTUAL_KEY else HapticFeedbackConstants.CLOCK_TICK) + } + checkedState.value = checked + } + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + thumbContent = thumbContent, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt new file mode 100644 index 00000000..3b5a11e9 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt @@ -0,0 +1,43 @@ +package app.revanced.manager.ui.component.haptics + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Tab +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView + +@Composable +fun HapticTab ( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: @Composable (() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + selectedContentColor: Color = LocalContentColor.current, + unselectedContentColor: Color = selectedContentColor, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + val view = LocalView.current + + Tab( + selected = selected, + onClick = { + // Perform haptic feedback + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + onClick() + }, + modifier = modifier, + enabled = enabled, + text = text, + icon = icon, + selectedContentColor = selectedContentColor, + unselectedContentColor = unselectedContentColor, + interactionSource = interactionSource + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt index e331db2e..bb667bad 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt @@ -2,12 +2,7 @@ package app.revanced.manager.ui.component.patcher import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ListItem -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -17,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType +import app.revanced.manager.ui.component.haptics.HapticRadioButton @Composable fun InstallPickerDialog( @@ -49,7 +45,7 @@ fun InstallPickerDialog( ListItem( modifier = Modifier.clickable { selectedInstallType = it }, leadingContent = { - RadioButton( + HapticRadioButton( selected = selectedInstallType == it, onClick = null ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index 433c711b..993270ea 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -20,53 +20,31 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.Restore -import androidx.compose.material.icons.outlined.SelectAll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TextButton +import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog as ComposeDialog import androidx.compose.ui.window.DialogProperties import app.revanced.manager.R import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.patcher.patch.Option -import app.revanced.manager.ui.component.AlertDialogExtended -import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.FloatInputDialog -import app.revanced.manager.ui.component.IntInputDialog -import app.revanced.manager.ui.component.LongInputDialog +import app.revanced.manager.ui.component.* +import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton +import app.revanced.manager.ui.component.haptics.HapticRadioButton +import app.revanced.manager.ui.component.haptics.HapticSwitch import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.mutableStateSetOf import app.revanced.manager.util.saver.snapshotStateListSaver @@ -80,6 +58,7 @@ import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyColumnState import java.io.Serializable import kotlin.random.Random +import androidx.compose.ui.window.Dialog as ComposeDialog private class OptionEditorScope( private val editor: OptionEditor, @@ -335,7 +314,7 @@ private object BooleanOptionEditor : OptionEditor { @Composable override fun ListItemTrailingContent(scope: OptionEditorScope) { - Switch(checked = scope.current, onCheckedChange = scope.setValue) + HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue) } @Composable @@ -422,7 +401,7 @@ private class PresetOptionEditor(private val innerEditor: OptionEditor< headlineContent = { Text(title) }, supportingContent = value?.toString()?.let { { Text(it) } }, leadingContent = { - RadioButton( + HapticRadioButton( selected = selectedPreset == presetKey, onClick = { selectedPreset = presetKey } ) @@ -568,7 +547,7 @@ private class ListOptionEditor(private val elementEditor: Opti floatingActionButton = { if (deleteMode) return@Scaffold - ExtendedFloatingActionButton( + HapticExtendedFloatingActionButton( text = { Text(stringResource(R.string.add)) }, icon = { Icon( diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt index 42e9a83e..0be1be91 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt @@ -2,13 +2,13 @@ package app.revanced.manager.ui.component.settings import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import app.revanced.manager.domain.manager.base.Preference +import app.revanced.manager.ui.component.haptics.HapticSwitch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -45,7 +45,7 @@ fun BooleanItem( headlineContent = stringResource(headline), supportingContent = stringResource(description), trailingContent = { - Switch( + HapticSwitch( checked = value, onCheckedChange = onValueChange, ) 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 bf310fc7..157986ec 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 @@ -33,6 +33,8 @@ import app.revanced.manager.ui.component.AvailableUpdateDialog import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.component.bundle.BundleItem import app.revanced.manager.ui.component.bundle.BundleTopBar +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.toast @@ -168,7 +170,7 @@ fun DashboardScreen( } }, floatingActionButton = { - FloatingActionButton( + HapticFloatingActionButton( onClick = { vm.cancelSourceSelection() @@ -181,7 +183,7 @@ fun DashboardScreen( DashboardPage.BUNDLES.ordinal ) } - return@FloatingActionButton + return@HapticFloatingActionButton } onAppSelectorClick() @@ -201,7 +203,7 @@ fun DashboardScreen( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) ) { DashboardPage.entries.forEachIndexed { index, page -> - Tab( + HapticTab( selected = pagerState.currentPage == index, onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } }, text = { Text(stringResource(page.titleResId)) }, 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 2c7792d0..6aedde2f 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 @@ -4,12 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -17,13 +12,7 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.PostAdd import androidx.compose.material.icons.outlined.Save -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -42,6 +31,7 @@ import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppTopBar 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 @@ -121,7 +111,7 @@ fun PatcherScreen( }, floatingActionButton = { AnimatedVisibility(visible = canInstall) { - ExtendedFloatingActionButton( + HapticExtendedFloatingActionButton( text = { Text( stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index f8109fab..511ede21 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -35,6 +35,9 @@ import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.SafeguardDialog import app.revanced.manager.ui.component.SearchView +import app.revanced.manager.ui.component.haptics.HapticCheckbox +import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton +import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED @@ -293,7 +296,7 @@ fun PatchesSelectorScreen( floatingActionButton = { if (!showPatchButton) return@Scaffold - ExtendedFloatingActionButton( + HapticExtendedFloatingActionButton( text = { Text(stringResource(R.string.save)) }, icon = { Icon( @@ -321,7 +324,7 @@ fun PatchesSelectorScreen( containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) ) { bundles.forEachIndexed { index, bundle -> - Tab( + HapticTab( selected = pagerState.currentPage == index, onClick = { composableScope.launch { @@ -432,13 +435,13 @@ private fun PatchItem( selected: Boolean, onToggle: () -> Unit, supported: Boolean = true -) = ListItem( +) = ListItem ( modifier = Modifier .let { if (!supported) it.alpha(0.5f) else it } .clickable(onClick = onToggle) .fillMaxSize(), leadingContent = { - Checkbox( + HapticCheckbox( checked = selected, onCheckedChange = { onToggle() }, enabled = supported @@ -452,7 +455,7 @@ private fun PatchItem( Icon(Icons.Outlined.Settings, null) } } - } + }, ) @Composable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index c4bfc841..9c3f59d4 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -8,13 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.filled.AutoFixHigh -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -28,6 +22,7 @@ import app.revanced.manager.R 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.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.SelectedApp @@ -36,11 +31,7 @@ import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.toast -import dev.olshevski.navigation.reimagined.AnimatedNavHost -import dev.olshevski.navigation.reimagined.NavBackHandler -import dev.olshevski.navigation.reimagined.navigate -import dev.olshevski.navigation.reimagined.pop -import dev.olshevski.navigation.reimagined.rememberNavController +import dev.olshevski.navigation.reimagined.* import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf @@ -161,7 +152,7 @@ private fun SelectedAppInfoScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( + HapticExtendedFloatingActionButton( text = { Text(stringResource(R.string.patch)) }, icon = { Icon( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index 2ca8baa6..cd18dcc7 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -11,10 +11,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton 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 @@ -34,6 +32,8 @@ import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LoadingIndicator +import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton +import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.NonSuggestedVersionDialog import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel @@ -81,7 +81,7 @@ fun VersionSelectorScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( + HapticExtendedFloatingActionButton( text = { Text(stringResource(R.string.select_version)) }, icon = { Icon( @@ -170,7 +170,7 @@ fun SelectedAppItem( alreadyPatched: Boolean = false, ) { ListItem( - leadingContent = { RadioButton(selected, null) }, + leadingContent = { HapticRadioButton(selected, null) }, headlineContent = { Text(selectedApp.version) }, supportingContent = when (selectedApp) { is SelectedApp.Installed -> diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 432c4808..41e80b40 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,6 +19,7 @@ import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel @@ -70,7 +70,7 @@ fun DownloadsSettingsScreen( modifier = Modifier.clickable { viewModel.toggleItem(app) }, headlineContent = app.packageName, leadingContent = (@Composable { - Checkbox( + HapticCheckbox( checked = selected, onCheckedChange = { viewModel.toggleItem(app) } ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt index 03eae09f..56242679 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt @@ -28,6 +28,7 @@ import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.theme.Theme @@ -113,7 +114,7 @@ private fun ThemePicker( .clickable { selectedTheme = it }, verticalAlignment = Alignment.CenterVertically ) { - RadioButton( + HapticRadioButton( selected = selectedTheme == it, onClick = { selectedTheme = it }) Text(stringResource(it.displayName)) From cf322147d5e16d816508c9b015eaed73a318275d Mon Sep 17 00:00:00 2001 From: Ax333l Date: Tue, 12 Nov 2024 21:17:02 +0100 Subject: [PATCH 02/21] fix: only perform haptics on events --- .../ui/component/haptics/HapticCheckbox.kt | 16 ++-------- .../HapticExtendedFloatingActionButton.kt | 11 ++----- .../haptics/HapticFloatingActionButton.kt | 11 ++----- .../ui/component/haptics/HapticRadioButton.kt | 22 ++++++-------- .../ui/component/haptics/HapticSwitch.kt | 29 ++++++++----------- .../manager/ui/component/haptics/HapticTab.kt | 11 ++----- .../screen/settings/AdvancedSettingsScreen.kt | 8 ++--- .../java/app/revanced/manager/util/Util.kt | 24 ++++++++++++++- 8 files changed, 56 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt index fb98e40f..fb5453f9 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt @@ -6,13 +6,12 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxColors import androidx.compose.material3.CheckboxDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView +import app.revanced.manager.util.withHapticFeedback @Composable -fun HapticCheckbox ( +fun HapticCheckbox( checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, @@ -20,18 +19,9 @@ fun HapticCheckbox ( colors: CheckboxColors = CheckboxDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - val checkedState = remember { mutableStateOf(checked) } - - // Perform haptic feedback - if (checkedState.value != checked) { - val view = LocalView.current - view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) - checkedState.value = checked - } - Checkbox( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = onCheckedChange?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK), modifier = modifier, enabled = enabled, colors = colors, diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt index f9d91caf..4fc6ad30 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalView +import app.revanced.manager.util.withHapticFeedback @Composable fun HapticExtendedFloatingActionButton ( @@ -26,17 +26,10 @@ fun HapticExtendedFloatingActionButton ( elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val view = LocalView.current - ExtendedFloatingActionButton( text = text, icon = icon, - onClick = { - // Perform haptic feedback - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - - onClick() - }, + onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY), modifier = modifier, expanded = expanded, shape = shape, diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt index 0268accc..f4a2e153 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalView +import app.revanced.manager.util.withHapticFeedback @Composable fun HapticFloatingActionButton ( @@ -24,15 +24,8 @@ fun HapticFloatingActionButton ( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable () -> Unit, ) { - val view = LocalView.current - FloatingActionButton( - onClick = { - // Perform haptic feedback - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - - onClick() - }, + onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY), modifier = modifier, shape = shape, containerColor = containerColor, diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt index 7ac6a7a8..63a9e582 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt @@ -6,13 +6,12 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonColors import androidx.compose.material3.RadioButtonDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView @Composable -fun HapticRadioButton ( +fun HapticRadioButton( selected: Boolean, onClick: (() -> Unit)?, modifier: Modifier = Modifier, @@ -20,20 +19,17 @@ fun HapticRadioButton ( colors: RadioButtonColors = RadioButtonDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - val selectedState = remember { mutableStateOf(selected) } - - // Perform haptic feedback - if (selectedState.value != selected) { - if (selected) { - val view = LocalView.current - view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) - } - selectedState.value = selected - } + val view = LocalView.current RadioButton( selected = selected, - onClick = onClick, + onClick = onClick?.let { + { + // Perform haptic feedback + if (!selected) view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + it() + } + }, modifier = modifier, enabled = enabled, colors = colors, diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt index fa3e894b..c2491397 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt @@ -1,4 +1,5 @@ package app.revanced.manager.ui.component.haptics + import android.os.Build import android.view.HapticFeedbackConstants import androidx.compose.foundation.interaction.MutableInteractionSource @@ -6,37 +7,31 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchColors import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView @Composable fun HapticSwitch( checked: Boolean, - onCheckedChange: ((Boolean) -> Unit), + onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, thumbContent: (@Composable () -> Unit)? = null, enabled: Boolean = true, colors: SwitchColors = SwitchDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val checkedState = remember { mutableStateOf(checked) } - - // Perform haptic feedback - if (checkedState.value != checked) { - val view = LocalView.current - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - view.performHapticFeedback(if (checked) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF) - } else { - view.performHapticFeedback(if (checked) HapticFeedbackConstants.VIRTUAL_KEY else HapticFeedbackConstants.CLOCK_TICK) - } - checkedState.value = checked - } - Switch( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = { newChecked -> + val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + when { + newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON + newChecked -> HapticFeedbackConstants.VIRTUAL_KEY + !newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF + !newChecked -> HapticFeedbackConstants.CLOCK_TICK + } + onCheckedChange(newChecked) + }, modifier = modifier, thumbContent = thumbContent, enabled = enabled, diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt index 3b5a11e9..d0676951 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView +import app.revanced.manager.util.withHapticFeedback @Composable fun HapticTab ( @@ -22,16 +22,9 @@ fun HapticTab ( unselectedContentColor: Color = selectedContentColor, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - val view = LocalView.current - Tab( selected = selected, - onClick = { - // Perform haptic feedback - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - - onClick() - }, + onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY), modifier = modifier, enabled = enabled, text = text, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index fa8cae28..9082f4bb 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -4,6 +4,7 @@ import android.app.ActivityManager import android.content.ClipData import android.content.ClipboardManager import android.os.Build +import android.view.HapticFeedbackConstants import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi @@ -17,9 +18,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -35,6 +34,7 @@ import app.revanced.manager.ui.component.settings.IntegerItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel import app.revanced.manager.util.toast +import app.revanced.manager.util.withHapticFeedback import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -52,7 +52,6 @@ fun AdvancedSettingsScreen( activityManager.largeMemoryClass ) } - val haptics = LocalHapticFeedback.current Scaffold( topBar = { @@ -159,13 +158,12 @@ fun AdvancedSettingsScreen( onClick = { }, onLongClickLabel = stringResource(R.string.copy_to_clipboard), onLongClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) clipboard.setPrimaryClip( ClipData.newPlainText("Device Information", deviceContent) ) context.toast(context.getString(R.string.toast_copied_to_clipboard)) - } + }.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS) ), headlineContent = stringResource(R.string.about_device), supportingContent = deviceContent 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 f1de38fd..bc48c54a 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -14,6 +14,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -21,6 +22,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -218,4 +220,24 @@ fun ScrollState.isScrollingUp(): State { } val LazyListState.isScrollingUp: Boolean @Composable get() = this.isScrollingUp().value -val ScrollState.isScrollingUp: Boolean @Composable get() = this.isScrollingUp().value \ No newline at end of file +val ScrollState.isScrollingUp: Boolean @Composable get() = this.isScrollingUp().value + +@Composable +@ReadOnlyComposable +fun (() -> R).withHapticFeedback(constant: Int): () -> R { + val view = LocalView.current + return { + view.performHapticFeedback(constant) + this() + } +} + +@Composable +@ReadOnlyComposable +fun ((T) -> R).withHapticFeedback(constant: Int): (T) -> R { + val view = LocalView.current + return { + view.performHapticFeedback(constant) + this(it) + } +} \ No newline at end of file From 20c13ee71cb3ed868a0a9375e72d8e9660c10c75 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Wed, 13 Nov 2024 22:11:36 +0100 Subject: [PATCH 03/21] chore: update dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🦀 integrations are gone! 🦀 --- app/build.gradle.kts | 21 +++--- .../1.json | 24 +++---- .../data/room/bundles/PatchBundleDao.kt | 8 +-- .../data/room/bundles/PatchBundleEntity.kt | 9 +-- .../manager/data/room/options/Option.kt | 23 ++++--- .../domain/bundles/LocalPatchBundle.kt | 19 ++---- .../domain/bundles/PatchBundleSource.kt | 9 ++- .../domain/bundles/RemotePatchBundle.kt | 67 +++++-------------- .../manager/domain/installer/RootInstaller.kt | 7 +- .../manager/domain/manager/KeystoreManager.kt | 16 +++-- .../domain/manager/PreferencesManager.kt | 1 - .../PatchBundlePersistenceRepository.kt | 9 ++- .../repository/PatchBundleRepository.kt | 8 +-- .../manager/network/dto/BundleInfo.kt | 9 --- .../manager/network/dto/PatchBundleInfo.kt | 7 ++ .../app/revanced/manager/patcher/Session.kt | 11 ++- .../manager/patcher/patch/PatchBundle.kt | 10 +-- .../manager/patcher/patch/PatchInfo.kt | 49 +++++--------- .../patcher/runtime/CoroutineRuntime.kt | 8 +-- .../manager/patcher/runtime/ProcessRuntime.kt | 4 +- .../manager/patcher/runtime/Runtime.kt | 1 - .../patcher/runtime/process/Parameters.kt | 2 - .../patcher/runtime/process/PatcherProcess.kt | 9 +-- .../manager/patcher/worker/PatcherWorker.kt | 2 +- .../manager/ui/component/AutoUpdatesDialog.kt | 4 +- .../bundle/BundleInformationDialog.kt | 2 +- .../manager/ui/component/bundle/BundleItem.kt | 2 +- .../ui/component/bundle/ImportBundleDialog.kt | 52 ++++---------- .../component/patcher/InstallPickerDialog.kt | 6 +- .../ui/component/patches/OptionFields.kt | 29 ++++---- .../manager/ui/screen/AppSelectorScreen.kt | 5 +- .../manager/ui/screen/DashboardScreen.kt | 4 +- .../ui/screen/PatchesSelectorScreen.kt | 4 +- .../ui/screen/VersionSelectorScreen.kt | 2 +- .../screen/settings/AdvancedSettingsScreen.kt | 6 -- .../ui/viewmodel/AppSelectorViewModel.kt | 2 +- .../ui/viewmodel/DashboardViewModel.kt | 7 +- .../manager/ui/viewmodel/PatcherViewModel.kt | 6 +- .../app/revanced/manager/util/Constants.kt | 10 +-- .../main/java/app/revanced/manager/util/PM.kt | 4 +- .../java/app/revanced/manager/util/Util.kt | 14 +++- app/src/main/res/values/strings.xml | 6 -- build.gradle.kts | 3 + gradle/libs.versions.toml | 35 +++++----- gradle/wrapper/gradle-wrapper.properties | 3 +- 45 files changed, 223 insertions(+), 316 deletions(-) delete mode 100644 app/src/main/java/app/revanced/manager/network/dto/BundleInfo.kt create mode 100644 app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70281945..9f04a1b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,21 +3,22 @@ import kotlin.random.Random plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose.compiler) alias(libs.plugins.devtools) alias(libs.plugins.about.libraries) - id("kotlin-parcelize") - kotlin("plugin.serialization") version "1.9.23" } android { namespace = "app.revanced.manager" - compileSdk = 34 - buildToolsVersion = "34.0.0" + compileSdk = 35 + buildToolsVersion = "35.0.0" defaultConfig { applicationId = "app.revanced.manager" minSdk = 26 - targetSdk = 34 + targetSdk = 35 versionCode = 1 versionName = "0.0.1" vectorDrawables.useSupportLibrary = true @@ -81,9 +82,11 @@ android { jvmTarget = "17" } - buildFeatures.compose = true - buildFeatures.aidl = true - buildFeatures.buildConfig=true + buildFeatures { + compose = true + aidl = true + buildConfig = true + } android { androidResources { @@ -91,7 +94,6 @@ android { } } - composeOptions.kotlinCompilerExtensionVersion = "1.5.10" externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") @@ -112,7 +114,6 @@ dependencies { implementation(libs.runtime.compose) implementation(libs.splash.screen) implementation(libs.compose.activity) - implementation(libs.paging.common.ktx) implementation(libs.work.runtime.ktx) implementation(libs.preferences.datastore) diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index e9c0fd3a..eff10786 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46", + "identityHash": "c385297c07ea54804dc8526c388f706d", "entities": [ { "tableName": "patch_bundles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `version` TEXT, `integrations_version` TEXT, PRIMARY KEY(`uid`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, PRIMARY KEY(`uid`))", "fields": [ { "fieldPath": "uid", @@ -20,6 +20,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "source", "columnName": "source", @@ -31,18 +37,6 @@ "columnName": "auto_update", "affinity": "INTEGER", "notNull": true - }, - { - "fieldPath": "versionInfo.patches", - "columnName": "version", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "versionInfo.integrations", - "columnName": "integrations_version", - "affinity": "TEXT", - "notNull": false } ], "primaryKey": { @@ -397,7 +391,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dd9d5c0201fdf3cfef3ae669fd65e46')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c385297c07ea54804dc8526c388f706d')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index 77de9b03..d9955a70 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -8,11 +8,11 @@ interface PatchBundleDao { @Query("SELECT * FROM patch_bundles") suspend fun all(): List - @Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid") + @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid") fun getPropsById(uid: Int): Flow - @Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid") - suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) + @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid") + suspend fun updateVersion(uid: Int, patches: String?) @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") suspend fun setAutoUpdate(uid: Int, value: Boolean) @@ -26,7 +26,7 @@ interface PatchBundleDao { @Transaction suspend fun reset() { purgeCustomBundles() - updateVersion(0, null, null) // Reset the main source + updateVersion(0, null) // Reset the main source } @Query("DELETE FROM patch_bundles WHERE uid = :uid") diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index d120abf5..8ba5f64a 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt @@ -29,21 +29,16 @@ sealed class Source { } } -data class VersionInfo( - @ColumnInfo(name = "version") val patches: String? = null, - @ColumnInfo(name = "integrations_version") val integrations: String? = null, -) - @Entity(tableName = "patch_bundles") data class PatchBundleEntity( @PrimaryKey val uid: Int, @ColumnInfo(name = "name") val name: String, - @Embedded val versionInfo: VersionInfo, + @ColumnInfo(name = "version") val version: String? = null, @ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean ) data class BundleProperties( - @Embedded val versionInfo: VersionInfo, + @ColumnInfo(name = "version") val version: String? = null, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/options/Option.kt b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt index b59dbd16..44bc3d40 100644 --- a/app/src/main/java/app/revanced/manager/data/room/options/Option.kt +++ b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt @@ -20,6 +20,8 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.long import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.typeOf @Entity( tableName = "options", @@ -46,8 +48,8 @@ data class Option( val errorMessage = "Cannot deserialize value as ${option.type}" try { - if (option.type.endsWith("Array")) { - val elementType = option.type.removeSuffix("Array") + if (option.type.classifier == List::class) { + val elementType = option.type.arguments.first().type!! return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) } } @@ -67,12 +69,17 @@ data class Option( allowSpecialFloatingPointValues = true } - private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) { - "Boolean" -> value.boolean - "Int" -> value.int - "Long" -> value.long - "Float" -> value.float - "String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") } + private fun deserializeBasicType(type: KType, value: JsonPrimitive) = when (type) { + typeOf() -> value.boolean + typeOf() -> value.int + typeOf() -> value.long + typeOf() -> value.float + typeOf() -> value.content.also { + if (!value.isString) throw SerializationException( + "Expected value to be a string: $value" + ) + } + else -> throw SerializationException("Unknown type: $type") } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt index 1d8b41f3..bcbc59cf 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt @@ -4,29 +4,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream -import java.nio.file.Files -import java.nio.file.StandardCopyOption class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) { - suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) { + suspend fun replace(patches: InputStream) { withContext(Dispatchers.IO) { - patches?.let { inputStream -> - patchBundleOutputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - integrations?.let { - Files.copy( - it, - this@LocalPatchBundle.integrationsFile.toPath(), - StandardCopyOption.REPLACE_EXISTING - ) + patchBundleOutputStream().use { outputStream -> + patches.copyTo(outputStream) } } reload()?.also { - saveVersion(it.readManifestAttribute("Version"), null) + saveVersion(it.readManifestAttribute("Version")) } } } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index 1ded6d43..308e2a56 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -28,7 +28,6 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil protected val configRepository: PatchBundlePersistenceRepository by inject() private val app: Application by inject() protected val patchesFile = directory.resolve("patches.jar") - protected val integrationsFile = directory.resolve("integrations.apk") private val _state = MutableStateFlow(load()) val state = _state.asStateFlow() @@ -58,7 +57,7 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil if (!hasInstalled()) return State.Missing return try { - State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists))) + State.Loaded(PatchBundle(patchesFile)) } catch (t: Throwable) { Log.e(tag, "Failed to load patch bundle with UID $uid", t) State.Failed(t) @@ -85,9 +84,9 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default) suspend fun getProps() = propsFlow().first()!! - suspend fun currentVersion() = getProps().versionInfo - protected suspend fun saveVersion(patches: String?, integrations: String?) = - configRepository.updateVersion(uid, patches, integrations) + suspend fun currentVersion() = getProps().version + protected suspend fun saveVersion(version: String?) = + configRepository.updateVersion(uid, version) suspend fun setName(name: String) { configRepository.setName(uid, name) diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 8bbc230d..e3214db9 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -1,20 +1,12 @@ package app.revanced.manager.domain.bundles import androidx.compose.runtime.Stable -import app.revanced.manager.data.room.bundles.VersionInfo import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType -import app.revanced.manager.network.dto.BundleAsset -import app.revanced.manager.network.dto.BundleInfo +import app.revanced.manager.network.dto.PatchBundleInfo import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.utils.getOrThrow -import app.revanced.manager.util.APK_MIMETYPE -import app.revanced.manager.util.JAR_MIMETYPE import io.ktor.client.request.url import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.inject import java.io.File @@ -24,27 +16,17 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo PatchBundleSource(name, id, directory) { protected val http: HttpService by inject() - protected abstract suspend fun getLatestInfo(): BundleInfo + protected abstract suspend fun getLatestInfo(): PatchBundleInfo - private suspend fun download(info: BundleInfo) = withContext(Dispatchers.IO) { - val (patches, integrations) = info - coroutineScope { - launch { - patchBundleOutputStream().use { - http.streamTo(it) { - url(patches.url) - } - } - } - - launch { - http.download(integrationsFile) { - url(integrations.url) - } + private suspend fun download(info: PatchBundleInfo) = withContext(Dispatchers.IO) { + val (version, url) = info + patchBundleOutputStream().use { + http.streamTo(it) { + url(url) } } - saveVersion(patches.version, integrations.version) + saveVersion(version) reload() } @@ -54,20 +36,15 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo suspend fun update(): Boolean = withContext(Dispatchers.IO) { val info = getLatestInfo() - if (hasInstalled() && VersionInfo( - info.patches.version, - info.integrations.version - ) == currentVersion() - ) { + if (hasInstalled() && info.version == currentVersion()) return@withContext false - } download(info) true } suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) { - arrayOf(patchesFile, integrationsFile).forEach(File::delete) + patchesFile.delete() reload() } @@ -81,7 +58,7 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : RemotePatchBundle(name, id, directory, endpoint) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { - http.request { + http.request { url(endpoint) }.getOrThrow() } @@ -91,22 +68,10 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) : RemotePatchBundle(name, id, directory, endpoint) { private val api: ReVancedAPI by inject() - override suspend fun getLatestInfo() = coroutineScope { - fun getAssetAsync(repo: String, mime: String) = async(Dispatchers.IO) { - api - .getLatestRelease(repo) - .getOrThrow() - .let { - BundleAsset(it.version, it.findAssetByType(mime).downloadUrl) - } + override suspend fun getLatestInfo() = api + .getLatestRelease("revanced-patches") + .getOrThrow() + .let { + PatchBundleInfo(it.version, it.assets.first { it.name.endsWith(".rvp") }.downloadUrl) } - - val patches = getAssetAsync("revanced-patches", JAR_MIMETYPE) - val integrations = getAssetAsync("revanced-integrations", APK_MIMETYPE) - - BundleInfo( - patches.await(), - integrations.await() - ) - } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt index 885f8ad1..293484ca 100644 --- a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt +++ b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt @@ -109,7 +109,12 @@ class RootInstaller( stockAPK?.let { stockApp -> pm.getPackageInfo(packageName)?.let { packageInfo -> - if (packageInfo.versionName <= version) + // TODO: get user id programmatically + if (pm.getVersionCode(packageInfo) <= pm.getVersionCode( + pm.getPackageInfo(patchedAPK) + ?: error("Failed to get package info for patched app") + ) + ) execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app") } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt index c1c4700d..4f9dc5a3 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt @@ -12,6 +12,8 @@ import java.io.InputStream import java.io.OutputStream import java.nio.file.Files import java.security.UnrecoverableKeyException +import java.util.Date +import kotlin.time.Duration.Companion.days class KeystoreManager(app: Application, private val prefs: PreferencesManager) { companion object Constants { @@ -19,6 +21,7 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) { * Default alias and password for the keystore. */ const val DEFAULT = "ReVanced" + private val eightYearsFromNow get() = Date(System.currentTimeMillis() + (365.days * 8).inWholeMilliseconds * 24) } private val keystorePath = @@ -29,23 +32,26 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) { prefs.keystorePass.value = pass } - private suspend fun signingOptions(path: File = keystorePath) = ApkUtils.SigningOptions( + private suspend fun signingDetails(path: File = keystorePath) = ApkUtils.KeyStoreDetails( keyStore = path, keyStorePassword = null, alias = prefs.keystoreCommonName.get(), - signer = prefs.keystoreCommonName.get(), password = prefs.keystorePass.get() ) suspend fun sign(input: File, output: File) = withContext(Dispatchers.Default) { - ApkUtils.sign(input, output, signingOptions()) + ApkUtils.signApk(input, output, prefs.keystoreCommonName.get(), signingDetails()) } suspend fun regenerate() = withContext(Dispatchers.Default) { + val keyCertPair = ApkSigner.newPrivateKeyCertificatePair( + prefs.keystoreCommonName.get(), + eightYearsFromNow + ) val ks = ApkSigner.newKeyStore( setOf( ApkSigner.KeyStoreEntry( - DEFAULT, DEFAULT + DEFAULT, DEFAULT, keyCertPair ) ) ) @@ -64,7 +70,7 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) { try { val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null) - ApkSigner.readKeyCertificatePair(ks, cn, pass) + ApkSigner.readPrivateKeyCertificatePair(ks, cn, pass) } catch (_: UnrecoverableKeyException) { return false } catch (_: IllegalArgumentException) { diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 548df41a..8cdc1f19 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -12,7 +12,6 @@ class PreferencesManager( val api = stringPreference("api_url", "https://api.revanced.app") - val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true) val useProcessRuntime = booleanPreference("use_process_runtime", false) val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt index 4b853ecf..5711d997 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt @@ -4,7 +4,6 @@ import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.Source -import app.revanced.manager.data.room.bundles.VersionInfo import kotlinx.coroutines.flow.distinctUntilChanged class PatchBundlePersistenceRepository(db: AppDatabase) { @@ -26,7 +25,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) { PatchBundleEntity( uid = generateUid(), name = name, - versionInfo = VersionInfo(), + version = null, source = source, autoUpdate = autoUpdate ).also { @@ -35,8 +34,8 @@ class PatchBundlePersistenceRepository(db: AppDatabase) { suspend fun delete(uid: Int) = dao.remove(uid) - suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) = - dao.updateVersion(uid, patches, integrations) + suspend fun updateVersion(uid: Int, version: String?) = + dao.updateVersion(uid, version) suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value) @@ -48,7 +47,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) { val defaultSource = PatchBundleEntity( uid = 0, name = "", - versionInfo = VersionInfo(), + version = null, source = Source.API, autoUpdate = false ) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index f40d6c0b..79bb5cea 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -3,7 +3,7 @@ package app.revanced.manager.domain.repository import android.app.Application import android.content.Context import android.util.Log -import app.revanced.library.PatchUtils +import app.revanced.library.mostCommonCompatibleVersions import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.room.bundles.PatchBundleEntity @@ -55,7 +55,7 @@ class PatchBundleRepository( val allPatches = it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet() - PatchUtils.getMostCommonCompatibleVersions(allPatches, countUnusedPatches = true) + allPatches.mostCommonCompatibleVersions(countUnusedPatches = true) .mapValues { (_, versions) -> if (versions.keys.size < 2) return@mapValues versions.keys.firstOrNull() @@ -137,11 +137,11 @@ class PatchBundleRepository( private fun addBundle(patchBundle: PatchBundleSource) = _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } } - suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) { + suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) { val uid = persistenceRepo.create("", SourceInfo.Local).uid val bundle = LocalPatchBundle("", uid, directoryOf(uid)) - bundle.replace(patches, integrations) + bundle.replace(patches) addBundle(bundle) } diff --git a/app/src/main/java/app/revanced/manager/network/dto/BundleInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/BundleInfo.kt deleted file mode 100644 index e2b56a87..00000000 --- a/app/src/main/java/app/revanced/manager/network/dto/BundleInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.revanced.manager.network.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class BundleInfo(val patches: BundleAsset, val integrations: BundleAsset) - -@Serializable -data class BundleAsset(val version: String, val url: String) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt new file mode 100644 index 00000000..02d89919 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt @@ -0,0 +1,7 @@ +package app.revanced.manager.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +// TODO: replace this +data class PatchBundleInfo(val version: String, val url: String) \ 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 4393794d..d1368f24 100644 --- a/app/src/main/java/app/revanced/manager/patcher/Session.kt +++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt @@ -22,7 +22,6 @@ class Session( cacheDir: String, frameworkDir: String, aaptPath: String, - multithreadingDexFileWriter: Boolean, private val androidContext: Context, private val logger: Logger, private val input: File, @@ -38,8 +37,7 @@ class Session( apkFile = input, temporaryFilesPath = tempDir, frameworkFileDirectory = frameworkDir, - aaptBinaryPath = aaptPath, - multithreadingDexFileWriter = multithreadingDexFileWriter, + aaptBinaryPath = aaptPath ) ) @@ -51,7 +49,7 @@ class Session( state = State.RUNNING ) - this.apply(true).collect { (patch, exception) -> + this().collect { (patch, exception) -> if (patch !in selectedPatches) return@collect if (exception != null) { @@ -89,7 +87,7 @@ class Session( ) } - suspend fun run(output: File, selectedPatches: PatchList, integrations: List) { + suspend fun run(output: File, selectedPatches: PatchList) { updateProgress(state = State.COMPLETED) // Unpacking java.util.logging.Logger.getLogger("").apply { @@ -103,8 +101,7 @@ class Session( with(patcher) { logger.info("Merging integrations") - acceptIntegrations(integrations.toSet()) - acceptPatches(selectedPatches.toSet()) + this += selectedPatches.toSet() logger.info("Applying patches...") applyPatchesVerbose(selectedPatches.sortedBy { it.name }) diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt index 8dbcf153..2b93a829 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt @@ -2,17 +2,17 @@ package app.revanced.manager.patcher.patch import android.util.Log import app.revanced.manager.util.tag -import app.revanced.patcher.PatchBundleLoader import app.revanced.patcher.patch.Patch +import app.revanced.patcher.patch.PatchLoader import java.io.File import java.io.IOException import java.util.jar.JarFile -class PatchBundle(val patchesJar: File, val integrations: File?) { +class PatchBundle(val patchesJar: File) { private val loader = object : Iterable> { private fun load(): Iterable> { patchesJar.setReadOnly() - return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null) + return PatchLoader.Dex(setOf(patchesJar)) } override fun iterator(): Iterator> = load().iterator() @@ -41,12 +41,12 @@ class PatchBundle(val patchesJar: File, val integrations: File?) { /** * Load all patches compatible with the specified package. */ - fun patchClasses(packageName: String) = loader.filter { patch -> + fun patches(packageName: String) = loader.filter { patch -> val compatiblePackages = patch.compatiblePackages ?: // The patch has no compatibility constraints, which means it is universal. return@filter true - if (!compatiblePackages.any { it.name == packageName }) { + if (!compatiblePackages.any { (name, _) -> name == packageName }) { // Patch is not compatible with this package. return@filter false } diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt index 31e707ba..cd2a2f83 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt @@ -1,14 +1,14 @@ package app.revanced.manager.patcher.patch import androidx.compose.runtime.Immutable -import app.revanced.patcher.data.ResourceContext import app.revanced.patcher.patch.Patch -import app.revanced.patcher.patch.ResourcePatch -import app.revanced.patcher.patch.options.PatchOption +import app.revanced.patcher.patch.Option as PatchOption +import app.revanced.patcher.patch.resourcePatch import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlin.reflect.KType data class PatchInfo( val name: String, @@ -21,7 +21,12 @@ data class PatchInfo( patch.name.orEmpty(), patch.description, patch.use, - patch.compatiblePackages?.map { CompatiblePackage(it) }?.toImmutableList(), + patch.compatiblePackages?.map { (pkgName, versions) -> + CompatiblePackage( + pkgName, + versions?.toImmutableSet() + ) + }?.toImmutableList(), patch.options.map { (_, option) -> Option(option) }.ifEmpty { null }?.toImmutableList() ) @@ -45,37 +50,19 @@ data class PatchInfo( * The resulting patch cannot be executed. * This is necessary because some functions in ReVanced Library only accept full [Patch] objects. */ - fun toPatcherPatch(): Patch<*> = object : ResourcePatch( - name = name, - description = description, - compatiblePackages = compatiblePackages - ?.map(app.revanced.manager.patcher.patch.CompatiblePackage::toPatcherCompatiblePackage) - ?.toSet(), - use = include, - ) { - override fun execute(context: ResourceContext) = - throw Exception("Metadata patches cannot be executed") - } + fun toPatcherPatch(): Patch<*> = + resourcePatch(name = name, description = description, use = include) { + compatiblePackages?.let { pkgs -> + compatibleWith(*pkgs.map { it.packageName to it.versions }.toTypedArray()) + } + } } @Immutable data class CompatiblePackage( val packageName: String, val versions: ImmutableSet? -) { - constructor(pkg: Patch.CompatiblePackage) : this( - pkg.name, - pkg.versions?.toImmutableSet() - ) - - /** - * Converts this [CompatiblePackage] into a [Patch.CompatiblePackage] from patcher. - */ - fun toPatcherCompatiblePackage() = Patch.CompatiblePackage( - name = packageName, - versions = versions, - ) -} +) @Immutable data class Option( @@ -83,7 +70,7 @@ data class Option( val key: String, val description: String, val required: Boolean, - val type: String, + val type: KType, val default: T?, val presets: Map?, val validator: (T?) -> Boolean, @@ -93,7 +80,7 @@ data class Option( option.key, option.description.orEmpty(), option.required, - option.valueType, + option.type, option.default, option.values, { option.validator(option, it) }, 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 e2aed2ee..3780e899 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 @@ -27,15 +27,13 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { val selectedBundles = selectedPatches.keys val allPatches = bundles.filterKeys { selectedBundles.contains(it) } - .mapValues { (_, bundle) -> bundle.patchClasses(packageName) } + .mapValues { (_, bundle) -> bundle.patches(packageName) } val patchList = selectedPatches.flatMap { (bundle, selected) -> allPatches[bundle]?.filter { selected.contains(it.name) } ?: throw IllegalArgumentException("Patch bundle $bundle does not exist") } - val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations } - // Set all patch options. options.forEach { (bundle, bundlePatchOptions) -> val patches = allPatches[bundle] ?: return@forEach @@ -53,7 +51,6 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { cacheDir, frameworkPath, aaptPath, - enableMultithreadedDexWriter(), context, logger, File(inputFile), @@ -62,8 +59,7 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { ).use { session -> session.run( File(outputFile), - patchList, - integrations + patchList ) } } 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 389d5201..ada1d943 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 @@ -70,7 +70,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { onProgress: ProgressEventHandler, ) = coroutineScope { // Get the location of our own Apk. - val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo.sourceDir + val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir val limit = "${prefs.patcherProcessMemoryLimit.get()}M" val propOverride = resolvePropOverride(context)?.absolutePath @@ -148,13 +148,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { packageName = packageName, inputFile = inputFile, outputFile = outputFile, - enableMultithrededDexWriter = enableMultithreadedDexWriter(), configurations = selectedPatches.map { (id, patches) -> val bundle = bundles[id]!! PatchConfiguration( bundle.patchesJar.absolutePath, - bundle.integrations?.absolutePath, patches, options[id].orEmpty() ) 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 fd39c3f3..434c97c6 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 @@ -26,7 +26,6 @@ sealed class Runtime(context: Context) : KoinComponent { context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath protected suspend fun bundles() = patchBundlesRepo.bundles.first() - protected suspend fun enableMultithreadedDexWriter() = prefs.multithreadingDexFileWriter.get() abstract suspend fun execute( inputFile: String, diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt index c669c875..b00d558a 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt @@ -12,14 +12,12 @@ data class Parameters( val packageName: String, val inputFile: String, val outputFile: String, - val enableMultithrededDexWriter: Boolean, val configurations: List, ) : Parcelable @Parcelize data class PatchConfiguration( val bundlePath: String, - val integrationsPath: String?, val patches: Set, val options: @RawValue Map> ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt index 4467f3ae..b0f8e248 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt @@ -54,13 +54,11 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") - val integrations = - parameters.configurations.mapNotNull { it.integrationsPath?.let(::File) } val patchList = parameters.configurations.flatMap { config -> - val bundle = PatchBundle(File(config.bundlePath), null) + val bundle = PatchBundle(File(config.bundlePath)) val patches = - bundle.patchClasses(parameters.packageName).filter { it.name in config.patches } + bundle.patches(parameters.packageName).filter { it.name in config.patches } .associateBy { it.name } config.options.forEach { (patchName, opts) -> @@ -81,7 +79,6 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { cacheDir = parameters.cacheDir, aaptPath = parameters.aaptPath, frameworkDir = parameters.frameworkDir, - multithreadingDexFileWriter = parameters.enableMultithrededDexWriter, androidContext = context, logger = logger, input = File(parameters.inputFile), @@ -90,7 +87,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { events.progress(name, state?.name, message) } ).use { - it.run(File(parameters.outputFile), patchList, integrations) + it.run(File(parameters.outputFile), patchList) } events.finished(null) 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 a5c551a4..c295bde1 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 @@ -154,7 +154,7 @@ class PatcherWorker( } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } - is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir) + is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir) } val runtime = if (prefs.useProcessRuntime.get()) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt index 1e2234eb..29f2f970 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.haptics.HapticCheckbox +import app.revanced.manager.util.transparentListItemColors @Composable fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) { @@ -77,5 +78,6 @@ private fun AutoUpdatesItem( leadingContent = { Icon(icon, null) }, headlineContent = { Text(stringResource(headline)) }, trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) }, - modifier = Modifier.clickable { onCheckedChange(!checked) } + modifier = Modifier.clickable { onCheckedChange(!checked) }, + colors = transparentListItemColors ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index f5919ced..83c60e0f 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -104,7 +104,7 @@ fun BundleInformationDialog( name = bundleName, remoteUrl = bundle.asRemoteOrNull?.endpoint, patchCount = patchCount, - version = props?.versionInfo?.patches, + version = props?.version, autoUpdate = props?.autoUpdate ?: false, onAutoUpdateChange = { composableScope.launch { diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt index 2fdd8f5d..6f3ae914 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt @@ -45,7 +45,7 @@ fun BundleItem( val state by bundle.state.collectAsStateWithLifecycle() val version by remember(bundle) { - bundle.propsFlow().map { props -> props?.versionInfo?.patches } + bundle.propsFlow().map { props -> props?.version } }.collectAsStateWithLifecycle(null) val name by bundle.nameState diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index 2de10053..cbc699ec 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -23,19 +23,18 @@ import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.model.BundleType -import app.revanced.manager.util.APK_MIMETYPE -import app.revanced.manager.util.JAR_MIMETYPE +import app.revanced.manager.util.BIN_MIMETYPE +import app.revanced.manager.util.transparentListItemColors @Composable fun ImportPatchBundleDialog( onDismiss: () -> Unit, onRemoteSubmit: (String, Boolean) -> Unit, - onLocalSubmit: (Uri, Uri?) -> Unit + onLocalSubmit: (Uri) -> Unit ) { var currentStep by rememberSaveable { mutableIntStateOf(0) } var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var patchBundle by rememberSaveable { mutableStateOf(null) } - var integrations by rememberSaveable { mutableStateOf(null) } var remoteUrl by rememberSaveable { mutableStateOf("") } var autoUpdate by rememberSaveable { mutableStateOf(false) } @@ -45,16 +44,7 @@ fun ImportPatchBundleDialog( } fun launchPatchActivity() { - patchActivityLauncher.launch(JAR_MIMETYPE) - } - - val integrationsActivityLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { integrations = it } - } - - fun launchIntegrationsActivity() { - integrationsActivityLauncher.launch(APK_MIMETYPE) + patchActivityLauncher.launch(BIN_MIMETYPE) } val steps = listOf<@Composable () -> Unit>( @@ -67,11 +57,9 @@ fun ImportPatchBundleDialog( ImportBundleStep( bundleType, patchBundle, - integrations, remoteUrl, autoUpdate, { launchPatchActivity() }, - { launchIntegrationsActivity() }, { remoteUrl = it }, { autoUpdate = it } ) @@ -99,13 +87,7 @@ fun ImportPatchBundleDialog( enabled = inputsAreValid, onClick = { when (bundleType) { - BundleType.Local -> patchBundle?.let { - onLocalSubmit( - it, - integrations - ) - } - + BundleType.Local -> patchBundle?.let(onLocalSubmit) BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate) } } @@ -159,7 +141,8 @@ fun SelectBundleTypeStep( selected = bundleType == BundleType.Remote, onClick = null ) - } + }, + colors = transparentListItemColors ) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) ListItem( @@ -175,7 +158,8 @@ fun SelectBundleTypeStep( selected = bundleType == BundleType.Local, onClick = null ) - } + }, + colors = transparentListItemColors ) } } @@ -186,11 +170,9 @@ fun SelectBundleTypeStep( fun ImportBundleStep( bundleType: BundleType, patchBundle: Uri?, - integrations: Uri?, remoteUrl: String, autoUpdate: Boolean, launchPatchActivity: () -> Unit, - launchIntegrationsActivity: () -> Unit, onRemoteUrlChange: (String) -> Unit, onAutoUpdateChange: (Boolean) -> Unit ) { @@ -210,19 +192,8 @@ fun ImportBundleStep( Icon(imageVector = Icons.Default.Topic, contentDescription = null) } }, - modifier = Modifier.clickable { launchPatchActivity() } - ) - ListItem( - headlineContent = { - Text(stringResource(R.string.integrations_field)) - }, - supportingContent = { Text(stringResource(if (integrations != null) R.string.file_field_set else R.string.file_field_not_set)) }, - trailingContent = { - IconButton(onClick = launchIntegrationsActivity) { - Icon(imageVector = Icons.Default.Topic, contentDescription = null) - } - }, - modifier = Modifier.clickable { launchIntegrationsActivity() } + modifier = Modifier.clickable { launchPatchActivity() }, + colors = transparentListItemColors ) } } @@ -256,6 +227,7 @@ fun ImportBundleStep( ) } }, + colors = transparentListItemColors ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt index bb667bad..b86124d9 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.res.stringResource import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.ui.component.haptics.HapticRadioButton +import app.revanced.manager.util.transparentListItemColors @Composable fun InstallPickerDialog( @@ -41,7 +42,7 @@ fun InstallPickerDialog( title = { Text(stringResource(R.string.select_install_type)) }, text = { Column { - InstallType.values().forEach { + InstallType.entries.forEach { ListItem( modifier = Modifier.clickable { selectedInstallType = it }, leadingContent = { @@ -50,7 +51,8 @@ fun InstallPickerDialog( onClick = null ) }, - headlineContent = { Text(stringResource(it.stringResource)) } + headlineContent = { Text(stringResource(it.stringResource)) }, + colors = transparentListItemColors ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index 993270ea..3c0504bc 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -50,6 +50,7 @@ import app.revanced.manager.util.mutableStateSetOf import app.revanced.manager.util.saver.snapshotStateListSaver import app.revanced.manager.util.saver.snapshotStateSetSaver import app.revanced.manager.util.toast +import app.revanced.manager.util.transparentListItemColors import kotlinx.parcelize.Parcelize import org.koin.compose.koinInject import org.koin.core.component.KoinComponent @@ -58,6 +59,7 @@ import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyColumnState import java.io.Serializable import kotlin.random.Random +import kotlin.reflect.typeOf import androidx.compose.ui.window.Dialog as ComposeDialog private class OptionEditorScope( @@ -96,17 +98,17 @@ private interface OptionEditor { fun Dialog(scope: OptionEditorScope) } +private inline fun OptionEditor.toMapEditorElements() = arrayOf( + typeOf() to this, + typeOf>() to ListOptionEditor(this) +) + private val optionEditors = mapOf( - "Boolean" to BooleanOptionEditor, - "String" to StringOptionEditor, - "Int" to IntOptionEditor, - "Long" to LongOptionEditor, - "Float" to FloatOptionEditor, - "BooleanArray" to ListOptionEditor(BooleanOptionEditor), - "StringArray" to ListOptionEditor(StringOptionEditor), - "IntArray" to ListOptionEditor(IntOptionEditor), - "LongArray" to ListOptionEditor(LongOptionEditor), - "FloatArray" to ListOptionEditor(FloatOptionEditor), + *BooleanOptionEditor.toMapEditorElements(), + *StringOptionEditor.toMapEditorElements(), + *IntOptionEditor.toMapEditorElements(), + *LongOptionEditor.toMapEditorElements(), + *FloatOptionEditor.toMapEditorElements() ) @Composable @@ -145,7 +147,7 @@ fun OptionItem(option: Option, value: T?, setValue: (T?) -> Unit) { val baseOptionEditor = optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor - if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor) + if (option.type != typeOf() && option.presets != null) PresetOptionEditor(baseOptionEditor) else baseOptionEditor } @@ -405,7 +407,8 @@ private class PresetOptionEditor(private val innerEditor: OptionEditor< selected = selectedPreset == presetKey, onClick = { selectedPreset = presetKey } ) - } + }, + colors = transparentListItemColors ) } @@ -430,7 +433,7 @@ private class ListOptionEditor(private val elementEditor: Opti option.key, option.description, option.required, - option.type.removeSuffix("Array"), + option.type.arguments.first().type!!, null, null ) { true } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index 6d2e1d5f..eb814c3b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -33,6 +33,7 @@ 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.transparentListItemColors import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -110,9 +111,9 @@ fun AppSelectorScreen( ) ) } - } + }, + colors = transparentListItemColors ) - } } } else { 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 157986ec..51caeb25 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 @@ -79,9 +79,9 @@ fun DashboardScreen( if (showAddBundleDialog) { ImportPatchBundleDialog( onDismiss = { showAddBundleDialog = false }, - onLocalSubmit = { patches, integrations -> + onLocalSubmit = { patches -> showAddBundleDialog = false - vm.createLocalSource(patches, integrations) + vm.createLocalSource(patches) }, onRemoteSubmit = { url, autoUpdate -> showAddBundleDialog = false diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 511ede21..511a1c36 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -46,6 +46,7 @@ import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.isScrollingUp +import app.revanced.manager.util.transparentListItemColors import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -480,7 +481,8 @@ private fun ListHeader( ) } } - } + }, + colors = transparentListItemColors ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index cd18dcc7..a8d12d50 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -104,7 +104,7 @@ fun VersionSelectorScreen( viewModel.installedApp?.let { (packageInfo, installedApp) -> SelectedApp.Installed( packageName = viewModel.packageName, - version = packageInfo.versionName + version = packageInfo.versionName!! ).let { item { SelectedAppItem( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index 9082f4bb..cb474f09 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -102,12 +102,6 @@ fun AdvancedSettingsScreen( headline = R.string.process_runtime_memory_limit, description = R.string.process_runtime_memory_limit_description, ) - BooleanItem( - preference = vm.prefs.multithreadingDexFileWriter, - coroutineScope = vm.viewModelScope, - headline = R.string.multithreaded_dex_file_writer, - description = R.string.multithreaded_dex_file_writer_description, - ) GroupHeader(stringResource(R.string.safeguards)) BooleanItem( 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 85cee8d1..2acfdcd5 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 @@ -69,7 +69,7 @@ class AppSelectorViewModel( pm.getPackageInfo(this)?.let { packageInfo -> SelectedApp.Local( packageName = packageInfo.packageName, - version = packageInfo.versionName, + version = packageInfo.versionName!!, file = this, temporary = true ) 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 ce68249d..5a019c51 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 @@ -95,13 +95,10 @@ class DashboardViewModel( selectedSources.clear() } - fun createLocalSource(patchBundle: Uri, integrations: Uri?) = + fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { contentResolver.openInputStream(patchBundle)!!.use { patchesStream -> - integrations?.let { contentResolver.openInputStream(it) } - .use { integrationsStream -> - patchBundleRepository.createLocal(patchesStream, integrationsStream) - } + patchBundleRepository.createLocal(patchesStream) } } 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 a63feed7..00c1231f 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 @@ -152,8 +152,8 @@ class PatcherViewModel( ) val patcherSucceeded = - workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo -> - when (workInfo.state) { + workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo? -> + when (workInfo?.state) { WorkInfo.State.SUCCEEDED -> true WorkInfo.State.FAILED -> false else -> null @@ -308,7 +308,7 @@ class PatcherViewModel( // 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 != null) { + if (currentPackageInfo.splitNames.isNotEmpty()) { // Exit if there is no base APK package installerStatusDialogModel.packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID diff --git a/app/src/main/java/app/revanced/manager/util/Constants.kt b/app/src/main/java/app/revanced/manager/util/Constants.kt index 983a7c42..000da463 100644 --- a/app/src/main/java/app/revanced/manager/util/Constants.kt +++ b/app/src/main/java/app/revanced/manager/util/Constants.kt @@ -1,14 +1,8 @@ package app.revanced.manager.util -private const val team = "revanced" -const val ghOrganization = "https://github.com/$team" -const val ghCli = "$team/revanced-cli" -const val ghPatches = "$team/revanced-patches" -const val ghPatcher = "$team/revanced-patcher" -const val ghManager = "$team/revanced-manager" -const val ghIntegrations = "$team/revanced-integrations" const val tag = "ReVanced Manager" const val JAR_MIMETYPE = "application/java-archive" const val APK_MIMETYPE = "application/vnd.android.package-archive" -const val JSON_MIMETYPE = "application/json" \ No newline at end of file +const val JSON_MIMETYPE = "application/json" +const val BIN_MIMETYPE = "application/octet-stream" \ No newline at end of file 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 0d7a822b..d0e6dbb4 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -106,7 +106,7 @@ class PM( val pkgInfo = app.packageManager.getPackageArchiveInfo(path, 0) ?: return null // This is needed in order to load label and icon. - pkgInfo.applicationInfo.apply { + pkgInfo.applicationInfo!!.apply { sourceDir = path publicSourceDir = path } @@ -114,7 +114,7 @@ class PM( return pkgInfo } - fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString() + fun PackageInfo.label() = this.applicationInfo!!.loadLabel(app.packageManager).toString() fun getVersionCode(packageInfo: PackageInfo) = PackageInfoCompat.getLongVersionCode(packageInfo) 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 bc48c54a..8fb13c88 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -13,6 +13,8 @@ import android.widget.Toast import androidx.annotation.StringRes import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.State @@ -240,4 +242,14 @@ fun ((T) -> R).withHapticFeedback(constant: Int): (T) -> R { view.performHapticFeedback(constant) this(it) } -} \ No newline at end of file +} + +private var transparentListItemColorsCached: ListItemColors? = null + +/** + * The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent]. + */ +val transparentListItemColors + @Composable get() = transparentListItemColorsCached + ?: ListItemDefaults.colors(containerColor = Color.Transparent) + .also { transparentListItemColorsCached = it } \ 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 0b260bb8..20722c02 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ ReVanced Manager Patcher Patches - Integrations CLI Manager @@ -20,7 +19,6 @@ Import patch bundle Bundle patches Patch bundle - Integrations Selected Not selected @@ -71,8 +69,6 @@ Adapt colors to the wallpaper Theme Choose between light or dark theme - Multi-threaded DEX file writer - Use multiple cores to write DEX files. This is faster, but uses more memory Safeguards Disable version compatibility check The check restricts patches to supported app versions @@ -123,7 +119,6 @@ Search apps… Loading… Downloading patch bundle… - Downloading Integrations… Options OK @@ -186,7 +181,6 @@ Download APK file Failed to download patch bundle: %s Failed to load updated patch bundle: %s - Failed to update integrations: %s No patched apps found Tap on the patches to get more information about them %s selected diff --git a/build.gradle.kts b/build.gradle.kts index 89d27215..ca1372dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,5 +2,8 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.devtools) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.about.libraries) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a3f425a..7c4dafcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,31 +1,30 @@ [versions] -ktx = "1.13.1" -material3 = "1.2.1" -ui-tooling = "1.6.8" -viewmodel-lifecycle = "2.8.3" +ktx = "1.15.0" +material3 = "1.3.1" +ui-tooling = "1.7.5" +viewmodel-lifecycle = "2.8.7" splash-screen = "1.0.1" -compose-activity = "1.9.0" -paging = "3.3.0" +compose-activity = "1.9.3" preferences-datastore = "1.1.1" -work-runtime = "2.9.0" -compose-bom = "2024.06.00" +work-runtime = "2.10.0" +compose-bom = "2024.10.01" accompanist = "0.34.0" placeholder = "1.1.2" reorderable = "1.5.2" -serialization = "1.6.3" -collection = "0.3.7" +serialization = "1.7.3" +collection = "0.3.8" room-version = "2.6.1" -revanced-patcher = "19.3.1" -revanced-library = "2.2.1" +revanced-patcher = "21.0.0" +revanced-library = "3.0.2" koin-version = "3.5.3" koin-version-compose = "3.5.3" reimagined-navigation = "1.5.0" ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" -android-gradle-plugin = "8.3.2" -kotlin-gradle-plugin = "1.9.22" -dev-tools-gradle-plugin = "1.9.22-1.0.17" +kotlin = "2.0.21" +android-gradle-plugin = "8.7.2" +dev-tools-gradle-plugin = "2.0.21-1.0.27" about-libraries-gradle-plugin = "11.1.1" coil = "2.6.0" app-icon-loader-coil = "1.5.0" @@ -44,7 +43,6 @@ runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", ve runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" } splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" } compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" } -paging-common-ktx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" } preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" } @@ -135,6 +133,9 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-gradle-plugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a80b22ce..dfe2d1c1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ +#Tue Nov 12 21:36:50 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 0685479d5362f898e694524b48a0eae63f048d75 Mon Sep 17 00:00:00 2001 From: somni <82272900+somnisomni@users.noreply.github.com> Date: Fri, 22 Nov 2024 22:57:23 +0900 Subject: [PATCH 04/21] feat: Make patch bundles list scrollable (#2322) --- .../manager/ui/screen/BundleListScreen.kt | 54 +++++++++++++++++++ .../manager/ui/screen/DashboardScreen.kt | 39 ++++---------- 2 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt new file mode 100644 index 00000000..c2758e71 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt @@ -0,0 +1,54 @@ +package app.revanced.manager.ui.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.ui.component.LazyColumnWithScrollbar +import app.revanced.manager.ui.component.bundle.BundleItem + +@Composable +fun BundleListScreen( + onDelete: (PatchBundleSource) -> Unit, + onUpdate: (PatchBundleSource) -> Unit, + sources: List, + selectedSources: SnapshotStateList, + bundlesSelectable: Boolean, +) { + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + items( + sources, + key = { it.uid } + ) { source -> + BundleItem( + bundle = source, + onDelete = { + onDelete(source) + }, + onUpdate = { + onUpdate(source) + }, + selectable = bundlesSelectable, + onSelect = { + selectedSources.add(source) + }, + isBundleSelected = selectedSources.contains(source), + toggleSelection = { bundleIsNotSelected -> + if (bundleIsNotSelected) { + selectedSources.add(source) + } else { + selectedSources.remove(source) + } + } + ) + } + } +} \ 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 51caeb25..4dc74766 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 @@ -31,7 +31,6 @@ import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.component.AvailableUpdateDialog import app.revanced.manager.ui.component.NotificationCard -import app.revanced.manager.ui.component.bundle.BundleItem import app.revanced.manager.ui.component.bundle.BundleTopBar import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab @@ -264,33 +263,17 @@ fun DashboardScreen( val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) - Column( - modifier = Modifier.fillMaxSize(), - ) { - sources.forEach { - BundleItem( - bundle = it, - onDelete = { - vm.delete(it) - }, - onUpdate = { - vm.update(it) - }, - selectable = bundlesSelectable, - onSelect = { - vm.selectedSources.add(it) - }, - isBundleSelected = vm.selectedSources.contains(it), - toggleSelection = { bundleIsNotSelected -> - if (bundleIsNotSelected) { - vm.selectedSources.add(it) - } else { - vm.selectedSources.remove(it) - } - } - ) - } - } + BundleListScreen( + onDelete = { + vm.delete(it) + }, + onUpdate = { + vm.update(it) + }, + sources = sources, + selectedSources = vm.selectedSources, + bundlesSelectable = bundlesSelectable + ) } } } From 31fb8b14049a5b7c31849cadd48e3b828cb1eb97 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 1 Dec 2024 01:13:03 +0700 Subject: [PATCH 05/21] chore: Nitpick on misspelling of comment --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 79364a6e..f66506b8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,7 +15,7 @@ dependencyResolutionManagement { maven("https://jitpack.io") mavenLocal() maven { - // A repository must be speficied for some reason. "registry" is a dummy. + // A repository must be specified for some reason. "registry" is a dummy. url = uri("https://maven.pkg.github.com/revanced/registry") credentials { username = System.getenv("GITHUB_ACTOR") ?: extra["gpr.user"] as String? From 9dc716b1c80dd9105ca9e8ee9de9dd6e60d52aae Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 12 Dec 2024 17:52:21 +0100 Subject: [PATCH 06/21] feat: switch to revanced api v4 --- app/build.gradle.kts | 1 + .../app/revanced/manager/di/ServiceModule.kt | 2 - .../domain/bundles/RemotePatchBundle.kt | 20 ++--- .../manager/network/api/ReVancedAPI.kt | 44 +++++----- .../manager/network/dto/GithubChangelog.kt | 16 ---- .../manager/network/dto/PatchBundleInfo.kt | 7 -- .../manager/network/dto/ReVancedAsset.kt | 18 ++++ .../network/dto/ReVancedContributors.kt | 10 +-- .../manager/network/dto/ReVancedInfo.kt | 9 +- .../manager/network/dto/ReVancedRelease.kt | 41 --------- .../network/service/ReVancedService.kt | 43 ---------- .../ui/component/settings/Changelog.kt | 5 -- .../settings/update/ChangelogsScreen.kt | 59 +++---------- .../ui/screen/settings/update/UpdateScreen.kt | 24 +++--- .../ui/viewmodel/ChangelogsViewModel.kt | 24 ++---- .../manager/ui/viewmodel/UpdateViewModel.kt | 37 ++------- .../java/app/revanced/manager/util/Util.kt | 83 ++++++++----------- gradle/libs.versions.toml | 2 + 18 files changed, 125 insertions(+), 320 deletions(-) delete mode 100644 app/src/main/java/app/revanced/manager/network/dto/GithubChangelog.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt create mode 100644 app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f04a1b9..79b726bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,6 +143,7 @@ dependencies { // KotlinX implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.collection.immutable) + implementation(libs.kotlinx.datetime) // Room implementation(libs.room.runtime) diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt index c30a711f..cfda5030 100644 --- a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt @@ -1,11 +1,9 @@ package app.revanced.manager.di import app.revanced.manager.network.service.HttpService -import app.revanced.manager.network.service.ReVancedService import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val serviceModule = module { - singleOf(::ReVancedService) singleOf(::HttpService) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index e3214db9..9deb7bbe 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -2,7 +2,7 @@ package app.revanced.manager.domain.bundles import androidx.compose.runtime.Stable import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.dto.PatchBundleInfo +import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.utils.getOrThrow import io.ktor.client.request.url @@ -16,17 +16,16 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo PatchBundleSource(name, id, directory) { protected val http: HttpService by inject() - protected abstract suspend fun getLatestInfo(): PatchBundleInfo + protected abstract suspend fun getLatestInfo(): ReVancedAsset - private suspend fun download(info: PatchBundleInfo) = withContext(Dispatchers.IO) { - val (version, url) = info + private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { patchBundleOutputStream().use { http.streamTo(it) { - url(url) + url(info.downloadUrl) } } - saveVersion(version) + saveVersion(info.version) reload() } @@ -58,7 +57,7 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : RemotePatchBundle(name, id, directory, endpoint) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { - http.request { + http.request { url(endpoint) }.getOrThrow() } @@ -68,10 +67,5 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) : RemotePatchBundle(name, id, directory, endpoint) { private val api: ReVancedAPI by inject() - override suspend fun getLatestInfo() = api - .getLatestRelease("revanced-patches") - .getOrThrow() - .let { - PatchBundleInfo(it.version, it.assets.first { it.name.endsWith(".rvp") }.downloadUrl) - } + override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow() } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index f52a8190..bb365580 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -2,37 +2,41 @@ package app.revanced.manager.network.api import android.os.Build import app.revanced.manager.domain.manager.PreferencesManager -import app.revanced.manager.network.dto.ReVancedRelease -import app.revanced.manager.network.service.ReVancedService +import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.dto.ReVancedGitRepository +import app.revanced.manager.network.dto.ReVancedInfo +import app.revanced.manager.network.service.HttpService +import app.revanced.manager.network.utils.APIResponse import app.revanced.manager.network.utils.getOrThrow -import app.revanced.manager.network.utils.transform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import io.ktor.client.request.url class ReVancedAPI( - private val service: ReVancedService, + private val client: HttpService, private val prefs: PreferencesManager ) { private suspend fun apiUrl() = prefs.api.get() - suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories } + private suspend inline fun request(api: String, route: String): APIResponse = + withContext( + Dispatchers.IO + ) { + client.request { + url("$api/v4/$route") + } + } - suspend fun getLatestRelease(name: String) = - service.getLatestRelease(apiUrl(), name).transform { it.release } - - suspend fun getReleases(name: String) = - service.getReleases(apiUrl(), name).transform { it.releases } + private suspend inline fun request(route: String) = request(apiUrl(), route) suspend fun getAppUpdate() = - getLatestRelease("revanced-manager") - .getOrThrow() - .takeIf { it.version != Build.VERSION.RELEASE } + getLatestAppInfo().getOrThrow().takeIf { it.version != Build.VERSION.RELEASE } - suspend fun getInfo(api: String? = null) = service.getInfo(api ?: apiUrl()).transform { it.info } + suspend fun getLatestAppInfo() = request("manager") + suspend fun getPatchesUpdate() = request("patches") - companion object Extensions { - fun ReVancedRelease.findAssetByType(mime: String) = - assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime) - } -} + suspend fun getContributors() = request>("contributors") -class MissingAssetException(type: String) : Exception("No asset with type $type") \ No newline at end of file + suspend fun getInfo(api: String? = null) = request(api ?: apiUrl(), "about") +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/GithubChangelog.kt b/app/src/main/java/app/revanced/manager/network/dto/GithubChangelog.kt deleted file mode 100644 index 52789017..00000000 --- a/app/src/main/java/app/revanced/manager/network/dto/GithubChangelog.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.revanced.manager.network.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class GithubChangelog( - @SerialName("tag_name") val version: String, - @SerialName("body") val body: String, - @SerialName("assets") val assets: List -) - -@Serializable -data class GithubAsset( - @SerialName("download_count") val downloadCount: Int, -) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt deleted file mode 100644 index 02d89919..00000000 --- a/app/src/main/java/app/revanced/manager/network/dto/PatchBundleInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.revanced.manager.network.dto - -import kotlinx.serialization.Serializable - -@Serializable -// TODO: replace this -data class PatchBundleInfo(val version: String, val url: String) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt new file mode 100644 index 00000000..64c05f31 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt @@ -0,0 +1,18 @@ +package app.revanced.manager.network.dto + +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReVancedAsset ( + @SerialName("download_url") + val downloadUrl: String, + @SerialName("created_at") + val createdAt: LocalDateTime, + @SerialName("signature_download_url") + val signatureDownloadUrl: String? = null, + val description: String, + val version: String, +) + diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt index 82117d96..6583ba7c 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt @@ -3,19 +3,15 @@ package app.revanced.manager.network.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class ReVancedGitRepositories( - val repositories: List, -) - @Serializable data class ReVancedGitRepository( val name: String, + val url: String, val contributors: List, ) @Serializable data class ReVancedContributor( - @SerialName("login") val username: String, + @SerialName("name") val username: String, @SerialName("avatar_url") val avatarUrl: String, -) +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt index 8f7e8966..89ed7445 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt @@ -1,12 +1,8 @@ package app.revanced.manager.network.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -@Serializable -data class ReVancedInfoParent( - val info: ReVancedInfo, -) - @Serializable data class ReVancedInfo( val name: String, @@ -43,7 +39,8 @@ data class ReVancedDonation( @Serializable data class ReVancedWallet( val network: String, - val currency_code: String, + @SerialName("currency_code") + val currencyCode: String, val address: String, val preferred: Boolean ) diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt deleted file mode 100644 index 442e4107..00000000 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt +++ /dev/null @@ -1,41 +0,0 @@ -package app.revanced.manager.network.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ReVancedLatestRelease( - val release: ReVancedRelease, -) - -@Serializable -data class ReVancedReleases( - val releases: List -) - -@Serializable -data class ReVancedRelease( - val metadata: ReVancedReleaseMeta, - val assets: List -) { - val version get() = metadata.tag -} - -@Serializable -data class ReVancedReleaseMeta( - @SerialName("tag_name") val tag: String, - val name: String, - val draft: Boolean, - val prerelease: Boolean, - @SerialName("created_at") val createdAt: String, - @SerialName("published_at") val publishedAt: String, - val body: String, -) - -@Serializable -data class Asset( - val name: String, - @SerialName("download_count") val downloadCount: Int, - @SerialName("browser_download_url") val downloadUrl: String, - @SerialName("content_type") val contentType: String -) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt b/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt deleted file mode 100644 index 537a3514..00000000 --- a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt +++ /dev/null @@ -1,43 +0,0 @@ -package app.revanced.manager.network.service - -import app.revanced.manager.network.dto.ReVancedGitRepositories -import app.revanced.manager.network.dto.ReVancedInfo -import app.revanced.manager.network.dto.ReVancedInfoParent -import app.revanced.manager.network.dto.ReVancedLatestRelease -import app.revanced.manager.network.dto.ReVancedReleases -import app.revanced.manager.network.utils.APIResponse -import io.ktor.client.request.url -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class ReVancedService( - private val client: HttpService, -) { - suspend fun getLatestRelease(api: String, repo: String): APIResponse = - withContext(Dispatchers.IO) { - client.request { - url("$api/v2/$repo/releases/latest") - } - } - - suspend fun getReleases(api: String, repo: String): APIResponse = - withContext(Dispatchers.IO) { - client.request { - url("$api/v2/$repo/releases") - } - } - - suspend fun getContributors(api: String): APIResponse = - withContext(Dispatchers.IO) { - client.request { - url("$api/contributors") - } - } - - suspend fun getInfo(api: String): APIResponse = - withContext(Dispatchers.IO) { - client.request { - url("$api/v2/info") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt index 6e707ae2..af26e232 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt @@ -26,7 +26,6 @@ import app.revanced.manager.ui.component.Markdown fun Changelog( markdown: String, version: String, - downloadCount: String, publishDate: String ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -55,10 +54,6 @@ fun Changelog( modifier = Modifier .fillMaxWidth() ) { - Tag( - Icons.Outlined.FileDownload, - downloadCount - ) Tag( Icons.Outlined.CalendarToday, publishDate diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt index 315bc1a1..eadd9990 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt @@ -5,12 +5,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -19,11 +15,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.ColumnWithScrollbar import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.settings.Changelog import app.revanced.manager.ui.viewmodel.ChangelogsViewModel -import app.revanced.manager.util.formatNumber import app.revanced.manager.util.relativeTime import org.koin.androidx.compose.koinViewModel @@ -33,8 +28,6 @@ fun ChangelogsScreen( onBackClick: () -> Unit, vm: ChangelogsViewModel = koinViewModel() ) { - val changelogs = vm.changelogs - Scaffold( topBar = { AppTopBar( @@ -43,54 +36,22 @@ fun ChangelogsScreen( ) } ) { paddingValues -> - LazyColumnWithScrollbar( + ColumnWithScrollbar( modifier = Modifier .padding(paddingValues) .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (changelogs.isNullOrEmpty()) Arrangement.Center else Arrangement.Top + verticalArrangement = if (vm.releaseInfo == null) Arrangement.Center else Arrangement.Top ) { - if (changelogs == null) { - item { - LoadingIndicator() - } - } else if (changelogs.isEmpty()) { - item { - Text( - text = stringResource(id = R.string.no_changelogs_found), - style = MaterialTheme.typography.titleLarge + vm.releaseInfo?.let { info -> + Column(modifier = Modifier.padding(16.dp)) { + Changelog( + markdown = info.description.replace("`", ""), + version = info.version, + publishDate = info.createdAt.relativeTime(LocalContext.current) ) } - } else { - val lastChangelog = changelogs.last() - items( - changelogs, - key = { it.version } - ) { changelog -> - ChangelogItem(changelog, lastChangelog) - } - } - } - } -} - -@Composable -fun ChangelogItem( - changelog: ChangelogsViewModel.Changelog, - lastChangelog: ChangelogsViewModel.Changelog -) { - Column(modifier = Modifier.padding(16.dp)) { - Changelog( - markdown = changelog.body.replace("`", ""), - version = changelog.version, - downloadCount = changelog.downloadCount.formatNumber(), - publishDate = changelog.publishDate.relativeTime(LocalContext.current) - ) - if (changelog != lastChangelog) { - HorizontalDivider( - modifier = Modifier.padding(top = 32.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) + } ?: LoadingIndicator() } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt index 4b41dbd9..693adc6a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt @@ -33,12 +33,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.BuildConfig import app.revanced.manager.R +import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.settings.Changelog import app.revanced.manager.ui.viewmodel.UpdateViewModel -import app.revanced.manager.ui.viewmodel.UpdateViewModel.Changelog import app.revanced.manager.ui.viewmodel.UpdateViewModel.State -import app.revanced.manager.util.formatNumber import app.revanced.manager.util.relativeTime import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig @@ -77,10 +76,10 @@ fun UpdateScreen( ) { Header( vm.state, - vm.changelog, + vm.releaseInfo, DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize) ) - vm.changelog?.let { changelog -> + vm.releaseInfo?.let { changelog -> HorizontalDivider() Changelog(changelog) } ?: Spacer(modifier = Modifier.weight(1f)) @@ -118,7 +117,7 @@ private fun MeteredDownloadConfirmationDialog( } @Composable -private fun Header(state: State, changelog: Changelog?, downloadData: DownloadData) { +private fun Header(state: State, releaseInfo: ReVancedAsset?, downloadData: DownloadData) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( text = stringResource(state.title), @@ -134,11 +133,11 @@ private fun Header(state: State, changelog: Changelog?, downloadData: DownloadDa style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - changelog?.let { changelog -> + releaseInfo?.version?.let { Text( text = stringResource( - id = R.string.new_version, - changelog.version.replace("v", "") + R.string.new_version, + it.replace("v", "") ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -170,7 +169,7 @@ private fun Header(state: State, changelog: Changelog?, downloadData: DownloadDa } @Composable -private fun ColumnScope.Changelog(changelog: Changelog) { +private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) { val scrollState = rememberScrollState() Column( modifier = Modifier @@ -194,10 +193,9 @@ private fun ColumnScope.Changelog(changelog: Changelog) { ) ) { Changelog( - markdown = changelog.body.replace("`", ""), - version = changelog.version, - downloadCount = changelog.downloadCount.formatNumber(), - publishDate = changelog.publishDate.relativeTime(LocalContext.current) + markdown = releaseInfo.description.replace("`", ""), + version = releaseInfo.version, + publishDate = releaseInfo.createdAt.relativeTime(LocalContext.current) ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt index aa9c878c..bfe1bbfa 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt @@ -8,9 +8,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType -import app.revanced.manager.network.utils.getOrNull -import app.revanced.manager.util.APK_MIMETYPE +import app.revanced.manager.network.dto.ReVancedAsset +import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.util.uiSafe import kotlinx.coroutines.launch @@ -18,27 +17,14 @@ class ChangelogsViewModel( private val api: ReVancedAPI, private val app: Application, ) : ViewModel() { - var changelogs: List? by mutableStateOf(null) + var releaseInfo: ReVancedAsset? by mutableStateOf(null) + private set init { viewModelScope.launch { uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") { - changelogs = api.getReleases("revanced-manager").getOrNull().orEmpty().map { release -> - Changelog( - release.version, - release.findAssetByType(APK_MIMETYPE).downloadCount, - release.metadata.publishedAt, - release.metadata.body - ) - } + releaseInfo = api.getLatestAppInfo().getOrThrow() } } } - - data class Changelog( - val version: String, - val downloadCount: Int, - val publishDate: String, - val body: String, - ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index 5ea4db74..f4dc457f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -6,7 +6,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageInstaller -import android.util.Log import androidx.annotation.StringRes import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -19,16 +18,10 @@ import app.revanced.manager.R import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType -import app.revanced.manager.network.dto.ReVancedRelease +import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.service.HttpService -import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.service.InstallService -import app.revanced.manager.service.UninstallService -import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.PM -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 io.ktor.client.plugins.onDownload @@ -38,7 +31,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import java.io.File class UpdateViewModel( private val downloadOnScreenEntry: Boolean @@ -65,23 +57,14 @@ class UpdateViewModel( var installError by mutableStateOf("") - var changelog: Changelog? by mutableStateOf(null) + var releaseInfo: ReVancedAsset? by mutableStateOf(null) + private set private val location = fs.tempDir.resolve("updater.apk") - private var release: ReVancedRelease? = null private val job = viewModelScope.launch { uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { - withContext(Dispatchers.IO) { - val response = reVancedAPI.getAppUpdate() ?: throw Exception("No update available") + releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available") - release = response - changelog = Changelog( - response.version, - response.findAssetByType(APK_MIMETYPE).downloadCount, - response.metadata.publishedAt, - response.metadata.body - ) - } if (downloadOnScreenEntry) { downloadUpdate() } else { @@ -92,16 +75,15 @@ class UpdateViewModel( fun downloadUpdate(ignoreInternetCheck: Boolean = false) = viewModelScope.launch { uiSafe(app, R.string.failed_to_download_update, "Failed to download update") { + val release = releaseInfo!! withContext(Dispatchers.IO) { if (!networkInfo.isSafe() && !ignoreInternetCheck) { showInternetCheckDialog = true } else { state = State.DOWNLOADING - val asset = release?.findAssetByType(APK_MIMETYPE) - ?: throw Exception("couldn't find asset to download") http.download(location) { - url(asset.downloadUrl) + url(release.downloadUrl) onDownload { bytesSentTotal, contentLength -> downloadedSize = bytesSentTotal totalSize = contentLength @@ -153,13 +135,6 @@ class UpdateViewModel( location.delete() } - data class Changelog( - val version: String, - val downloadCount: Int, - val publishDate: String, - val body: String, - ) - enum class State(@StringRes val title: Int, val showCancel: Boolean = false) { CAN_DOWNLOAD(R.string.update_available), DOWNLOADING(R.string.downloading_manager_update, true), 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 8fb13c88..5cb379e3 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -3,11 +3,6 @@ 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.StringRes @@ -40,11 +35,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch -import java.time.Duration -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.char +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import java.util.Locale typealias PatchSelection = Map> @@ -134,53 +131,43 @@ suspend fun Flow>.collectEach(block: suspend (T) -> Unit) { } } -fun Int.formatNumber(): String { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - NumberFormatter.with() - .notation(Notation.compactShort()) - .decimal(NumberFormatter.DecimalSeparatorDisplay.ALWAYS) - .precision(Precision.fixedFraction(1)) - .locale(Locale.getDefault()) - .format(this) - .toString() - } else { - val compact = CompactDecimalFormat.getInstance( - Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT - ) - compact.maximumFractionDigits = 1 - compact.format(this) - } -} - -fun String.relativeTime(context: Context): String { +fun LocalDateTime.relativeTime(context: Context): String { try { - val currentTime = ZonedDateTime.now(ZoneId.of("UTC")) - val inputDateTime = ZonedDateTime.parse(this) - val duration = Duration.between(inputDateTime, currentTime) + val now = Clock.System.now() + val duration = now - this.toInstant(TimeZone.UTC) return when { - duration.toMinutes() < 1 -> context.getString(R.string.just_now) - duration.toMinutes() < 60 -> context.getString(R.string.minutes_ago, duration.toMinutes().toString()) - duration.toHours() < 24 -> context.getString(R.string.hours_ago, duration.toHours().toString()) - duration.toDays() < 30 -> context.getString(R.string.days_ago, duration.toDays().toString()) - else -> { - val formatter = DateTimeFormatter.ofPattern("MMM d") - val formattedDate = inputDateTime.format(formatter) - if (inputDateTime.year != currentTime.year) { - val yearFormatter = DateTimeFormatter.ofPattern(", yyyy") - val formattedYear = inputDateTime.format(yearFormatter) - "$formattedDate$formattedYear" - } else { - formattedDate + duration.inWholeMinutes < 1 -> context.getString(R.string.just_now) + duration.inWholeMinutes < 60 -> context.getString( + R.string.minutes_ago, + duration.inWholeMinutes.toString() + ) + + duration.inWholeHours < 24 -> context.getString( + R.string.hours_ago, + duration.inWholeHours.toString() + ) + + duration.inWholeHours < 30 -> context.getString( + R.string.days_ago, + duration.inWholeDays.toString() + ) + + else -> LocalDateTime.Format { + monthName(MonthNames.ENGLISH_ABBREVIATED) + char(' ') + dayOfMonth() + if (now.toLocalDateTime(TimeZone.UTC).year != this@relativeTime.year) { + chars(", ") + year() } - } + }.format(this) } - } catch (e: DateTimeParseException) { + } catch (e: IllegalArgumentException) { return context.getString(R.string.invalid_date) } } - const val isScrollingUpSensitivity = 10 @Composable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c4dafcc..47d9b401 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ placeholder = "1.1.2" reorderable = "1.5.2" serialization = "1.7.3" collection = "0.3.8" +datetime = "0.6.0" room-version = "2.6.1" revanced-patcher = "21.0.0" revanced-library = "3.0.2" @@ -68,6 +69,7 @@ placeholder-material3 = { group = "io.github.fornewid", name = "placeholder-mate # Kotlinx kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-collection-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collection" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "datetime" } # Room room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room-version" } From 2ec1c0238dd80548afb5e36f1bee0b1779ad2445 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 19 Dec 2024 21:41:04 +0100 Subject: [PATCH 07/21] feat: Add downloader plugin system (#2041) --- app/build.gradle.kts | 8 +- app/proguard-rules.pro | 4 + .../1.json | 38 ++- app/src/main/AndroidManifest.xml | 19 +- .../java/app/revanced/manager/MainActivity.kt | 56 +-- .../revanced/manager/ManagerApplication.kt | 6 + .../revanced/manager/data/room/AppDatabase.kt | 8 +- .../room/apps/downloaded/DownloadedApp.kt | 1 + .../room/apps/downloaded/DownloadedAppDao.kt | 8 +- .../room/plugins/TrustedDownloaderPlugin.kt | 11 + .../plugins/TrustedDownloaderPluginDao.kt | 22 ++ .../revanced/manager/di/RepositoryModule.kt | 1 + .../revanced/manager/di/ViewModelModule.kt | 1 - .../domain/manager/PreferencesManager.kt | 4 +- .../manager/base/BasePreferencesManager.kt | 17 + .../repository/DownloadedAppRepository.kt | 112 ++++-- .../repository/DownloaderPluginRepository.kt | 168 +++++++++ .../manager/network/downloader/APKMirror.kt | 277 --------------- .../network/downloader/AppDownloader.kt | 27 -- .../downloader/DownloaderPluginState.kt | 9 + .../downloader/LoadedDownloaderPlugin.kt | 15 + .../downloader/ParceledDownloaderData.kt | 45 +++ .../manager/patcher/patch/PatchInfo.kt | 9 +- .../manager/patcher/worker/PatcherWorker.kt | 80 ++++- .../ui/component/ExceptionViewerDialog.kt | 79 +++++ .../manager/ui/component/SearchView.kt | 49 +-- .../bundle/BundleInformationDialog.kt | 66 +--- .../manager/ui/component/patcher/Steps.kt | 18 +- .../ui/component/settings/SettingsListItem.kt | 25 +- .../manager/ui/destination/Destination.kt | 3 - .../destination/SelectedAppInfoDestination.kt | 3 - .../revanced/manager/ui/model/BundleInfo.kt | 4 +- .../revanced/manager/ui/model/PatcherStep.kt | 2 +- .../revanced/manager/ui/model/SelectedApp.kt | 29 +- .../manager/ui/screen/AppSelectorScreen.kt | 31 +- .../manager/ui/screen/DashboardScreen.kt | 22 +- .../ui/screen/InstalledAppInfoScreen.kt | 7 +- .../manager/ui/screen/PatcherScreen.kt | 34 ++ .../ui/screen/PatchesSelectorScreen.kt | 22 +- .../ui/screen/SelectedAppInfoScreen.kt | 318 ++++++++++++------ .../ui/screen/VersionSelectorScreen.kt | 201 ----------- .../settings/DownloadsSettingsScreen.kt | 204 +++++++++-- .../ui/viewmodel/AppSelectorViewModel.kt | 7 +- .../ui/viewmodel/DashboardViewModel.kt | 8 + .../ui/viewmodel/DownloadsViewModel.kt | 42 ++- .../manager/ui/viewmodel/MainViewModel.kt | 34 ++ .../manager/ui/viewmodel/PatcherViewModel.kt | 109 ++++-- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 168 ++++++++- .../ui/viewmodel/VersionSelectorViewModel.kt | 173 ---------- .../main/java/app/revanced/manager/util/PM.kt | 39 ++- .../java/app/revanced/manager/util/Util.kt | 39 ++- app/src/main/res/values/strings.xml | 42 ++- app/src/main/res/values/themes.xml | 2 + build.gradle.kts | 9 +- downloader-plugin/.gitignore | 1 + downloader-plugin/api/downloader-plugin.api | 171 ++++++++++ downloader-plugin/build.gradle.kts | 61 ++++ downloader-plugin/consumer-rules.pro | 0 downloader-plugin/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 3 + .../plugin/downloader/webview/IWebView.aidl | 8 + .../downloader/webview/IWebViewEvents.aidl | 11 + .../manager/plugin/downloader/Constants.kt | 7 + .../manager/plugin/downloader/Downloader.kt | 165 +++++++++ .../manager/plugin/downloader/Extensions.kt | 42 +++ .../manager/plugin/downloader/Parcelables.kt | 39 +++ .../manager/plugin/downloader/webview/API.kt | 176 ++++++++++ .../downloader/webview/WebViewActivity.kt | 161 +++++++++ .../src/main/res/layout/activity_webview.xml | 11 + .../src/main/res/values/strings.xml | 1 + .../src/main/res/values/themes.xml | 7 + example-downloader-plugin/.gitignore | 1 + example-downloader-plugin/build.gradle.kts | 53 +++ example-downloader-plugin/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 23 ++ .../downloader/example/ExamplePlugin.kt | 69 ++++ .../downloader/example/InteractionActivity.kt | 65 ++++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 ++ .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 20 +- settings.gradle.kts | 2 + 84 files changed, 2984 insertions(+), 1105 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt create mode 100644 app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt create mode 100644 app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt create mode 100644 downloader-plugin/.gitignore create mode 100644 downloader-plugin/api/downloader-plugin.api create mode 100644 downloader-plugin/build.gradle.kts create mode 100644 downloader-plugin/consumer-rules.pro create mode 100644 downloader-plugin/proguard-rules.pro create mode 100644 downloader-plugin/src/main/AndroidManifest.xml create mode 100644 downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl create mode 100644 downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt create mode 100644 downloader-plugin/src/main/res/layout/activity_webview.xml create mode 100644 downloader-plugin/src/main/res/values/strings.xml create mode 100644 downloader-plugin/src/main/res/values/themes.xml create mode 100644 example-downloader-plugin/.gitignore create mode 100644 example-downloader-plugin/build.gradle.kts create mode 100644 example-downloader-plugin/proguard-rules.pro create mode 100644 example-downloader-plugin/src/main/AndroidManifest.xml create mode 100644 example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt create mode 100644 example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt create mode 100644 example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml create mode 100644 example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 example-downloader-plugin/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79b726bb..87f37444 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,9 +113,10 @@ dependencies { implementation(libs.runtime.ktx) implementation(libs.runtime.compose) implementation(libs.splash.screen) - implementation(libs.compose.activity) + implementation(libs.activity.compose) implementation(libs.work.runtime.ktx) implementation(libs.preferences.datastore) + implementation(libs.appcompat) // Compose implementation(platform(libs.compose.bom)) @@ -155,6 +156,9 @@ dependencies { implementation(libs.revanced.patcher) implementation(libs.revanced.library) + // Downloader plugins + implementation(project(":downloader-plugin")) + // Native processes implementation(libs.kotlin.process) @@ -196,7 +200,7 @@ dependencies { // EnumUtil implementation(libs.enumutil) ksp(libs.enumutil.ksp) - + // Reorderable lists implementation(libs.reorderable) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f284b52a..b9b9c1af 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -49,6 +49,10 @@ -keep class com.android.** { *; } +-keep class app.revanced.manager.plugin.** { + *; +} + -dontwarn com.google.auto.value.** -dontwarn java.awt.** -dontwarn javax.** diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index eff10786..fd83a51e 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "c385297c07ea54804dc8526c388f706d", + "identityHash": "d0119047505da435972c5247181de675", "entities": [ { "tableName": "patch_bundles", @@ -144,7 +144,7 @@ }, { "tableName": "downloaded_app", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))", "fields": [ { "fieldPath": "packageName", @@ -163,6 +163,12 @@ "columnName": "directory", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "last_used", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -386,12 +392,38 @@ ] } ] + }, + { + "tableName": "trusted_downloader_plugins", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name" + ] + }, + "indices": [], + "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c385297c07ea54804dc8526c388f706d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0119047505da435972c5247181de675')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3acb1c04..0404d045 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,16 @@ - + + + @@ -17,12 +24,6 @@ tools:ignore="ScopedStorage" /> - - - - - - + + diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 4c8d9ef7..ff01094c 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -3,10 +3,12 @@ package app.revanced.manager import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.screen.AppSelectorScreen @@ -15,11 +17,11 @@ import app.revanced.manager.ui.screen.InstalledAppInfoScreen import app.revanced.manager.ui.screen.PatcherScreen import app.revanced.manager.ui.screen.SelectedAppInfoScreen import app.revanced.manager.ui.screen.SettingsScreen -import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel +import app.revanced.manager.util.EventEffect import dev.olshevski.navigation.reimagined.AnimatedNavHost import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.navigate @@ -35,6 +37,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() installSplashScreen() val vm: MainViewModel = getAndroidViewModel() @@ -52,6 +56,10 @@ class MainActivity : ComponentActivity() { rememberNavController(startDestination = Destination.Dashboard) NavBackHandler(navController) + EventEffect(vm.appSelectFlow) { app -> + navController.navigate(Destination.SelectedApplicationInfo(app)) + } + AnimatedNavHost( controller = navController ) { destination -> @@ -59,9 +67,12 @@ class MainActivity : ComponentActivity() { is Destination.Dashboard -> DashboardScreen( onSettingsClick = { navController.navigate(Destination.Settings()) }, onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, - onUpdateClick = { navController.navigate( - Destination.Settings(SettingsDestination.Update()) - ) }, + onUpdateClick = { + navController.navigate(Destination.Settings(SettingsDestination.Update())) + }, + onDownloaderPluginClick = { + navController.navigate(Destination.Settings(SettingsDestination.Downloads)) + }, onAppClick = { installedApp -> navController.navigate( Destination.InstalledApplicationInfo( @@ -72,14 +83,7 @@ class MainActivity : ComponentActivity() { ) is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen( - onPatchClick = { packageName, patchSelection -> - navController.navigate( - Destination.VersionSelector( - packageName, - patchSelection - ) - ) - }, + onPatchClick = vm::selectApp, onBackClick = { navController.pop() }, viewModel = getComposeViewModel { parametersOf(destination.installedApp) } ) @@ -90,35 +94,11 @@ class MainActivity : ComponentActivity() { ) is Destination.AppSelector -> AppSelectorScreen( - onAppClick = { navController.navigate(Destination.VersionSelector(it)) }, - onStorageClick = { - navController.navigate( - Destination.SelectedApplicationInfo( - it - ) - ) - }, + onSelect = vm::selectApp, + onStorageSelect = vm::selectApp, onBackClick = { navController.pop() } ) - is Destination.VersionSelector -> VersionSelectorScreen( - onBackClick = { navController.pop() }, - onAppClick = { selectedApp -> - navController.navigate( - Destination.SelectedApplicationInfo( - selectedApp, - destination.patchSelection, - ) - ) - }, - viewModel = getComposeViewModel { - parametersOf( - destination.packageName, - destination.patchSelection - ) - } - ) - is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( onPatchClick = { app, patches, options -> navController.navigate( diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 66ab2483..2060e602 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -3,6 +3,7 @@ package app.revanced.manager import android.app.Application 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 kotlinx.coroutines.Dispatchers import coil.Coil @@ -23,6 +24,8 @@ class ManagerApplication : Application() { private val scope = MainScope() private val prefs: PreferencesManager by inject() private val patchBundleRepository: PatchBundleRepository by inject() + private val downloaderPluginRepository: DownloaderPluginRepository by inject() + override fun onCreate() { super.onCreate() @@ -59,6 +62,9 @@ class ManagerApplication : Application() { scope.launch { prefs.preload() } + scope.launch(Dispatchers.Default) { + downloaderPluginRepository.reload() + } scope.launch(Dispatchers.Default) { with(patchBundleRepository) { reload() diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 0440a7c2..403bd1cf 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -16,9 +16,14 @@ import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.options.Option import app.revanced.manager.data.room.options.OptionDao import app.revanced.manager.data.room.options.OptionGroup +import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin +import app.revanced.manager.data.room.plugins.TrustedDownloaderPluginDao import kotlin.random.Random -@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class], version = 1) +@Database( + entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloaderPlugin::class], + version = 1 +) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun patchBundleDao(): PatchBundleDao @@ -26,6 +31,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun downloadedAppDao(): DownloadedAppDao abstract fun installedAppDao(): InstalledAppDao abstract fun optionDao(): OptionDao + abstract fun trustedDownloaderPluginDao(): TrustedDownloaderPluginDao companion object { fun generateUid() = Random.Default.nextInt() diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt index 60d1561d..f1703314 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt @@ -12,4 +12,5 @@ data class DownloadedApp( @ColumnInfo(name = "package_name") val packageName: String, @ColumnInfo(name = "version") val version: String, @ColumnInfo(name = "directory") val directory: File, + @ColumnInfo(name = "last_used") val lastUsed: Long = System.currentTimeMillis() ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt index 4f4d9623..492dbde1 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt @@ -4,6 +4,7 @@ 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 @Dao @@ -14,8 +15,11 @@ interface DownloadedAppDao { @Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version") suspend fun get(packageName: String, version: String): DownloadedApp? - @Insert - suspend fun insert(downloadedApp: DownloadedApp) + @Upsert + suspend fun upsert(downloadedApp: DownloadedApp) + + @Query("UPDATE downloaded_app SET last_used = :newValue WHERE package_name = :packageName AND version = :version") + suspend fun markUsed(packageName: String, version: String, newValue: Long = System.currentTimeMillis()) @Delete suspend fun delete(downloadedApps: Collection) diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt new file mode 100644 index 00000000..8e1b9c39 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt @@ -0,0 +1,11 @@ +package app.revanced.manager.data.room.plugins + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "trusted_downloader_plugins") +class TrustedDownloaderPlugin( + @PrimaryKey @ColumnInfo(name = "package_name") val packageName: String, + @ColumnInfo(name = "signature") val signature: ByteArray +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt new file mode 100644 index 00000000..ad1845f7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt @@ -0,0 +1,22 @@ +package app.revanced.manager.data.room.plugins + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert + +@Dao +interface TrustedDownloaderPluginDao { + @Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName") + suspend fun getTrustedSignature(packageName: String): ByteArray? + + @Upsert + suspend fun upsertTrust(plugin: TrustedDownloaderPlugin) + + @Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName") + suspend fun remove(packageName: String) + + @Transaction + @Query("DELETE FROM trusted_downloader_plugins WHERE package_name IN (:packageNames)") + suspend fun removeAll(packageNames: Set) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index df2d7018..159436d4 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -22,6 +22,7 @@ val repositoryModule = module { // It is best to load patch bundles ASAP createdAtStart() } + singleOf(::DownloaderPluginRepository) singleOf(::WorkerRepository) singleOf(::DownloadedAppRepository) singleOf(::InstalledAppRepository) diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 0c69767c..a59d65a2 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -12,7 +12,6 @@ val viewModelModule = module { viewModelOf(::SettingsViewModel) viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AppSelectorViewModel) - viewModelOf(::VersionSelectorViewModel) viewModelOf(::PatcherViewModel) viewModelOf(::UpdateViewModel) viewModelOf(::ChangelogsViewModel) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 8cdc1f19..dbf2f100 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -18,8 +18,6 @@ class PreferencesManager( val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT) val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT) - val preferSplits = booleanPreference("prefer_splits", false) - val firstLaunch = booleanPreference("first_launch", true) val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true) @@ -28,4 +26,6 @@ class PreferencesManager( val disableSelectionWarning = booleanPreference("disable_selection_warning", false) val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false) val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true) + + val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet()) } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt index 2b04bd41..06f75465 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt @@ -26,6 +26,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String protected fun stringPreference(key: String, default: String) = StringPreference(dataStore, key, default) + protected fun stringSetPreference(key: String, default: Set) = + StringSetPreference(dataStore, key, default) + protected fun booleanPreference(key: String, default: Boolean) = BooleanPreference(dataStore, key, default) @@ -52,6 +55,10 @@ class EditorContext(private val prefs: MutablePreferences) { var Preference.value get() = prefs.run { read() } set(value) = prefs.run { write(value) } + + operator fun Preference>.plusAssign(value: String) = prefs.run { + write(read() + value) + } } abstract class Preference( @@ -65,10 +72,12 @@ abstract class Preference( suspend fun get() = flow.first() fun getBlocking() = runBlocking { get() } + @Composable fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember { getBlocking() }) + suspend fun update(value: T) = dataStore.editor { this@Preference.value = value } @@ -108,6 +117,14 @@ class StringPreference( override val key = stringPreferencesKey(key) } +class StringSetPreference( + dataStore: DataStore, + key: String, + default: Set +) : BasePreference>(dataStore, default) { + override val key = stringSetPreferencesKey(key) +} + class BooleanPreference( dataStore: DataStore, key: String, diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt index fe339a2e..b4598fb9 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt @@ -2,56 +2,126 @@ package app.revanced.manager.domain.repository import android.app.Application import android.content.Context +import android.os.Parcelable import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.apps.downloaded.DownloadedApp -import app.revanced.manager.network.downloader.AppDownloader +import app.revanced.manager.network.downloader.LoadedDownloaderPlugin +import app.revanced.manager.plugin.downloader.OutputDownloadScope +import app.revanced.manager.util.PM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn import java.io.File +import java.io.FilterOutputStream +import java.nio.file.StandardOpenOption +import java.util.concurrent.atomic.AtomicLong +import kotlin.io.path.outputStream class DownloadedAppRepository( - app: Application, - db: AppDatabase + private val app: Application, + db: AppDatabase, + private val pm: PM ) { private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE) private val dao = db.downloadedAppDao() fun getAll() = dao.getAllApps().distinctUntilChanged() - fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory)) + fun getApkFileForApp(app: DownloadedApp): File = + getApkFileForDir(dir.resolve(app.directory)) + private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first() suspend fun download( - app: AppDownloader.App, - preferSplits: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit = {}, + plugin: LoadedDownloaderPlugin, + data: Parcelable, + expectedPackageName: String, + expectedVersion: String?, + onDownload: suspend (downloadProgress: Pair) -> Unit, ): File { - this.get(app.packageName, app.version)?.let { downloaded -> - return getApkFileForApp(downloaded) - } - // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here. val relativePath = File(generateUid().toString()) - val savePath = dir.resolve(relativePath).also { it.mkdirs() } + val saveDir = dir.resolve(relativePath).also { it.mkdirs() } + val targetFile = saveDir.resolve("base.apk").toPath() try { - app.download(savePath, preferSplits, onDownload) + val downloadSize = AtomicLong(0) + val downloadedBytes = AtomicLong(0) - dao.insert(DownloadedApp( - packageName = app.packageName, - version = app.version, - directory = relativePath, - )) + channelFlow { + val scope = object : OutputDownloadScope { + override val pluginPackageName = plugin.packageName + override val hostPackageName = app.packageName + override suspend fun reportSize(size: Long) { + require(size > 0) { "Size must be greater than zero" } + require( + downloadSize.compareAndSet( + 0, + size + ) + ) { "Download size has already been set" } + send(downloadedBytes.get() to size) + } + } + + fun emitProgress(bytes: Long) { + val newValue = downloadedBytes.addAndGet(bytes) + val totalSize = downloadSize.get() + if (totalSize < 1) return + trySend(newValue to totalSize).getOrThrow() + } + + targetFile.outputStream(StandardOpenOption.CREATE_NEW).buffered().use { + val stream = object : FilterOutputStream(it) { + override fun write(b: Int) = out.write(b).also { emitProgress(1) } + + override fun write(b: ByteArray?, off: Int, len: Int) = + out.write(b, off, len).also { + emitProgress( + (len - off).toLong() + ) + } + } + plugin.download(scope, data, stream) + } + } + .conflate() + .flowOn(Dispatchers.IO) + .collect { (downloaded, size) -> onDownload(downloaded to size) } + + if (downloadedBytes.get() < 1) error("Downloader did not download anything.") + val pkgInfo = + pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid") + if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}") + if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}") + + // Delete the previous copy (if present). + dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let { + if (!dir.resolve(it).deleteRecursively()) throw Exception("Failed to delete existing directory") + } + dao.upsert( + DownloadedApp( + packageName = pkgInfo.packageName, + version = pkgInfo.versionName!!, + directory = relativePath, + ) + ) } catch (e: Exception) { - savePath.deleteRecursively() + saveDir.deleteRecursively() throw e } // Return the Apk file. - return getApkFileForDir(savePath) + return getApkFileForDir(saveDir) } - suspend fun get(packageName: String, version: String) = dao.get(packageName, version) + suspend fun get(packageName: String, version: String, markUsed: Boolean = false) = + dao.get(packageName, version)?.also { + if (markUsed) dao.markUsed(packageName, version) + } suspend fun delete(downloadedApps: Collection) { downloadedApps.forEach { diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt new file mode 100644 index 00000000..791a09ac --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt @@ -0,0 +1,168 @@ +package app.revanced.manager.domain.repository + +import android.app.Application +import android.content.pm.PackageManager +import android.os.Parcelable +import android.util.Log +import app.revanced.manager.data.room.AppDatabase +import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin +import app.revanced.manager.domain.manager.PreferencesManager +import app.revanced.manager.network.downloader.DownloaderPluginState +import app.revanced.manager.network.downloader.LoadedDownloaderPlugin +import app.revanced.manager.network.downloader.ParceledDownloaderData +import app.revanced.manager.plugin.downloader.DownloaderBuilder +import app.revanced.manager.plugin.downloader.PluginHostApi +import app.revanced.manager.plugin.downloader.Scope +import app.revanced.manager.util.PM +import app.revanced.manager.util.tag +import dalvik.system.PathClassLoader +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.lang.reflect.Modifier + +@OptIn(PluginHostApi::class) +class DownloaderPluginRepository( + private val pm: PM, + private val prefs: PreferencesManager, + private val app: Application, + db: AppDatabase +) { + private val trustDao = db.trustedDownloaderPluginDao() + private val _pluginStates = MutableStateFlow(emptyMap()) + val pluginStates = _pluginStates.asStateFlow() + val loadedPluginsFlow = pluginStates.map { states -> + states.values.filterIsInstance().map { it.plugin } + } + + private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins + private val installedPluginPackageNames = MutableStateFlow(emptySet()) + val newPluginPackageNames = combine( + installedPluginPackageNames, + acknowledgedDownloaderPlugins.flow + ) { installed, acknowledged -> + installed subtract acknowledged + } + + suspend fun reload() { + val plugins = + withContext(Dispatchers.IO) { + pm.getPackagesWithFeature(PLUGIN_FEATURE) + .associate { it.packageName to loadPlugin(it.packageName) } + } + + _pluginStates.value = plugins + installedPluginPackageNames.value = plugins.keys + + val acknowledgedPlugins = acknowledgedDownloaderPlugins.get() + val uninstalledPlugins = acknowledgedPlugins subtract installedPluginPackageNames.value + if (uninstalledPlugins.isNotEmpty()) { + Log.d(tag, "Uninstalled plugins: ${uninstalledPlugins.joinToString(", ")}") + acknowledgedDownloaderPlugins.update(acknowledgedPlugins subtract uninstalledPlugins) + trustDao.removeAll(uninstalledPlugins) + } + } + + fun unwrapParceledData(data: ParceledDownloaderData): Pair { + val plugin = + (_pluginStates.value[data.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin + ?: throw Exception("Downloader plugin with name ${data.pluginPackageName} is not available") + + return plugin to data.unwrapWith(plugin) + } + + private suspend fun loadPlugin(packageName: String): DownloaderPluginState { + try { + if (!verify(packageName)) return DownloaderPluginState.Untrusted + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(tag, "Got exception while verifying plugin $packageName", e) + return DownloaderPluginState.Failed(e) + } + + return try { + val packageInfo = pm.getPackageInfo(packageName, flags = PackageManager.GET_META_DATA)!! + val className = packageInfo.applicationInfo!!.metaData.getString(METADATA_PLUGIN_CLASS) + ?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS") + + val classLoader = + PathClassLoader(packageInfo.applicationInfo!!.sourceDir, app.classLoader) + val pluginContext = app.createPackageContext(packageName, 0) + + val downloader = classLoader + .loadClass(className) + .getDownloaderBuilder() + .build( + scopeImpl = object : Scope { + override val hostPackageName = app.packageName + override val pluginPackageName = pluginContext.packageName + }, + context = pluginContext + ) + + DownloaderPluginState.Loaded( + LoadedDownloaderPlugin( + packageName, + with(pm) { packageInfo.label() }, + packageInfo.versionName!!, + downloader.get, + downloader.download, + classLoader + ) + ) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + Log.e(tag, "Failed to load plugin $packageName", t) + DownloaderPluginState.Failed(t) + } + } + + suspend fun trustPackage(packageName: String) { + trustDao.upsertTrust( + TrustedDownloaderPlugin( + packageName, + pm.getSignature(packageName).toByteArray() + ) + ) + + reload() + prefs.edit { + acknowledgedDownloaderPlugins += packageName + } + } + + suspend fun revokeTrustForPackage(packageName: String) = + trustDao.remove(packageName).also { reload() } + + suspend fun acknowledgeAllNewPlugins() = + acknowledgedDownloaderPlugins.update(installedPluginPackageNames.value) + + private suspend fun verify(packageName: String): Boolean { + val expectedSignature = + trustDao.getTrustedSignature(packageName) ?: return false + + return pm.hasSignature(packageName, expectedSignature) + } + + private companion object { + const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader" + const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class" + + const val PUBLIC_STATIC = Modifier.PUBLIC or Modifier.STATIC + val Int.isPublicStatic get() = (this and PUBLIC_STATIC) == PUBLIC_STATIC + val Class<*>.isDownloaderBuilder get() = DownloaderBuilder::class.java.isAssignableFrom(this) + + @Suppress("UNCHECKED_CAST") + fun Class<*>.getDownloaderBuilder() = + declaredMethods + .firstOrNull { it.modifiers.isPublicStatic && it.returnType.isDownloaderBuilder && it.parameterTypes.isEmpty() } + ?.let { it(null) as DownloaderBuilder } + ?: throw Exception("Could not find a valid downloader implementation in class $canonicalName") + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt deleted file mode 100644 index 25365551..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt +++ /dev/null @@ -1,277 +0,0 @@ -package app.revanced.manager.network.downloader - -import android.os.Build.SUPPORTED_ABIS -import app.revanced.manager.network.service.HttpService -import io.ktor.client.plugins.onDownload -import io.ktor.client.request.parameter -import io.ktor.client.request.url -import it.skrape.selects.html5.a -import it.skrape.selects.html5.div -import it.skrape.selects.html5.form -import it.skrape.selects.html5.h5 -import it.skrape.selects.html5.input -import it.skrape.selects.html5.p -import it.skrape.selects.html5.span -import kotlinx.coroutines.flow.flow -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koin.core.component.inject -import java.io.File - -class APKMirror : AppDownloader, KoinComponent { - private val httpClient: HttpService = get() - - enum class APKType { - APK, - BUNDLE - } - - data class Variant( - val apkType: APKType, - val arch: String, - val link: String - ) - - private suspend fun getAppLink(packageName: String): String { - val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") } - .div { - withId = "content" - findFirst { - div { - withClass = "listWidget" - findAll { - - find { - it.children.first().text.contains(packageName) - }!!.children.mapNotNull { - if (it.classNames.isEmpty()) { - it.h5 { - withClass = "appRowTitle" - findFirst { - a { - findFirst { - attribute("href") - } - } - } - } - } else null - } - - } - } - } - } - - return searchResults.find { url -> - httpClient.getHtml { url(APK_MIRROR + url) } - .div { - withId = "primary" - findFirst { - div { - withClass = "tab-buttons" - findFirst { - div { - withClass = "tab-button-positioning" - findFirst { - children.any { - it.attribute("href") == "https://play.google.com/store/apps/details?id=$packageName" - } - } - } - } - } - } - } - } ?: throw Exception("App isn't available for download") - } - - override fun getAvailableVersions(packageName: String, versionFilter: Set) = flow { - - // We have to hardcode some apps since there are multiple apps with that package name - val appCategory = when (packageName) { - "com.google.android.apps.youtube.music" -> "youtube-music" - "com.google.android.youtube" -> "youtube" - else -> getAppLink(packageName).split("/")[3] - } - - var page = 1 - - val versions = mutableListOf() - - while ( - if (versionFilter.isNotEmpty()) - versions.size < versionFilter.size && page <= 7 - else - page <= 1 - ) { - httpClient.getHtml { - url("$APK_MIRROR/uploads/page/$page/") - parameter("appcategory", appCategory) - }.div { - withClass = "widget_appmanager_recentpostswidget" - findFirst { - div { - withClass = "listWidget" - findFirst { - children.mapNotNull { element -> - if (element.className.isEmpty()) { - - APKMirrorApp( - packageName = packageName, - version = element.div { - withClass = "infoSlide" - findFirst { - p { - findFirst { - span { - withClass = "infoSlide-value" - findFirst { - text - } - } - } - } - } - }.also { - if (it in versionFilter) - versions.add(it) - }, - downloadLink = element.findFirst { - a { - withClass = "downloadLink" - findFirst { - attribute("href") - } - } - } - ) - - } else null - } - } - } - } - }.onEach { version -> emit(version) } - - page++ - } - } - - @Parcelize - private class APKMirrorApp( - override val packageName: String, - override val version: String, - private val downloadLink: String, - ) : AppDownloader.App, KoinComponent { - @IgnoredOnParcel private val httpClient: HttpService by inject() - - override suspend fun download( - saveDirectory: File, - preferSplit: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit - ) { - val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) } - .div { - withClass = "variants-table" - findFirst { // list of variants - children.drop(1).map { - Variant( - apkType = it.div { - findFirst { - span { - findFirst { - enumValueOf(text) - } - } - } - }, - arch = it.div { - findSecond { - text - } - }, - link = it.div { - findFirst { - a { - findFirst { - attribute("href") - } - } - } - } - ) - } - } - } - - val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE) - .also { if (preferSplit) it.reverse() } - - val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType -> - supportedArches.firstNotNullOfOrNull { arch -> - variants.find { it.arch == arch && it.apkType == apkType } - } - } ?: throw Exception("No compatible variant found") - - if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO - - val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) } - .a { - withClass = "downloadButton" - findFirst { - attribute("href") - } - } - - val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) } - .form { - withId = "filedownload" - findFirst { - val apkLink = attribute("action") - val id = input { - withAttribute = "name" to "id" - findFirst { - attribute("value") - } - } - val key = input { - withAttribute = "name" to "key" - findFirst { - attribute("value") - } - } - "$apkLink?id=$id&key=$key" - } - } - - val targetFile = saveDirectory.resolve("base.apk") - - try { - httpClient.download(targetFile) { - url(APK_MIRROR + downloadLink) - onDownload { bytesSentTotal, contentLength -> - onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10)) - } - } - - if (variant.apkType == APKType.BUNDLE) { - // TODO: Extract temp.zip - - targetFile.delete() - } - } finally { - onDownload(null) - } - } - } - - companion object { - const val APK_MIRROR = "https://www.apkmirror.com" - - val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS - } - -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt deleted file mode 100644 index dcefa26e..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.revanced.manager.network.downloader - -import android.os.Parcelable -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface AppDownloader { - - /** - * Returns all downloadable apps. - * - * @param packageName The package name of the app. - * @param versionFilter A set of versions to filter. - */ - fun getAvailableVersions(packageName: String, versionFilter: Set): Flow - - interface App : Parcelable { - val packageName: String - val version: String - - suspend fun download( - saveDirectory: File, - preferSplit: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit = {} - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt new file mode 100644 index 00000000..a72d60c7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt @@ -0,0 +1,9 @@ +package app.revanced.manager.network.downloader + +sealed interface DownloaderPluginState { + data object Untrusted : DownloaderPluginState + + data class Loaded(val plugin: LoadedDownloaderPlugin) : DownloaderPluginState + + data class Failed(val throwable: Throwable) : DownloaderPluginState +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt new file mode 100644 index 00000000..50ddd561 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt @@ -0,0 +1,15 @@ +package app.revanced.manager.network.downloader + +import android.os.Parcelable +import app.revanced.manager.plugin.downloader.OutputDownloadScope +import app.revanced.manager.plugin.downloader.GetScope +import java.io.OutputStream + +class LoadedDownloaderPlugin( + val packageName: String, + val name: String, + val version: String, + val get: suspend GetScope.(packageName: String, version: String?) -> Pair?, + val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit, + val classLoader: ClassLoader +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt new file mode 100644 index 00000000..a43db930 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt @@ -0,0 +1,45 @@ +package app.revanced.manager.network.downloader + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +/** + * A container for [Parcelable] data returned from downloaders. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader]. + */ +class ParceledDownloaderData private constructor( + val pluginPackageName: String, + private val bundle: Bundle +) : Parcelable { + constructor(plugin: LoadedDownloaderPlugin, data: Parcelable) : this( + plugin.packageName, + createBundle(data) + ) + + fun unwrapWith(plugin: LoadedDownloaderPlugin): Parcelable { + bundle.classLoader = plugin.classLoader + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val className = bundle.getString(CLASS_NAME_KEY)!! + val clazz = plugin.classLoader.loadClass(className) + + bundle.getParcelable(DATA_KEY, clazz)!! as Parcelable + } else @Suppress("Deprecation") bundle.getParcelable(DATA_KEY)!! + } + + private companion object { + const val CLASS_NAME_KEY = "class" + const val DATA_KEY = "data" + + fun createBundle(data: Parcelable) = Bundle().apply { + putParcelable(DATA_KEY, data) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString( + CLASS_NAME_KEY, + data::class.java.canonicalName + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt index cd2a2f83..2babc7f4 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt @@ -33,15 +33,14 @@ data class PatchInfo( fun compatibleWith(packageName: String) = compatiblePackages?.any { it.packageName == packageName } ?: true - fun supportsVersion(packageName: String, versionName: String): Boolean { + fun supports(packageName: String, versionName: String?): Boolean { val packages = compatiblePackages ?: return true // Universal patch return packages.any { pkg -> - if (pkg.packageName != packageName) { - return@any false - } + if (pkg.packageName != packageName) return@any false + if (pkg.versions == null) return@any true - pkg.versions == null || pkg.versions.contains(versionName) + versionName != null && versionName in pkg.versions } } 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 c295bde1..d2b5babb 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 @@ -1,5 +1,6 @@ package app.revanced.manager.patcher.worker +import android.app.Activity import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -9,9 +10,11 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.graphics.drawable.Icon import android.os.Build +import android.os.Parcelable import android.os.PowerManager import android.util.Log import android.view.WindowManager +import androidx.activity.result.ActivityResult import androidx.core.content.ContextCompat import androidx.work.ForegroundInfo import androidx.work.WorkerParameters @@ -22,26 +25,35 @@ import app.revanced.manager.domain.installer.RootInstaller 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.DownloaderPluginRepository 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.logger.Logger import app.revanced.manager.patcher.runtime.CoroutineRuntime import app.revanced.manager.patcher.runtime.ProcessRuntime +import app.revanced.manager.plugin.downloader.GetScope +import app.revanced.manager.plugin.downloader.PluginHostApi +import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.State import app.revanced.manager.util.Options import app.revanced.manager.util.PM import app.revanced.manager.util.PatchSelection 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 import java.io.File typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit +@OptIn(PluginHostApi::class) class PatcherWorker( context: Context, parameters: WorkerParameters @@ -49,20 +61,22 @@ class PatcherWorker( private val workerRepository: WorkerRepository by inject() private val prefs: PreferencesManager by inject() private val keystoreManager: KeystoreManager by inject() + private val downloaderPluginRepository: DownloaderPluginRepository by inject() private val downloadedAppRepository: DownloadedAppRepository by inject() private val pm: PM by inject() private val fs: Filesystem by inject() private val installedAppRepository: InstalledAppRepository by inject() private val rootInstaller: RootInstaller by inject() - data class Args( + class Args( val input: SelectedApp, val output: String, val selectedPatches: PatchSelection, val options: Options, val logger: Logger, - val downloadProgress: MutableStateFlow?>, + val downloadProgress: MutableStateFlow?>, val patchesProgress: MutableStateFlow>, + val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult, val setInputFile: (File) -> Unit, val onProgress: ProgressEventHandler ) { @@ -141,16 +155,57 @@ class PatcherWorker( } } + suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) = + downloadedAppRepository.download( + plugin, + data, + args.packageName, + args.input.version, + onDownload = args.downloadProgress::emit + ).also { + args.setInputFile(it) + updateProgress(state = State.COMPLETED) // Download APK + } + val inputFile = when (val selectedApp = args.input) { is SelectedApp.Download -> { - downloadedAppRepository.download( - selectedApp.app, - prefs.preferSplits.get(), - onDownload = { args.downloadProgress.emit(it) } - ).also { - args.setInputFile(it) - updateProgress(state = State.COMPLETED) // Download APK - } + val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data) + + download(plugin, data) + } + + is SelectedApp.Search -> { + downloaderPluginRepository.loadedPluginsFlow.first() + .firstNotNullOfOrNull { plugin -> + try { + val getScope = object : GetScope { + override val pluginPackageName = plugin.packageName + override val hostPackageName = applicationContext.packageName + override suspend fun requestStartActivity(intent: Intent): Intent? { + val result = args.handleStartActivityRequest(plugin, intent) + return when (result.resultCode) { + Activity.RESULT_OK -> result.data + Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled() + else -> throw UserInteractionException.Activity.NotCompleted( + result.resultCode, + result.data + ) + } + } + } + withContext(Dispatchers.IO) { + plugin.get( + getScope, + selectedApp.packageName, + selectedApp.version + ) + }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version } + } catch (e: UserInteractionException.Activity.NotCompleted) { + throw e + } catch (_: UserInteractionException) { + null + }?.let { (data, _) -> download(plugin, data) } + } ?: throw Exception("App is not available.") } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } @@ -184,7 +239,10 @@ class PatcherWorker( Log.i(tag, "Patching succeeded".logFmt()) Result.success() } catch (e: ProcessRuntime.RemoteFailureException) { - Log.e(tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()) + Log.e( + tag, + "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt() + ) updateProgress(state = State.FAILED, message = e.originalStackTrace) Result.failure() } catch (e: Exception) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt new file mode 100644 index 00000000..1ceb9cef --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt @@ -0,0 +1,79 @@ +package app.revanced.manager.ui.component + +import android.content.Intent +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.revanced.manager.R +import app.revanced.manager.ui.component.bundle.BundleTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) { + val context = LocalContext.current + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true + ) + ) { + Scaffold( + topBar = { + BundleTopBar( + title = stringResource(R.string.bundle_error), + onBackClick = onDismiss, + backIcon = { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(R.string.back) + ) + }, + actions = { + IconButton( + onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, + text + ) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + ) { + Icon( + Icons.Outlined.Share, + contentDescription = stringResource(R.string.share) + ) + } + } + ) + } + ) { paddingValues -> + ColumnWithScrollbar( + modifier = Modifier.padding(paddingValues) + ) { + Text(text, modifier = Modifier.horizontalScroll(rememberScrollState())) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt index 303cff05..04b5b589 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt @@ -1,13 +1,15 @@ package app.revanced.manager.ui.component import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -27,29 +29,38 @@ fun SearchView( placeholder: (@Composable () -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) { + val colors = SearchBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + dividerColor = MaterialTheme.colorScheme.outline + ) val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current SearchBar( - query = query, - onQueryChange = onQueryChange, - onSearch = { - keyboardController?.hide() - }, - active = true, - onActiveChange = onActiveChange, - modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester), - placeholder = placeholder, - leadingIcon = { - IconButton({ onActiveChange(false) }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - stringResource(R.string.back) - ) - } + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { + keyboardController?.hide() + }, + expanded = true, + onExpandedChange = onActiveChange, + placeholder = placeholder, + leadingIcon = { + IconButton(onClick = { onActiveChange(false) }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(R.string.back) + ) + } + } + ) }, + expanded = true, + onExpandedChange = onActiveChange, + modifier = Modifier.focusRequester(focusRequester), + colors = colors, content = content ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 83c60e0f..eaebd834 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,21 +1,16 @@ package app.revanced.manager.ui.component.bundle -import android.content.Intent import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -26,7 +21,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState -import app.revanced.manager.ui.component.ColumnWithScrollbar +import app.revanced.manager.ui.component.ExceptionViewerDialog import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -119,7 +114,7 @@ fun BundleInformationDialog( var showDialog by rememberSaveable { mutableStateOf(false) } - if (showDialog) BundleErrorViewerDialog( + if (showDialog) ExceptionViewerDialog( onDismiss = { showDialog = false }, text = remember(it) { it.stackTraceToString() } ) @@ -148,61 +143,4 @@ fun BundleInformationDialog( ) } } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) { - val context = LocalContext.current - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true - ) - ) { - Scaffold( - topBar = { - BundleTopBar( - title = stringResource(R.string.bundle_error), - onBackClick = onDismiss, - backIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - }, - actions = { - IconButton( - onClick = { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra( - Intent.EXTRA_TEXT, - text - ) - type = "text/plain" - } - - val shareIntent = Intent.createChooser(sendIntent, null) - context.startActivity(shareIntent) - } - ) { - Icon( - Icons.Outlined.Share, - contentDescription = stringResource(R.string.share) - ) - } - } - ) - } - ) { paddingValues -> - ColumnWithScrollbar( - modifier = Modifier.padding(paddingValues) - ) { - Text(text, modifier = Modifier.horizontalScroll(rememberScrollState())) - } - } - } } \ No newline at end of file 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 6840837b..280635ce 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 @@ -43,6 +43,7 @@ import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.Step import app.revanced.manager.ui.model.StepCategory +import java.util.Locale import kotlin.math.floor // Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt @@ -134,7 +135,7 @@ fun SubStep( name: String, state: State, message: String? = null, - downloadProgress: Pair? = null + downloadProgress: Pair? = null ) { var messageExpanded by rememberSaveable { mutableStateOf(true) } @@ -180,7 +181,7 @@ fun SubStep( } else { downloadProgress?.let { (current, total) -> Text( - "$current/$total MB", + if (total != null) "${current.megaBytes}/${total.megaBytes} MB" else "${current.megaBytes} MB", style = MaterialTheme.typography.labelSmall ) } @@ -199,7 +200,7 @@ fun SubStep( } @Composable -fun StepIcon(state: State, progress: Pair? = null, size: Dp) { +fun StepIcon(state: State, progress: Pair? = null, size: Dp) { val strokeWidth = Dp(floor(size.value / 10) + 1) when (state) { @@ -233,8 +234,15 @@ fun StepIcon(state: State, progress: Pair? = null, size: Dp) { contentDescription = description } }, - progress = { progress?.let { (current, total) -> current / total } }, + progress = { + progress?.let { (current, total) -> + if (total == null) return@let null + current / total + }?.toFloat() + }, strokeWidth = strokeWidth ) } -} \ No newline at end of file +} + +private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt index 2d40dda7..7c680477 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt @@ -22,13 +22,36 @@ fun SettingsListItem( colors: ListItemColors = ListItemDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, -) = ListItem( +) = SettingsListItem( headlineContent = { Text( text = headlineContent, style = MaterialTheme.typography.titleLarge ) }, + modifier = modifier, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation +) + +@Composable +fun SettingsListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: String? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemDefaults.Elevation, + shadowElevation: Dp = ListItemDefaults.Elevation, +) = ListItem( + headlineContent = headlineContent, modifier = modifier.then(Modifier.padding(horizontal = 8.dp)), overlineContent = overlineContent, supportingContent = { diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index e15bdfb6..93c59411 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -22,9 +22,6 @@ sealed interface Destination : Parcelable { @Parcelize data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination - @Parcelize - data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination - @Parcelize data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt index a1fafa32..9a1f3e29 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt @@ -13,7 +13,4 @@ sealed interface SelectedAppInfoDestination : Parcelable { @Parcelize data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination - - @Parcelize - data object VersionSelector: SelectedAppInfoDestination } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt index e8dc938d..e2bd8b1e 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt @@ -49,7 +49,7 @@ data class BundleInfo( bundle.uid to patches } - fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) = + fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) = sources.flatMapLatestAndCombine( combiner = { it.filterNotNull() } ) { source -> @@ -64,7 +64,7 @@ data class BundleInfo( bundle.patches.filter { it.compatibleWith(packageName) }.forEach { val targetList = when { it.compatiblePackages == null -> universal - it.supportsVersion( + it.supports( packageName, version ) -> supported 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 4c7fc417..c08c823e 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 @@ -19,5 +19,5 @@ data class Step( val category: StepCategory, val state: State = State.WAITING, val message: String? = null, - val downloadProgress: StateFlow?>? = null + val downloadProgress: StateFlow?>? = null ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt index 4e3e8807..5d05c4ea 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt @@ -1,20 +1,35 @@ package app.revanced.manager.ui.model import android.os.Parcelable -import app.revanced.manager.network.downloader.AppDownloader +import app.revanced.manager.network.downloader.ParceledDownloaderData import kotlinx.parcelize.Parcelize import java.io.File -sealed class SelectedApp : Parcelable { - abstract val packageName: String - abstract val version: String +sealed interface SelectedApp : Parcelable { + val packageName: String + val version: String? @Parcelize - data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp() + data class Download( + override val packageName: String, + override val version: String?, + val data: ParceledDownloaderData + ) : SelectedApp @Parcelize - data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp() + data class Search(override val packageName: String, override val version: String?) : SelectedApp @Parcelize - data class Installed(override val packageName: String, override val version: String) : SelectedApp() + 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 } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index eb814c3b..ce6a13ce 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,19 +32,20 @@ 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 import app.revanced.manager.util.transparentListItemColors import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppSelectorScreen( - onAppClick: (packageName: String) -> Unit, - onStorageClick: (SelectedApp.Local) -> Unit, + onSelect: (String) -> Unit, + onStorageSelect: (SelectedApp.Local) -> Unit, onBackClick: () -> Unit, vm: AppSelectorViewModel = koinViewModel() ) { - SideEffect { - vm.onStorageClick = onStorageClick + EventEffect(flow = vm.storageSelectionFlow) { + onStorageSelect(it) } val pickApkLauncher = @@ -75,7 +75,7 @@ fun AppSelectorScreen( ) } - if (search) { + if (search) SearchView( query = filterText, onQueryChange = { filterText = it }, @@ -83,15 +83,15 @@ fun AppSelectorScreen( placeholder = { Text(stringResource(R.string.search_apps)) } ) { if (appList.isNotEmpty() && filterText.isNotEmpty()) { - LazyColumnWithScrollbar( - modifier = Modifier.fillMaxSize() - ) { + LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) { items( items = filteredAppList, key = { it.packageName } ) { app -> ListItem( - modifier = Modifier.clickable { onAppClick(app.packageName) }, + modifier = Modifier.clickable { + onSelect(app.packageName) + }, leadingContent = { AppIcon( app.packageInfo, @@ -125,17 +125,18 @@ fun AppSelectorScreen( Icon( imageVector = Icons.Outlined.Search, contentDescription = stringResource(R.string.search), - modifier = Modifier.size(64.dp) + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = stringResource(R.string.type_anything), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - } Scaffold( topBar = { @@ -184,7 +185,9 @@ fun AppSelectorScreen( key = { it.packageName } ) { app -> ListItem( - modifier = Modifier.clickable { onAppClick(app.packageName) }, + modifier = Modifier.clickable { + onSelect(app.packageName) + }, leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) }, headlineContent = { AppLabel( 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 4dc74766..6d316899 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,7 +5,7 @@ import android.content.Intent import android.net.Uri import android.provider.Settings import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -49,17 +49,21 @@ enum class DashboardPage( } @SuppressLint("BatteryLife") -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( vm: DashboardViewModel = koinViewModel(), onAppSelectorClick: () -> Unit, onSettingsClick: () -> Unit, onUpdateClick: () -> Unit, + onDownloaderPluginClick: () -> Unit, onAppClick: (InstalledApp) -> Unit ) { val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) + val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle( + false + ) val androidContext = LocalContext.current val composableScope = rememberCoroutineScope() val pagerState = rememberPagerState( @@ -237,6 +241,20 @@ fun DashboardScreen( } ) } + } else null, + if (showNewDownloaderPluginsNotification) { + { + NotificationCard( + text = stringResource(R.string.new_downloader_plugins_notification), + icon = Icons.Outlined.Download, + modifier = Modifier.clickable(onClick = onDownloaderPluginClick), + actions = { + TextButton(onClick = vm::ignoreNewDownloaderPlugins) { + Text(stringResource(R.string.dismiss)) + } + } + ) + } } else null ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 239aebbf..9ddf2ef8 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -41,13 +41,12 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel -import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.toast @OptIn(ExperimentalMaterial3Api::class) @Composable fun InstalledAppInfoScreen( - onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit, + onPatchClick: (packageName: String) -> Unit, onBackClick: () -> Unit, viewModel: InstalledAppInfoViewModel ) { @@ -134,9 +133,7 @@ fun InstalledAppInfoScreen( icon = Icons.Outlined.Update, text = stringResource(R.string.repatch), onClick = { - viewModel.appliedPatches?.let { - onPatchClick(viewModel.installedApp.originalPackageName, it) - } + onPatchClick(viewModel.installedApp.originalPackageName) }, enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess() ) 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 6aedde2f..5df81ed3 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 @@ -2,6 +2,7 @@ package app.revanced.manager.ui.screen import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* @@ -38,6 +39,7 @@ 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 +import app.revanced.manager.util.EventEffect @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,6 +88,38 @@ fun PatcherScreen( if (vm.installerStatusDialogModel.packageInstallerStatus != null) InstallerStatusDialog(vm.installerStatusDialogModel) + val activityLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = vm::handleActivityResult + ) + EventEffect(flow = vm.launchActivityFlow) { intent -> + activityLauncher.launch(intent) + } + + vm.activityPromptDialog?.let { title -> + AlertDialog( + onDismissRequest = vm::rejectInteraction, + confirmButton = { + TextButton( + onClick = vm::allowInteraction + ) { + Text(stringResource(R.string.continue_)) + } + }, + dismissButton = { + TextButton( + onClick = vm::rejectInteraction + ) { + Text(stringResource(R.string.cancel)) + } + }, + title = { Text(title) }, + text = { + Text(stringResource(R.string.plugin_activity_dialog_body)) + } + ) + } + AppScaffold( topBar = { AppTopBar( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 511a1c36..e38f320b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -1,6 +1,5 @@ package app.revanced.manager.ui.screen -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope @@ -49,7 +48,7 @@ import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.transparentListItemColors import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PatchesSelectorScreen( onSave: (PatchSelection?, Options) -> Unit, @@ -137,7 +136,7 @@ fun PatchesSelectorScreen( if (vm.compatibleVersions.isNotEmpty()) UnsupportedPatchDialog( - appVersion = vm.appVersion, + appVersion = vm.appVersion ?: stringResource(R.string.any_version), supportedVersions = vm.compatibleVersions, onDismissRequest = vm::dismissDialogs ) @@ -146,7 +145,7 @@ fun PatchesSelectorScreen( } if (showUnsupportedPatchesDialog) UnsupportedPatchesDialog( - appVersion = vm.appVersion, + appVersion = vm.appVersion ?: stringResource(R.string.any_version), onDismissRequest = { showUnsupportedPatchesDialog = false } ) @@ -204,15 +203,15 @@ fun PatchesSelectorScreen( when { // Open unsupported dialog if the patch is not supported !supported -> vm.openUnsupportedDialog(patch) - + // Show selection warning if enabled vm.selectionWarningEnabled -> showSelectionWarning = true - + // Set pending universal patch action if the universal patch warning is enabled and there are no compatible packages vm.universalPatchWarningEnabled && patch.compatiblePackages == null -> { vm.pendingUniversalPatchAction = { vm.togglePatch(uid, patch) } } - + // Toggle the patch otherwise else -> vm.togglePatch(uid, patch) } @@ -275,7 +274,11 @@ fun PatchesSelectorScreen( Scaffold( topBar = { AppTopBar( - title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount), + title = stringResource( + R.string.patches_selected, + selectedPatchCount, + availablePatchCount + ), onBackClick = onBackClick, actions = { IconButton(onClick = vm::reset) { @@ -436,7 +439,7 @@ private fun PatchItem( selected: Boolean, onToggle: () -> Unit, supported: Boolean = true -) = ListItem ( +) = ListItem( modifier = Modifier .let { if (!supported) it.alpha(0.5f) else it } .clickable(onClick = onToggle) @@ -457,6 +460,7 @@ private fun PatchItem( } } }, + colors = transparentListItemColors ) @Composable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 9c3f59d4..9fb48372 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -1,10 +1,14 @@ package app.revanced.manager.ui.screen -import android.content.pm.PackageInfo +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.PaddingValues 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 @@ -19,22 +23,31 @@ 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.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.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel 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.toast +import app.revanced.manager.util.transparentListItemColors import dev.olshevski.navigation.reimagined.* import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectedAppInfoScreen( onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit, @@ -61,56 +74,137 @@ fun SelectedAppInfoScreen( } } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = vm::handlePluginActivityResult + ) + EventEffect(flow = vm.launchActivityFlow) { intent -> + launcher.launch(intent) + } + val navController = rememberNavController(startDestination = SelectedAppInfoDestination.Main) NavBackHandler(controller = navController) AnimatedNavHost(controller = navController) { destination -> + val error by vm.errorFlow.collectAsStateWithLifecycle(null) when (destination) { - is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen( - onPatchClick = patchClick@{ - if (selectedPatchCount == 0) { - context.toast(context.getString(R.string.no_patches_selected)) - - return@patchClick - } - onPatchClick( - vm.selectedApp, - patches, - vm.getOptionsFiltered(bundles) + is SelectedAppInfoDestination.Main -> Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.app_info), + onBackClick = onBackClick ) }, - onPatchSelectorClick = { - navController.navigate( - SelectedAppInfoDestination.PatchesSelector( - vm.selectedApp, - vm.getCustomPatches( - bundles, - allowIncompatiblePatches - ), - vm.options + floatingActionButton = { + if (error != null) return@Scaffold + + HapticExtendedFloatingActionButton( + text = { Text(stringResource(R.string.patch)) }, + icon = { + Icon( + Icons.Default.AutoFixHigh, + stringResource(R.string.patch) + ) + }, + onClick = patchClick@{ + if (selectedPatchCount == 0) { + context.toast(context.getString(R.string.no_patches_selected)) + + return@patchClick + } + onPatchClick( + vm.selectedApp, + patches, + vm.getOptionsFiltered(bundles) + ) + } + ) + } + ) { 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, ) - ) - }, - onVersionSelectorClick = { - navController.navigate(SelectedAppInfoDestination.VersionSelector) - }, - onBackClick = onBackClick, - selectedPatchCount = selectedPatchCount, - packageName = packageName, - version = version, - packageInfo = vm.selectedAppInfo, - ) + } - is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen( - onBackClick = navController::pop, - onAppClick = { - vm.selectedApp = it - navController.pop() - }, - viewModel = koinViewModel { parametersOf(packageName) } - ) + PageItem( + R.string.patch_selector_item, + stringResource( + R.string.patch_selector_item_description, + selectedPatchCount + ), + onClick = { + navController.navigate( + SelectedAppInfoDestination.PatchesSelector( + vm.selectedApp, + vm.getCustomPatches( + bundles, + allowIncompatiblePatches + ), + vm.options + ) + ) + } + ) + 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() + } + ) + error?.let { + Text( + stringResource(it.resourceId), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } + } is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( onSave = { patches, options -> @@ -132,65 +226,6 @@ fun SelectedAppInfoScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SelectedAppInfoScreen( - onPatchClick: () -> Unit, - onPatchSelectorClick: () -> Unit, - onVersionSelectorClick: () -> Unit, - onBackClick: () -> Unit, - selectedPatchCount: Int, - packageName: String, - version: String, - packageInfo: PackageInfo?, -) { - Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.app_info), - onBackClick = onBackClick - ) - }, - floatingActionButton = { - HapticExtendedFloatingActionButton( - text = { Text(stringResource(R.string.patch)) }, - icon = { - Icon( - Icons.Default.AutoFixHigh, - stringResource(R.string.patch) - ) - }, - onClick = onPatchClick - ) - } - ) { paddingValues -> - ColumnWithScrollbar( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - AppInfo(packageInfo, placeholderLabel = packageName) { - Text( - stringResource(R.string.selected_app_meta, version), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } - - PageItem( - R.string.patch_selector_item, - stringResource(R.string.patch_selector_item_description, selectedPatchCount), - onPatchSelectorClick - ) - PageItem( - R.string.version_selector_item, - stringResource(R.string.version_selector_item_description, version), - onVersionSelectorClick - ) - } - } -} - @Composable private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) { ListItem( @@ -215,4 +250,89 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Icon(Icons.AutoMirrored.Outlined.ArrowRight, null) } ) +} + +@Composable +private fun AppSourceSelectorDialog( + plugins: List, + installedApp: Pair?, + 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 + ) + } + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt deleted file mode 100644 index a8d12d50..00000000 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ /dev/null @@ -1,201 +0,0 @@ -package app.revanced.manager.ui.screen - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.pluralStringResource -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.AppTopBar -import app.revanced.manager.ui.component.GroupHeader -import app.revanced.manager.ui.component.LazyColumnWithScrollbar -import app.revanced.manager.ui.component.LoadingIndicator -import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton -import app.revanced.manager.ui.component.haptics.HapticRadioButton -import app.revanced.manager.ui.component.NonSuggestedVersionDialog -import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel -import app.revanced.manager.util.isScrollingUp - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VersionSelectorScreen( - onBackClick: () -> Unit, - onAppClick: (SelectedApp) -> Unit, - viewModel: VersionSelectorViewModel -) { - val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap()) - val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList()) - - val list by remember { - derivedStateOf { - val apps = (downloadedVersions + viewModel.downloadableVersions) - .distinctBy { it.version } - .sortedWith( - compareByDescending { - it is SelectedApp.Local - }.thenByDescending { supportedVersions[it.version] } - .thenByDescending { it.version } - ) - - viewModel.requiredVersion?.let { requiredVersion -> - apps.filter { it.version == requiredVersion } - } ?: apps - } - } - - if (viewModel.showNonSuggestedVersionDialog) - NonSuggestedVersionDialog( - suggestedVersion = viewModel.requiredVersion.orEmpty(), - onDismiss = viewModel::dismissNonSuggestedVersionDialog - ) - - val lazyListState = rememberLazyListState() - Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.select_version), - onBackClick = onBackClick, - ) - }, - floatingActionButton = { - HapticExtendedFloatingActionButton( - text = { Text(stringResource(R.string.select_version)) }, - icon = { - Icon( - Icons.Default.Check, - stringResource(R.string.select_version) - ) - }, - expanded = lazyListState.isScrollingUp, - onClick = { viewModel.selectedVersion?.let(onAppClick) } - ) - } - ) { paddingValues -> - LazyColumnWithScrollbar( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState - ) { - viewModel.installedApp?.let { (packageInfo, installedApp) -> - SelectedApp.Installed( - packageName = viewModel.packageName, - version = packageInfo.versionName!! - ).let { - item { - SelectedAppItem( - selectedApp = it, - selected = viewModel.selectedVersion == it, - onClick = { viewModel.select(it) }, - patchCount = supportedVersions[it.version], - enabled = - !(installedApp?.installType == InstallType.MOUNT && !viewModel.rootInstaller.hasRootAccess()), - alreadyPatched = installedApp != null && installedApp.installType != InstallType.MOUNT - ) - } - } - } - - item { - Row(Modifier.fillMaxWidth()) { - GroupHeader(stringResource(R.string.downloadable_versions)) - } - } - - items( - items = list, - key = { it.version } - ) { - SelectedAppItem( - selectedApp = it, - selected = viewModel.selectedVersion == it, - onClick = { viewModel.select(it) }, - patchCount = supportedVersions[it.version] - ) - } - - if (viewModel.errorMessage != null) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.error_occurred)) - Text( - text = viewModel.errorMessage!!, - modifier = Modifier.padding(horizontal = 15.dp) - ) - } - } - } else if (viewModel.isLoading) { - item { - LoadingIndicator() - } - } - } - } -} - -@Composable -fun SelectedAppItem( - selectedApp: SelectedApp, - selected: Boolean, - onClick: () -> Unit, - patchCount: Int?, - enabled: Boolean = true, - alreadyPatched: Boolean = false, -) { - ListItem( - leadingContent = { HapticRadioButton(selected, null) }, - headlineContent = { Text(selectedApp.version) }, - supportingContent = when (selectedApp) { - is SelectedApp.Installed -> - if (alreadyPatched) { - { Text(stringResource(R.string.already_patched)) } - } else { - { Text(stringResource(R.string.installed)) } - } - - is SelectedApp.Local -> { - { Text(stringResource(R.string.already_downloaded)) } - } - - else -> null - }, - trailingContent = patchCount?.let { - { - Text(pluralStringResource(R.plurals.patch_count, it, it)) - } - }, - modifier = Modifier - .clickable(enabled = !alreadyPatched && enabled, onClick = onClick) - .run { - if (!enabled || alreadyPatched) alpha(0.5f) - else this - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 41e80b40..68e02942 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -1,39 +1,60 @@ package app.revanced.manager.ui.screen.settings +import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ColumnWithScrollbar +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.haptics.HapticCheckbox -import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel import org.koin.androidx.compose.koinViewModel +import java.security.MessageDigest -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) @Composable fun DownloadsSettingsScreen( onBackClick: () -> Unit, viewModel: DownloadsViewModel = koinViewModel() ) { - val prefs = viewModel.prefs - - val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList()) + val pullRefreshState = rememberPullToRefreshState() + val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) + val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -41,8 +62,8 @@ fun DownloadsSettingsScreen( title = stringResource(R.string.downloads), onBackClick = onBackClick, actions = { - if (viewModel.selection.isNotEmpty()) { - IconButton(onClick = { viewModel.delete() }) { + if (viewModel.appSelection.isNotEmpty()) { + IconButton(onClick = { viewModel.deleteApps() }) { Icon(Icons.Default.Delete, stringResource(R.string.delete)) } } @@ -50,35 +71,178 @@ fun DownloadsSettingsScreen( ) } ) { paddingValues -> - ColumnWithScrollbar( + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth() + .zIndex(1f) + ) { + PullToRefreshDefaults.Indicator( + state = pullRefreshState, + isRefreshing = viewModel.isRefreshingPlugins + ) + } + + LazyColumnWithScrollbar( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .pullToRefresh( + isRefreshing = viewModel.isRefreshingPlugins, + state = pullRefreshState, + onRefresh = viewModel::refreshPlugins + ) ) { - BooleanItem( - preference = prefs.preferSplits, - headline = R.string.prefer_splits, - description = R.string.prefer_splits_description, - ) + item { + GroupHeader(stringResource(R.string.downloader_plugins)) + } + pluginStates.forEach { (packageName, state) -> + item(key = packageName) { + var showDialog by rememberSaveable { + mutableStateOf(false) + } - GroupHeader(stringResource(R.string.downloaded_apps)) + fun dismiss() { + showDialog = false + } - downloadedApps.forEach { app -> - val selected = app in viewModel.selection + val packageInfo = + remember(packageName) { + viewModel.pm.getPackageInfo( + packageName + ) + } ?: return@item + + if (showDialog) { + val signature = + remember(packageName) { + val androidSignature = + viewModel.pm.getSignature(packageName) + val hash = MessageDigest.getInstance("SHA-256") + .digest(androidSignature.toByteArray()) + hash.toHexString(format = HexFormat.UpperCase) + } + + when (state) { + is DownloaderPluginState.Loaded -> TrustDialog( + title = R.string.downloader_plugin_revoke_trust_dialog_title, + body = stringResource( + R.string.downloader_plugin_trust_dialog_body, + packageName, + signature + ), + onDismiss = ::dismiss, + onConfirm = { + viewModel.revokePluginTrust(packageName) + dismiss() + } + ) + + is DownloaderPluginState.Failed -> ExceptionViewerDialog( + text = remember(state.throwable) { + state.throwable.stackTraceToString() + }, + onDismiss = ::dismiss + ) + + is DownloaderPluginState.Untrusted -> TrustDialog( + title = R.string.downloader_plugin_trust_dialog_title, + body = stringResource( + R.string.downloader_plugin_trust_dialog_body, + packageName, + signature + ), + onDismiss = ::dismiss, + onConfirm = { + viewModel.trustPlugin(packageName) + dismiss() + } + ) + } + } + + SettingsListItem( + modifier = Modifier.clickable { showDialog = true }, + headlineContent = { + AppLabel( + packageInfo = packageInfo, + style = MaterialTheme.typography.titleLarge + ) + }, + supportingContent = stringResource( + when (state) { + is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted + is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed + is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted + } + ), + trailingContent = { Text(packageInfo.versionName!!) } + ) + } + } + if (pluginStates.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_no_plugins_installed), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + item { + GroupHeader(stringResource(R.string.downloaded_apps)) + } + items(downloadedApps, key = { it.packageName to it.version }) { app -> + val selected = app in viewModel.appSelection SettingsListItem( - modifier = Modifier.clickable { viewModel.toggleItem(app) }, + modifier = Modifier.clickable { viewModel.toggleApp(app) }, headlineContent = app.packageName, leadingContent = (@Composable { HapticCheckbox( checked = selected, - onCheckedChange = { viewModel.toggleItem(app) } + onCheckedChange = { viewModel.toggleApp(app) } ) - }).takeIf { viewModel.selection.isNotEmpty() }, + }).takeIf { viewModel.appSelection.isNotEmpty() }, supportingContent = app.version, tonalElevation = if (selected) 8.dp else 0.dp ) } + if (downloadedApps.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_settings_no_apps), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } } } +} + +@Composable +private fun TrustDialog( + @StringRes title: Int, + body: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.continue_)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dismiss)) + } + }, + title = { Text(stringResource(title)) }, + text = { Text(body) } + ) } \ No newline at end of file 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 2acfdcd5..eaa66f47 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 @@ -14,7 +14,9 @@ import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.util.PM import app.revanced.manager.util.toast import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -30,7 +32,8 @@ class AppSelectorViewModel( } val appList = pm.appList - var onStorageClick: (SelectedApp.Local) -> Unit = {} + private val storageSelectionChannel = Channel() + val storageSelectionFlow = storageSelectionChannel.receiveAsFlow() val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default) @@ -54,7 +57,7 @@ class AppSelectorViewModel( } if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) { - onStorageClick(selectedApp) + storageSelectionChannel.send(selectedApp) } else { nonSuggestedVersionDialogSubject = selectedApp } 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 5a019c51..99be81ec 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 @@ -17,6 +17,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.RemotePatchBundle 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.toast @@ -28,6 +29,7 @@ import kotlinx.coroutines.launch class DashboardViewModel( private val app: Application, private val patchBundleRepository: PatchBundleRepository, + private val downloaderPluginRepository: DownloaderPluginRepository, private val reVancedAPI: ReVancedAPI, private val networkInfo: NetworkInfo, val prefs: PreferencesManager @@ -39,6 +41,8 @@ class DashboardViewModel( val sources = patchBundleRepository.sources val selectedSources = mutableStateListOf() + val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() } + var updatedManagerVersion: String? by mutableStateOf(null) private set var showBatteryOptimizationsWarning by mutableStateOf(false) @@ -52,6 +56,10 @@ class DashboardViewModel( } } + fun ignoreNewDownloaderPlugins() = viewModelScope.launch { + downloaderPluginRepository.acknowledgeAllNewPlugins() + } + fun dismissUpdateDialog() { updatedManagerVersion = null } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 4688cf16..e2c750df 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -1,10 +1,15 @@ package app.revanced.manager.ui.viewmodel +import android.content.pm.PackageInfo +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.data.room.apps.downloaded.DownloadedApp -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.util.PM import app.revanced.manager.util.mutableStateSetOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -14,8 +19,10 @@ import kotlinx.coroutines.withContext class DownloadsViewModel( private val downloadedAppRepository: DownloadedAppRepository, - val prefs: PreferencesManager + private val downloaderPluginRepository: DownloaderPluginRepository, + val pm: PM ) : ViewModel() { + val downloaderPluginStates = downloaderPluginRepository.pluginStates val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps -> downloadedApps.sortedWith( compareBy { @@ -23,24 +30,39 @@ class DownloadsViewModel( }.thenBy { it.version } ) } + val appSelection = mutableStateSetOf() - val selection = mutableStateSetOf() + var isRefreshingPlugins by mutableStateOf(false) + private set - fun toggleItem(downloadedApp: DownloadedApp) { - if (selection.contains(downloadedApp)) - selection.remove(downloadedApp) + fun toggleApp(downloadedApp: DownloadedApp) { + if (appSelection.contains(downloadedApp)) + appSelection.remove(downloadedApp) else - selection.add(downloadedApp) + appSelection.add(downloadedApp) } - fun delete() { + fun deleteApps() { viewModelScope.launch(NonCancellable) { - downloadedAppRepository.delete(selection) + downloadedAppRepository.delete(appSelection) withContext(Dispatchers.Main) { - selection.clear() + appSelection.clear() } } } + fun refreshPlugins() = viewModelScope.launch { + isRefreshingPlugins = true + downloaderPluginRepository.reload() + isRefreshingPlugins = false + } + + fun trustPlugin(packageName: String) = viewModelScope.launch { + downloaderPluginRepository.trustPackage(packageName) + } + + fun revokePluginTrust(packageName: String) = viewModelScope.launch { + downloaderPluginRepository.revokeTrustForPackage(packageName) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt index 1b9aab30..d665efb0 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt @@ -15,13 +15,17 @@ 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 +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -29,10 +33,40 @@ 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 ) : ViewModel() { + private val appSelectChannel = Channel() + val appSelectFlow = appSelectChannel.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))) + } + fun importLegacySettings(componentActivity: ComponentActivity) { if (!prefs.firstLaunch.getBlocking()) return 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 00c1231f..5ab42a89 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 @@ -8,7 +8,9 @@ import android.content.IntentFilter import android.content.pm.PackageInstaller import android.net.Uri 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.setValue @@ -29,6 +31,8 @@ import app.revanced.manager.domain.worker.WorkerRepository 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.component.InstallerStatusDialogModel @@ -42,11 +46,14 @@ 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.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 @@ -58,6 +65,7 @@ import java.time.Duration import java.util.UUID @Stable +@OptIn(PluginHostApi::class) class PatcherViewModel( private val input: Destination.Patcher ) : ViewModel(), KoinComponent { @@ -68,19 +76,20 @@ class PatcherViewModel( private val installedAppRepository: InstalledAppRepository by inject() private val rootInstaller: RootInstaller by inject() - val installerStatusDialogModel : InstallerStatusDialogModel = object : InstallerStatusDialogModel { - override var packageInstallerStatus: Int? by mutableStateOf(null) + val installerStatusDialogModel: InstallerStatusDialogModel = + object : InstallerStatusDialogModel { + override var packageInstallerStatus: Int? by mutableStateOf(null) - override fun reinstall() { - this@PatcherViewModel.reinstall() - } + 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) + override fun install() { + // Since this is a package installer status dialog, + // InstallType.MOUNT is never used here. + install(InstallType.DEFAULT) + } } - } private var installedApp: InstalledApp? = null val packageName: String = input.selectedApp.packageName @@ -89,6 +98,13 @@ class PatcherViewModel( var isInstalling by mutableStateOf(false) private set + 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() @@ -109,7 +125,7 @@ class PatcherViewModel( } val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size })) - private val downloadProgress = MutableStateFlow?>(null) + private val downloadProgress = MutableStateFlow?>(null) val steps = generateSteps( app, input.selectedApp, @@ -130,6 +146,33 @@ class PatcherViewModel( downloadProgress, patchesProgress, setInputFile = { 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()) { + currentActivityRequest = this to plugin.name + + await() + } + if (!accepted) throw UserInteractionException.RequestDenied() + + // Launch the activity and wait for the result. + try { + with(CompletableDeferred()) { + launchedActivity = this + launchActivityChannel.send(intent) + await() + } + } finally { + launchedActivity = null + } + } finally { + currentActivityRequest = null + } + } + }, onProgress = { name, state, message -> viewModelScope.launch { steps[currentStepIndex] = steps[currentStepIndex].run { @@ -173,13 +216,15 @@ class PatcherViewModel( ?.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, + input.selectedApp.version + ?: pm.getPackageInfo(outputFile)?.versionName!!, InstallType.DEFAULT, input.selectedPatches ) @@ -245,6 +290,18 @@ class PatcherViewModel( fun isDeviceRooted() = rootInstaller.isDeviceRooted() + fun rejectInteraction() { + currentActivityRequest?.first?.complete(false) + } + + fun allowInteraction() { + currentActivityRequest?.first?.complete(true) + } + + fun handleActivityResult(result: ActivityResult) { + launchedActivity?.complete(result) + } + fun export(uri: Uri?) = viewModelScope.launch { uri?.let { withContext(Dispatchers.IO) { @@ -285,7 +342,8 @@ 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 + installerStatusDialogModel.packageInstallerStatus = + PackageInstaller.STATUS_FAILURE_CONFLICT return@launch } } @@ -305,6 +363,11 @@ class PatcherViewModel( InstallType.MOUNT -> { try { + val packageInfo = pm.getPackageInfo(outputFile) + ?: throw Exception("Failed to load application info") + 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 @@ -316,24 +379,23 @@ class PatcherViewModel( } } - // Get label - val label = with(pm) { - currentPackageInfo.label() - } + val inputVersion = input.selectedApp.version + ?: inputFile?.let(pm::getPackageInfo)?.versionName + ?: throw Exception("Failed to determine input APK version") // Install as root rootInstaller.install( outputFile, inputFile, packageName, - input.selectedApp.version, + inputVersion, label ) installedAppRepository.addOrUpdate( + packageInfo.packageName, packageName, - packageName, - input.selectedApp.version, + inputVersion, InstallType.MOUNT, input.selectedPatches ) @@ -353,7 +415,7 @@ class PatcherViewModel( } } } - } catch(e: Exception) { + } catch (e: Exception) { Log.e(tag, "Failed to install", e) app.toast(app.getString(R.string.install_app_fail, e.simpleMessage())) } finally { @@ -385,9 +447,10 @@ class PatcherViewModel( fun generateSteps( context: Context, selectedApp: SelectedApp, - downloadProgress: StateFlow?>? = null + downloadProgress: StateFlow?>? = null ): List { - val needsDownload = selectedApp is SelectedApp.Download + val needsDownload = + selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search return listOfNotNull( Step( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 4dba4136..3e16d69e 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -1,50 +1,91 @@ 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.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.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.network.downloader.LoadedDownloaderPlugin +import app.revanced.manager.network.downloader.ParceledDownloaderData +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.BundleInfo import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import org.koin.core.component.KoinComponent import org.koin.core.component.get -@OptIn(SavedStateHandleSaveableApi::class) +@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class) class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { + private val app: Application = get() val bundlesRepo: PatchBundleRepository = 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 pm: PM = get() private val savedStateHandle: SavedStateHandle = get() val prefs: PreferencesManager = get() + val plugins = pluginsRepository.loadedPluginsFlow + val desiredVersion = input.app.version + val packageName = input.app.packageName private val persistConfiguration = input.patches == null + val hasRoot = rootInstaller.hasRootAccess() + var installedAppData: Pair? by mutableStateOf(null) + private set + private var _selectedApp by savedStateHandle.saveable { mutableStateOf(input.app) } + var selectedAppInfo: PackageInfo? by mutableStateOf(null) + private set + var selectedApp get() = _selectedApp set(value) { @@ -52,10 +93,27 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { invalidateSelectedAppInfo() } - var selectedAppInfo: PackageInfo? by mutableStateOf(null) - 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] } var options: Options by savedStateHandle.saveable { @@ -64,9 +122,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { viewModelScope.launch { if (!persistConfiguration) return@launch // TODO: save options for patched apps. - val packageName = - selectedApp.packageName // Accessing this from another thread may cause crashes. - state.value = withContext(Dispatchers.Default) { val bundlePatches = bundleRepository.bundles.first() .mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } } @@ -89,7 +144,7 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { viewModelScope.launch { if (!prefs.disableSelectionWarning.get()) return@launch - val previous = selectionRepository.getSelection(selectedApp.packageName) + val previous = selectionRepository.getSelection(packageName) if (previous.values.sumOf { it.size } == 0) return@launch selection.value = SelectionState.Customized(previous) } @@ -97,11 +152,102 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { selection } + var showSourceSelector by mutableStateOf(false) + private set + private var pluginAction: Pair? by mutableStateOf(null) + val activePluginAction get() = pluginAction?.first?.packageName + private var launchedActivity by mutableStateOf?>(null) + private val launchActivityChannel = Channel() + 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()) { + 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) + } + private fun invalidateSelectedAppInfo() = viewModelScope.launch { val info = when (val app = selectedApp) { - is SelectedApp.Download -> null is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) } is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) } + else -> null } selectedAppInfo = info @@ -129,8 +275,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { this.options = filteredOptions if (!persistConfiguration) return - - val packageName = selectedApp.packageName viewModelScope.launch(Dispatchers.Default) { selection?.let { selectionRepository.updateSelection(packageName, it) } ?: selectionRepository.clearSelection(packageName) @@ -144,6 +288,10 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { val patches: PatchSelection?, ) + enum class Error(@StringRes val resourceId: Int) { + NoPlugins(R.string.downloader_no_plugins_available) + } + private companion object { /** * Returns a copy with all nonexistent options removed. diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt deleted file mode 100644 index d9f73264..00000000 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ /dev/null @@ -1,173 +0,0 @@ -package app.revanced.manager.ui.viewmodel - -import android.content.pm.PackageInfo -import android.util.Log -import androidx.compose.runtime.derivedStateOf -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.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.InstalledAppRepository -import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.network.downloader.APKMirror -import app.revanced.manager.network.downloader.AppDownloader -import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.util.PM -import app.revanced.manager.util.mutableStateSetOf -import app.revanced.manager.util.simpleMessage -import app.revanced.manager.util.tag -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -class VersionSelectorViewModel( - val packageName: String -) : ViewModel(), KoinComponent { - private val downloadedAppRepository: DownloadedAppRepository by inject() - private val installedAppRepository: InstalledAppRepository by inject() - private val patchBundleRepository: PatchBundleRepository by inject() - private val pm: PM by inject() - private val prefs: PreferencesManager by inject() - private val appDownloader: AppDownloader = APKMirror() - val rootInstaller: RootInstaller by inject() - - var installedApp: Pair? by mutableStateOf(null) - private set - var isLoading by mutableStateOf(true) - private set - var errorMessage: String? by mutableStateOf(null) - private set - - var requiredVersion: String? by mutableStateOf(null) - private set - - var selectedVersion: SelectedApp? by mutableStateOf(null) - private set - - private var nonSuggestedVersionDialogSubject by mutableStateOf(null) - val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null } - - private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) { - if (!prefs.suggestedVersionSafeguard.get()) return@async null - - patchBundleRepository.suggestedVersions.first()[packageName] - } - - val supportedVersions = patchBundleRepository.bundles.map supportedVersions@{ bundles -> - requiredVersionAsync.await()?.let { version -> - // It is mandatory to use the suggested version if the safeguard is enabled. - return@supportedVersions mapOf( - version to bundles - .asSequence() - .flatMap { (_, bundle) -> bundle.patches } - .flatMap { it.compatiblePackages.orEmpty() } - .filter { it.packageName == packageName } - .count { it.versions.isNullOrEmpty() || version in it.versions } - ) - } - - var patchesWithoutVersions = 0 - - bundles.flatMap { (_, bundle) -> - bundle.patches.flatMap { patch -> - patch.compatiblePackages.orEmpty() - .filter { it.packageName == packageName } - .onEach { if (it.versions == null) patchesWithoutVersions++ } - .flatMap { it.versions.orEmpty() } - } - }.groupingBy { it } - .eachCount() - .toMutableMap() - .apply { - replaceAll { _, count -> - count + patchesWithoutVersions - } - } - }.flowOn(Dispatchers.Default) - - init { - viewModelScope.launch { - requiredVersion = requiredVersionAsync.await() - } - } - - val downloadableVersions = mutableStateSetOf() - - val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps -> - downloadedApps.filter { it.packageName == packageName }.map { - SelectedApp.Local( - it.packageName, - it.version, - downloadedAppRepository.getApkFileForApp(it), - false - ) - } - } - - init { - viewModelScope.launch(Dispatchers.Main) { - val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) } - val installedAppDeferred = - async(Dispatchers.IO) { installedAppRepository.get(packageName) } - - installedApp = - packageInfo.await()?.let { - it to installedAppDeferred.await() - } - } - - viewModelScope.launch(Dispatchers.IO) { - try { - val compatibleVersions = supportedVersions.first() - - appDownloader.getAvailableVersions( - packageName, - compatibleVersions.keys - ).collect { - if (it.version in compatibleVersions || compatibleVersions.isEmpty()) { - downloadableVersions.add( - SelectedApp.Download( - packageName, - it.version, - it - ) - ) - } - } - - withContext(Dispatchers.Main) { - isLoading = false - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Log.e(tag, "Failed to load apps", e) - errorMessage = e.simpleMessage() - } - } - } - } - - fun dismissNonSuggestedVersionDialog() { - nonSuggestedVersionDialogSubject = null - } - - fun select(app: SelectedApp) { - if (requiredVersion != null && app.version != requiredVersion) { - nonSuggestedVersionDialogSubject = app - return - } - - selectedVersion = app - } -} \ No newline at end of file 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 d0e6dbb4..f137e699 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -8,9 +8,10 @@ import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager -import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES +import android.content.pm.PackageManager.PackageInfoFlags import android.content.pm.PackageManager.NameNotFoundException import androidx.core.content.pm.PackageInfoCompat +import android.content.pm.Signature import android.os.Build import android.os.Parcelable import androidx.compose.runtime.Immutable @@ -37,7 +38,6 @@ data class AppInfo( ) : Parcelable @SuppressLint("QueryPermissionsNeeded") -@Suppress("DEPRECATION") class PM( private val app: Application, patchBundleRepository: PatchBundleRepository @@ -68,7 +68,7 @@ class PM( } val installedApps = scope.async { - app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo -> + getInstalledPackages().map { packageInfo -> AppInfo( packageInfo.packageName, 0, @@ -81,7 +81,7 @@ class PM( (compatibleApps.await() + installedApps.await()) .distinctBy { it.packageName } .sortedWith( - compareByDescending{ + compareByDescending { it.packageInfo != null && (it.patches ?: 0) > 0 }.thenByDescending { it.patches @@ -94,9 +94,24 @@ class PM( } }.flowOn(Dispatchers.IO) - fun getPackageInfo(packageName: String): PackageInfo? = + private fun getInstalledPackages(flags: Int = 0): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + app.packageManager.getInstalledPackages(PackageInfoFlags.of(flags.toLong())) + else + app.packageManager.getInstalledPackages(flags) + + fun getPackagesWithFeature(feature: String) = + getInstalledPackages(PackageManager.GET_CONFIGURATIONS) + .filter { pkg -> + pkg.reqFeatures?.any { it.name == feature } ?: false + } + + fun getPackageInfo(packageName: String, flags: Int = 0): PackageInfo? = try { - app.packageManager.getPackageInfo(packageName, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + app.packageManager.getPackageInfo(packageName, PackageInfoFlags.of(flags.toLong())) + else + app.packageManager.getPackageInfo(packageName, flags) } catch (e: NameNotFoundException) { null } @@ -118,6 +133,18 @@ class PM( fun getVersionCode(packageInfo: PackageInfo) = PackageInfoCompat.getLongVersionCode(packageInfo) + fun getSignature(packageName: String): Signature = + // Get the last signature from the list because we want the newest one if SigningInfo.getSigningCertificateHistory() was used. + PackageInfoCompat.getSignatures(app.packageManager, packageName).last() + + @SuppressLint("InlinedApi") + fun hasSignature(packageName: String, signature: ByteArray) = PackageInfoCompat.hasSignatures( + app.packageManager, + packageName, + mapOf(signature to PackageManager.CERT_INPUT_RAW_X509), + false + ) + suspend fun installApp(apks: List) = withContext(Dispatchers.IO) { val packageInstaller = app.packageManager.packageInstaller packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> 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 5cb379e3..ac0046df 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -12,17 +12,22 @@ import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import app.revanced.manager.R @@ -168,6 +173,30 @@ fun LocalDateTime.relativeTime(context: Context): String { } } +private var transparentListItemColorsCached: ListItemColors? = null + +/** + * The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent]. + */ +val transparentListItemColors + @Composable get() = transparentListItemColorsCached + ?: ListItemDefaults.colors(containerColor = Color.Transparent) + .also { transparentListItemColorsCached = it } + +@Composable +fun EventEffect(flow: Flow, vararg keys: Any?, state: Lifecycle.State = Lifecycle.State.STARTED, block: suspend (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + val currentBlock by rememberUpdatedState(block) + + LaunchedEffect(flow, state, *keys) { + lifecycleOwner.repeatOnLifecycle(state) { + flow.collect { + currentBlock(it) + } + } + } +} + const val isScrollingUpSensitivity = 10 @Composable @@ -231,12 +260,4 @@ fun ((T) -> R).withHapticFeedback(constant: Int): (T) -> R { } } -private var transparentListItemColorsCached: ListItemColors? = null - -/** - * The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent]. - */ -val transparentListItemColors - @Composable get() = transparentListItemColorsCached - ?: ListItemDefaults.colors(containerColor = Color.Transparent) - .also { transparentListItemColorsCached = it } \ No newline at end of file +fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f) \ 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 20722c02..b9ba4305 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,9 @@ CLI Manager + ReVanced Manager plugin host + Used to control access to ReVanced Manager plugins. Only ReVanced Manager has this. + Copied! Copy to clipboard @@ -13,6 +16,7 @@ Select an app %1$d/%2$d selected + New downloader plugins available. Click here to configure them. Patching on this device architecture is unsupported and will most likely fail. Import @@ -31,15 +35,24 @@ Default Unnamed - %1$s + Any available version + Select source + Auto + Use all installed downloaders to find a suitable APK file + No plugins available + Mounted apps cannot be patched again without root access + Version %s does not match the suggested version Start patching the application Patch selection and options %d patches selected No patches selected - Change version - %s selected + Change source + Current: All downloaders + Current: %s + Current: Installed + Current: File Could not import legacy settings @@ -111,10 +124,14 @@ Resets patch options for all patches in a bundle Reset patch options Resets all patch options - Prefer split APK\'s - Prefer split APK\'s instead of full APK\'s - Prefer universal APK\'s - Prefer universal instead of arch-specific APK\'s + Plugins + Trusted + Failed to load. Click for more details + Untrusted + Trust plugin? + Revoke trust? + Package name: %1$s\nSignature (SHA-256): %2$s + No downloaded apps found Search apps… Loading… @@ -227,10 +244,11 @@ Unpatch app? Are you sure you want to unpatch this app? - An error occurred - Already downloaded - Select version - Downloadable versions + Downloader did not fetch the correct version + Downloader did not find the app + Downloader error: %s + No plugins installed. + No trusted plugins available for use. Check your settings. Already patched Filter @@ -258,6 +276,7 @@ APK Saved Failed to sign APK: %s Save logs + User interaction is required in order to proceed with this plugin. Select installation type Preparing @@ -383,6 +402,7 @@ Auto update These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details. Unsupported patch + Any Never show again Show update message on launch Shows a popup notification whenever there is a new update available on launch. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 25bde821..59eb8c34 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,5 +4,7 @@ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ca1372dd..8ed32e02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,16 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.devtools) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.parcelize) apply false - alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.about.libraries) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.binary.compatibility.validator) } + +apiValidation { + ignoredProjects.addAll(listOf("app", "example-downloader-plugin")) + nonPublicMarkers += "app.revanced.manager.plugin.downloader.PluginHostApi" +} \ No newline at end of file diff --git a/downloader-plugin/.gitignore b/downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloader-plugin/api/downloader-plugin.api b/downloader-plugin/api/downloader-plugin.api new file mode 100644 index 00000000..d3a22653 --- /dev/null +++ b/downloader-plugin/api/downloader-plugin.api @@ -0,0 +1,171 @@ +public abstract interface class app/revanced/manager/plugin/downloader/BaseDownloadScope : app/revanced/manager/plugin/downloader/Scope { +} + +public final class app/revanced/manager/plugin/downloader/ConstantsKt { + public static final field PLUGIN_HOST_PERMISSION Ljava/lang/String; +} + +public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/os/Parcelable { + public static final field CREATOR Landroid/os/Parcelable$Creator; + public fun (Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/DownloadUrl;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public final fun describeContents ()I + public fun equals (Ljava/lang/Object;)Z + public final fun getHeaders ()Ljava/util/Map; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun toDownloadResult ()Lkotlin/Pair; + public fun toString ()Ljava/lang/String; + public final fun writeToParcel (Landroid/os/Parcel;I)V +} + +public final class app/revanced/manager/plugin/downloader/DownloadUrl$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/Downloader { +} + +public final class app/revanced/manager/plugin/downloader/DownloaderBuilder { +} + +public final class app/revanced/manager/plugin/downloader/DownloaderKt { + public static final fun Downloader (Lkotlin/jvm/functions/Function1;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder; +} + +public final class app/revanced/manager/plugin/downloader/DownloaderScope : app/revanced/manager/plugin/downloader/Scope { + public final fun download (Lkotlin/jvm/functions/Function3;)V + public final fun get (Lkotlin/jvm/functions/Function4;)V + public fun getHostPackageName ()Ljava/lang/String; + public fun getPluginPackageName ()Ljava/lang/String; + public final fun useService (Landroid/content/Intent;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/ExtensionsKt { + public static final fun download (Lapp/revanced/manager/plugin/downloader/DownloaderScope;Lkotlin/jvm/functions/Function4;)V +} + +public abstract interface class app/revanced/manager/plugin/downloader/GetScope : app/revanced/manager/plugin/downloader/Scope { + public abstract fun requestStartActivity (Landroid/content/Intent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class app/revanced/manager/plugin/downloader/InputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope { +} + +public abstract interface class app/revanced/manager/plugin/downloader/OutputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope { + public abstract fun reportSize (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/Package : android/os/Parcelable { + public static final field CREATOR Landroid/os/Parcelable$Creator; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/manager/plugin/downloader/Package; + public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/Package;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/Package; + public final fun describeContents ()I + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getVersion ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public final fun writeToParcel (Landroid/os/Parcel;I)V +} + +public final class app/revanced/manager/plugin/downloader/Package$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/Package; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/Package; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation { +} + +public abstract interface class app/revanced/manager/plugin/downloader/Scope { + public abstract fun getHostPackageName ()Ljava/lang/String; + public abstract fun getPluginPackageName ()Ljava/lang/String; +} + +public abstract class app/revanced/manager/plugin/downloader/UserInteractionException : java/lang/Exception { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public abstract class app/revanced/manager/plugin/downloader/UserInteractionException$Activity : app/revanced/manager/plugin/downloader/UserInteractionException { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$Cancelled : app/revanced/manager/plugin/downloader/UserInteractionException$Activity { +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$NotCompleted : app/revanced/manager/plugin/downloader/UserInteractionException$Activity { + public final fun getIntent ()Landroid/content/Intent; + public final fun getResultCode ()I +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$RequestDenied : app/revanced/manager/plugin/downloader/UserInteractionException { +} + +public final class app/revanced/manager/plugin/downloader/webview/APIKt { + public static final fun WebViewDownloader (Lkotlin/jvm/functions/Function4;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder; + public static final fun runWebView (Lapp/revanced/manager/plugin/downloader/GetScope;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public class app/revanced/manager/plugin/downloader/webview/IWebView$Default : app/revanced/manager/plugin/downloader/webview/IWebView { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public fun finish ()V + public fun load (Ljava/lang/String;)V +} + +public abstract class app/revanced/manager/plugin/downloader/webview/IWebView$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebView { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebView; + public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z +} + +public class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Default : app/revanced/manager/plugin/downloader/webview/IWebViewEvents { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public fun download (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun pageLoad (Ljava/lang/String;)V + public fun ready (Lapp/revanced/manager/plugin/downloader/webview/IWebView;)V +} + +public abstract class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebViewEvents { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebViewEvents; + public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z +} + +public final class app/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope { + public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/webview/WebViewScope : app/revanced/manager/plugin/downloader/Scope { + public final fun download (Lkotlin/jvm/functions/Function5;)V + public fun getHostPackageName ()Ljava/lang/String; + public fun getPluginPackageName ()Ljava/lang/String; + public final fun pageLoad (Lkotlin/jvm/functions/Function3;)V +} + diff --git a/downloader-plugin/build.gradle.kts b/downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..9d66a6e0 --- /dev/null +++ b/downloader-plugin/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + `maven-publish` +} + +android { + namespace = "app.revanced.manager.plugin.downloader" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + aidl = true + } +} +dependencies { + implementation(libs.androidx.ktx) + implementation(libs.activity.ktx) + implementation(libs.runtime.ktx) + implementation(libs.appcompat) +} + +publishing { + repositories { + mavenLocal() + } + + publications { + create("release") { + groupId = "app.revanced" + artifactId = "manager-downloader-plugin" + version = "1.0" + + afterEvaluate { + from(components["release"]) + } + } + } +} \ No newline at end of file diff --git a/downloader-plugin/consumer-rules.pro b/downloader-plugin/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/downloader-plugin/proguard-rules.pro b/downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/downloader-plugin/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/downloader-plugin/src/main/AndroidManifest.xml b/downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..74b7379f --- /dev/null +++ b/downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl new file mode 100644 index 00000000..d657fcc3 --- /dev/null +++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl @@ -0,0 +1,8 @@ +// IWebView.aidl +package app.revanced.manager.plugin.downloader.webview; + +@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi") +oneway interface IWebView { + void load(String url); + void finish(); +} \ No newline at end of file diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl new file mode 100644 index 00000000..b0237de2 --- /dev/null +++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl @@ -0,0 +1,11 @@ +// IWebViewEvents.aidl +package app.revanced.manager.plugin.downloader.webview; + +import app.revanced.manager.plugin.downloader.webview.IWebView; + +@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi") +oneway interface IWebViewEvents { + void ready(IWebView iface); + void pageLoad(String url); + void download(String url, String mimetype, String userAgent); +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt new file mode 100644 index 00000000..469daaae --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt @@ -0,0 +1,7 @@ +package app.revanced.manager.plugin.downloader + +/** + * The permission ID of the special plugin host permission. Only ReVanced Manager will have this permission. + * Plugin UI activities and internal services can be protected using this permission. + */ +const val PLUGIN_HOST_PERMISSION = "app.revanced.manager.permission.PLUGIN_HOST" \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt new file mode 100644 index 00000000..bf0a219b --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt @@ -0,0 +1,165 @@ +package app.revanced.manager.plugin.downloader + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.app.Activity +import android.os.Parcelable +import kotlinx.coroutines.withTimeout +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This API is only intended for plugin hosts, don't use it in a plugin.", +) +@Retention(AnnotationRetention.BINARY) +annotation class PluginHostApi + +/** + * The base interface for all DSL scopes. + */ +interface Scope { + /** + * The package name of ReVanced Manager. + */ + val hostPackageName: String + + /** + * The package name of the plugin. + */ + val pluginPackageName: String +} + +/** + * The scope of [DownloaderScope.get]. + */ +interface GetScope : Scope { + /** + * Ask the user to perform some required interaction in the activity specified by the provided [Intent]. + * This function returns normally with the resulting [Intent] when the activity finishes with code [Activity.RESULT_OK]. + * + * @throws UserInteractionException.RequestDenied User decided to skip this plugin. + * @throws UserInteractionException.Activity.Cancelled The activity was cancelled. + * @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code. + */ + suspend fun requestStartActivity(intent: Intent): Intent? +} + +interface BaseDownloadScope : Scope + +/** + * The scope for [DownloaderScope.download]. + */ +interface InputDownloadScope : BaseDownloadScope + +typealias Size = Long +typealias DownloadResult = Pair + +typealias Version = String +typealias GetResult = Pair + +class DownloaderScope internal constructor( + private val scopeImpl: Scope, + internal val context: Context +) : Scope by scopeImpl { + // Returning an InputStream is the primary way for plugins to implement the download function, but we also want to offer an OutputStream API since using InputStream might not be convenient in all cases. + // It is much easier to implement the main InputStream API on top of OutputStreams compared to doing it the other way around, which is why we are using OutputStream here. This detail is not visible to plugins. + internal var download: (suspend OutputDownloadScope.(T, OutputStream) -> Unit)? = null + internal var get: (suspend GetScope.(String, String?) -> GetResult?)? = null + private val inputDownloadScopeImpl = object : InputDownloadScope, Scope by scopeImpl {} + + /** + * Define the download block of the plugin. + */ + fun download(block: suspend InputDownloadScope.(data: T) -> DownloadResult) { + download = { app, outputStream -> + val (inputStream, size) = inputDownloadScopeImpl.block(app) + + inputStream.use { + if (size != null) reportSize(size) + it.copyTo(outputStream) + } + } + } + + /** + * Define the get block of the plugin. + * The block should return null if the app cannot be found. The version in the result must match the version argument unless it is null. + */ + fun get(block: suspend GetScope.(packageName: String, version: String?) -> GetResult?) { + get = block + } + + /** + * Utilize the service specified by the provided [Intent]. The service will be unbound when the scope ends. + */ + suspend fun useService(intent: Intent, block: suspend (IBinder) -> R): R { + var onBind: ((IBinder) -> Unit)? = null + val serviceConn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) = + onBind!!(service!!) + + override fun onServiceDisconnected(name: ComponentName?) {} + } + + return try { + val binder = withTimeout(10000L) { + suspendCoroutine { continuation -> + onBind = continuation::resume + context.bindService(intent, serviceConn, Context.BIND_AUTO_CREATE) + } + } + block(binder) + } finally { + onBind = null + context.unbindService(serviceConn) + } + } +} + +class DownloaderBuilder internal constructor(private val block: DownloaderScope.() -> Unit) { + @PluginHostApi + fun build(scopeImpl: Scope, context: Context) = + with(DownloaderScope(scopeImpl, context)) { + block() + + Downloader( + download = download!!, + get = get!! + ) + } +} + +class Downloader internal constructor( + @property:PluginHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult?, + @property:PluginHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit +) + +/** + * Define a downloader plugin. + */ +fun Downloader(block: DownloaderScope.() -> Unit) = DownloaderBuilder(block) + +/** + * @see GetScope.requestStartActivity + */ +sealed class UserInteractionException(message: String) : Exception(message) { + class RequestDenied @PluginHostApi constructor() : + UserInteractionException("Request denied by user") + + sealed class Activity(message: String) : UserInteractionException(message) { + class Cancelled @PluginHostApi constructor() : Activity("Interaction cancelled") + + /** + * @param resultCode The result code of the activity. + * @param intent The [Intent] of the activity. + */ + class NotCompleted @PluginHostApi constructor(val resultCode: Int, val intent: Intent?) : + Activity("Unexpected activity result code: $resultCode") + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt new file mode 100644 index 00000000..a1e6bf79 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt @@ -0,0 +1,42 @@ +package app.revanced.manager.plugin.downloader + +import android.app.Activity +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Parcelable +import java.io.OutputStream + +/** + * The scope of the [OutputStream] version of [DownloaderScope.download]. + */ +interface OutputDownloadScope : BaseDownloadScope { + suspend fun reportSize(size: Long) +} + +/** + * A replacement for [DownloaderScope.download] that uses [OutputStream]. + * The provided [OutputStream] does not need to be closed manually. + */ +fun DownloaderScope.download(block: suspend OutputDownloadScope.(T, OutputStream) -> Unit) { + download = block +} + +/** + * Performs [GetScope.requestStartActivity] with an [Intent] created using the type information of [ACTIVITY]. + * @see [GetScope.requestStartActivity] + */ +suspend inline fun GetScope.requestStartActivity() = + requestStartActivity( + Intent().apply { setClassName(pluginPackageName, ACTIVITY::class.qualifiedName!!) } + ) + +/** + * Performs [DownloaderScope.useService] with an [Intent] created using the type information of [SERVICE]. + * @see [DownloaderScope.useService] + */ +suspend inline fun DownloaderScope<*>.useService( + noinline block: suspend (IBinder) -> R +) = useService( + Intent().apply { setClassName(pluginPackageName, SERVICE::class.qualifiedName!!) }, block +) \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt new file mode 100644 index 00000000..414ad889 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt @@ -0,0 +1,39 @@ +package app.revanced.manager.plugin.downloader + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.net.HttpURLConnection +import java.net.URI + +/** + * A simple parcelable data class for storing a package name and version. + * This can be used as the data type for plugins that only need a name and version to implement their [DownloaderScope.download] function. + * + * @param name The package name. + * @param version The version. + */ +@Parcelize +data class Package(val name: String, val version: String) : Parcelable + +/** + * A data class for storing a download URL. + * + * @param url The download URL. + * @param headers The headers to use for the request. + */ +@Parcelize +data class DownloadUrl(val url: String, val headers: Map = emptyMap()) : Parcelable { + /** + * Converts this into a [DownloadResult]. + */ + fun toDownloadResult(): DownloadResult = with(URI.create(url).toURL().openConnection() as HttpURLConnection) { + useCaches = false + allowUserInteraction = false + headers.forEach(::setRequestProperty) + + connectTimeout = 10_000 + connect() + + inputStream to getHeaderField("Content-Length").toLong() + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt new file mode 100644 index 00000000..2e5034e1 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt @@ -0,0 +1,176 @@ +package app.revanced.manager.plugin.downloader.webview + +import android.content.Intent +import app.revanced.manager.plugin.downloader.DownloadUrl +import app.revanced.manager.plugin.downloader.DownloaderScope +import app.revanced.manager.plugin.downloader.GetScope +import app.revanced.manager.plugin.downloader.Scope +import app.revanced.manager.plugin.downloader.Downloader +import app.revanced.manager.plugin.downloader.PluginHostApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlin.properties.Delegates + +typealias InitialUrl = String +typealias PageLoadCallback = suspend WebViewCallbackScope.(url: String) -> Unit +typealias DownloadCallback = suspend WebViewCallbackScope.(url: String, mimeType: String, userAgent: String) -> Unit + +interface WebViewCallbackScope : Scope { + /** + * Finishes the activity and returns the [result]. + */ + suspend fun finish(result: T) + + /** + * Tells the WebView to load the specified [url]. + */ + suspend fun load(url: String) +} + +@OptIn(PluginHostApi::class) +class WebViewScope internal constructor( + coroutineScope: CoroutineScope, + private val scopeImpl: Scope, + setResult: (T) -> Unit +) : Scope by scopeImpl { + private var onPageLoadCallback: PageLoadCallback = {} + private var onDownloadCallback: DownloadCallback = { _, _, _ -> } + + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatcher = Dispatchers.Default.limitedParallelism(1) + private lateinit var webView: IWebView + internal lateinit var initialUrl: String + + internal val binder = object : IWebViewEvents.Stub() { + override fun ready(iface: IWebView?) { + coroutineScope.launch(dispatcher) { + webView = iface!!.also { + it.load(initialUrl) + } + } + } + + override fun pageLoad(url: String?) { + coroutineScope.launch(dispatcher) { onPageLoadCallback(callbackScope, url!!) } + } + + override fun download(url: String?, mimetype: String?, userAgent: String?) { + coroutineScope.launch(dispatcher) { + onDownloadCallback( + callbackScope, + url!!, + mimetype!!, + userAgent!! + ) + } + } + } + + private val callbackScope = object : WebViewCallbackScope, Scope by scopeImpl { + override suspend fun finish(result: T) { + setResult(result) + // Tell the WebViewActivity to finish + webView.let { withContext(Dispatchers.IO) { it.finish() } } + } + + override suspend fun load(url: String) { + webView.let { withContext(Dispatchers.IO) { it.load(url) } } + } + + } + + /** + * Called when the WebView attempts to download a file to disk. + */ + fun download(block: DownloadCallback) { + onDownloadCallback = block + } + + /** + * Called when the WebView finishes loading a page. + */ + fun pageLoad(block: PageLoadCallback) { + onPageLoadCallback = block + } +} + +@JvmInline +private value class Container(val value: U) + +/** + * Run a [android.webkit.WebView] Activity controlled by the provided code block. + * The activity will keep running until it is cancelled or an event handler calls [WebViewCallbackScope.finish]. + * The [block] defines the event handlers and returns the initial URL. + * + * @param title The string displayed in the action bar. + * @param block The control block. + */ +@OptIn(PluginHostApi::class) +suspend fun GetScope.runWebView( + title: String, + block: suspend WebViewScope.() -> InitialUrl +) = supervisorScope { + var result by Delegates.notNull>() + + val scope = WebViewScope(this@supervisorScope, this@runWebView) { result = Container(it) } + scope.initialUrl = scope.block() + + // Start the webview activity and wait until it finishes. + requestStartActivity(Intent().apply { + putExtra( + WebViewActivity.KEY, + WebViewActivity.Parameters(title, scope.binder) + ) + setClassName( + hostPackageName, + WebViewActivity::class.qualifiedName!! + ) + }) + + // Return the result and cancel any leftover coroutines. + coroutineContext.cancelChildren() + result.value +} + +/** + * Implement a downloader using [runWebView] and [DownloadUrl]. This function will automatically define a handler for download events unlike [runWebView]. + * Returning null inside the [block] is equivalent to returning null inside [DownloaderScope.get]. + * + * @see runWebView + */ +fun WebViewDownloader(block: suspend WebViewScope.(packageName: String, version: String?) -> InitialUrl?) = + Downloader { + val label = context.applicationInfo.loadLabel( + context.packageManager + ).toString() + + get { packageName, version -> + class ReturnNull : Exception() + + try { + runWebView(label) { + download { url, _, userAgent -> + finish( + DownloadUrl( + url, + mapOf("User-Agent" to userAgent) + ) + ) + } + + block(this@runWebView, packageName, version) ?: throw ReturnNull() + } to version + } catch (_: ReturnNull) { + null + } + } + + download { + it.toDownloadResult() + } + } \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt new file mode 100644 index 00000000..aff01337 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt @@ -0,0 +1,161 @@ +package app.revanced.manager.plugin.downloader.webview + +import android.annotation.SuppressLint +import android.os.Bundle +import android.os.IBinder +import android.os.Parcelable +import android.view.MenuItem +import android.webkit.CookieManager +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import app.revanced.manager.plugin.downloader.PluginHostApi +import app.revanced.manager.plugin.downloader.R +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@OptIn(PluginHostApi::class) +@PluginHostApi +class WebViewActivity : ComponentActivity() { + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val vm by viewModels() + enableEdgeToEdge() + setContentView(R.layout.activity_webview) + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + val webView = findViewById(R.id.webview) + onBackPressedDispatcher.addCallback { + if (webView.canGoBack()) webView.goBack() + else cancelActivity() + } + + val params = intent.getParcelableExtra(KEY)!! + actionBar?.apply { + title = params.title + setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel) + setDisplayHomeAsUpEnabled(true) + } + + val events = IWebViewEvents.Stub.asInterface(params.events)!! + vm.setup(events) + + webView.apply { + settings.apply { + cacheMode = WebSettings.LOAD_NO_CACHE + allowContentAccess = false + domStorageEnabled = true + javaScriptEnabled = true + } + + webViewClient = vm.webViewClient + setDownloadListener { url, userAgent, _, mimetype, _ -> + vm.onDownload(url, mimetype, userAgent) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.commands.collect { + when (it) { + is WebViewModel.Command.Finish -> { + setResult(RESULT_OK) + finish() + } + + is WebViewModel.Command.Load -> webView.loadUrl(it.url) + } + } + } + } + } + + private fun cancelActivity() { + setResult(RESULT_CANCELED) + finish() + } + + override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { + cancelActivity() + + true + } else super.onOptionsItemSelected(item) + + @Parcelize + internal class Parameters( + val title: String, val events: IBinder + ) : Parcelable + + internal companion object { + const val KEY = "params" + } +} + +@OptIn(PluginHostApi::class) +internal class WebViewModel : ViewModel() { + init { + CookieManager.getInstance().apply { + removeAllCookies(null) + setAcceptCookie(true) + } + } + + private val commandChannel = Channel() + val commands = commandChannel.receiveAsFlow() + + private var eventBinder: IWebViewEvents? = null + private val ctrlBinder = object : IWebView.Stub() { + override fun load(url: String?) { + viewModelScope.launch { + commandChannel.send(Command.Load(url!!)) + } + } + + override fun finish() { + viewModelScope.launch { + commandChannel.send(Command.Finish) + } + } + } + + val webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + eventBinder!!.pageLoad(url) + } + } + + fun onDownload(url: String, mimeType: String, userAgent: String) { + eventBinder!!.download(url, mimeType, userAgent) + } + + fun setup(binder: IWebViewEvents) { + if (eventBinder != null) return + eventBinder = binder + binder.ready(ctrlBinder) + } + + sealed interface Command { + data class Load(val url: String) : Command + data object Finish : Command + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/res/layout/activity_webview.xml b/downloader-plugin/src/main/res/layout/activity_webview.xml new file mode 100644 index 00000000..51f761d9 --- /dev/null +++ b/downloader-plugin/src/main/res/layout/activity_webview.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/downloader-plugin/src/main/res/values/strings.xml b/downloader-plugin/src/main/res/values/strings.xml new file mode 100644 index 00000000..73862c41 --- /dev/null +++ b/downloader-plugin/src/main/res/values/strings.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/downloader-plugin/src/main/res/values/themes.xml b/downloader-plugin/src/main/res/values/themes.xml new file mode 100644 index 00000000..495cde8e --- /dev/null +++ b/downloader-plugin/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/example-downloader-plugin/.gitignore b/example-downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/example-downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/example-downloader-plugin/build.gradle.kts b/example-downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..b480add9 --- /dev/null +++ b/example-downloader-plugin/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose.compiler) +} + +android { + val packageName = "app.revanced.manager.plugin.downloader.example" + + namespace = packageName + compileSdk = 35 + + defaultConfig { + applicationId = packageName + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + + if (project.hasProperty("signAsDebug")) { + signingConfig = signingConfigs.getByName("debug") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures.compose = true +} + +dependencies { + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.material3) + + compileOnly(project(":downloader-plugin")) +} \ No newline at end of file diff --git a/example-downloader-plugin/proguard-rules.pro b/example-downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/example-downloader-plugin/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/example-downloader-plugin/src/main/AndroidManifest.xml b/example-downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e10b2d28 --- /dev/null +++ b/example-downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt new file mode 100644 index 00000000..dd2b26c5 --- /dev/null +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt @@ -0,0 +1,69 @@ +@file:Suppress("Unused") + +package app.revanced.manager.plugin.downloader.example + +import android.app.Application +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Parcelable +import app.revanced.manager.plugin.downloader.Downloader +import app.revanced.manager.plugin.downloader.requestStartActivity +import app.revanced.manager.plugin.downloader.webview.WebViewDownloader +import kotlinx.parcelize.Parcelize +import kotlin.io.path.* + +val apkMirrorDownloader = WebViewDownloader { packageName, version -> + with(Uri.Builder()) { + scheme("https") + authority("www.apkmirror.com") + mapOf( + "post_type" to "app_release", + "searchtype" to "apk", + "s" to (version?.let { "$packageName $it" } ?: packageName), + "bundles%5B%5D" to "apk_files" // bundles[] + ).forEach { (key, value) -> + appendQueryParameter(key, value) + } + + build().toString() + } +} + +@Parcelize +class InstalledApp(val path: String) : Parcelable + +private val application by lazy { + // Don't do this in a real plugin. + val clazz = Class.forName("android.app.ActivityThread") + val activityThread = clazz.getMethod("currentActivityThread")(null) + clazz.getMethod("getApplication")(activityThread) as Application +} + +val installedAppDownloader = Downloader { + val pm = application.packageManager + + get { packageName, version -> + val packageInfo = try { + pm.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + return@get null + } + if (version != null && packageInfo.versionName != version) return@get null + + requestStartActivity() + + InstalledApp(packageInfo.applicationInfo!!.sourceDir) to packageInfo.versionName + } + + + download { app -> + with(Path(app.path)) { inputStream() to fileSize() } + } + + /* + download { app, outputStream -> + val path = Path(app.path) + reportSize(path.fileSize()) + Files.copy(path, outputStream) + }*/ +} diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt new file mode 100644 index 00000000..0390f3bd --- /dev/null +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt @@ -0,0 +1,65 @@ +package app.revanced.manager.plugin.downloader.example + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.Modifier + +class InteractionActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val isDarkTheme = isSystemInDarkTheme() + + val colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme() + + MaterialTheme(colorScheme) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("User interaction example") } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + Text("This is an example interaction.") + Row { + TextButton( + onClick = { + setResult(RESULT_CANCELED) + finish() + } + ) { + Text("Cancel") + } + + TextButton( + onClick = { + setResult(RESULT_OK) + finish() + } + ) { + Text("Continue") + } + } + } + } + } + + } + } +} \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/values/strings.xml b/example-downloader-plugin/src/main/res/values/strings.xml new file mode 100644 index 00000000..4006549c --- /dev/null +++ b/example-downloader-plugin/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Example Downloader Plugin + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47d9b401..325e1127 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,14 @@ [versions] ktx = "1.15.0" material3 = "1.3.1" -ui-tooling = "1.7.5" +ui-tooling = "1.7.6" viewmodel-lifecycle = "2.8.7" splash-screen = "1.0.1" -compose-activity = "1.9.3" +activity = "1.9.3" +appcompat = "1.7.0" preferences-datastore = "1.1.1" work-runtime = "2.10.0" -compose-bom = "2024.10.01" +compose-bom = "2024.12.01" accompanist = "0.34.0" placeholder = "1.1.2" reorderable = "1.5.2" @@ -23,10 +24,11 @@ reimagined-navigation = "1.5.0" ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" -kotlin = "2.0.21" -android-gradle-plugin = "8.7.2" -dev-tools-gradle-plugin = "2.0.21-1.0.27" +kotlin = "2.1.0" +android-gradle-plugin = "8.7.3" +dev-tools-gradle-plugin = "2.1.0-1.0.29" about-libraries-gradle-plugin = "11.1.1" +binary-compatibility-validator = "0.17.0" coil = "2.6.0" app-icon-loader-coil = "1.5.0" skrapeit = "1.2.2" @@ -43,9 +45,11 @@ androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "viewmodel-lifecycle" } runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" } splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" } -compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } +activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" } preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } @@ -135,9 +139,11 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" } +binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f66506b8..18b2f456 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,5 @@ dependencyResolutionManagement { } rootProject.name = "ReVanced Manager" include(":app") +include(":downloader-plugin") +include(":example-downloader-plugin") From 9916e4da4dfc56fc3f9db5ee5f700719de73aea7 Mon Sep 17 00:00:00 2001 From: kitadai31 <90122968+kitadai31@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:25:59 +0900 Subject: [PATCH 08/21] fix: Screen turns off while patching due to wrong WakeLock (#2147) --- .../manager/patcher/worker/PatcherWorker.kt | 3 +-- .../app/revanced/manager/ui/screen/PatcherScreen.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) 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 d2b5babb..4bc0dfd9 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 @@ -13,7 +13,6 @@ import android.os.Build import android.os.Parcelable import android.os.PowerManager import android.util.Log -import android.view.WindowManager import androidx.activity.result.ActivityResult import androidx.core.content.ContextCompat import androidx.work.ForegroundInfo @@ -124,7 +123,7 @@ class PatcherWorker( val wakeLock: PowerManager.WakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) - .newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher") + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::Patcher") .apply { acquire(10 * 60 * 1000L) Log.d(tag, "Acquired wakelock.") 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 5df81ed3..8d4b3bbc 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 @@ -1,5 +1,7 @@ package app.revanced.manager.ui.screen +import android.app.Activity +import android.view.WindowManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -15,6 +17,7 @@ import androidx.compose.material.icons.outlined.PostAdd import androidx.compose.material.icons.outlined.Save import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -79,6 +82,16 @@ fun PatcherScreen( } } + if (patcherSucceeded == null) { + DisposableEffect(Unit) { + val window = (context as Activity).window + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + if (showInstallPicker) InstallPickerDialog( onDismiss = { showInstallPicker = false }, From 49f75f9edda5f217d561024f1f13969a490357a3 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 22 Dec 2024 22:28:54 +0100 Subject: [PATCH 09/21] fix: process death resilience and account for android 11 bug (#2355) --- .../revanced/manager/ManagerApplication.kt | 35 ++++ .../manager/data/platform/Filesystem.kt | 24 ++- .../app/revanced/manager/patcher/Session.kt | 2 +- .../patcher/runtime/CoroutineRuntime.kt | 2 +- .../manager/patcher/runtime/ProcessRuntime.kt | 6 +- .../manager/patcher/runtime/Runtime.kt | 2 +- .../manager/patcher/worker/PatcherWorker.kt | 16 +- .../ui/component/InstallerStatusDialog.kt | 30 +-- .../manager/ui/component/patcher/Steps.kt | 45 ++-- .../manager/ui/model/InstallerModel.kt | 6 + .../revanced/manager/ui/model/PatcherStep.kt | 16 +- .../manager/ui/screen/DashboardScreen.kt | 50 ++++- .../manager/ui/screen/PatcherScreen.kt | 36 ++-- .../ui/viewmodel/AppSelectorViewModel.kt | 16 +- .../ui/viewmodel/DashboardViewModel.kt | 13 +- .../manager/ui/viewmodel/PatcherViewModel.kt | 195 +++++++++++------- .../main/java/app/revanced/manager/util/PM.kt | 2 + .../util/RequestInstallAppsContract.kt | 18 ++ .../java/app/revanced/manager/util/Util.kt | 31 ++- app/src/main/res/values/strings.xml | 3 + 20 files changed, 377 insertions(+), 171 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/model/InstallerModel.kt create mode 100644 app/src/main/java/app/revanced/manager/util/RequestInstallAppsContract.kt diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 2060e602..1d17e5ef 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 3afbe6e8..7bad2deb 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 d1368f24..dd5e7dc4 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 3780e899..eb50bd35 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 ada1d943..d7e9d342 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 434c97c6..7f4616bc 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 4bc0dfd9..5096170c 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 a31a813e..2ae48ce6 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 280635ce..d6c78263 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 00000000..410b64c1 --- /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 c08c823e..3dbb390e 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 6d316899..55fc771b 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 8d4b3bbc..d2195e96 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 eaa66f47..538b85e3 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 99be81ec..303bd06a 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 5ab42a89..c8577295 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 - private var currentActivityRequest: Pair, String>? by mutableStateOf(null) + var isInstalling by mutableStateOf(ongoingPmSession) + private set + + 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 f137e699..b484fc50 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 00000000..b5060999 --- /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 ac0046df..1359ae24 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 b9ba4305..f88f1b1c 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 From 8a20d8cf9b84115aeea8c42685ee2ee648b052bd Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 22 Dec 2024 22:32:15 +0100 Subject: [PATCH 10/21] fix: contributors screen repository name --- .../revanced/manager/ui/screen/settings/ContributorScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt index 0b3a7888..af575ca1 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt @@ -202,7 +202,7 @@ fun ContributorsCard( } fun processHeadlineText(repositoryName: String): String { - return "ReVanced " + repositoryName.replace("revanced/revanced-", "") + return repositoryName.replace("revanced/revanced-", "") .replace("-", " ") .split(" ").joinToString(" ") { if (it.length > 3) it else it.uppercase() } .replaceFirstChar { it.uppercase() } From f9831d4da56aea7e885b3b0b368e3209edaf7990 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 23 Dec 2024 13:13:08 +0100 Subject: [PATCH 11/21] refactor: remove unnecessary function --- .../manager/ui/screen/settings/ContributorScreen.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt index af575ca1..bbdb6331 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt @@ -1,6 +1,5 @@ package app.revanced.manager.ui.screen.settings -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -96,7 +95,7 @@ fun ContributorScreen( } } -@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable fun ContributorsCard( title: String, @@ -131,7 +130,7 @@ fun ContributorsCard( verticalAlignment = Alignment.CenterVertically ) { Text( - text = processHeadlineText(title), + text = title, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium) ) Text( @@ -199,11 +198,4 @@ fun ContributorsCard( } } } -} - -fun processHeadlineText(repositoryName: String): String { - return repositoryName.replace("revanced/revanced-", "") - .replace("-", " ") - .split(" ").joinToString(" ") { if (it.length > 3) it else it.uppercase() } - .replaceFirstChar { it.uppercase() } } \ No newline at end of file From 5d3a81f4b9bba11ac342be0543e2c5d61c69f5c8 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 23 Dec 2024 14:31:31 +0100 Subject: [PATCH 12/21] feat: switch to androidx.navigation (#2362) --- app/build.gradle.kts | 5 +- .../java/app/revanced/manager/MainActivity.kt | 304 +++++++++++++----- .../data/room/apps/installed/InstalledApp.kt | 5 +- .../revanced/manager/di/ViewModelModule.kt | 2 +- .../manager/ui/destination/Destination.kt | 31 -- .../destination/SelectedAppInfoDestination.kt | 16 - .../ui/destination/SettingsDestination.kt | 43 --- .../manager/ui/model/navigation/Nav.kt | 93 ++++++ .../manager/ui/screen/DashboardScreen.kt | 5 +- .../ui/screen/InstalledAppInfoScreen.kt | 22 +- .../ui/screen/SelectedAppInfoScreen.kt | 242 ++++++-------- .../manager/ui/screen/SettingsScreen.kt | 190 ++++------- .../{settings/update => }/UpdateScreen.kt | 2 +- .../ui/screen/settings/AboutSettingsScreen.kt | 11 +- .../screen/settings/GeneralSettingsScreen.kt | 6 +- ...ewModel.kt => GeneralSettingsViewModel.kt} | 2 +- .../ui/viewmodel/InstalledAppInfoViewModel.kt | 66 ++-- .../manager/ui/viewmodel/PatcherViewModel.kt | 4 +- .../ui/viewmodel/PatchesSelectorViewModel.kt | 9 +- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 28 +- .../java/app/revanced/manager/util/Util.kt | 5 - gradle/libs.versions.toml | 18 +- 22 files changed, 554 insertions(+), 555 deletions(-) delete mode 100644 app/src/main/java/app/revanced/manager/ui/destination/Destination.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt rename app/src/main/java/app/revanced/manager/ui/screen/{settings/update => }/UpdateScreen.kt (99%) rename app/src/main/java/app/revanced/manager/ui/viewmodel/{SettingsViewModel.kt => GeneralSettingsViewModel.kt} (92%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 87f37444..de997d72 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -126,6 +126,7 @@ dependencies { implementation(libs.compose.livedata) implementation(libs.compose.material.icons.extended) implementation(libs.compose.material3) + implementation(libs.navigation.compose) // Accompanist implementation(libs.accompanist.drawablepainter) @@ -173,11 +174,9 @@ dependencies { // Koin implementation(libs.koin.android) implementation(libs.koin.compose) + implementation(libs.koin.compose.navigation) implementation(libs.koin.workmanager) - // Compose Navigation - implementation(libs.reimagined.navigation) - // Licenses implementation(libs.about.libraries) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index ff01094c..61393481 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -1,36 +1,38 @@ package app.revanced.manager import android.os.Bundle +import android.os.Parcelable import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat -import app.revanced.manager.ui.destination.Destination -import app.revanced.manager.ui.destination.SettingsDestination -import app.revanced.manager.ui.screen.AppSelectorScreen -import app.revanced.manager.ui.screen.DashboardScreen -import app.revanced.manager.ui.screen.InstalledAppInfoScreen -import app.revanced.manager.ui.screen.PatcherScreen -import app.revanced.manager.ui.screen.SelectedAppInfoScreen -import app.revanced.manager.ui.screen.SettingsScreen +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.revanced.manager.ui.model.navigation.* +import app.revanced.manager.ui.screen.* +import app.revanced.manager.ui.screen.settings.* +import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen +import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.EventEffect -import dev.olshevski.navigation.reimagined.AnimatedNavHost -import dev.olshevski.navigation.reimagined.NavBackHandler -import dev.olshevski.navigation.reimagined.navigate -import dev.olshevski.navigation.reimagined.pop -import dev.olshevski.navigation.reimagined.popUpTo -import dev.olshevski.navigation.reimagined.rememberNavController +import org.koin.androidx.compose.koinViewModel +import org.koin.androidx.compose.navigation.koinNavViewModel import org.koin.core.parameter.parametersOf -import org.koin.androidx.compose.koinViewModel as getComposeViewModel -import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel +import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel class MainActivity : ComponentActivity() { @ExperimentalAnimationApi @@ -41,7 +43,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() installSplashScreen() - val vm: MainViewModel = getAndroidViewModel() + val vm: MainViewModel = getActivityViewModel() vm.importLegacySettings(this) setContent { @@ -52,79 +54,203 @@ class MainActivity : ComponentActivity() { darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK, dynamicColor = dynamicColor ) { - val navController = - rememberNavController(startDestination = Destination.Dashboard) - NavBackHandler(navController) - - EventEffect(vm.appSelectFlow) { app -> - navController.navigate(Destination.SelectedApplicationInfo(app)) - } - - AnimatedNavHost( - controller = navController - ) { destination -> - when (destination) { - is Destination.Dashboard -> DashboardScreen( - onSettingsClick = { navController.navigate(Destination.Settings()) }, - onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, - onUpdateClick = { - navController.navigate(Destination.Settings(SettingsDestination.Update())) - }, - onDownloaderPluginClick = { - navController.navigate(Destination.Settings(SettingsDestination.Downloads)) - }, - onAppClick = { installedApp -> - navController.navigate( - Destination.InstalledApplicationInfo( - installedApp - ) - ) - } - ) - - is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen( - onPatchClick = vm::selectApp, - onBackClick = { navController.pop() }, - viewModel = getComposeViewModel { parametersOf(destination.installedApp) } - ) - - is Destination.Settings -> SettingsScreen( - onBackClick = { navController.pop() }, - startDestination = destination.startDestination - ) - - is Destination.AppSelector -> AppSelectorScreen( - onSelect = vm::selectApp, - onStorageSelect = vm::selectApp, - onBackClick = { navController.pop() } - ) - - is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( - onPatchClick = { app, patches, options -> - navController.navigate( - Destination.Patcher( - app, patches, options - ) - ) - }, - onBackClick = navController::pop, - vm = getComposeViewModel { - parametersOf( - SelectedAppInfoViewModel.Params( - destination.selectedApp, - destination.patchSelection - ) - ) - } - ) - - is Destination.Patcher -> PatcherScreen( - onBackClick = { navController.popUpTo { it is Destination.Dashboard } }, - vm = getComposeViewModel { parametersOf(destination) } - ) - } - } + ReVancedManager(vm) } } } } + +@Composable +private fun ReVancedManager(vm: MainViewModel) { + val navController = rememberNavController() + + EventEffect(vm.appSelectFlow) { app -> + navController.navigateComplex( + SelectedApplicationInfo, + SelectedApplicationInfo.ViewModelParams(app) + ) + } + + NavHost( + navController = navController, + startDestination = Dashboard, + ) { + composable { + DashboardScreen( + onSettingsClick = { navController.navigate(Settings) }, + onAppSelectorClick = { + navController.navigate(AppSelector) + }, + onUpdateClick = { + navController.navigate(Update()) + }, + onDownloaderPluginClick = { + navController.navigate(Settings.Downloads) + }, + onAppClick = { packageName -> + navController.navigate(InstalledApplicationInfo(packageName)) + } + ) + } + + composable { + val data = it.toRoute() + + InstalledAppInfoScreen( + onPatchClick = vm::selectApp, + onBackClick = navController::popBackStack, + viewModel = koinViewModel { parametersOf(data.packageName) } + ) + } + + composable { + AppSelectorScreen( + onSelect = vm::selectApp, + onStorageSelect = vm::selectApp, + onBackClick = navController::popBackStack + ) + } + + composable { + PatcherScreen( + onBackClick = { + navController.navigate(route = Dashboard) { + launchSingleTop = true + popUpTo { + inclusive = false + } + } + }, + vm = koinViewModel { parametersOf(it.getComplexArg()) } + ) + } + + composable { + val data = it.toRoute() + + UpdateScreen( + onBackClick = navController::popBackStack, + vm = koinViewModel { parametersOf(data.downloadOnScreenEntry) } + ) + } + + navigation(startDestination = SelectedApplicationInfo.Main) { + composable { + val parentBackStackEntry = navController.navGraphEntry(it) + val data = + parentBackStackEntry.getComplexArg() + + SelectedAppInfoScreen( + onBackClick = navController::popBackStack, + onPatchClick = { app, patches, options -> + navController.navigateComplex( + Patcher, + Patcher.ViewModelParams(app, patches, options) + ) + }, + onPatchSelectorClick = { app, patches, options -> + navController.navigateComplex( + SelectedApplicationInfo.PatchesSelector, + SelectedApplicationInfo.PatchesSelector.ViewModelParams( + app, + patches, + options + ) + ) + }, + vm = koinNavViewModel(viewModelStoreOwner = parentBackStackEntry) { + parametersOf(data) + } + ) + } + + composable { + val data = + it.getComplexArg() + val selectedAppInfoVm = koinNavViewModel( + viewModelStoreOwner = navController.navGraphEntry(it) + ) + + PatchesSelectorScreen( + onBackClick = navController::popBackStack, + onSave = { patches, options -> + selectedAppInfoVm.updateConfiguration(patches, options) + navController.popBackStack() + }, + vm = koinViewModel { parametersOf(data) } + ) + } + } + + navigation(startDestination = Settings.Main) { + composable { + SettingsScreen( + onBackClick = navController::popBackStack, + navigate = navController::navigate + ) + } + + composable { + GeneralSettingsScreen(onBackClick = navController::popBackStack) + } + + composable { + AdvancedSettingsScreen(onBackClick = navController::popBackStack) + } + + composable { + UpdatesSettingsScreen( + onBackClick = navController::popBackStack, + onChangelogClick = { navController.navigate(Settings.Changelogs) }, + onUpdateClick = { navController.navigate(Update()) } + ) + } + + composable { + DownloadsSettingsScreen(onBackClick = navController::popBackStack) + } + + composable { + ImportExportSettingsScreen(onBackClick = navController::popBackStack) + } + + composable { + AboutSettingsScreen( + onBackClick = navController::popBackStack, + navigate = navController::navigate + ) + } + + composable { + ChangelogsScreen(onBackClick = navController::popBackStack) + } + + composable { + ContributorScreen(onBackClick = navController::popBackStack) + } + + composable { + LicensesScreen(onBackClick = navController::popBackStack) + } + + composable { + DeveloperOptionsScreen(onBackClick = navController::popBackStack) + } + } + } +} + +@Composable +private fun NavController.navGraphEntry(entry: NavBackStackEntry) = + remember(entry) { getBackStackEntry(entry.destination.parent!!.id) } + +// Androidx Navigation does not support storing complex types in route objects, so we have to store them inside the saved state handle of the back stack entry instead. +private fun > NavController.navigateComplex( + route: R, + data: T +) { + navigate(route) + getBackStackEntry(route).savedStateHandle["args"] = data +} + +private fun NavBackStackEntry.getComplexArg() = savedStateHandle.get("args")!! \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt index 290a226d..c0986dfd 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt @@ -1,18 +1,15 @@ package app.revanced.manager.data.room.apps.installed -import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import app.revanced.manager.R -import kotlinx.parcelize.Parcelize enum class InstallType(val stringResource: Int) { DEFAULT(R.string.default_install), MOUNT(R.string.mount_install) } -@Parcelize @Entity(tableName = "installed_app") data class InstalledApp( @PrimaryKey @@ -20,4 +17,4 @@ data class InstalledApp( @ColumnInfo(name = "original_package_name") val originalPackageName: String, @ColumnInfo(name = "version") val version: String, @ColumnInfo(name = "install_type") val installType: InstallType -) : Parcelable \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index a59d65a2..4846510f 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -9,7 +9,7 @@ val viewModelModule = module { viewModelOf(::DashboardViewModel) viewModelOf(::SelectedAppInfoViewModel) viewModelOf(::PatchesSelectorViewModel) - viewModelOf(::SettingsViewModel) + viewModelOf(::GeneralSettingsViewModel) viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AppSelectorViewModel) viewModelOf(::PatcherViewModel) diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt deleted file mode 100644 index 93c59411..00000000 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ /dev/null @@ -1,31 +0,0 @@ -package app.revanced.manager.ui.destination - -import android.os.Parcelable -import app.revanced.manager.data.room.apps.installed.InstalledApp -import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.util.Options -import app.revanced.manager.util.PatchSelection -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue - -sealed interface Destination : Parcelable { - - @Parcelize - data object Dashboard : Destination - - @Parcelize - data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination - - @Parcelize - data object AppSelector : Destination - - @Parcelize - data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination - - @Parcelize - data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination - - @Parcelize - data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchSelection, val options: @RawValue Options) : Destination - -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt deleted file mode 100644 index 9a1f3e29..00000000 --- a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt +++ /dev/null @@ -1,16 +0,0 @@ -package app.revanced.manager.ui.destination - -import android.os.Parcelable -import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.util.Options -import app.revanced.manager.util.PatchSelection -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue - -sealed interface SelectedAppInfoDestination : Parcelable { - @Parcelize - data object Main : SelectedAppInfoDestination - - @Parcelize - data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt deleted file mode 100644 index e62ab4f4..00000000 --- a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt +++ /dev/null @@ -1,43 +0,0 @@ -package app.revanced.manager.ui.destination - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -sealed interface SettingsDestination : Parcelable { - - @Parcelize - data object Settings : SettingsDestination - - @Parcelize - data object General : SettingsDestination - - @Parcelize - data object Advanced : SettingsDestination - - @Parcelize - data object Updates : SettingsDestination - - @Parcelize - data object Downloads : SettingsDestination - - @Parcelize - data object ImportExport : SettingsDestination - - @Parcelize - data object About : SettingsDestination - - @Parcelize - data class Update(val downloadOnScreenEntry: Boolean = false) : SettingsDestination - - @Parcelize - data object Changelogs : SettingsDestination - - @Parcelize - data object Contributors: SettingsDestination - - @Parcelize - data object Licenses: SettingsDestination - - @Parcelize - data object DeveloperOptions: SettingsDestination -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt new file mode 100644 index 00000000..ad235a3a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt @@ -0,0 +1,93 @@ +package app.revanced.manager.ui.model.navigation + +import android.os.Parcelable +import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.util.Options +import app.revanced.manager.util.PatchSelection +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import kotlinx.serialization.Serializable + +interface ComplexParameter + +@Serializable +object Dashboard + +@Serializable +object AppSelector + +@Serializable +data class InstalledApplicationInfo(val packageName: String) + +@Serializable +data class Update(val downloadOnScreenEntry: Boolean = false) + +@Serializable +data object SelectedApplicationInfo : ComplexParameter { + @Parcelize + data class ViewModelParams( + val app: SelectedApp, + val patches: PatchSelection? = null + ) : Parcelable + + @Serializable + object Main + + @Serializable + data object PatchesSelector : ComplexParameter { + @Parcelize + data class ViewModelParams( + val app: SelectedApp, + val currentSelection: PatchSelection?, + val options: @RawValue Options, + ) : Parcelable + } +} + +@Serializable +data object Patcher : ComplexParameter { + @Parcelize + data class ViewModelParams( + val selectedApp: SelectedApp, + val selectedPatches: PatchSelection, + val options: @RawValue Options + ) : Parcelable +} + +@Serializable +object Settings { + sealed interface Destination + + @Serializable + data object Main : Destination + + @Serializable + data object General : Destination + + @Serializable + data object Advanced : Destination + + @Serializable + data object Updates : Destination + + @Serializable + data object Downloads : Destination + + @Serializable + data object ImportExport : Destination + + @Serializable + data object About : Destination + + @Serializable + data object Changelogs : Destination + + @Serializable + data object Contributors : Destination + + @Serializable + data object Licenses : Destination + + @Serializable + data object DeveloperOptions : Destination +} \ 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 55fc771b..2557c436 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 @@ -25,7 +25,6 @@ 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.InstalledApp import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.ui.component.AlertDialogExtended @@ -60,7 +59,7 @@ fun DashboardScreen( onSettingsClick: () -> Unit, onUpdateClick: () -> Unit, onDownloaderPluginClick: () -> Unit, - onAppClick: (InstalledApp) -> Unit + onAppClick: (String) -> Unit ) { val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) @@ -289,7 +288,7 @@ fun DashboardScreen( when (DashboardPage.entries[index]) { DashboardPage.DASHBOARD -> { InstalledAppsScreen( - onAppClick = onAppClick + onAppClick = { onAppClick(it.currentPackageName) } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 9ddf2ef8..360b4aae 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -77,10 +77,12 @@ fun InstalledAppInfoScreen( .fillMaxSize() .padding(paddingValues) ) { - AppInfo(viewModel.appInfo) { - Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + val installedApp = viewModel.installedApp ?: return@ColumnWithScrollbar - if (viewModel.installedApp.installType == InstallType.MOUNT) { + AppInfo(viewModel.appInfo) { + Text(installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + + if (installedApp.installType == InstallType.MOUNT) { Text( text = if (viewModel.isMounted) { stringResource(R.string.mounted) @@ -104,7 +106,7 @@ fun InstalledAppInfoScreen( onClick = viewModel::launch ) - when (viewModel.installedApp.installType) { + when (installedApp.installType) { InstallType.DEFAULT -> SegmentedButton( icon = Icons.Outlined.Delete, text = stringResource(R.string.uninstall), @@ -133,9 +135,9 @@ fun InstalledAppInfoScreen( icon = Icons.Outlined.Update, text = stringResource(R.string.repatch), onClick = { - onPatchClick(viewModel.installedApp.originalPackageName) + onPatchClick(installedApp.originalPackageName) }, - enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess() + enabled = installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess() ) } @@ -158,19 +160,19 @@ fun InstalledAppInfoScreen( SettingsListItem( headlineContent = stringResource(R.string.package_name), - supportingContent = viewModel.installedApp.currentPackageName + supportingContent = installedApp.currentPackageName ) - if (viewModel.installedApp.originalPackageName != viewModel.installedApp.currentPackageName) { + if (installedApp.originalPackageName != installedApp.currentPackageName) { SettingsListItem( headlineContent = stringResource(R.string.original_package_name), - supportingContent = viewModel.installedApp.originalPackageName + supportingContent = installedApp.originalPackageName ) } SettingsListItem( headlineContent = stringResource(R.string.install_type), - supportingContent = stringResource(viewModel.installedApp.installType.stringResource) + supportingContent = stringResource(installedApp.installType.stringResource) ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 9fb48372..00441dae 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -32,10 +32,8 @@ 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.haptics.HapticExtendedFloatingActionButton -import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.EventEffect import app.revanced.manager.util.Options @@ -43,13 +41,11 @@ import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.enabled import app.revanced.manager.util.toast import app.revanced.manager.util.transparentListItemColors -import dev.olshevski.navigation.reimagined.* -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectedAppInfoScreen( + onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit, onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit, onBackClick: () -> Unit, vm: SelectedAppInfoViewModel @@ -82,147 +78,119 @@ fun SelectedAppInfoScreen( launcher.launch(intent) } - val navController = - rememberNavController(startDestination = SelectedAppInfoDestination.Main) + val error by vm.errorFlow.collectAsStateWithLifecycle(null) + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.app_info), + onBackClick = onBackClick + ) + }, + floatingActionButton = { + if (error != null) return@Scaffold - NavBackHandler(controller = navController) - - AnimatedNavHost(controller = navController) { destination -> - val error by vm.errorFlow.collectAsStateWithLifecycle(null) - when (destination) { - is SelectedAppInfoDestination.Main -> Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.app_info), - onBackClick = onBackClick + HapticExtendedFloatingActionButton( + text = { Text(stringResource(R.string.patch)) }, + icon = { + Icon( + Icons.Default.AutoFixHigh, + stringResource(R.string.patch) ) }, - floatingActionButton = { - if (error != null) return@Scaffold + onClick = patchClick@{ + if (selectedPatchCount == 0) { + context.toast(context.getString(R.string.no_patches_selected)) - HapticExtendedFloatingActionButton( - text = { Text(stringResource(R.string.patch)) }, - icon = { - Icon( - Icons.Default.AutoFixHigh, - stringResource(R.string.patch) - ) - }, - onClick = patchClick@{ - if (selectedPatchCount == 0) { - context.toast(context.getString(R.string.no_patches_selected)) - - return@patchClick - } - onPatchClick( - vm.selectedApp, - patches, - vm.getOptionsFiltered(bundles) - ) - } - ) - } - ) { 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, - ) + return@patchClick } - - PageItem( - R.string.patch_selector_item, - stringResource( - R.string.patch_selector_item_description, - selectedPatchCount - ), - onClick = { - navController.navigate( - SelectedAppInfoDestination.PatchesSelector( - vm.selectedApp, - vm.getCustomPatches( - bundles, - allowIncompatiblePatches - ), - vm.options - ) - ) - } - ) - 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() - } - ) - error?.let { - Text( - stringResource(it.resourceId), - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(horizontal = 24.dp) - ) - } - } - } - - is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( - onSave = { patches, options -> - vm.updateConfiguration(patches, options, bundles) - navController.pop() - }, - onBackClick = navController::pop, - vm = koinViewModel { - parametersOf( - PatchesSelectorViewModel.Params( - destination.app, - destination.currentSelection, - destination.options, - ) + onPatchClick( + vm.selectedApp, + patches, + vm.getOptionsFiltered(bundles) ) } ) } + ) { 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, + ) + } + + PageItem( + R.string.patch_selector_item, + stringResource( + R.string.patch_selector_item_description, + selectedPatchCount + ), + onClick = { + onPatchSelectorClick( + vm.selectedApp, + vm.getCustomPatches( + bundles, + allowIncompatiblePatches + ), + vm.options + ) + } + ) + 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() + } + ) + error?.let { + Text( + stringResource(it.resourceId), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index 0d0bd46e..46a25b10 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -13,146 +13,64 @@ import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.settings.SettingsListItem -import app.revanced.manager.ui.destination.SettingsDestination -import app.revanced.manager.ui.screen.settings.* -import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen -import app.revanced.manager.ui.screen.settings.update.UpdateScreen -import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen -import app.revanced.manager.ui.viewmodel.SettingsViewModel -import dev.olshevski.navigation.reimagined.* -import org.koin.androidx.compose.koinViewModel -import org.koin.core.parameter.parametersOf +import app.revanced.manager.ui.model.navigation.Settings + +private val settingsSections = listOf( + Triple( + R.string.general, + R.string.general_description, + Icons.Outlined.Settings + ) to Settings.General, + Triple( + R.string.updates, + R.string.updates_description, + Icons.Outlined.Update + ) to Settings.Updates, + Triple( + R.string.downloads, + R.string.downloads_description, + Icons.Outlined.Download + ) to Settings.Downloads, + Triple( + R.string.import_export, + R.string.import_export_description, + Icons.Outlined.SwapVert + ) to Settings.ImportExport, + Triple( + R.string.advanced, + R.string.advanced_description, + Icons.Outlined.Tune + ) to Settings.Advanced, + Triple( + R.string.about, + R.string.app_name, + Icons.Outlined.Info + ) to Settings.About, +) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen( - onBackClick: () -> Unit, - startDestination: SettingsDestination, - viewModel: SettingsViewModel = koinViewModel() -) { - val navController = rememberNavController(startDestination) - - val backClick: () -> Unit = { - if (navController.backstack.entries.size == 1) - onBackClick() - else navController.pop() - } - - val settingsSections = listOf( - Triple( - R.string.general, - R.string.general_description, - Icons.Outlined.Settings - ) to SettingsDestination.General, - Triple( - R.string.updates, - R.string.updates_description, - Icons.Outlined.Update - ) to SettingsDestination.Updates, - Triple( - R.string.downloads, - R.string.downloads_description, - Icons.Outlined.Download - ) to SettingsDestination.Downloads, - Triple( - R.string.import_export, - R.string.import_export_description, - Icons.Outlined.SwapVert - ) to SettingsDestination.ImportExport, - Triple( - R.string.advanced, - R.string.advanced_description, - Icons.Outlined.Tune - ) to SettingsDestination.Advanced, - Triple( - R.string.about, - R.string.app_name, - Icons.Outlined.Info - ) to SettingsDestination.About, - ) - NavBackHandler(navController) - - AnimatedNavHost( - controller = navController - ) { destination -> - when (destination) { - is SettingsDestination.General -> GeneralSettingsScreen( - onBackClick = backClick, - viewModel = viewModel +fun SettingsScreen(onBackClick: () -> Unit, navigate: (Settings.Destination) -> Unit) { + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.settings), + onBackClick = onBackClick, ) - - is SettingsDestination.Advanced -> AdvancedSettingsScreen( - onBackClick = backClick - ) - - is SettingsDestination.Updates -> UpdatesSettingsScreen( - onBackClick = backClick, - onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) }, - onUpdateClick = { navController.navigate(SettingsDestination.Update(false)) } - ) - - is SettingsDestination.Downloads -> DownloadsSettingsScreen( - onBackClick = backClick - ) - - is SettingsDestination.ImportExport -> ImportExportSettingsScreen( - onBackClick = backClick - ) - - is SettingsDestination.About -> AboutSettingsScreen( - onBackClick = backClick, - onContributorsClick = { navController.navigate(SettingsDestination.Contributors) }, - onDeveloperOptionsClick = { navController.navigate(SettingsDestination.DeveloperOptions) }, - onLicensesClick = { navController.navigate(SettingsDestination.Licenses) }, - ) - - is SettingsDestination.Update -> UpdateScreen( - onBackClick = backClick, - vm = koinViewModel { - parametersOf( - destination.downloadOnScreenEntry - ) - } - ) - - is SettingsDestination.Changelogs -> ChangelogsScreen( - onBackClick = backClick, - ) - - is SettingsDestination.Contributors -> ContributorScreen( - onBackClick = backClick, - ) - - is SettingsDestination.Licenses -> LicensesScreen( - onBackClick = backClick, - ) - - is SettingsDestination.DeveloperOptions -> DeveloperOptionsScreen(onBackClick = backClick) - - is SettingsDestination.Settings -> { - Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.settings), - onBackClick = backClick, - ) - } - ) { paddingValues -> - ColumnWithScrollbar( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - ) { - settingsSections.forEach { (titleDescIcon, destination) -> - SettingsListItem( - modifier = Modifier.clickable { navController.navigate(destination) }, - headlineContent = stringResource(titleDescIcon.first), - supportingContent = stringResource(titleDescIcon.second), - leadingContent = { Icon(titleDescIcon.third, null) } - ) - } - } - } + } + ) { paddingValues -> + ColumnWithScrollbar( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + settingsSections.forEach { (titleDescIcon, destination) -> + SettingsListItem( + modifier = Modifier.clickable { navigate(destination) }, + headlineContent = stringResource(titleDescIcon.first), + supportingContent = stringResource(titleDescIcon.second), + leadingContent = { Icon(titleDescIcon.third, null) } + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt similarity index 99% rename from app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt rename to app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt index 693adc6a..6d8d6b48 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/UpdateScreen.kt @@ -1,4 +1,4 @@ -package app.revanced.manager.ui.screen.settings.update +package app.revanced.manager.ui.screen import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.spring diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index 7ee21609..a3b9f860 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -37,6 +37,7 @@ import app.revanced.manager.network.dto.ReVancedSocial import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.settings.SettingsListItem +import app.revanced.manager.ui.model.navigation.Settings import app.revanced.manager.ui.viewmodel.AboutViewModel import app.revanced.manager.ui.viewmodel.AboutViewModel.Companion.getSocialIcon import app.revanced.manager.util.openUrl @@ -47,9 +48,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun AboutSettingsScreen( onBackClick: () -> Unit, - onContributorsClick: () -> Unit, - onLicensesClick: () -> Unit, - onDeveloperOptionsClick: () -> Unit, + navigate: (Settings.Destination) -> Unit, viewModel: AboutViewModel = koinViewModel() ) { val context = LocalContext.current @@ -114,17 +113,17 @@ fun AboutSettingsScreen( Triple( stringResource(R.string.contributors), stringResource(R.string.contributors_description), - third = onContributorsClick + third = { navigate(Settings.Contributors) } ), Triple( stringResource(R.string.developer_options), stringResource(R.string.developer_options_description), - third = onDeveloperOptionsClick + third = { navigate(Settings.DeveloperOptions) } ), Triple( stringResource(R.string.opensource_licenses), stringResource(R.string.opensource_licenses_description), - third = onLicensesClick + third = { navigate(Settings.Licenses) } ) ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt index 56242679..89cd223c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -32,14 +31,15 @@ import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.theme.Theme -import app.revanced.manager.ui.viewmodel.SettingsViewModel +import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel +import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun GeneralSettingsScreen( onBackClick: () -> Unit, - viewModel: SettingsViewModel + viewModel: GeneralSettingsViewModel = koinViewModel() ) { val prefs = viewModel.prefs val coroutineScope = viewModel.viewModelScope diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt similarity index 92% rename from app/src/main/java/app/revanced/manager/ui/viewmodel/SettingsViewModel.kt rename to app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt index 1e75bdea..ea15c757 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/GeneralSettingsViewModel.kt @@ -6,7 +6,7 @@ import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.theme.Theme import kotlinx.coroutines.launch -class SettingsViewModel( +class GeneralSettingsViewModel( val prefs: PreferencesManager ) : ViewModel() { fun setTheme(theme: Theme) = viewModelScope.launch { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt index 93e2cb74..27b86263 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt @@ -32,15 +32,17 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject class InstalledAppInfoViewModel( - val installedApp: InstalledApp + packageName: String ) : ViewModel(), KoinComponent { - private val app: Application by inject() + private val context: Application by inject() private val pm: PM by inject() private val installedAppRepository: InstalledAppRepository by inject() val rootInstaller: RootInstaller by inject() lateinit var onBackClick: () -> Unit + var installedApp: InstalledApp? by mutableStateOf(null) + private set var appInfo: PackageInfo? by mutableStateOf(null) private set var appliedPatches: PatchSelection? by mutableStateOf(null) @@ -49,38 +51,48 @@ class InstalledAppInfoViewModel( init { viewModelScope.launch { - isMounted = rootInstaller.isAppMounted(installedApp.currentPackageName) + installedApp = installedAppRepository.get(packageName)?.also { + isMounted = rootInstaller.isAppMounted(it.currentPackageName) + appInfo = withContext(Dispatchers.IO) { + pm.getPackageInfo(it.currentPackageName) + } + appliedPatches = withContext(Dispatchers.IO) { + installedAppRepository.getAppliedPatches(it.currentPackageName) + } + } } } - fun launch() = pm.launch(installedApp.currentPackageName) + fun launch() = installedApp?.currentPackageName?.let(pm::launch) fun mountOrUnmount() = viewModelScope.launch { + val pkgName = installedApp?.currentPackageName ?: return@launch try { if (isMounted) - rootInstaller.unmount(installedApp.currentPackageName) + rootInstaller.unmount(pkgName) else - rootInstaller.mount(installedApp.currentPackageName) + rootInstaller.mount(pkgName) } catch (e: Exception) { if (isMounted) { - app.toast(app.getString(R.string.failed_to_unmount, e.simpleMessage())) + context.toast(context.getString(R.string.failed_to_unmount, e.simpleMessage())) Log.e(tag, "Failed to unmount", e) } else { - app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage())) + context.toast(context.getString(R.string.failed_to_mount, e.simpleMessage())) Log.e(tag, "Failed to mount", e) } } finally { - isMounted = rootInstaller.isAppMounted(installedApp.currentPackageName) + isMounted = rootInstaller.isAppMounted(pkgName) } } fun uninstall() { - when (installedApp.installType) { - InstallType.DEFAULT -> pm.uninstallPackage(installedApp.currentPackageName) + val app = installedApp ?: return + when (app.installType) { + InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName) InstallType.MOUNT -> viewModelScope.launch { - rootInstaller.uninstall(installedApp.currentPackageName) - installedAppRepository.delete(installedApp) + rootInstaller.uninstall(app.currentPackageName) + installedAppRepository.delete(app) onBackClick() } } @@ -97,34 +109,22 @@ class InstalledAppInfoViewModel( if (extraStatus == PackageInstaller.STATUS_SUCCESS) { viewModelScope.launch { - installedAppRepository.delete(installedApp) + installedApp?.let { + installedAppRepository.delete(it) + } onBackClick() } } else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) { - app.toast(app.getString(R.string.uninstall_app_fail, extraStatusMessage)) + this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage)) } } } } - } - - init { - viewModelScope.launch { - appInfo = withContext(Dispatchers.IO) { - pm.getPackageInfo(installedApp.currentPackageName) - } - } - - viewModelScope.launch { - appliedPatches = withContext(Dispatchers.IO) { - installedAppRepository.getAppliedPatches(installedApp.currentPackageName) - } - } - + }.also { ContextCompat.registerReceiver( - app, - uninstallBroadcastReceiver, + context, + it, IntentFilter(UninstallService.APP_UNINSTALL_ACTION), ContextCompat.RECEIVER_NOT_EXPORTED ) @@ -132,6 +132,6 @@ class InstalledAppInfoViewModel( override fun onCleared() { super.onCleared() - app.unregisterReceiver(uninstallBroadcastReceiver) + context.unregisterReceiver(uninstallBroadcastReceiver) } } \ No newline at end of file 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 c8577295..4ca53667 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 @@ -39,7 +39,6 @@ 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.destination.Destination import app.revanced.manager.ui.model.InstallerModel import app.revanced.manager.ui.model.ProgressKey import app.revanced.manager.ui.model.SelectedApp @@ -47,6 +46,7 @@ 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.navigation.Patcher import app.revanced.manager.util.PM import app.revanced.manager.util.saveableVar import app.revanced.manager.util.saver.snapshotStateListSaver @@ -72,7 +72,7 @@ import java.time.Duration @OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class) class PatcherViewModel( - private val input: Destination.Patcher + private val input: Patcher.ViewModelParams ) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel { private val app: Application by inject() private val fs: Filesystem by inject() diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index b2986fbb..ff815266 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -23,6 +23,7 @@ import app.revanced.manager.ui.model.BundleInfo import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.saver.Nullable @@ -40,7 +41,7 @@ import kotlinx.coroutines.flow.map @Stable @OptIn(SavedStateHandleSaveableApi::class) -class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { +class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) : ViewModel(), KoinComponent { private val app: Application = get() private val savedStateHandle: SavedStateHandle = get() private val prefs: PreferencesManager = get() @@ -214,12 +215,6 @@ class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { private val selectionSaver: Saver> = nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver())) } - - data class Params( - val app: SelectedApp, - val currentSelection: PatchSelection?, - val options: Options, - ) } // Versions of other types, but utilizing persistent/observable collection types. diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 3e16d69e..cdf87267 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -33,8 +33,10 @@ 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.BundleInfo +import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PM import app.revanced.manager.util.PatchSelection @@ -57,7 +59,9 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.get @OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class) -class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { +class SelectedAppInfoViewModel( + input: SelectedApplicationInfo.ViewModelParams +) : ViewModel(), KoinComponent { private val app: Application = get() val bundlesRepo: PatchBundleRepository = get() private val bundleRepository: PatchBundleRepository = get() @@ -110,7 +114,10 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { } } - val requiredVersion = combine(prefs.suggestedVersionSafeguard.flow, bundleRepository.suggestedVersions) { suggestedVersionSafeguard, suggestedVersions -> + val requiredVersion = combine( + prefs.suggestedVersionSafeguard.flow, + bundleRepository.suggestedVersions + ) { suggestedVersionSafeguard, suggestedVersions -> if (!suggestedVersionSafeguard) return@combine null suggestedVersions[input.app.packageName] @@ -264,17 +271,15 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { ): PatchSelection? = (selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported) - fun updateConfiguration( - selection: PatchSelection?, - options: Options, - bundles: List - ) { + fun updateConfiguration(selection: PatchSelection?, options: Options) = viewModelScope.launch { + val bundles = bundlesRepo.bundleInfoFlow(packageName, selectedApp.version).first() + selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default val filteredOptions = options.filtered(bundles) - this.options = filteredOptions + this@SelectedAppInfoViewModel.options = filteredOptions - if (!persistConfiguration) return + if (!persistConfiguration) return@launch viewModelScope.launch(Dispatchers.Default) { selection?.let { selectionRepository.updateSelection(packageName, it) } ?: selectionRepository.clearSelection(packageName) @@ -283,11 +288,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { } } - data class Params( - val app: SelectedApp, - val patches: PatchSelection?, - ) - enum class Error(@StringRes val resourceId: Int) { NoPlugins(R.string.downloader_no_plugins_available) } 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 1359ae24..09c22022 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -3,11 +3,6 @@ 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 325e1127..08cbb2d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ appcompat = "1.7.0" preferences-datastore = "1.1.1" work-runtime = "2.10.0" compose-bom = "2024.12.01" +navigation = "2.8.5" accompanist = "0.34.0" placeholder = "1.1.2" reorderable = "1.5.2" @@ -18,9 +19,7 @@ datetime = "0.6.0" room-version = "2.6.1" revanced-patcher = "21.0.0" revanced-library = "3.0.2" -koin-version = "3.5.3" -koin-version-compose = "3.5.3" -reimagined-navigation = "1.5.0" +koin = "3.5.3" ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" @@ -57,8 +56,9 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" } compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } -compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3"} +compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } # Coil coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } @@ -85,12 +85,10 @@ revanced-patcher = { group = "app.revanced", name = "revanced-patcher", version. revanced-library = { group = "app.revanced", name = "revanced-library", version.ref = "revanced-library" } # Koin -koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin-version" } -koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin-version-compose" } -koin-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin-version" } - -# Compose Navigation -reimagined-navigation = { group = "dev.olshevski.navigation", name = "reimagined", version.ref = "reimagined-navigation" } +koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } +koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +koin-compose-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation", version.ref = "koin" } +koin-workmanager = { group = "io.insert-koin", name = "koin-androidx-workmanager", version.ref = "koin" } # About Libraries about-libraries = { group = "com.mikepenz", name = "aboutlibraries-compose", version.ref = "about-libraries-gradle-plugin" } From cf3866f892da8ca246d49f250e78139ccb3d87a1 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 23 Dec 2024 14:39:57 +0100 Subject: [PATCH 13/21] fix: remove battery optimization notification if user grants the permission --- .../revanced/manager/ui/screen/DashboardScreen.kt | 6 +++--- .../manager/ui/viewmodel/DashboardViewModel.kt | 15 +++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) 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 2557c436..8ed9b231 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 @@ -109,7 +109,6 @@ fun DashboardScreen( ) } - val context = LocalContext.current var showAndroid11Dialog by rememberSaveable { mutableStateOf(false) } val installAppsPermissionLauncher = rememberLauncherForActivityResult(RequestInstallAppsContract) { granted -> @@ -121,7 +120,7 @@ fun DashboardScreen( showAndroid11Dialog = false }, onContinue = { - installAppsPermissionLauncher.launch(context.packageName) + installAppsPermissionLauncher.launch(androidContext.packageName) } ) @@ -239,6 +238,7 @@ fun DashboardScreen( } } + val showBatteryOptimizationsWarning by vm.showBatteryOptimizationsWarningFlow.collectAsStateWithLifecycle(false) Notifications( if (!Aapt.supportsDevice()) { { @@ -250,7 +250,7 @@ fun DashboardScreen( ) } } else null, - if (vm.showBatteryOptimizationsWarning) { + if (showBatteryOptimizationsWarning) { { NotificationCard( isWarning = true, 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 303bd06a..e524c83e 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 @@ -24,7 +24,9 @@ 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.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -56,14 +58,19 @@ class DashboardViewModel( var updatedManagerVersion: String? by mutableStateOf(null) private set - var showBatteryOptimizationsWarning by mutableStateOf(false) - private set + val showBatteryOptimizationsWarningFlow = flow { + while (true) { + // There is no callback for this, so we have to poll it. + val result = !powerManager.isIgnoringBatteryOptimizations(app.packageName) + emit(result) + if (!result) return@flow + delay(500L) + } + } init { viewModelScope.launch { checkForManagerUpdates() - showBatteryOptimizationsWarning = - !powerManager.isIgnoringBatteryOptimizations(app.packageName) } } From b81bd17fbc7c5e14adfbb03dc9e55a64cb4dc829 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 23 Dec 2024 14:40:28 +0100 Subject: [PATCH 14/21] chore: add .kotlin to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 05c97148..c0af92eb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .cxx local.properties +.kotlin/ From 9db3bd5b3fb3436d33d1f25383dff1a23b377ff3 Mon Sep 17 00:00:00 2001 From: aAbed <39409020+TheAabedKhan@users.noreply.github.com> Date: Mon, 23 Dec 2024 21:20:27 +0545 Subject: [PATCH 15/21] feat: Add confirm dialogs when toggling dangerous settings (#2072) Co-authored-by: Ax333l --- .../settings/SafeguardBooleanItem.kt | 54 +++++++++++++++++++ .../settings/SafeguardConfirmationDialog.kt | 46 ++++++++++++++++ .../screen/settings/AdvancedSettingsScreen.kt | 21 +++++--- app/src/main/res/values/strings.xml | 6 +++ 4 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardConfirmationDialog.kt diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt new file mode 100644 index 00000000..b48d80d1 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardBooleanItem.kt @@ -0,0 +1,54 @@ +package app.revanced.manager.ui.component.settings + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.revanced.manager.domain.manager.base.Preference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun SafeguardBooleanItem( + modifier: Modifier = Modifier, + preference: Preference, + coroutineScope: CoroutineScope = rememberCoroutineScope(), + @StringRes headline: Int, + @StringRes description: Int, + @StringRes confirmationText: Int +) { + val value by preference.getAsState() + var showSafeguardWarning by rememberSaveable { + mutableStateOf(false) + } + + if (showSafeguardWarning) { + SafeguardConfirmationDialog( + onDismiss = { showSafeguardWarning = false }, + onConfirm = { + coroutineScope.launch { preference.update(!value) } + showSafeguardWarning = false + }, + body = stringResource(confirmationText) + ) + } + + BooleanItem( + modifier = modifier, + value = value, + onValueChange = { + if (it != preference.default) { + showSafeguardWarning = true + } else { + coroutineScope.launch { preference.update(it) } + } + }, + headline = headline, + description = description + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardConfirmationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardConfirmationDialog.kt new file mode 100644 index 00000000..d3b6d0e8 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SafeguardConfirmationDialog.kt @@ -0,0 +1,46 @@ +package app.revanced.manager.ui.component.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import app.revanced.manager.R + +@Composable +fun SafeguardConfirmationDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + body: String, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.no)) + } + }, + icon = { + Icon(Icons.Outlined.WarningAmber, null) + }, + title = { + Text( + text = stringResource(id = R.string.warning), + style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center) + ) + }, + text = { + Text(body) + } + ) +} diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index cb474f09..25ec67e6 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -31,6 +31,7 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.IntegerItem +import app.revanced.manager.ui.component.settings.SafeguardBooleanItem import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel import app.revanced.manager.util.toast @@ -104,29 +105,33 @@ fun AdvancedSettingsScreen( ) GroupHeader(stringResource(R.string.safeguards)) - BooleanItem( + SafeguardBooleanItem( preference = vm.prefs.disablePatchVersionCompatCheck, coroutineScope = vm.viewModelScope, headline = R.string.patch_compat_check, - description = R.string.patch_compat_check_description + description = R.string.patch_compat_check_description, + confirmationText = R.string.patch_compat_check_confirmation ) - BooleanItem( + SafeguardBooleanItem( preference = vm.prefs.disableUniversalPatchWarning, coroutineScope = vm.viewModelScope, headline = R.string.universal_patches_safeguard, - description = R.string.universal_patches_safeguard_description + description = R.string.universal_patches_safeguard_description, + confirmationText = R.string.universal_patches_safeguard_confirmation ) - BooleanItem( + SafeguardBooleanItem( preference = vm.prefs.suggestedVersionSafeguard, coroutineScope = vm.viewModelScope, headline = R.string.suggested_version_safeguard, - description = R.string.suggested_version_safeguard_description + description = R.string.suggested_version_safeguard_description, + confirmationText = R.string.suggested_version_safeguard_confirmation ) - BooleanItem( + SafeguardBooleanItem( preference = vm.prefs.disableSelectionWarning, coroutineScope = vm.viewModelScope, headline = R.string.patch_selection_safeguard, - description = R.string.patch_selection_safeguard_description + description = R.string.patch_selection_safeguard_description, + confirmationText = R.string.patch_selection_safeguard_confirmation ) GroupHeader(stringResource(R.string.debugging)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f88f1b1c..f9ee7dae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -88,12 +88,16 @@ Safeguards Disable version compatibility check The check restricts patches to supported app versions + Selecting incompatible patches can result in a broken app.\n\nDo you want to proceed anyways? Require suggested app version Enforce selection of the suggested app version + Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways? Allow changing patch selection Do not prevent selecting or deselecting patches + Changing the selection of patches may cause unexpected issues.\n\nEnable anyways? Disable universal patch warning Disables the warning that appears when you try to select universal patches + Universal patches are not as well tested as those that target specific apps.\n\nEnable anyways? Import keystore Import a custom keystore Enter keystore credentials @@ -142,6 +146,8 @@ Options OK + Yes + No Edit Value Reset From 7644a746481540bebde797f33f5a81a8747c6fe3 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 3 Jan 2025 22:26:40 +0100 Subject: [PATCH 16/21] feat: add required options screen (#2378) --- .../java/app/revanced/manager/MainActivity.kt | 54 +++++- .../ui/component/patches/OptionFields.kt | 21 ++- .../revanced/manager/ui/model/BundleInfo.kt | 49 ++++-- .../manager/ui/model/navigation/Nav.kt | 3 + .../ui/screen/PatchesSelectorScreen.kt | 7 +- .../ui/screen/RequiredOptionsScreen.kt | 158 ++++++++++++++++++ .../ui/screen/SelectedAppInfoScreen.kt | 42 ++--- .../ui/viewmodel/PatchesSelectorViewModel.kt | 24 ++- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 28 +++- app/src/main/res/values/strings.xml | 2 + 10 files changed, 337 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 61393481..72f046dc 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -29,6 +30,7 @@ import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.EventEffect +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.navigation.koinNavViewModel import org.koin.core.parameter.parametersOf @@ -139,14 +141,20 @@ private fun ReVancedManager(vm: MainViewModel) { val parentBackStackEntry = navController.navGraphEntry(it) val data = parentBackStackEntry.getComplexArg() + val viewModel = + koinNavViewModel(viewModelStoreOwner = parentBackStackEntry) { + parametersOf(data) + } SelectedAppInfoScreen( onBackClick = navController::popBackStack, - onPatchClick = { app, patches, options -> - navController.navigateComplex( - Patcher, - Patcher.ViewModelParams(app, patches, options) - ) + onPatchClick = { + it.lifecycleScope.launch { + navController.navigateComplex( + Patcher, + viewModel.getPatcherParams() + ) + } }, onPatchSelectorClick = { app, patches, options -> navController.navigateComplex( @@ -158,9 +166,17 @@ private fun ReVancedManager(vm: MainViewModel) { ) ) }, - vm = koinNavViewModel(viewModelStoreOwner = parentBackStackEntry) { - parametersOf(data) - } + onRequiredOptions = { app, patches, options -> + navController.navigateComplex( + SelectedApplicationInfo.RequiredOptions, + SelectedApplicationInfo.PatchesSelector.ViewModelParams( + app, + patches, + options + ) + ) + }, + vm = viewModel ) } @@ -180,6 +196,28 @@ private fun ReVancedManager(vm: MainViewModel) { vm = koinViewModel { parametersOf(data) } ) } + + composable { + val data = + it.getComplexArg() + val selectedAppInfoVm = koinNavViewModel( + viewModelStoreOwner = navController.navGraphEntry(it) + ) + + RequiredOptionsScreen( + onBackClick = navController::popBackStack, + onContinue = { patches, options -> + selectedAppInfoVm.updateConfiguration(patches, options) + it.lifecycleScope.launch { + navController.navigateComplex( + Patcher, + selectedAppInfoVm.getPatcherParams() + ) + } + }, + vm = koinViewModel { parametersOf(data) } + ) + } } navigation(startDestination = Settings.Main) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index 3c0504bc..06438176 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -141,13 +142,19 @@ private inline fun WithOptionEditor( } @Composable -fun OptionItem(option: Option, value: T?, setValue: (T?) -> Unit) { +fun OptionItem( + option: Option, + value: T?, + setValue: (T?) -> Unit, +) { val editor = remember(option.type, option.presets) { @Suppress("UNCHECKED_CAST") val baseOptionEditor = optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor - if (option.type != typeOf() && option.presets != null) PresetOptionEditor(baseOptionEditor) + if (option.type != typeOf() && option.presets != null) PresetOptionEditor( + baseOptionEditor + ) else baseOptionEditor } @@ -155,7 +162,15 @@ fun OptionItem(option: Option, value: T?, setValue: (T?) -> Unit) { ListItem( modifier = Modifier.clickable(onClick = ::clickAction), headlineContent = { Text(option.title) }, - supportingContent = { Text(option.description) }, + supportingContent = { + Column { + Text(option.description) + if (option.required && value == null) Text( + stringResource(R.string.option_required), + color = MaterialTheme.colorScheme.error + ) + } + }, trailingContent = { ListItemTrailingContent() } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt index e2bd8b1e..9dd9d1b0 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt @@ -34,20 +34,23 @@ data class BundleInfo( } companion object Extensions { - inline fun Iterable.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchSelection = this.associate { bundle -> - val patches = - bundle.patchSequence(allowUnsupported) - .mapNotNullTo(mutableSetOf()) { patch -> - patch.name.takeIf { - condition( - bundle.uid, - patch - ) - } + inline fun Iterable.toPatchSelection( + allowUnsupported: Boolean, + condition: (Int, PatchInfo) -> Boolean + ): PatchSelection = this.associate { bundle -> + val patches = + bundle.patchSequence(allowUnsupported) + .mapNotNullTo(mutableSetOf()) { patch -> + patch.name.takeIf { + condition( + bundle.uid, + patch + ) } + } - bundle.uid to patches - } + bundle.uid to patches + } fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) = sources.flatMapLatestAndCombine( @@ -78,6 +81,28 @@ data class BundleInfo( BundleInfo(source.getName(), source.uid, supported, unsupported, universal) } } + + /** + * Algorithm for determining whether all required options have been set. + */ + inline fun Iterable.requiredOptionsSet( + crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean, + crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map? + ) = all bundle@{ bundle -> + bundle + .all + .filter { isSelected(bundle, it) } + .all patch@{ + if (it.options.isNullOrEmpty()) return@patch true + val opts by lazy { optionsForPatch(bundle, it).orEmpty() } + + it.options.all option@{ option -> + if (!option.required || option.default != null) return@option true + + option.key in opts + } + } + } } } diff --git a/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt index ad235a3a..c4063ebb 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt @@ -42,6 +42,9 @@ data object SelectedApplicationInfo : ComplexParameter } @Serializable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index e38f320b..69e0d45e 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -67,7 +67,7 @@ fun PatchesSelectorScreen( mutableStateOf(null) } var showBottomSheet by rememberSaveable { mutableStateOf(false) } - val showPatchButton by remember { + val showSaveButton by remember { derivedStateOf { vm.selectionIsValid(bundles) } } @@ -298,7 +298,7 @@ fun PatchesSelectorScreen( ) }, floatingActionButton = { - if (!showPatchButton) return@Scaffold + if (!showSaveButton) return@Scaffold HapticExtendedFloatingActionButton( text = { Text(stringResource(R.string.save)) }, @@ -311,7 +311,6 @@ fun PatchesSelectorScreen( expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp ?: true, onClick = { - // TODO: only allow this if all required options have been set. onSave(vm.getCustomSelection(), vm.getOptions()) } ) @@ -464,7 +463,7 @@ private fun PatchItem( ) @Composable -private fun ListHeader( +fun ListHeader( title: String, onHelpClick: (() -> Unit)? = null ) { diff --git a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt new file mode 100644 index 00000000..c441f1ac --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt @@ -0,0 +1,158 @@ +package app.revanced.manager.ui.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +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.patcher.patch.Option +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.component.haptics.HapticTab +import app.revanced.manager.ui.component.patches.OptionItem +import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet +import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel +import app.revanced.manager.util.Options +import app.revanced.manager.util.PatchSelection +import app.revanced.manager.util.isScrollingUp +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RequiredOptionsScreen( + onContinue: (PatchSelection?, Options) -> Unit, + onBackClick: () -> Unit, + vm: PatchesSelectorViewModel +) { + val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList()) + + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f + ) { + list.size + } + val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) } + val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList()) + val showContinueButton by remember { + derivedStateOf { + bundles.requiredOptionsSet( + isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) }, + optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) } + ) + } + } + val composableScope = rememberCoroutineScope() + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.required_options_screen), + onBackClick = onBackClick + ) + }, + floatingActionButton = { + if (!showContinueButton) return@Scaffold + + HapticExtendedFloatingActionButton( + text = { Text(stringResource(R.string.patch)) }, + icon = { + Icon( + Icons.Default.AutoFixHigh, + stringResource(R.string.patch) + ) + }, + expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp + ?: true, + onClick = { + onContinue(vm.getCustomSelection(), vm.getOptions()) + } + ) + } + ) { paddingValues -> + Column( + Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (list.isEmpty()) return@Column + else if (list.size > 1) ScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) + ) { + list.forEachIndexed { index, (bundle, _) -> + HapticTab( + selected = pagerState.currentPage == index, + onClick = { + composableScope.launch { + pagerState.animateScrollToPage( + index + ) + } + }, + text = { Text(bundle.name) }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = true, + pageContent = { index -> + // Avoid crashing if the lists have not been fully initialized yet. + if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager + val (bundle, patches) = list[index] + + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize(), + state = patchLazyListStates[index] + ) { + items(patches, key = { it.name }) { + ListHeader(it.name) + + val values = vm.getOptions(bundle.uid, it) + it.options?.forEach { option -> + val key = option.key + val value = + if (values == null || key !in values) option.default else values[key] + + @Suppress("UNCHECKED_CAST") + OptionItem( + option = option as Option, + value = value, + setValue = { new -> + vm.setOption(bundle.uid, it, key, new) + } + ) + } + } + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 00441dae..990b6548 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -14,9 +14,9 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -32,7 +32,6 @@ 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.haptics.HapticExtendedFloatingActionButton -import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.EventEffect @@ -41,12 +40,14 @@ import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.enabled import app.revanced.manager.util.toast import app.revanced.manager.util.transparentListItemColors +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectedAppInfoScreen( onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit, - onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit, + onRequiredOptions: (SelectedApp, PatchSelection?, Options) -> Unit, + onPatchClick: () -> Unit, onBackClick: () -> Unit, vm: SelectedAppInfoViewModel ) { @@ -54,20 +55,14 @@ fun SelectedAppInfoScreen( val packageName = vm.selectedApp.packageName val version = vm.selectedApp.version - val bundles by remember(packageName, version) { - vm.bundlesRepo.bundleInfoFlow(packageName, version) - }.collectAsStateWithLifecycle(initialValue = emptyList()) + val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList()) val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState() - val patches by remember { - derivedStateOf { - vm.getPatches(bundles, allowIncompatiblePatches) - } + val patches = remember(bundles, allowIncompatiblePatches) { + vm.getPatches(bundles, allowIncompatiblePatches) } - val selectedPatchCount by remember { - derivedStateOf { - patches.values.sumOf { it.size } - } + val selectedPatchCount = remember(patches) { + patches.values.sumOf { it.size } } val launcher = rememberLauncherForActivityResult( @@ -77,6 +72,7 @@ fun SelectedAppInfoScreen( EventEffect(flow = vm.launchActivityFlow) { intent -> launcher.launch(intent) } + val composableScope = rememberCoroutineScope() val error by vm.errorFlow.collectAsStateWithLifecycle(null) Scaffold( @@ -103,11 +99,19 @@ fun SelectedAppInfoScreen( return@patchClick } - onPatchClick( - vm.selectedApp, - patches, - vm.getOptionsFiltered(bundles) - ) + + composableScope.launch { + if (!vm.hasSetRequiredOptions(patches)) { + onRequiredOptions( + vm.selectedApp, + vm.getCustomPatches(bundles, allowIncompatiblePatches), + vm.options + ) + return@launch + } + + onPatchClick() + } } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index ff815266..31f6d643 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -22,7 +22,6 @@ import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.model.BundleInfo import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection -import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection @@ -37,11 +36,14 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get import kotlinx.collections.immutable.* +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -@Stable @OptIn(SavedStateHandleSaveableApi::class) -class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) : ViewModel(), KoinComponent { +class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) : + ViewModel(), KoinComponent { private val app: Application = get() private val savedStateHandle: SavedStateHandle = get() private val prefs: PreferencesManager = get() @@ -114,6 +116,22 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi selection.values.sumOf { it.size } } + // This is for the required options screen. + private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) { + bundlesFlow.first().map { bundle -> + bundle to bundle.all.filter { patch -> + val opts by lazy { + getOptions(bundle.uid, patch).orEmpty() + } + isSelected( + bundle.uid, + patch + ) && patch.options?.any { it.required && it.default == null && it.key !in opts } ?: false + }.toList() + }.filter { (_, patches) -> patches.isNotEmpty() } + } + val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) } + fun selectionIsValid(bundles: List) = bundles.any { bundle -> bundle.patchSequence(allowIncompatiblePatches).any { patch -> isSelected(bundle.uid, patch) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index cdf87267..302b5e89 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -9,6 +9,7 @@ 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 @@ -29,13 +30,16 @@ import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.network.downloader.ParceledDownloaderData +import app.revanced.manager.patcher.patch.PatchInfo 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.BundleInfo import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection +import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PM @@ -63,7 +67,6 @@ class SelectedAppInfoViewModel( input: SelectedApplicationInfo.ViewModelParams ) : ViewModel(), KoinComponent { private val app: Application = get() - val bundlesRepo: PatchBundleRepository = get() private val bundleRepository: PatchBundleRepository = get() private val selectionRepository: PatchSelectionRepository = get() private val optionsRepository: PatchOptionsRepository = get() @@ -174,6 +177,10 @@ class SelectedAppInfoViewModel( } } + val bundleInfoFlow by derivedStateOf { + bundleRepository.bundleInfoFlow(packageName, selectedApp.version) + } + fun showSourceSelector() { dismissSourceSelector() showSourceSelector = true @@ -260,6 +267,23 @@ class SelectedAppInfoViewModel( selectedAppInfo = info } + suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow + .first() + .requiredOptionsSet( + isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! }, + optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) }, + ) + + suspend fun getPatcherParams(): Patcher.ViewModelParams { + val allowUnsupported = prefs.disablePatchVersionCompatCheck.get() + val bundles = bundleInfoFlow.first() + return Patcher.ViewModelParams( + selectedApp, + getPatches(bundles, allowUnsupported), + getOptionsFiltered(bundles) + ) + } + fun getOptionsFiltered(bundles: List) = options.filtered(bundles) fun getPatches(bundles: List, allowUnsupported: Boolean) = @@ -272,7 +296,7 @@ class SelectedAppInfoViewModel( (selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported) fun updateConfiguration(selection: PatchSelection?, options: Options) = viewModelScope.launch { - val bundles = bundlesRepo.bundleInfoFlow(packageName, selectedApp.version).first() + val bundles = bundleInfoFlow.first() selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9ee7dae..b24cce62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,6 +365,8 @@ Invalid date Disable battery optimization Invalid value + This option is required + Required options Failed to check for updates: %s No update available From fff1a41feec50a050f11b7c6f3bb937c4d435615 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 5 Jan 2025 00:08:29 +0100 Subject: [PATCH 17/21] refactor: use EventEffect for legacy import --- .../java/app/revanced/manager/MainActivity.kt | 15 +++- .../manager/ui/viewmodel/MainViewModel.kt | 73 ++++++++++--------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 72f046dc..2dfc1f58 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -1,10 +1,13 @@ package app.revanced.manager +import android.content.ActivityNotFoundException import android.os.Bundle import android.os.Parcelable import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable @@ -46,12 +49,22 @@ class MainActivity : ComponentActivity() { installSplashScreen() val vm: MainViewModel = getActivityViewModel() - vm.importLegacySettings(this) setContent { + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + onResult = vm::applyLegacySettings + ) val theme by vm.prefs.theme.getAsState() val dynamicColor by vm.prefs.dynamicColor.getAsState() + EventEffect(vm.legacyImportActivityFlow) { + try { + launcher.launch(it) + } catch (_: ActivityNotFoundException) { + } + } + ReVancedManagerTheme( darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK, dynamicColor = dynamicColor diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt index d665efb0..dd87c084 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt @@ -2,13 +2,10 @@ package app.revanced.manager.ui.viewmodel import android.app.Activity import android.app.Application -import android.content.ActivityNotFoundException import android.content.Intent import android.util.Base64 import android.util.Log -import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R @@ -28,6 +25,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json class MainViewModel( @@ -36,10 +34,13 @@ class MainViewModel( private val downloadedAppRepository: DownloadedAppRepository, private val keystoreManager: KeystoreManager, private val app: Application, - val prefs: PreferencesManager + val prefs: PreferencesManager, + private val json: Json ) : ViewModel() { private val appSelectChannel = Channel() val appSelectFlow = appSelectChannel.receiveAsFlow() + private val legacyImportActivityChannel = Channel() + val legacyImportActivityFlow = legacyImportActivityChannel.receiveAsFlow() private suspend fun suggestedVersion(packageName: String) = patchBundleRepository.suggestedVersions.first()[packageName] @@ -50,7 +51,8 @@ class MainViewModel( val suggestedVersion = suggestedVersion(app.packageName) ?: return null val downloadedApp = - downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true) ?: return null + downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true) + ?: return null return SelectedApp.Local( downloadedApp.packageName, downloadedApp.version, @@ -67,42 +69,46 @@ class MainViewModel( selectApp(SelectedApp.Search(packageName, suggestedVersion(packageName))) } - fun importLegacySettings(componentActivity: ComponentActivity) { - if (!prefs.firstLaunch.getBlocking()) return - - try { - val launcher = componentActivity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - result.data?.getStringExtra("data")?.let { - applyLegacySettings(it) - } ?: app.toast(app.getString(R.string.legacy_import_failed)) - } else { - app.toast(app.getString(R.string.legacy_import_failed)) - } - } - - val intent = Intent().apply { + init { + viewModelScope.launch { + if (!prefs.firstLaunch.get()) return@launch + legacyImportActivityChannel.send(Intent().apply { setClassName( "app.revanced.manager.flutter", "app.revanced.manager.flutter.ExportSettingsActivity" ) - } - - launcher.launch(intent) - } catch (e: Exception) { - if (e !is ActivityNotFoundException) { - app.toast(app.getString(R.string.legacy_import_failed)) - Log.e(tag, "Failed to launch legacy import activity: $e") - } + }) } } - private fun applyLegacySettings(data: String) = viewModelScope.launch { - val json = Json { ignoreUnknownKeys = true } - val settings = json.decodeFromString(data) + fun applyLegacySettings(result: ActivityResult) { + if (result.resultCode != Activity.RESULT_OK) { + app.toast(app.getString(R.string.legacy_import_failed)) + Log.e( + tag, + "Got unknown result code while importing legacy settings: ${result.resultCode}" + ) + return + } + val jsonStr = result.data?.getStringExtra("data") + if (jsonStr == null) { + app.toast(app.getString(R.string.legacy_import_failed)) + Log.e(tag, "Legacy settings data is null") + return + } + val settings = try { + json.decodeFromString(jsonStr) + } catch (e: SerializationException) { + app.toast(app.getString(R.string.legacy_import_failed)) + Log.e(tag, "Legacy settings data could not be deserialized", e) + return + } + + applyLegacySettings(settings) + } + + private fun applyLegacySettings(settings: LegacySettings) = viewModelScope.launch { settings.themeMode?.let { theme -> val themeMap = mapOf( 0 to Theme.SYSTEM, @@ -145,6 +151,7 @@ class MainViewModel( settings.patches?.let { selection -> patchSelectionRepository.import(0, selection) } + Log.d(tag, "Imported legacy settings") } @Serializable From 0ab424bfdbc753fcd494b36918ab9b049241e2b7 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 5 Jan 2025 00:12:00 +0100 Subject: [PATCH 18/21] fix: available updates dialog list item color --- .../manager/ui/component/AvailableUpdateDialog.kt | 8 +++++--- .../manager/ui/component/bundle/ImportBundleDialog.kt | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt index 4a684c1e..7b6ecd9a 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt @@ -12,11 +12,12 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.haptics.HapticCheckbox +import app.revanced.manager.util.transparentListItemColors -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AvailableUpdateDialog( onDismiss: () -> Unit, @@ -70,10 +71,11 @@ fun AvailableUpdateDialog( Text(stringResource(R.string.never_show_again)) }, leadingContent = { - CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { HapticCheckbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it }) } - } + }, + colors = transparentListItemColors ) } }, diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index cbc699ec..37d9ed1a 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AlertDialogExtended @@ -218,7 +219,7 @@ fun ImportBundleStep( ), headlineContent = { Text(stringResource(R.string.auto_update)) }, leadingContent = { - CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { HapticCheckbox( checked = autoUpdate, onCheckedChange = { From 74338931b81737bca1a116dee1f640d04215ec28 Mon Sep 17 00:00:00 2001 From: Tornike Khintibidze <48173186+X1nto@users.noreply.github.com> Date: Sat, 18 Jan 2025 20:03:38 +0400 Subject: [PATCH 19/21] feat: Redesign the patches screen (#2381) --- .../manager/ui/component/CheckedFilterChip.kt | 61 ++++ .../manager/ui/component/SearchBar.kt | 60 ++++ .../ui/screen/PatchesSelectorScreen.kt | 295 +++++++++++------- .../ui/viewmodel/PatchesSelectorViewModel.kt | 6 +- app/src/main/res/values/strings.xml | 10 +- 5 files changed, 311 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt diff --git a/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt b/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt new file mode 100644 index 00000000..a81c456f --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt @@ -0,0 +1,61 @@ +package app.revanced.manager.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandIn +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.SelectableChipColors +import androidx.compose.material3.SelectableChipElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape + +@Composable +fun CheckedFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = FilterChipDefaults.shape, + colors: SelectableChipColors = FilterChipDefaults.filterChipColors(), + elevation: SelectableChipElevation? = FilterChipDefaults.filterChipElevation(), + border: BorderStroke? = FilterChipDefaults.filterChipBorder(enabled, selected), + interactionSource: MutableInteractionSource? = null +) { + FilterChip( + selected = selected, + onClick = onClick, + label = label, + modifier = modifier, + enabled = enabled, + leadingIcon = { + AnimatedVisibility( + visible = selected, + enter = expandIn(expandFrom = Alignment.CenterStart), + exit = shrinkOut(shrinkTowards = Alignment.CenterStart) + ) { + Icon( + modifier = Modifier.size(FilterChipDefaults.IconSize), + imageVector = Icons.Filled.Done, + contentDescription = null, + ) + } + }, + trailingIcon = trailingIcon, + shape = shape, + colors = colors, + elevation = elevation, + border = border, + interactionSource = interactionSource + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt b/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt new file mode 100644 index 00000000..7c48b812 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt @@ -0,0 +1,60 @@ +package app.revanced.manager.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + placeholder: (@Composable () -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + val colors = SearchBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + dividerColor = MaterialTheme.colorScheme.outline + ) + val keyboardController = LocalSoftwareKeyboardController.current + + Box(modifier = Modifier.fillMaxWidth()) { + SearchBar( + modifier = Modifier.align(Alignment.Center), + inputField = { + SearchBarDefaults.InputField( + modifier = Modifier.sizeIn(minWidth = 380.dp), + query = query, + onQueryChange = onQueryChange, + onSearch = { + keyboardController?.hide() + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon + ) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + colors = colors, + content = content + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 69e0d45e..0fe44136 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -1,16 +1,50 @@ package app.revanced.manager.ui.screen +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -19,8 +53,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -31,24 +67,24 @@ import app.revanced.manager.R import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.CheckedFilterChip import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.SafeguardDialog -import app.revanced.manager.ui.component.SearchView +import app.revanced.manager.ui.component.SearchBar import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel -import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED -import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED +import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.transparentListItemColors import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun PatchesSelectorScreen( onSave: (PatchSelection?, Options) -> Unit, @@ -63,20 +99,17 @@ fun PatchesSelectorScreen( bundles.size } val composableScope = rememberCoroutineScope() - var search: String? by rememberSaveable { - mutableStateOf(null) + val (query, setQuery) = rememberSaveable { + mutableStateOf("") + } + val (searchExpanded, setSearchExpanded) = rememberSaveable { + mutableStateOf(false) } var showBottomSheet by rememberSaveable { mutableStateOf(false) } val showSaveButton by remember { derivedStateOf { vm.selectionIsValid(bundles) } } - val availablePatchCount by remember { - derivedStateOf { - bundles.sumOf { it.patchCount } - } - } - val defaultPatchSelectionCount by vm.defaultSelectionCount .collectAsStateWithLifecycle(initialValue = 0) @@ -108,27 +141,22 @@ fun PatchesSelectorScreen( style = MaterialTheme.typography.titleMedium ) - Row( + FlowRow( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - FilterChip( - selected = vm.filter and SHOW_SUPPORTED != 0, - onClick = { vm.toggleFlag(SHOW_SUPPORTED) }, + CheckedFilterChip( + selected = vm.filter and SHOW_UNSUPPORTED == 0, + onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) }, label = { Text(stringResource(R.string.supported)) } ) - FilterChip( + CheckedFilterChip( selected = vm.filter and SHOW_UNIVERSAL != 0, onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, label = { Text(stringResource(R.string.universal)) }, ) - - FilterChip( - selected = vm.filter and SHOW_UNSUPPORTED != 0, - onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) }, - label = { Text(stringResource(R.string.unsupported)) }, - ) } } } @@ -175,20 +203,21 @@ fun PatchesSelectorScreen( fun LazyListScope.patchList( uid: Int, patches: List, - filterFlag: Int, + visible: Boolean, supported: Boolean, header: (@Composable () -> Unit)? = null ) { - if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) { + if (patches.isNotEmpty() && visible) { header?.let { - item { + item(contentType = 0) { it() } } items( items = patches, - key = { it.name } + key = { it.name }, + contentType = { 1 } ) { patch -> PatchItem( patch = patch, @@ -222,102 +251,142 @@ fun PatchesSelectorScreen( } } - search?.let { query -> - SearchView( - query = query, - onQueryChange = { search = it }, - onActiveChange = { if (!it) search = null }, - placeholder = { Text(stringResource(R.string.search_patches)) } - ) { - val bundle = bundles[pagerState.currentPage] - - LazyColumnWithScrollbar( - modifier = Modifier.fillMaxSize() - ) { - fun List.searched() = filter { - it.name.contains(query, true) - } - - patchList( - uid = bundle.uid, - patches = bundle.supported.searched(), - filterFlag = SHOW_SUPPORTED, - supported = true - ) - patchList( - uid = bundle.uid, - patches = bundle.universal.searched(), - filterFlag = SHOW_UNIVERSAL, - supported = true - ) { - ListHeader( - title = stringResource(R.string.universal_patches), - ) - } - - if (!vm.allowIncompatiblePatches) return@LazyColumnWithScrollbar - patchList( - uid = bundle.uid, - patches = bundle.unsupported.searched(), - filterFlag = SHOW_UNSUPPORTED, - supported = true - ) { - ListHeader( - title = stringResource(R.string.unsupported_patches), - onHelpClick = { showUnsupportedPatchesDialog = true } - ) - } - } - } - } - Scaffold( topBar = { - AppTopBar( - title = stringResource( - R.string.patches_selected, - selectedPatchCount, - availablePatchCount - ), - onBackClick = onBackClick, - actions = { - IconButton(onClick = vm::reset) { - Icon(Icons.Outlined.Restore, stringResource(R.string.reset)) - } - IconButton(onClick = { showBottomSheet = true }) { - Icon(Icons.Outlined.FilterList, stringResource(R.string.more)) - } + SearchBar( + query = query, + onQueryChange = setQuery, + expanded = searchExpanded, + onExpandedChange = setSearchExpanded, + placeholder = { + Text(stringResource(R.string.search_patches)) + }, + leadingIcon = { + val rotation by animateFloatAsState( + targetValue = if (searchExpanded) 360f else 0f, + animationSpec = tween(durationMillis = 400, easing = EaseInOut), + label = "SearchBar back button" + ) IconButton( onClick = { - search = "" + if (searchExpanded) { + setSearchExpanded(false) + } else { + onBackClick() + } } ) { - Icon(Icons.Outlined.Search, stringResource(R.string.search)) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + trailingIcon = { + AnimatedContent( + targetState = searchExpanded, + label = "Filter/Clear", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { searchExpanded -> + if (searchExpanded) { + IconButton( + onClick = { setQuery("") }, + enabled = query.isNotEmpty() + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.clear) + ) + } + } else { + IconButton(onClick = { showBottomSheet = true }) { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = stringResource(R.string.more) + ) + } + } } } - ) + ) { + val bundle = bundles[pagerState.currentPage] + + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize() + ) { + fun List.searched() = filter { + it.name.contains(query, true) + } + + patchList( + uid = bundle.uid, + patches = bundle.supported.searched(), + visible = true, + supported = true + ) + patchList( + uid = bundle.uid, + patches = bundle.universal.searched(), + visible = vm.filter and SHOW_UNIVERSAL != 0, + supported = true + ) { + ListHeader( + title = stringResource(R.string.universal_patches), + ) + } + + patchList( + uid = bundle.uid, + patches = bundle.unsupported.searched(), + visible = vm.filter and SHOW_UNSUPPORTED != 0, + supported = vm.allowIncompatiblePatches + ) { + ListHeader( + title = stringResource(R.string.unsupported_patches), + onHelpClick = { showUnsupportedPatchesDialog = true } + ) + } + } + } }, floatingActionButton = { if (!showSaveButton) return@Scaffold - HapticExtendedFloatingActionButton( - text = { Text(stringResource(R.string.save)) }, - icon = { - Icon( - Icons.Outlined.Save, - stringResource(R.string.save) + AnimatedVisibility( + visible = !searchExpanded, + enter = slideInHorizontally { it } + fadeIn(), + exit = slideOutHorizontally { it } + fadeOut() + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + SmallFloatingActionButton( + onClick = vm::reset, + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) { + Icon(Icons.Outlined.Restore, stringResource(R.string.reset)) + } + HapticExtendedFloatingActionButton( + text = { Text(stringResource(R.string.save_with_count, selectedPatchCount)) }, + icon = { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = stringResource(R.string.save) + ) + }, + expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp ?: true, + onClick = { + onSave(vm.getCustomSelection(), vm.getOptions()) + } ) - }, - expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp - ?: true, - onClick = { - onSave(vm.getCustomSelection(), vm.getOptions()) } - ) + } } ) { paddingValues -> Column( - Modifier + modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { @@ -359,13 +428,13 @@ fun PatchesSelectorScreen( patchList( uid = bundle.uid, patches = bundle.supported, - filterFlag = SHOW_SUPPORTED, + visible = true, supported = true ) patchList( uid = bundle.uid, patches = bundle.universal, - filterFlag = SHOW_UNIVERSAL, + visible = vm.filter and SHOW_UNIVERSAL != 0, supported = true ) { ListHeader( @@ -375,7 +444,7 @@ fun PatchesSelectorScreen( patchList( uid = bundle.uid, patches = bundle.unsupported, - filterFlag = SHOW_UNSUPPORTED, + visible = vm.filter and SHOW_UNSUPPORTED != 0, supported = vm.allowIncompatiblePatches ) { ListHeader( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 31f6d643..54f2b7b8 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -1,7 +1,6 @@ package app.revanced.manager.ui.viewmodel import android.app.Application -import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue @@ -104,7 +103,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi val compatibleVersions = mutableStateListOf() - var filter by mutableIntStateOf(0) + var filter by mutableIntStateOf(SHOW_UNIVERSAL) private set private val defaultPatchSelection = bundlesFlow.map { bundles -> @@ -218,9 +217,8 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi } companion object { - const val SHOW_SUPPORTED = 1 // 2^0 + const val SHOW_UNSUPPORTED = 1 // 2^0 const val SHOW_UNIVERSAL = 2 // 2^1 - const val SHOW_UNSUPPORTED = 4 // 2^2 private val optionsSaver: Saver = snapshotStateMapSaver( // Patch name -> Options diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b24cce62..025b1973 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,7 @@ Warning Add Close + Clear System Light Dark @@ -210,7 +211,7 @@ No patched apps found Tap on the patches to get more information about them %s selected - Unsupported patches + Incompatible patches Universal patches Patch selection and options has been reset to recommended defaults Patch options have been reset @@ -219,10 +220,10 @@ Stop using defaults? 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. 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\nThis warning can be disabled in the advanced settings. - Supported - Universal + This version + Any app Unsupported - Patch name + Search patches This patch is not compatible with the selected app version (%1$s).\n\nIt only supports the following version(s): %2$s. Continue with this version? Not all patches support this version (%s). Do you want to continue anyway? @@ -354,6 +355,7 @@ Failed to download update: %s Cancel Save + Save (%1$s) Update Empty Tap on Update when prompted. \n ReVanced Manager will close when updating. From a762969966359fe48365b82934fcef8aed0ee949 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 19 Jan 2025 17:08:07 +0700 Subject: [PATCH 20/21] docs: Merge documentation from Flutter to Compose --- SECURITY.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index b8c6fd14..15e3660d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,8 @@
- - + +     @@ -70,9 +70,8 @@ If a vulnerability is confirmed and accepted, you can join our [Discord](https:/ ### ⏳ Supported Versions -| Version | Branch | Supported | -| ------- | ------------|------------------- | -| v1.18.0 | main | :white_check_mark: | -| latest | dev | :white_check_mark: | -| latest | compose-dev | :white_check_mark: | - +| Version | Branch | Supported | +| --------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------ | +| ![Latest stable release](https://img.shields.io/github/v/release/ReVanced/revanced-manager?style=for-the-badge "Latest stable release") | main | :white_check_mark: | +| ![Latest version](https://img.shields.io/badge/version-latest-brightgreen?style=for-the-badge "Latest version") | dev | :white_check_mark: | +| ![Latest version](https://img.shields.io/badge/version-latest-brightgreen?style=for-the-badge "Latest version") | compose-dev | :white_check_mark: | From 818dc09aa40e2c1b74e28c3daf9115b77a47f1ee Mon Sep 17 00:00:00 2001 From: validcube Date: Sat, 11 Jan 2025 22:59:51 +0700 Subject: [PATCH 21/21] build: Bump AGP to 8.8.0 build: Bump AGP to 8.8.0 --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++-- gradlew | 6 ++++-- gradlew.bat | 22 ++++++++++++---------- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08cbb2d3..809103ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" kotlin = "2.1.0" -android-gradle-plugin = "8.7.3" +android-gradle-plugin = "8.8.0" dev-tools-gradle-plugin = "2.1.0-1.0.29" about-libraries-gradle-plugin = "11.1.1" binary-compatibility-validator = "0.17.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 34592 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 delta 34555 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dfe2d1c1..e1b837a1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ -#Tue Nov 12 21:36:50 CET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=7a00d51fb93147819aab76024feece20b6b84e420694101f276be952e08bef03 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a42..f3b75f3b 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 6689b85b..9b42019c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail