Compare commits

..

29 Commits

Author SHA1 Message Date
419fe3bac1 feat: Sort bundles by patch count 2025-05-05 01:29:06 +03:00
6e57a6e977 fix: Ignore long click when already in delete mode
closes #2503
2025-04-30 13:34:33 +03:00
10001b492b feat: add network checks for features that require it 2025-04-28 18:10:01 +02:00
d37ed050bc feat: move plugin api to another repository 2025-04-28 17:19:07 +02:00
ee07621e37 fix: Do not poll battery optimization status (#2491) 2025-04-23 19:26:04 +02:00
fc05f95837 feat: Improve update screen design (#2487) 2025-04-23 20:09:05 +03:00
d5c63ead26 fix: Use compatible rather than support when referring to patch compatibility (#2422) 2025-02-13 00:40:47 +07:00
1956982060 feat: Improve APK file name formatting on save (#2421) 2025-02-13 00:40:36 +07:00
e10e5e4e3f build(deps): bump the gradle-compose group with 16 updates (#2407) 2025-02-01 10:45:14 +07:00
ede1ab5ed4 feat: Reorder Import & Export settings (#2403) 2025-02-01 04:05:28 +03:00
1092188ab0 feat: TopAppBar scroll behavior (#2397) 2025-01-31 15:03:50 +03:00
f348eba115 ci: Generate release artifact provenance (#2324)
Signed-off-by: validcube <pun.butrach@gmail.com>
2025-01-29 20:43:16 +01:00
3d234820a3 fix: improve keystore import error handling and show toast 2025-01-29 19:58:38 +01:00
cd06d36f68 build: Enable pseudo locale for debug variant 2025-01-29 23:22:24 +07:00
242c4570ce chore: Update project's dependencies to latest 2025-01-29 23:17:34 +07:00
71b73a3b42 fix: show install button when installation has been cancelled 2025-01-29 15:17:41 +01:00
067020f38f feat: Screen slide transition (#2396) 2025-01-27 19:28:55 +07:00
2aef67872d fix: Offset badge 2025-01-27 03:56:15 +03:00
818dc09aa4 build: Bump AGP to 8.8.0
build: Bump AGP to 8.8.0
2025-01-19 17:47:56 +07:00
a762969966 docs: Merge documentation from Flutter to Compose 2025-01-19 17:08:07 +07:00
74338931b8 feat: Redesign the patches screen (#2381) 2025-01-18 17:03:38 +01:00
0ab424bfdb fix: available updates dialog list item color 2025-01-05 00:12:00 +01:00
fff1a41fee refactor: use EventEffect for legacy import 2025-01-05 00:08:29 +01:00
7644a74648 feat: add required options screen (#2378) 2025-01-03 22:26:40 +01:00
9db3bd5b3f feat: Add confirm dialogs when toggling dangerous settings (#2072)
Co-authored-by: Ax333l <main@axelen.xyz>
2024-12-23 16:35:27 +01:00
b81bd17fbc chore: add .kotlin to gitignore 2024-12-23 14:40:28 +01:00
cf3866f892 fix: remove battery optimization notification if user grants the permission 2024-12-23 14:39:57 +01:00
5d3a81f4b9 feat: switch to androidx.navigation (#2362) 2024-12-23 14:31:31 +01:00
f9831d4da5 refactor: remove unnecessary function 2024-12-23 13:13:08 +01:00
80 changed files with 1567 additions and 1945 deletions

View File

@ -9,6 +9,9 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- name: Set env
@ -41,6 +44,11 @@ jobs:
- name: Add version to APK
run: mv ${{ steps.sign_apk.outputs.signedFile }} revanced-manager-${{ env.RELEASE_VERSION }}.apk
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-path: revanced-manager-${{ env.RELEASE_VERSION }}.apk
- name: Publish release APK
uses: "marvinpinto/action-automatic-releases@latest"
with:

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
.cxx
local.properties
.kotlin/

View File

@ -13,8 +13,8 @@
<br>
<a href="https://revanced.app/">
<picture>
<source height="24px" media="(prefers-color-scheme: dark)" srcset="assets/revanced-logo/revanced-logo-round.svg" />
<img height="24px" src="assets/revanced-logo/revanced-logo-round.svg" />
<source height="24px" media="(prefers-color-scheme: dark)" srcset="assets/revanced-logo/revanced-logo.svg" />
<img height="24px" src="assets/revanced-logo/revanced-logo.svg" />
</picture>
</a>&nbsp;&nbsp;&nbsp;
<a href="https://github.com/ReVanced">
@ -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: |

View File

@ -13,7 +13,7 @@ plugins {
android {
namespace = "app.revanced.manager"
compileSdk = 35
buildToolsVersion = "35.0.0"
buildToolsVersion = "35.0.1"
defaultConfig {
applicationId = "app.revanced.manager"
@ -28,6 +28,7 @@ android {
debug {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager (dev)")
isPseudoLocalesEnabled = true
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
}
@ -43,6 +44,8 @@ android {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager Debug")
signingConfig = signingConfigs.getByName("debug")
isPseudoLocalesEnabled = true
}
buildConfigField("long", "BUILD_ID", "0L")
@ -158,7 +161,7 @@ dependencies {
implementation(libs.revanced.library)
// Downloader plugins
implementation(project(":downloader-plugin"))
implementation(libs.plugin.api)
// Native processes
implementation(libs.kotlin.process)

View File

@ -1,17 +1,23 @@
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.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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 androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
@ -19,9 +25,31 @@ 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.model.navigation.AppSelector
import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
import app.revanced.manager.ui.model.navigation.InstalledApplicationInfo
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
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.PatchesSelectorScreen
import app.revanced.manager.ui.screen.RequiredOptionsScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.UpdateScreen
import app.revanced.manager.ui.screen.settings.AboutSettingsScreen
import app.revanced.manager.ui.screen.settings.AdvancedSettingsScreen
import app.revanced.manager.ui.screen.settings.ContributorScreen
import app.revanced.manager.ui.screen.settings.DeveloperOptionsScreen
import app.revanced.manager.ui.screen.settings.DownloadsSettingsScreen
import app.revanced.manager.ui.screen.settings.GeneralSettingsScreen
import app.revanced.manager.ui.screen.settings.ImportExportSettingsScreen
import app.revanced.manager.ui.screen.settings.LicensesScreen
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
@ -29,6 +57,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
@ -44,12 +73,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
@ -74,6 +113,10 @@ private fun ReVancedManager(vm: MainViewModel) {
NavHost(
navController = navController,
startDestination = Dashboard,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
composable<Dashboard> {
DashboardScreen(
@ -139,14 +182,20 @@ private fun ReVancedManager(vm: MainViewModel) {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(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 +207,17 @@ private fun ReVancedManager(vm: MainViewModel) {
)
)
},
vm = koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
}
onRequiredOptions = { app, patches, options ->
navController.navigateComplex(
SelectedApplicationInfo.RequiredOptions,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
)
)
},
vm = viewModel
)
}
@ -180,6 +237,28 @@ private fun ReVancedManager(vm: MainViewModel) {
vm = koinViewModel { parametersOf(data) }
)
}
composable<SelectedApplicationInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
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<Settings>(startDestination = Settings.Main) {

View File

@ -4,9 +4,20 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -44,9 +55,14 @@ fun AppTopBar(
)
},
actions: @Composable (RowScope.() -> Unit) = {},
scrollBehavior: TopAppBarScrollBehavior? = null
scrollBehavior: TopAppBarScrollBehavior? = null,
applyContainerColor: Boolean = false
) {
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
val containerColor = if (applyContainerColor) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
} else {
Color.Unspecified
}
TopAppBar(
title = { Text(title) },
@ -65,3 +81,41 @@ fun AppTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppTopBar(
title: @Composable () -> Unit,
onBackClick: (() -> Unit)? = null,
backIcon: @Composable (() -> Unit) = @Composable {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
R.string.back
)
)
},
actions: @Composable (RowScope.() -> Unit) = {},
scrollBehavior: TopAppBarScrollBehavior? = null,
applyContainerColor: Boolean = false
) {
val containerColor = if (applyContainerColor) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
} else {
Color.Unspecified
}
TopAppBar(
title = title,
scrollBehavior = scrollBehavior,
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick) {
backIcon()
}
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = containerColor
)
)
}

View File

@ -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
)
}
},

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
@ -23,6 +24,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefaul
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ExceptionViewerDialog
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -32,6 +34,8 @@ fun BundleInformationDialog(
bundle: PatchBundleSource,
onUpdate: () -> Unit,
) {
val networkInfo = koinInject<NetworkInfo>()
val hasNetwork = remember { networkInfo.isConnected() }
val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = bundle is LocalPatchBundle
@ -81,7 +85,7 @@ fun BundleInformationDialog(
)
}
}
if (!isLocal) {
if (!isLocal && hasNetwork) {
IconButton(onClick = onUpdate) {
Icon(
Icons.Outlined.Update,

View File

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

View File

@ -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
@ -20,8 +21,25 @@ 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.*
import androidx.compose.material3.*
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
@ -41,7 +59,11 @@ 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.*
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.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.component.haptics.HapticSwitch
@ -141,13 +163,19 @@ private inline fun <T : Any> WithOptionEditor(
}
@Composable
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
fun <T : Any> OptionItem(
option: Option<T>,
value: T?,
setValue: (T?) -> Unit,
) {
val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST")
val baseOptionEditor =
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
if (option.type != typeOf<Boolean>() && option.presets != null) PresetOptionEditor(baseOptionEditor)
if (option.type != typeOf<Boolean>() && option.presets != null) PresetOptionEditor(
baseOptionEditor
)
else baseOptionEditor
}
@ -155,7 +183,15 @@ fun <T : Any> OptionItem(option: Option<T>, 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() }
)
}
@ -587,8 +623,10 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
interactionSource = interactionSource,
onLongClickLabel = stringResource(R.string.select),
onLongClick = {
deletionTargets.add(item.key)
deleteMode = true
if (!deleteMode) {
deletionTargets.add(item.key)
deleteMode = true
}
},
onClick = {
if (!deleteMode) {

View File

@ -7,10 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Campaign
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -37,28 +34,18 @@ fun Changelog(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Campaign,
imageVector = Icons.Outlined.NewReleases,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(32.dp)
)
Text(
version.removePrefix("v"),
"${version.removePrefix("v")} ($publishDate)",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
color = MaterialTheme.colorScheme.primary,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
) {
Tag(
Icons.Outlined.CalendarToday,
publishDate
)
}
}
Markdown(
markdown,

View File

@ -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<Boolean>,
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
)
}

View File

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

View File

@ -12,42 +12,45 @@ import kotlinx.coroutines.flow.map
data class BundleInfo(
val name: String,
val uid: Int,
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>,
val compatible: List<PatchInfo>,
val incompatible: List<PatchInfo>,
val universal: List<PatchInfo>
) {
val all = sequence {
yieldAll(supported)
yieldAll(unsupported)
yieldAll(compatible)
yieldAll(incompatible)
yieldAll(universal)
}
val patchCount get() = supported.size + unsupported.size + universal.size
val patchCount get() = compatible.size + incompatible.size + universal.size
fun patchSequence(allowUnsupported: Boolean) = if (allowUnsupported) {
fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) {
all
} else {
sequence {
yieldAll(supported)
yieldAll(compatible)
yieldAll(universal)
}
}
companion object Extensions {
inline fun Iterable<BundleInfo>.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<BundleInfo>.toPatchSelection(
allowIncompatible: Boolean,
condition: (Int, PatchInfo) -> Boolean
): PatchSelection = this.associate { bundle ->
val patches =
bundle.patchSequence(allowIncompatible)
.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(
@ -57,8 +60,8 @@ data class BundleInfo(
source.state.map { state ->
val bundle = state.patchBundleOrNull() ?: return@map null
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
val compatible = mutableListOf<PatchInfo>()
val incompatible = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
@ -67,17 +70,39 @@ data class BundleInfo(
it.supports(
packageName,
version
) -> supported
) -> compatible
else -> unsupported
else -> incompatible
}
targetList.add(it)
}
BundleInfo(source.getName(), source.uid, supported, unsupported, universal)
BundleInfo(source.getName(), source.uid, compatible, incompatible, universal)
}
}
/**
* Algorithm for determining whether all required options have been set.
*/
inline fun Iterable<BundleInfo>.requiredOptionsSet(
crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean,
crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map<String, Any?>?
) = 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
}
}
}
}
}

View File

@ -42,6 +42,9 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
val options: @RawValue Options,
) : Parcelable
}
@Serializable
data object RequiredOptions : ComplexParameter<PatchesSelector.ViewModelParams>
}
@Serializable

View File

@ -3,12 +3,26 @@ package app.revanced.manager.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.*
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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -17,6 +31,7 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -138,10 +153,13 @@ fun AppSelectorScreen(
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_app),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
actions = {
IconButton(onClick = { search = true }) {
@ -149,7 +167,8 @@ fun AppSelectorScreen(
}
}
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier

View File

@ -19,13 +19,17 @@ fun BundleListScreen(
selectedSources: SnapshotStateList<PatchBundleSource>,
bundlesSelectable: Boolean,
) {
val sortedSources = sources.sortedBy {
it.state.value.patchBundleOrNull()?.patches?.size
}
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
sources,
sortedSources,
key = { it.uid }
) { source ->
BundleItem(

View File

@ -6,18 +6,48 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
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.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@ -33,9 +63,9 @@ 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.BundleTopBar
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
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
@ -109,7 +139,6 @@ fun DashboardScreen(
)
}
val context = LocalContext.current
var showAndroid11Dialog by rememberSaveable { mutableStateOf(false) }
val installAppsPermissionLauncher =
rememberLauncherForActivityResult(RequestInstallAppsContract) { granted ->
@ -121,7 +150,7 @@ fun DashboardScreen(
showAndroid11Dialog = false
},
onContinue = {
installAppsPermissionLauncher.launch(context.packageName)
installAppsPermissionLauncher.launch(androidContext.packageName)
}
)
@ -172,11 +201,7 @@ fun DashboardScreen(
) {
BadgedBox(
badge = {
Badge(
// A size value above 6.dp forces the Badge icon to be closer to the center, fixing a clipping issue
modifier = Modifier.size(7.dp),
containerColor = MaterialTheme.colorScheme.primary,
)
Badge(modifier = Modifier.size(6.dp))
}
) {
Icon(Icons.Outlined.Update, stringResource(R.string.update))
@ -186,7 +211,8 @@ fun DashboardScreen(
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
}
}
},
applyContainerColor = true
)
}
},
@ -252,14 +278,21 @@ fun DashboardScreen(
} else null,
if (vm.showBatteryOptimizationsWarning) {
{
val batteryOptimizationsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
vm.updateBatteryOptimizationsWarning()
}
NotificationCard(
isWarning = true,
icon = Icons.Default.BatteryAlert,
text = stringResource(R.string.battery_optimization_notification),
onClick = {
androidContext.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${androidContext.packageName}")
})
batteryOptimizationsLauncher.launch(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.fromParts("package", androidContext.packageName, null)
)
)
}
)
}

View File

@ -21,6 +21,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
@ -29,6 +31,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@ -64,13 +67,17 @@ fun InstalledAppInfoScreen(
onConfirm = { viewModel.uninstall() }
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier

View File

@ -7,7 +7,12 @@ 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.*
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
@ -15,7 +20,14 @@ 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.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
@ -121,9 +133,10 @@ fun PatcherScreen(
}
AppScaffold(
topBar = {
topBar = { scrollBehavior ->
AppTopBar(
title = stringResource(R.string.patcher),
scrollBehavior = scrollBehavior,
onBackClick = ::leaveScreen
)
},
@ -131,7 +144,7 @@ fun PatcherScreen(
BottomAppBar(
actions = {
IconButton(
onClick = { exportApkLauncher.launch("${vm.packageName}.apk") },
onClick = { exportApkLauncher.launch("${vm.packageName}_${vm.version}_revanced_patched.apk") },
enabled = patcherSucceeded == true
) {
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))

View File

@ -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_INCOMPATIBLE
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
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 showPatchButton by remember {
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,45 +141,40 @@ 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) },
label = { Text(stringResource(R.string.supported)) }
CheckedFilterChip(
selected = vm.filter and SHOW_INCOMPATIBLE == 0,
onClick = { vm.toggleFlag(SHOW_INCOMPATIBLE) },
label = { Text(stringResource(R.string.this_version)) }
)
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)) },
)
}
}
}
}
if (vm.compatibleVersions.isNotEmpty())
UnsupportedPatchDialog(
IncompatiblePatchDialog(
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
supportedVersions = vm.compatibleVersions,
compatibleVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)
var showUnsupportedPatchesDialog by rememberSaveable {
var showIncompatiblePatchesDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUnsupportedPatchesDialog)
UnsupportedPatchesDialog(
if (showIncompatiblePatchesDialog)
IncompatiblePatchesDialog(
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
onDismissRequest = { showUnsupportedPatchesDialog = false }
onDismissRequest = { showIncompatiblePatchesDialog = false }
)
vm.optionsDialog?.let { (bundle, patch) ->
@ -175,34 +203,35 @@ fun PatchesSelectorScreen(
fun LazyListScope.patchList(
uid: Int,
patches: List<PatchInfo>,
filterFlag: Int,
supported: Boolean,
visible: Boolean,
compatible: 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,
onOptionsDialog = {
vm.optionsDialog = uid to patch
},
selected = supported && vm.isSelected(
selected = compatible && vm.isSelected(
uid,
patch
),
onToggle = {
when {
// Open unsupported dialog if the patch is not supported
!supported -> vm.openUnsupportedDialog(patch)
// Open incompatible dialog if the patch is not supported
!compatible -> vm.openIncompatibleDialog(patch)
// Show selection warning if enabled
vm.selectionWarningEnabled -> showSelectionWarning = true
@ -216,109 +245,148 @@ fun PatchesSelectorScreen(
else -> vm.togglePatch(uid, patch)
}
},
supported = supported
compatible = compatible
)
}
}
}
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<PatchInfo>.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<PatchInfo>.searched() = filter {
it.name.contains(query, true)
}
patchList(
uid = bundle.uid,
patches = bundle.compatible.searched(),
visible = true,
compatible = true
)
patchList(
uid = bundle.uid,
patches = bundle.universal.searched(),
visible = vm.filter and SHOW_UNIVERSAL != 0,
compatible = true
) {
ListHeader(
title = stringResource(R.string.universal_patches),
)
}
patchList(
uid = bundle.uid,
patches = bundle.incompatible.searched(),
visible = vm.filter and SHOW_INCOMPATIBLE != 0,
compatible = vm.allowIncompatiblePatches
) {
ListHeader(
title = stringResource(R.string.incompatible_patches),
onHelpClick = { showIncompatiblePatchesDialog = true }
)
}
}
}
},
floatingActionButton = {
if (!showPatchButton) return@Scaffold
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 = {
// TODO: only allow this if all required options have been set.
onSave(vm.getCustomSelection(), vm.getOptions())
}
)
}
}
) { paddingValues ->
Column(
Modifier
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
@ -359,15 +427,15 @@ fun PatchesSelectorScreen(
) {
patchList(
uid = bundle.uid,
patches = bundle.supported,
filterFlag = SHOW_SUPPORTED,
supported = true
patches = bundle.compatible,
visible = true,
compatible = true
)
patchList(
uid = bundle.uid,
patches = bundle.universal,
filterFlag = SHOW_UNIVERSAL,
supported = true
visible = vm.filter and SHOW_UNIVERSAL != 0,
compatible = true
) {
ListHeader(
title = stringResource(R.string.universal_patches),
@ -375,13 +443,13 @@ fun PatchesSelectorScreen(
}
patchList(
uid = bundle.uid,
patches = bundle.unsupported,
filterFlag = SHOW_UNSUPPORTED,
supported = vm.allowIncompatiblePatches
patches = bundle.incompatible,
visible = vm.filter and SHOW_INCOMPATIBLE != 0,
compatible = vm.allowIncompatiblePatches
) {
ListHeader(
title = stringResource(R.string.unsupported_patches),
onHelpClick = { showUnsupportedPatchesDialog = true }
title = stringResource(R.string.incompatible_patches),
onHelpClick = { showIncompatiblePatchesDialog = true }
)
}
}
@ -438,24 +506,24 @@ private fun PatchItem(
onOptionsDialog: () -> Unit,
selected: Boolean,
onToggle: () -> Unit,
supported: Boolean = true
compatible: Boolean = true
) = ListItem(
modifier = Modifier
.let { if (!supported) it.alpha(0.5f) else it }
.let { if (!compatible) it.alpha(0.5f) else it }
.clickable(onClick = onToggle)
.fillMaxSize(),
leadingContent = {
HapticCheckbox(
checked = selected,
onCheckedChange = { onToggle() },
enabled = supported
enabled = compatible
)
},
headlineContent = { Text(patch.name) },
supportingContent = patch.description?.let { { Text(it) } },
trailingContent = {
if (patch.options?.isNotEmpty() == true) {
IconButton(onClick = onOptionsDialog, enabled = supported) {
IconButton(onClick = onOptionsDialog, enabled = compatible) {
Icon(Icons.Outlined.Settings, null)
}
}
@ -464,7 +532,7 @@ private fun PatchItem(
)
@Composable
private fun ListHeader(
fun ListHeader(
title: String,
onHelpClick: (() -> Unit)? = null
) {
@ -491,7 +559,7 @@ private fun ListHeader(
}
@Composable
private fun UnsupportedPatchesDialog(
private fun IncompatiblePatchesDialog(
appVersion: String,
onDismissRequest: () -> Unit
) = AlertDialog(
@ -504,11 +572,11 @@ private fun UnsupportedPatchesDialog(
Text(stringResource(R.string.ok))
}
},
title = { Text(stringResource(R.string.unsupported_patches)) },
title = { Text(stringResource(R.string.incompatible_patches)) },
text = {
Text(
stringResource(
R.string.unsupported_patches_dialog,
R.string.incompatible_patches_dialog,
appVersion
)
)
@ -516,9 +584,9 @@ private fun UnsupportedPatchesDialog(
)
@Composable
private fun UnsupportedPatchDialog(
private fun IncompatiblePatchDialog(
appVersion: String,
supportedVersions: List<String>,
compatibleVersions: List<String>,
onDismissRequest: () -> Unit
) = AlertDialog(
icon = {
@ -530,13 +598,13 @@ private fun UnsupportedPatchDialog(
Text(stringResource(R.string.ok))
}
},
title = { Text(stringResource(R.string.unsupported_patch)) },
title = { Text(stringResource(R.string.incompatible_patch)) },
text = {
Text(
stringResource(
R.string.app_not_supported,
R.string.app_version_not_compatible,
appVersion,
supportedVersions.joinToString(", ")
compatibleVersions.joinToString(", ")
)
)
}

View File

@ -0,0 +1,165 @@
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.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
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.input.nestedscroll.nestedScroll
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()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.required_options_screen),
scrollBehavior = scrollBehavior,
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())
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { 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<Any>,
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, key, new)
}
)
}
}
}
}
)
}
}
}

View File

@ -4,6 +4,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -12,17 +14,28 @@ 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
import androidx.compose.material3.*
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api
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.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
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.input.nestedscroll.nestedScroll
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.platform.NetworkInfo
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
@ -31,8 +44,8 @@ import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
@ -41,33 +54,33 @@ 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
import org.koin.compose.koinInject
@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
) {
val context = LocalContext.current
val networkInfo = koinInject<NetworkInfo>()
val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() }
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,12 +90,17 @@ fun SelectedAppInfoScreen(
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val composableScope = rememberCoroutineScope()
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
},
@ -103,14 +121,23 @@ 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()
}
}
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
@ -190,6 +217,35 @@ fun SelectedAppInfoScreen(
modifier = Modifier.padding(horizontal = 24.dp)
)
}
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val needsInternet =
vm.selectedApp.let { it is SelectedApp.Search || it is SelectedApp.Download }
when {
!needsInternet -> {}
!networkConnected -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_unavailable_warning),
onDismiss = null
)
}
networkMetered -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_metered_warning),
onDismiss = null
)
}
}
}
}
}
}

View File

@ -5,36 +5,36 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.material.icons.outlined.InstallMobile
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.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.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.settings.Changelog
import app.revanced.manager.ui.viewmodel.UpdateViewModel
import app.revanced.manager.ui.viewmodel.UpdateViewModel.State
@ -52,38 +52,86 @@ fun UpdateScreen(
onBackClick: () -> Unit,
vm: UpdateViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.update),
title = {
Column {
Text(stringResource(vm.state.title))
if (vm.state == State.DOWNLOADING) {
Text(
text = "${vm.downloadedSize.div(1000000)} MB / ${
vm.totalSize.div(1000000)
} MB (${vm.downloadProgress.times(100).toInt()}%)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
},
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
floatingActionButton = {
val buttonConfig = when (vm.state) {
State.CAN_DOWNLOAD -> Triple(
{ vm.downloadUpdate() },
R.string.download,
Icons.Outlined.InstallMobile
)
State.DOWNLOADING -> Triple(onBackClick, R.string.cancel, Icons.Outlined.Cancel)
State.CAN_INSTALL -> Triple(
{ vm.installUpdate() },
R.string.install_app,
Icons.Outlined.InstallMobile
)
else -> null
}
buttonConfig?.let { (onClick, textRes, icon) ->
HapticExtendedFloatingActionButton(
onClick = onClick::invoke,
icon = { Icon(icon, null) },
text = { Text(stringResource(textRes)) }
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
AnimatedVisibility(visible = vm.showInternetCheckDialog) {
MeteredDownloadConfirmationDialog(
onDismiss = { vm.showInternetCheckDialog = false },
onDownloadAnyways = { vm.downloadUpdate(true) }
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(vertical = 16.dp, horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp)
.padding(paddingValues),
) {
Header(
vm.state,
vm.releaseInfo,
DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize)
)
vm.releaseInfo?.let { changelog ->
HorizontalDivider()
Changelog(changelog)
} ?: Spacer(modifier = Modifier.weight(1f))
Buttons(vm.state, vm::downloadUpdate, vm::installUpdate, onBackClick)
if (vm.state == State.DOWNLOADING)
LinearProgressIndicator(
progress = { vm.downloadProgress },
modifier = Modifier.fillMaxWidth(),
)
AnimatedVisibility(visible = vm.showInternetCheckDialog) {
MeteredDownloadConfirmationDialog(
onDismiss = { vm.showInternetCheckDialog = false },
onDownloadAnyways = { vm.downloadUpdate(true) }
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
vm.releaseInfo?.let { changelog ->
Changelog(changelog)
}
}
}
}
}
@ -116,58 +164,6 @@ private fun MeteredDownloadConfirmationDialog(
)
}
@Composable
private fun Header(state: State, releaseInfo: ReVancedAsset?, downloadData: DownloadData) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(state.title),
style = MaterialTheme.typography.headlineMedium
)
if (state == State.CAN_DOWNLOAD) {
Column {
Text(
text = stringResource(
id = R.string.current_version,
BuildConfig.VERSION_NAME
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
releaseInfo?.version?.let {
Text(
text = stringResource(
R.string.new_version,
it.replace("v", "")
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else if (state == State.DOWNLOADING) {
LinearProgressIndicator(
progress = { downloadData.downloadProgress },
modifier = Modifier.fillMaxWidth(),
)
Text(
text =
"${downloadData.downloadedSize.div(1000000)} MB / ${
downloadData.totalSize.div(
1000000
)
} MB (${
downloadData.downloadProgress.times(
100
).toInt()
}%)",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
}
@Composable
private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) {
val scrollState = rememberScrollState()
@ -198,40 +194,4 @@ private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) {
publishDate = releaseInfo.createdAt.relativeTime(LocalContext.current)
)
}
}
@Composable
private fun Buttons(
state: State,
onDownloadClick: () -> Unit,
onInstallClick: () -> Unit,
onBackClick: () -> Unit
) {
Row(modifier = Modifier.fillMaxWidth()) {
if (state.showCancel) {
TextButton(
onClick = onBackClick,
) {
Text(text = stringResource(R.string.cancel))
}
}
Spacer(modifier = Modifier.weight(1f))
if (state == State.CAN_DOWNLOAD) {
Button(onClick = onDownloadClick) {
Text(text = stringResource(R.string.update))
}
} else if (state == State.CAN_INSTALL) {
Button(
onClick = onInstallClick
) {
Text(text = stringResource(R.string.install_app))
}
}
}
}
data class DownloadData(
val downloadProgress: Float,
val downloadedSize: Long,
val totalSize: Long
)
}

View File

@ -24,10 +24,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -41,6 +44,7 @@ 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
import app.revanced.manager.util.toast
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.koin.androidx.compose.koinViewModel
@ -113,7 +117,14 @@ fun AboutSettingsScreen(
Triple(
stringResource(R.string.contributors),
stringResource(R.string.contributors_description),
third = { navigate(Settings.Contributors) }
third = nav@{
if (!viewModel.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
return@nav
}
navigate(Settings.Contributors)
}
),
Triple(
stringResource(R.string.developer_options),
@ -127,13 +138,17 @@ fun AboutSettingsScreen(
)
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.about),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier

View File

@ -10,14 +10,33 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Api
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -31,6 +50,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
@ -52,14 +72,17 @@ fun AdvancedSettingsScreen(
activityManager.largeMemoryClass
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.advanced),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
@ -104,29 +127,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))

View File

@ -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
@ -26,11 +25,14 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -54,13 +56,17 @@ fun ContributorScreen(
viewModel: ContributorViewModel = koinViewModel()
) {
val repositories = viewModel.repositories
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.contributors),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
@ -91,12 +97,19 @@ fun ContributorScreen(
)
}
}
} ?: item { LoadingIndicator() }
} ?: item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
LoadingIndicator()
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ContributorsCard(
title: String,
@ -131,7 +144,7 @@ fun ContributorsCard(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = processHeadlineText(title),
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium)
)
Text(
@ -199,11 +212,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() }
}

View File

@ -5,8 +5,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
@ -21,13 +24,17 @@ fun DeveloperOptionsScreen(
onBackClick: () -> Unit,
vm: DeveloperOptionsViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.developer_options),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
GroupHeader(stringResource(R.string.patch_bundles_section))

View File

@ -17,9 +17,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -28,6 +30,7 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -55,11 +58,13 @@ fun DownloadsSettingsScreen(
val pullRefreshState = rememberPullToRefreshState()
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.downloads),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
actions = {
if (viewModel.appSelection.isNotEmpty()) {
@ -69,7 +74,8 @@ fun DownloadsSettingsScreen(
}
}
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Box(
contentAlignment = Alignment.TopCenter,

View File

@ -13,6 +13,8 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -20,6 +22,7 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
@ -51,14 +54,17 @@ fun GeneralSettingsScreen(
onConfirm = { viewModel.setTheme(it) }
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.general),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier

View File

@ -13,7 +13,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material3.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -22,6 +32,7 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -37,6 +48,7 @@ import app.revanced.manager.ui.component.bundle.BundleSelector
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.ImportExportViewModel
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@ -86,27 +98,56 @@ fun ImportExportSettingsScreen(
onDismissRequest = vm::cancelKeystoreImport,
onSubmit = { cn, pass ->
vm.viewModelScope.launch {
val result = vm.tryKeystoreImport(cn, pass)
if (!result) context.toast(context.getString(R.string.import_keystore_wrong_credentials))
uiSafe(context, R.string.failed_to_import_keystore, "Failed to import keystore") {
val result = vm.tryKeystoreImport(cn, pass)
if (!result) context.toast(context.getString(R.string.import_keystore_wrong_credentials))
}
}
}
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.import_export),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
GroupHeader(stringResource(R.string.signing))
var showPackageSelector by rememberSaveable {
mutableStateOf(false)
}
var showBundleSelector by rememberSaveable {
mutableStateOf(false)
}
if (showPackageSelector) {
PackageSelector(packages = packagesWithOptions) { selected ->
selected?.let(vm::resetOptionsForPackage)
showPackageSelector = false
}
}
if (showBundleSelector) {
BundleSelector(bundles = patchBundles) { bundle ->
bundle?.let(vm::clearOptionsForBundle)
showBundleSelector = false
}
}
GroupHeader(stringResource(R.string.import_))
GroupItem(
onClick = {
importKeystoreLauncher.launch("*/*")
@ -114,6 +155,13 @@ fun ImportExportSettingsScreen(
headline = R.string.import_keystore,
description = R.string.import_keystore_description
)
GroupItem(
onClick = vm::importSelection,
headline = R.string.import_patch_selection,
description = R.string.import_patch_selection_description
)
GroupHeader(stringResource(R.string.export))
GroupItem(
onClick = {
if (!vm.canExport()) {
@ -125,54 +173,25 @@ fun ImportExportSettingsScreen(
headline = R.string.export_keystore,
description = R.string.export_keystore_description
)
GroupItem(
onClick = vm::regenerateKeystore,
headline = R.string.regenerate_keystore,
description = R.string.regenerate_keystore_description
)
GroupHeader(stringResource(R.string.patches))
GroupItem(
onClick = vm::importSelection,
headline = R.string.import_patch_selection,
description = R.string.import_patch_selection_description
)
GroupItem(
onClick = vm::exportSelection,
headline = R.string.export_patch_selection,
description = R.string.export_patch_selection_description
)
// TODO: allow resetting selection for specific bundle or package name.
GroupHeader(stringResource(R.string.reset))
GroupItem(
onClick = vm::resetSelection,
onClick = vm::regenerateKeystore,
headline = R.string.regenerate_keystore,
description = R.string.regenerate_keystore_description
)
GroupItem(
onClick = vm::resetSelection, // TODO: allow resetting selection for specific bundle or package name.
headline = R.string.reset_patch_selection,
description = R.string.reset_patch_selection_description
)
var showPackageSelector by rememberSaveable {
mutableStateOf(false)
}
var showBundleSelector by rememberSaveable {
mutableStateOf(false)
}
if (showPackageSelector)
PackageSelector(packages = packagesWithOptions) { selected ->
selected?.let(vm::resetOptionsForPackage)
showPackageSelector = false
}
if (showBundleSelector)
BundleSelector(bundles = patchBundles) { bundle ->
bundle?.let(vm::clearOptionsForBundle)
showBundleSelector = false
}
// TODO: patch options import/export.
GroupItem(
onClick = vm::resetOptions,
onClick = vm::resetOptions, // TODO: patch options import/export.
headline = R.string.patch_options_reset_all,
description = R.string.patch_options_reset_all_description,
)
@ -181,12 +200,13 @@ fun ImportExportSettingsScreen(
headline = R.string.patch_options_reset_package,
description = R.string.patch_options_reset_package_description
)
if (patchBundles.size > 1)
if (patchBundles.size > 1) {
GroupItem(
onClick = { showBundleSelector = true },
headline = R.string.patch_options_reset_bundle,
description = R.string.patch_options_reset_bundle_description,
)
}
}
}
}

View File

@ -7,9 +7,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -28,13 +31,17 @@ fun ChangelogsScreen(
onBackClick: () -> Unit,
vm: ChangelogsViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.changelog),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier

View File

@ -5,9 +5,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
@ -15,6 +19,7 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@ -26,15 +31,19 @@ fun UpdatesSettingsScreen(
onUpdateClick: () -> Unit,
vm: UpdatesSettingsViewModel = koinViewModel(),
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.updates),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
@ -44,6 +53,10 @@ fun UpdatesSettingsScreen(
SettingsListItem(
modifier = Modifier.clickable {
coroutineScope.launch {
if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
return@launch
}
if (vm.checkForUpdates()) onUpdateClick()
}
},
@ -52,7 +65,13 @@ fun UpdatesSettingsScreen(
)
SettingsListItem(
modifier = Modifier.clickable(onClick = onChangelogClick),
modifier = Modifier.clickable {
if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
return@clickable
}
onChangelogClick()
},
headlineContent = stringResource(R.string.changelog),
supportingContent = stringResource(
R.string.changelog_description

View File

@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedDonationLink
import app.revanced.manager.network.dto.ReVancedSocial
@ -23,16 +24,24 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AboutViewModel(private val reVancedAPI: ReVancedAPI) : ViewModel() {
class AboutViewModel(
private val reVancedAPI: ReVancedAPI,
private val network: NetworkInfo,
) : ViewModel() {
var socials by mutableStateOf(emptyList<ReVancedSocial>())
private set
private set
var contact by mutableStateOf<String?>(null)
private set
private set
var donate by mutableStateOf<String?>(null)
private set
private set
val isConnected: Boolean
get() = network.isConnected()
init {
viewModelScope.launch {
if (!isConnected) {
return@launch
}
withContext(Dispatchers.IO) {
reVancedAPI.getInfo("https://api.revanced.app").getOrNull()
}?.let {

View File

@ -62,8 +62,7 @@ class DashboardViewModel(
init {
viewModelScope.launch {
checkForManagerUpdates()
showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName)
updateBatteryOptimizationsWarning()
}
}
@ -83,6 +82,10 @@ class DashboardViewModel(
}
}
fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning = !powerManager.isIgnoringBatteryOptimizations(app.packageName)
}
fun setShowManagerUpdateDialogOnLaunch(value: Boolean) {
viewModelScope.launch {
prefs.showManagerUpdateDialogOnLaunch.update(value)

View File

@ -69,25 +69,27 @@ class ImportExportViewModel(
}
fun startKeystoreImport(content: Uri) = viewModelScope.launch {
val path = withContext(Dispatchers.IO) {
File.createTempFile("signing", "ks", app.cacheDir).toPath().also {
Files.copy(
contentResolver.openInputStream(content)!!,
it,
StandardCopyOption.REPLACE_EXISTING
)
}
}
aliases.forEach { alias ->
knownPasswords.forEach { pass ->
if (tryKeystoreImport(alias, pass, path)) {
return@launch
uiSafe(app, R.string.failed_to_import_keystore, "Failed to import keystore") {
val path = withContext(Dispatchers.IO) {
File.createTempFile("signing", "ks", app.cacheDir).toPath().also {
Files.copy(
contentResolver.openInputStream(content)!!,
it,
StandardCopyOption.REPLACE_EXISTING
)
}
}
}
keystoreImportPath = path
aliases.forEach { alias ->
knownPasswords.forEach { pass ->
if (tryKeystoreImport(alias, pass, path)) {
return@launch
}
}
}
keystoreImportPath = path
}
}
fun cancelKeystoreImport() {

View File

@ -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<SelectedApp>()
val appSelectFlow = appSelectChannel.receiveAsFlow()
private val legacyImportActivityChannel = Channel<Intent>()
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<LegacySettings>(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<LegacySettings>(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

View File

@ -83,7 +83,9 @@ class PatcherViewModel(
private val savedStateHandle: SavedStateHandle = get()
private var installedApp: InstalledApp? = null
val packageName = input.selectedApp.packageName
private val selectedApp = input.selectedApp
val packageName = selectedApp.packageName
val version = selectedApp.version
var installedPackageName by savedStateHandle.saveable(
key = "installedPackageName",

View File

@ -1,14 +1,13 @@
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
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
@ -22,7 +21,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
@ -32,16 +30,24 @@ import app.revanced.manager.util.saver.persistentMapSaver
import app.revanced.manager.util.saver.persistentSetSaver
import app.revanced.manager.util.saver.snapshotStateMapSaver
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import kotlinx.collections.immutable.*
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()
@ -102,7 +108,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
val compatibleVersions = mutableStateListOf<String>()
var filter by mutableIntStateOf(0)
var filter by mutableIntStateOf(SHOW_UNIVERSAL)
private set
private val defaultPatchSelection = bundlesFlow.map { bundles ->
@ -114,6 +120,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<BundleInfo>) = bundles.any { bundle ->
bundle.patchSequence(allowIncompatiblePatches).any { patch ->
isSelected(bundle.uid, patch)
@ -191,8 +213,8 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
compatibleVersions.clear()
}
fun openUnsupportedDialog(unsupportedPatch: PatchInfo) {
compatibleVersions.addAll(unsupportedPatch.compatiblePackages?.find { it.packageName == packageName }?.versions.orEmpty())
fun openIncompatibleDialog(incompatiblePatch: PatchInfo) {
compatibleVersions.addAll(incompatiblePatch.compatiblePackages?.find { it.packageName == packageName }?.versions.orEmpty())
}
fun toggleFlag(flag: Int) {
@ -200,9 +222,8 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
}
companion object {
const val SHOW_SUPPORTED = 1 // 2^0
const val SHOW_INCOMPATIBLE = 1 // 2^0
const val SHOW_UNIVERSAL = 2 // 2^1
const val SHOW_UNSUPPORTED = 4 // 2^2
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
// Patch name -> Options

View File

@ -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
@ -34,8 +35,10 @@ 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.requiredOptionsSet
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
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 +66,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 +176,10 @@ class SelectedAppInfoViewModel(
}
}
val bundleInfoFlow by derivedStateOf {
bundleRepository.bundleInfoFlow(packageName, selectedApp.version)
}
fun showSourceSelector() {
dismissSourceSelector()
showSourceSelector = true
@ -260,19 +266,36 @@ 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 allowIncompatible = prefs.disablePatchVersionCompatCheck.get()
val bundles = bundleInfoFlow.first()
return Patcher.ViewModelParams(
selectedApp,
getPatches(bundles, allowIncompatible),
getOptionsFiltered(bundles)
)
}
fun getOptionsFiltered(bundles: List<BundleInfo>) = options.filtered(bundles)
fun getPatches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
selectionState.patches(bundles, allowUnsupported)
fun getPatches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible)
fun getCustomPatches(
bundles: List<BundleInfo>,
allowUnsupported: Boolean
allowIncompatible: Boolean
): PatchSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported)
(selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible)
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
@ -319,13 +342,13 @@ class SelectedAppInfoViewModel(
}
private sealed interface SelectionState : Parcelable {
fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean): PatchSelection
fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean): PatchSelection
@Parcelize
data class Customized(val patchSelection: PatchSelection) : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
bundles.toPatchSelection(
allowUnsupported
allowIncompatible
) { uid, patch ->
patchSelection[uid]?.contains(patch.name) ?: false
}
@ -333,8 +356,8 @@ private sealed interface SelectionState : Parcelable {
@Parcelize
data object Default : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
bundles.toPatchSelection(allowUnsupported) { _, patch -> patch.include }
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include }
}
}

View File

@ -9,6 +9,7 @@ import android.content.pm.PackageInstaller
import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
@ -42,9 +43,9 @@ class UpdateViewModel(
private val networkInfo: NetworkInfo by inject()
private val fs: Filesystem by inject()
var downloadedSize by mutableStateOf(0L)
var downloadedSize by mutableLongStateOf(0L)
private set
var totalSize by mutableStateOf(0L)
var totalSize by mutableLongStateOf(0L)
private set
val downloadProgress by derivedStateOf {
if (downloadedSize == 0L || totalSize == 0L) return@derivedStateOf 0f
@ -89,7 +90,7 @@ class UpdateViewModel(
totalSize = contentLength
}
}
state = State.CAN_INSTALL
installUpdate()
}
}
}
@ -108,14 +109,19 @@ class UpdateViewModel(
val extra =
intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
} else {
state = State.FAILED
// TODO: handle install fail with a popup
installError = extra
app.toast(app.getString(R.string.install_app_fail, extra))
when(pmStatus) {
PackageInstaller.STATUS_SUCCESS -> {
app.toast(app.getString(R.string.install_app_success))
state = State.SUCCESS
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
state = State.CAN_INSTALL
}
else -> {
app.toast(app.getString(R.string.install_app_fail, extra))
installError = extra
state = State.FAILED
}
}
}
}
@ -135,10 +141,10 @@ class UpdateViewModel(
location.delete()
}
enum class State(@StringRes val title: Int, val showCancel: Boolean = false) {
enum class State(@StringRes val title: Int) {
CAN_DOWNLOAD(R.string.update_available),
DOWNLOADING(R.string.downloading_manager_update, true),
CAN_INSTALL(R.string.ready_to_install_update, true),
DOWNLOADING(R.string.downloading_manager_update),
CAN_INSTALL(R.string.ready_to_install_update),
INSTALLING(R.string.installing_manager_update),
FAILED(R.string.install_update_manager_failed),
SUCCESS(R.string.update_completed)

View File

@ -3,6 +3,7 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.util.toast
@ -12,10 +13,14 @@ class UpdatesSettingsViewModel(
prefs: PreferencesManager,
private val app: Application,
private val reVancedAPI: ReVancedAPI,
private val network: NetworkInfo,
) : ViewModel() {
val managerAutoUpdates = prefs.managerAutoUpdates
val showManagerUpdateDialogOnLaunch = prefs.showManagerUpdateDialogOnLaunch
val isConnected: Boolean
get() = network.isConnected()
suspend fun checkForUpdates(): Boolean {
uiSafe(app, R.string.failed_to_check_updates, "Failed to check for updates") {
app.toast(app.getString(R.string.update_check))

View File

@ -38,6 +38,8 @@
<string name="android_11_bug_dialog_title">Android 11 bug</string>
<string name="android_11_bug_dialog_description">The app installation permission must be granted ahead of time to avoid a bug in the Android 11 system that will negatively affect the user experience.</string>
<string name="no_network_toast">No internet connection available</string>
<string name="selected_app_meta_any_version">Any available version</string>
<string name="app_source_dialog_title">Select source</string>
<string name="app_source_dialog_option_auto">Auto</string>
@ -51,6 +53,9 @@
<string name="patch_selector_item_description">%d patches selected</string>
<string name="no_patches_selected">No patches selected</string>
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
<string name="apk_source_selector_item">Change source</string>
<string name="apk_source_auto">Current: All downloaders</string>
<string name="apk_source_downloader">Current: %s</string>
@ -87,13 +92,17 @@
<string name="theme_description">Choose between light or dark theme</string>
<string name="safeguards">Safeguards</string>
<string name="patch_compat_check">Disable version compatibility check</string>
<string name="patch_compat_check_description">The check restricts patches to supported app versions</string>
<string name="patch_compat_check_description">The check restricts patches to compatible app versions</string>
<string name="patch_compat_check_confirmation">Selecting incompatible patches can result in a broken app.\n\nDo you want to proceed anyways?</string>
<string name="suggested_version_safeguard">Require suggested app version</string>
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways?</string>
<string name="patch_selection_safeguard">Allow changing patch selection</string>
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches</string>
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.\n\nEnable anyways?</string>
<string name="universal_patches_safeguard">Disable universal patch warning</string>
<string name="universal_patches_safeguard_description">Disables the warning that appears when you try to select universal patches</string>
<string name="universal_patches_safeguard_confirmation">Universal patches are not as well tested as those that target specific apps.\n\nEnable anyways?</string>
<string name="import_keystore">Import keystore</string>
<string name="import_keystore_description">Import a custom keystore</string>
<string name="import_keystore_dialog_title">Enter keystore credentials</string>
@ -142,6 +151,8 @@
<string name="options">Options</string>
<string name="ok">OK</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="edit">Edit</string>
<string name="dialog_input_placeholder">Value</string>
<string name="reset">Reset</string>
@ -158,6 +169,7 @@
<string name="warning">Warning</string>
<string name="add">Add</string>
<string name="close">Close</string>
<string name="clear">Clear</string>
<string name="system">System</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
@ -204,7 +216,7 @@
<string name="no_patched_apps_found">No patched apps found</string>
<string name="tap_on_patches">Tap on the patches to get more information about them</string>
<string name="bundles_selected">%s selected</string>
<string name="unsupported_patches">Unsupported patches</string>
<string name="incompatible_patches">Incompatible patches</string>
<string name="universal_patches">Universal patches</string>
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
<string name="patch_options_reset_toast">Patch options have been reset</string>
@ -213,13 +225,12 @@
<string name="selection_warning_title">Stop using defaults?</string>
<string name="selection_warning_description">It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.\n\nYou need to turn on \"Allow changing patch selection\" in the advanced settings before toggling patches.</string>
<string name="universal_patch_warning_description">Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.\n\nThis warning can be disabled in the advanced settings.</string>
<string name="supported">Supported</string>
<string name="universal">Universal</string>
<string name="unsupported">Unsupported</string>
<string name="search_patches">Patch name</string>
<string name="app_not_supported">This patch is not compatible with the selected app version (%1$s).\n\nIt only supports the following version(s): %2$s.</string>
<string name="this_version">This version</string>
<string name="universal">Any app</string>
<string name="search_patches">Search patches</string>
<string name="app_version_not_compatible">This patch is not compatible with the selected app version (%1$s).\n\nIt is only compatible with the following version(s): %2$s.</string>
<string name="continue_with_version">Continue with this version?</string>
<string name="version_not_supported">Not all patches support this version (%s). Do you want to continue anyway?</string>
<string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string>
<string name="download_application">Download application?</string>
<string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string>
<string name="failed_to_load_apk">Failed to load APK</string>
@ -348,6 +359,7 @@
<string name="download_manager_failed">Failed to download update: %s</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="save_with_count">Save (%1$s)</string>
<string name="update">Update</string>
<string name="empty">Empty</string>
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
@ -359,6 +371,8 @@
<string name="invalid_date">Invalid date</string>
<string name="disable_battery_optimization">Disable battery optimization</string>
<string name="input_dialog_value_invalid">Invalid value</string>
<string name="option_required">This option is required</string>
<string name="required_options_screen">Required options</string>
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
<string name="no_update_available">No update available</string>
@ -390,7 +404,7 @@
<string name="installation_aborted_description">The installation was cancelled manually. Try again?</string>
<string name="installation_blocked_description">The installation was blocked. Review your device security settings and try again.</string>
<string name="installation_conflict_description">The installation was prevented by an existing installation of the app. Uninstall the installed app and try again?</string>
<string name="installation_incompatible_description">The app is incompatible with this device. Use an APK that is supported by this device and try again.</string>
<string name="installation_incompatible_description">The app is incompatible with this device. Use an APK that is compatible by this device and try again.</string>
<string name="installation_invalid_description">The app is invalid. Uninstall the app and try again?</string>
<string name="installation_storage_issue_description">The app could not be installed due to insufficient storage. Free up some space and try again.</string>
<string name="installation_timeout_description">The installation took too long. Try again?</string>
@ -403,10 +417,12 @@
<string name="add_patch_bundle">Add patch bundle</string>
<string name="bundle_url">Bundle URL</string>
<string name="auto_update">Auto update</string>
<string name="unsupported_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
<string name="unsupported_patch">Unsupported patch</string>
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
<string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string>
<string name="never_show_again">Never show again</string>
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
<string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string>
<string name="failed_to_import_keystore">Failed to import keystore</string>
<string name="export">Export</string>
</resources>

View File

@ -7,10 +7,4 @@ plugins {
alias(libs.plugins.kotlin.parcelize) 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"
}

View File

@ -1 +0,0 @@
/build

View File

@ -1,171 +0,0 @@
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 <init> (Ljava/lang/String;Ljava/util/Map;)V
public synthetic fun <init> (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 <init> ()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 <init> (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 <init> ()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 <init> (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 <init> (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 <init> ()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 <init> ()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 <init> ()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 <init> ()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 <init> ()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
}

View File

@ -1,61 +0,0 @@
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<MavenPublication>("release") {
groupId = "app.revanced"
artifactId = "manager-downloader-plugin"
version = "1.0"
afterEvaluate {
from(components["release"])
}
}
}
}

View File

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

View File

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -1,8 +0,0 @@
// 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();
}

View File

@ -1,11 +0,0 @@
// 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);
}

View File

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

View File

@ -1,165 +0,0 @@
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<InputStream, Size?>
typealias Version = String
typealias GetResult<T> = Pair<T, Version?>
class DownloaderScope<T : Parcelable> 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<T>?)? = 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<T>?) {
get = block
}
/**
* Utilize the service specified by the provided [Intent]. The service will be unbound when the scope ends.
*/
suspend fun <R : Any?> 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<T : Parcelable> internal constructor(private val block: DownloaderScope<T>.() -> Unit) {
@PluginHostApi
fun build(scopeImpl: Scope, context: Context) =
with(DownloaderScope<T>(scopeImpl, context)) {
block()
Downloader(
download = download!!,
get = get!!
)
}
}
class Downloader<T : Parcelable> internal constructor(
@property:PluginHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?,
@property:PluginHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit
)
/**
* Define a downloader plugin.
*/
fun <T : Parcelable> Downloader(block: DownloaderScope<T>.() -> 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")
}
}

View File

@ -1,42 +0,0 @@
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 <T : Parcelable> DownloaderScope<T>.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 <reified ACTIVITY : Activity> 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 <reified SERVICE : Service, R : Any?> DownloaderScope<*>.useService(
noinline block: suspend (IBinder) -> R
) = useService(
Intent().apply { setClassName(pluginPackageName, SERVICE::class.qualifiedName!!) }, block
)

View File

@ -1,39 +0,0 @@
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<String, String> = 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()
}
}

View File

@ -1,176 +0,0 @@
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<T> = suspend WebViewCallbackScope<T>.(url: String) -> Unit
typealias DownloadCallback<T> = suspend WebViewCallbackScope<T>.(url: String, mimeType: String, userAgent: String) -> Unit
interface WebViewCallbackScope<T> : 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<T> internal constructor(
coroutineScope: CoroutineScope,
private val scopeImpl: Scope,
setResult: (T) -> Unit
) : Scope by scopeImpl {
private var onPageLoadCallback: PageLoadCallback<T> = {}
private var onDownloadCallback: DownloadCallback<T> = { _, _, _ -> }
@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<T>, 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<T>) {
onDownloadCallback = block
}
/**
* Called when the WebView finishes loading a page.
*/
fun pageLoad(block: PageLoadCallback<T>) {
onPageLoadCallback = block
}
}
@JvmInline
private value class Container<U>(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 <T> GetScope.runWebView(
title: String,
block: suspend WebViewScope<T>.() -> InitialUrl
) = supervisorScope {
var result by Delegates.notNull<Container<T>>()
val scope = WebViewScope<T>(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<DownloadUrl>.(packageName: String, version: String?) -> InitialUrl?) =
Downloader<DownloadUrl> {
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()
}
}

View File

@ -1,161 +0,0 @@
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<WebViewModel>()
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<WebView>(R.id.webview)
onBackPressedDispatcher.addCallback {
if (webView.canGoBack()) webView.goBack()
else cancelActivity()
}
val params = intent.getParcelableExtra<Parameters>(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<Command>()
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
}
}

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/main">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -1 +0,0 @@
<resources></resources>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WebViewActivity" parent="Theme.AppCompat.DayNight">
<item name="android:windowActionBar">true</item>
<item name="android:windowNoTitle">false</item>
</style>
</resources>

View File

@ -1 +0,0 @@
/build

View File

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

View File

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

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="app.revanced.manager.plugin.downloader" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
tools:targetApi="34">
<activity
android:name=".InteractionActivity"
android:exported="true"
android:permission="app.revanced.manager.permission.PLUGIN_HOST"
android:theme="@android:style/Theme.DeviceDefault" />
<meta-data
android:name="app.revanced.manager.plugin.downloader.class"
android:value="app.revanced.manager.plugin.downloader.example.ExamplePluginKt" />
</application>
</manifest>

View File

@ -1,69 +0,0 @@
@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<InstalledApp> {
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<InteractionActivity>()
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)
}*/
}

View File

@ -1,65 +0,0 @@
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")
}
}
}
}
}
}
}
}

View File

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -1,3 +0,0 @@
<resources>
<string name="app_name">Example Downloader Plugin</string>
</resources>

View File

@ -1,41 +1,41 @@
[versions]
ktx = "1.15.0"
material3 = "1.3.1"
ui-tooling = "1.7.6"
ui-tooling = "1.7.7"
viewmodel-lifecycle = "2.8.7"
splash-screen = "1.0.1"
activity = "1.9.3"
activity = "1.10.0"
appcompat = "1.7.0"
preferences-datastore = "1.1.1"
preferences-datastore = "1.1.2"
work-runtime = "2.10.0"
compose-bom = "2024.12.01"
navigation = "2.8.5"
accompanist = "0.34.0"
compose-bom = "2025.01.01"
navigation = "2.8.6"
accompanist = "0.37.0"
placeholder = "1.1.2"
reorderable = "1.5.2"
serialization = "1.7.3"
reorderable = "2.4.3"
serialization = "1.8.0"
collection = "0.3.8"
datetime = "0.6.0"
datetime = "0.6.1"
room-version = "2.6.1"
revanced-patcher = "21.0.0"
revanced-library = "3.0.2"
plugin-api = "1.0.0"
koin = "3.5.3"
ktor = "2.3.9"
markdown-renderer = "0.22.0"
markdown-renderer = "0.30.0"
fading-edges = "1.0.4"
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"
kotlin = "2.1.10"
android-gradle-plugin = "8.8.0"
dev-tools-gradle-plugin = "2.1.10-1.0.29"
about-libraries-gradle-plugin = "11.5.0"
coil = "2.7.0"
app-icon-loader-coil = "1.5.0"
skrapeit = "1.2.2"
libsu = "5.2.2"
libsu = "6.0.0"
scrollbars = "1.0.4"
enumutil = "1.1.0"
enumutil = "1.1.1"
compose-icons = "1.2.4"
kotlin-process = "1.4.1"
kotlin-process = "1.5.1"
hidden-api-stub = "4.3.3"
[libraries]
@ -45,7 +45,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" }
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" }
@ -84,6 +83,9 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref =
revanced-patcher = { group = "app.revanced", name = "revanced-patcher", version.ref = "revanced-patcher" }
revanced-library = { group = "app.revanced", name = "revanced-library", version.ref = "revanced-library" }
# Plugin API
plugin-api = { group = "app.revanced", name = "revanced-manager-downloader-api", version.ref = "plugin-api" }
# Koin
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" }
@ -143,5 +145,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
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" }
about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" }

Binary file not shown.

View File

@ -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=8d97a97984f6cbd2b85fe4c60a743440a347544bf18818048e611f5288d46c94
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

6
gradlew vendored
View File

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

22
gradlew.bat vendored
View File

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

View File

@ -26,5 +26,3 @@ dependencyResolutionManagement {
}
rootProject.name = "ReVanced Manager"
include(":app")
include(":downloader-plugin")
include(":example-downloader-plugin")