From 5762859906f534232ecbbf64dafe02bbd049fc80 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 13 Oct 2023 18:11:40 +0200 Subject: [PATCH 01/48] feat: add patches selector bottom sheet (#1360) --- .../ui/screen/PatchesSelectorScreen.kt | 346 +++++++++++------- app/src/main/res/values/strings.xml | 6 +- 2 files changed, 224 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 36d12f23..01cda7c0 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -14,17 +14,17 @@ 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.ArrowBack import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip @@ -32,8 +32,10 @@ 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.SearchBar import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -79,9 +81,86 @@ fun PatchesSelectorScreen( ) { val pagerState = rememberPagerState() val composableScope = rememberCoroutineScope() + var search: String? by rememberSaveable { + mutableStateOf(null) + } + var showBottomSheet by rememberSaveable { mutableStateOf(false) } val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + } + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + Text( + text = stringResource(R.string.patches_selector_sheet_filter_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.patches_selector_sheet_filter_compat_title), + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + FilterChip( + selected = vm.filter and SHOW_SUPPORTED != 0, + onClick = { vm.toggleFlag(SHOW_SUPPORTED) }, + label = { Text(stringResource(R.string.supported)) } + ) + + FilterChip( + 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)) }, + ) + } + } + + Divider() + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = vm.hasPreviousSelection, + onClick = vm::switchBaseSelectionMode + ), + leadingContent = { + Checkbox( + checked = vm.baseSelectionMode == BaseSelectionMode.PREVIOUS, + onCheckedChange = { + vm.switchBaseSelectionMode() + }, + enabled = vm.hasPreviousSelection + ) + }, + headlineContent = { + Text( + "Use previous selection", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + ) + } + } + if (vm.compatibleVersions.isNotEmpty()) UnsupportedDialog( appVersion = vm.input.selectedApp.version, @@ -106,6 +185,113 @@ fun PatchesSelectorScreen( ) } + val allowExperimental by vm.allowExperimental.getAsState() + + fun LazyListScope.patchList( + uid: Int, + patches: List, + filterFlag: Int, + supported: Boolean, + header: (@Composable () -> Unit)? = null + ) { + if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) { + header?.let { + item { + it() + } + } + + items( + items = patches, + key = { it.name } + ) { patch -> + PatchItem( + patch = patch, + onOptionsDialog = { + vm.optionsDialog = uid to patch + }, + selected = supported && vm.isSelected( + uid, + patch + ), + onToggle = { + if (vm.selectionWarningEnabled) { + vm.pendingSelectionAction = { + vm.togglePatch(uid, patch) + } + } else { + vm.togglePatch(uid, patch) + } + }, + supported = supported + ) + } + } + } + + search?.let { query -> + SearchBar( + query = query, + onQueryChange = { new -> + search = new + }, + onSearch = {}, + active = true, + onActiveChange = { new -> + if (new) return@SearchBar + search = null + }, + placeholder = { + Text(stringResource(R.string.search_patches)) + }, + leadingIcon = { + IconButton(onClick = { search = null }) { + Icon( + Icons.Default.ArrowBack, + stringResource(R.string.back) + ) + } + } + ) { + val bundle = bundles[pagerState.currentPage] + LazyColumn(modifier = Modifier.fillMaxSize()) { + fun List.searched() = filter { + it.name.contains(query, true) + } + + patchList( + uid = bundle.uid, + patches = bundle.supported.searched(), + filterFlag = SHOW_SUPPORTED, + supported = true + ) + patchList( + uid = bundle.uid, + patches = bundle.universal.searched(), + filterFlag = SHOW_UNIVERSAL, + supported = true + ) { + ListHeader( + title = stringResource(R.string.universal_patches), + ) + } + + if (!allowExperimental) return@LazyColumn + patchList( + uid = bundle.uid, + patches = bundle.unsupported.searched(), + filterFlag = SHOW_UNSUPPORTED, + supported = true + ) { + ListHeader( + title = stringResource(R.string.unsupported_patches), + onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } + ) + } + } + } + } + Scaffold( topBar = { AppTopBar( @@ -115,34 +301,14 @@ fun PatchesSelectorScreen( IconButton(onClick = vm::reset) { Icon(Icons.Outlined.Restore, stringResource(R.string.reset)) } - - var dropdownActive by rememberSaveable { - mutableStateOf(false) + IconButton(onClick = { showBottomSheet = true }) { + Icon(Icons.Outlined.FilterList, stringResource(R.string.more)) } - // This part should probably be changed - IconButton(onClick = { dropdownActive = true }) { - Icon(Icons.Outlined.MoreVert, stringResource(R.string.more)) - DropdownMenu( - expanded = dropdownActive, - onDismissRequest = { dropdownActive = false } - ) { - DropdownMenuItem( - text = { - val id = - if (vm.baseSelectionMode == BaseSelectionMode.DEFAULT) - R.string.menu_opt_selection_mode_previous else R.string.menu_opt_selection_mode_default - - Text(stringResource(id)) - }, - onClick = { - dropdownActive = false - vm.switchBaseSelectionMode() - }, - enabled = vm.hasPreviousSelection - ) + IconButton( + onClick = { + search = "" } - } - IconButton(onClick = { }) { + ) { Icon(Icons.Outlined.Search, stringResource(R.string.search)) } } @@ -198,107 +364,35 @@ fun PatchesSelectorScreen( pageContent = { index -> val bundle = bundles[index] - Column { - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp) + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + patchList( + uid = bundle.uid, + patches = bundle.supported, + filterFlag = SHOW_SUPPORTED, + supported = true + ) + patchList( + uid = bundle.uid, + patches = bundle.universal, + filterFlag = SHOW_UNIVERSAL, + supported = true ) { - FilterChip( - selected = vm.filter and SHOW_SUPPORTED != 0 && bundle.supported.isNotEmpty(), - onClick = { vm.toggleFlag(SHOW_SUPPORTED) }, - label = { Text(stringResource(R.string.supported)) }, - enabled = bundle.supported.isNotEmpty() - ) - - FilterChip( - selected = vm.filter and SHOW_UNIVERSAL != 0 && bundle.universal.isNotEmpty(), - onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, - label = { Text(stringResource(R.string.universal)) }, - enabled = bundle.universal.isNotEmpty() - ) - - FilterChip( - selected = vm.filter and SHOW_UNSUPPORTED != 0 && bundle.unsupported.isNotEmpty(), - onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) }, - label = { Text(stringResource(R.string.unsupported)) }, - enabled = bundle.unsupported.isNotEmpty() + ListHeader( + title = stringResource(R.string.universal_patches), ) } - - val allowExperimental by vm.allowExperimental.getAsState() - - LazyColumn( - modifier = Modifier.fillMaxSize() + patchList( + uid = bundle.uid, + patches = bundle.unsupported, + filterFlag = SHOW_UNSUPPORTED, + supported = allowExperimental ) { - fun LazyListScope.patchList( - patches: List, - filterFlag: Int, - supported: Boolean, - header: (@Composable () -> Unit)? = null - ) { - if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) { - header?.let { - item { - it() - } - } - - items( - items = patches, - key = { it.name } - ) { patch -> - PatchItem( - patch = patch, - onOptionsDialog = { - vm.optionsDialog = bundle.uid to patch - }, - selected = supported && vm.isSelected( - bundle.uid, - patch - ), - onToggle = { - if (vm.selectionWarningEnabled) { - vm.pendingSelectionAction = { - vm.togglePatch(bundle.uid, patch) - } - } else { - vm.togglePatch(bundle.uid, patch) - } - }, - supported = supported - ) - } - } - } - - patchList( - patches = bundle.supported, - filterFlag = SHOW_SUPPORTED, - supported = true + ListHeader( + title = stringResource(R.string.unsupported_patches), + onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } ) - patchList( - patches = bundle.universal, - filterFlag = SHOW_UNIVERSAL, - supported = true - ) { - ListHeader( - title = stringResource(R.string.universal_patches), - onHelpClick = { } - ) - } - patchList( - patches = bundle.unsupported, - filterFlag = SHOW_UNSUPPORTED, - supported = allowExperimental - ) { - ListHeader( - title = stringResource(R.string.unsupported_patches), - onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } - ) - } } } } @@ -400,7 +494,7 @@ fun PatchItem( leadingContent = { Checkbox( checked = selected, - onCheckedChange = null, + onCheckedChange = { onToggle() }, enabled = supported ) }, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58c4b940..381ae555 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,8 +143,6 @@ Unsupported app Unsupported patches Universal patches - Use default selection - Use previous selection Patch selection and options has been reset to recommended defaults Stop using defaults? You may encounter issues when not using the default patch selection and options. @@ -152,6 +150,7 @@ Supported Universal Unsupported + Patch name Some of the patches do not support this app version (%1$s). The patches only support the following version(s): %2$s. Continue with this version? Not all patches support this version (%s). Do you want to continue anyway? @@ -188,6 +187,9 @@ Downloadable versions Already patched + Filter + Compatibility + Edit More options Value From 56a4a7043d8fb2ed1fb20baee2a42f5794724465 Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Fri, 13 Oct 2023 10:39:10 -0700 Subject: [PATCH 02/48] feat: settings migration (compose) (#1309) --- .../java/app/revanced/manager/MainActivity.kt | 67 ++++++++++++++-- .../domain/manager/PreferencesManager.kt | 2 +- .../manager/ui/viewmodel/MainViewModel.kt | 77 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 + 4 files changed, 138 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 162ecff6..944656e3 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -1,24 +1,36 @@ package app.revanced.manager +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.screen.AppInfoScreen -import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.screen.AppSelectorScreen import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.InstallerScreen import app.revanced.manager.ui.screen.PatchesSelectorScreen import app.revanced.manager.ui.screen.SettingsScreen +import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel +import app.revanced.manager.util.tag +import app.revanced.manager.util.toast import dev.olshevski.navigation.reimagined.AnimatedNavHost import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.navigate @@ -51,9 +63,48 @@ class MainActivity : ComponentActivity() { NavBackHandler(navController) - val showAutoUpdatesDialog by vm.prefs.showAutoUpdatesDialog.getAsState() - if (showAutoUpdatesDialog) { - AutoUpdatesDialog(vm::applyAutoUpdatePrefs) + val firstLaunch by vm.prefs.firstLaunch.getAsState() + + if (firstLaunch) { + var legacyActivityState by rememberSaveable { mutableStateOf(LegacyActivity.NOT_LAUNCHED) } + if (legacyActivityState == LegacyActivity.NOT_LAUNCHED) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == RESULT_OK) { + if (result.data != null) { + val jsonData = result.data!!.getStringExtra("data")!! + vm.applyLegacySettings(jsonData) + } + } else { + legacyActivityState = LegacyActivity.FAILED + toast(getString(R.string.legacy_import_failed)) + } + } + + val intent = Intent().apply { + setClassName( + "app.revanced.manager.flutter", + "app.revanced.manager.flutter.ExportSettingsActivity" + ) + } + + LaunchedEffect(Unit) { + try { + launcher.launch(intent) + } catch (e: Exception) { + if (e !is ActivityNotFoundException) { + toast(getString(R.string.legacy_import_failed)) + Log.e(tag, "Failed to launch legacy import activity: $e") + } + legacyActivityState = LegacyActivity.FAILED + } + } + + legacyActivityState = LegacyActivity.LAUNCHED + } else if (legacyActivityState == LegacyActivity.FAILED){ + AutoUpdatesDialog(vm::applyAutoUpdatePrefs) + } } AnimatedNavHost( @@ -120,4 +171,10 @@ class MainActivity : ComponentActivity() { } } } -} \ No newline at end of file + + private enum class LegacyActivity { + NOT_LAUNCHED, + LAUNCHED, + FAILED + } +} diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 5c08ba17..44f61794 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -19,7 +19,7 @@ class PreferencesManager( val preferSplits = booleanPreference("prefer_splits", false) - val showAutoUpdatesDialog = booleanPreference("show_auto_updates_dialog", true) + val firstLaunch = booleanPreference("first_launch", true) val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val disableSelectionWarning = booleanPreference("disable_selection_warning", false) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt index 5592d3e7..baf017b1 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt @@ -1,21 +1,28 @@ package app.revanced.manager.ui.viewmodel +import android.util.Base64 import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull +import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository -import kotlinx.coroutines.Dispatchers +import app.revanced.manager.domain.repository.PatchSelectionRepository +import app.revanced.manager.domain.repository.SerializedSelection +import app.revanced.manager.ui.theme.Theme import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json class MainViewModel( private val patchBundleRepository: PatchBundleRepository, + private val patchSelectionRepository: PatchSelectionRepository, + private val keystoreManager: KeystoreManager, val prefs: PreferencesManager ) : ViewModel() { - fun applyAutoUpdatePrefs(manager: Boolean, patches: Boolean) = viewModelScope.launch { - prefs.showAutoUpdatesDialog.update(false) + prefs.firstLaunch.update(false) prefs.managerAutoUpdates.update(manager) if (patches) { @@ -30,4 +37,66 @@ class MainViewModel( } } } -} \ No newline at end of file + + fun applyLegacySettings(data: String) = viewModelScope.launch { + val json = Json { ignoreUnknownKeys = true } + val settings = json.decodeFromString(data) + + settings.themeMode?.let { theme -> + val themeMap = mapOf( + 0 to Theme.SYSTEM, + 1 to Theme.LIGHT, + 2 to Theme.DARK + ) + prefs.theme.update(themeMap[theme]!!) + } + settings.useDynamicTheme?.let { dynamicColor -> + prefs.dynamicColor.update(dynamicColor) + } + settings.apiUrl?.let { api -> + prefs.api.update(api.removeSuffix("/")) + } + settings.experimentalPatchesEnabled?.let { allowExperimental -> + prefs.allowExperimental.update(allowExperimental) + } + settings.patchesAutoUpdate?.let { autoUpdate -> + with(patchBundleRepository) { + sources + .first() + .find { it.uid == 0 } + ?.asRemoteOrNull + ?.setAutoUpdate(autoUpdate) + + updateCheck() + } + } + settings.patchesChangeEnabled?.let { disableSelectionWarning -> + prefs.disableSelectionWarning.update(disableSelectionWarning) + } + settings.keystore?.let { keystore -> + val keystoreBytes = Base64.decode(keystore, Base64.DEFAULT) + keystoreManager.import( + "ReVanced", + settings.keystorePassword, + keystoreBytes.inputStream() + ) + } + settings.patches?.let { selection -> + patchSelectionRepository.import(0, selection) + } + prefs.firstLaunch.update(false) + } + + @Serializable + private data class LegacySettings( + val keystorePassword: String, + val themeMode: Int? = null, + val useDynamicTheme: Boolean? = null, + val apiUrl: String? = null, + val experimentalPatchesEnabled: Boolean? = null, + val patchesAutoUpdate: Boolean? = null, + val patchesChangeEnabled: Boolean? = null, + val keystore: String? = null, + val patches: SerializedSelection? = null, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 381ae555..5b22bc46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,8 @@ Missing Error + Could not import legacy settings + Select updates to receive Periodically connect to update providers to check for updates. ReVanced Manager From 8f6d720454bc5096c4e1b067447177380b23cde8 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sat, 14 Oct 2023 17:42:10 +0200 Subject: [PATCH 03/48] refactor(downloaders): improve file system code (#1379) --- .../1.json | 10 ++-- .../revanced/manager/data/room/Converters.kt | 2 +- .../room/apps/downloaded/DownloadedApp.kt | 2 +- .../repository/DownloadedAppRepository.kt | 53 ++++++++++++++----- .../manager/network/downloader/APKMirror.kt | 21 ++------ .../network/downloader/AppDownloader.kt | 3 +- .../manager/patcher/worker/PatcherWorker.kt | 12 +---- .../ui/viewmodel/VersionSelectorViewModel.kt | 2 +- 8 files changed, 55 insertions(+), 50 deletions(-) diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index 543d7a70..041d1dea 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "5515d164bc8f713201506d42a02d337f", + "identityHash": "371c7a84b122a2de8b660b35e6e9ce14", "entities": [ { "tableName": "patch_bundles", @@ -160,7 +160,7 @@ }, { "tableName": "downloaded_app", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `file` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))", "fields": [ { "fieldPath": "packageName", @@ -175,8 +175,8 @@ "notNull": true }, { - "fieldPath": "file", - "columnName": "file", + "fieldPath": "directory", + "columnName": "directory", "affinity": "TEXT", "notNull": true } @@ -300,7 +300,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5515d164bc8f713201506d42a02d337f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '371c7a84b122a2de8b660b35e6e9ce14')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt index f8aa073d..7de50382 100644 --- a/app/src/main/java/app/revanced/manager/data/room/Converters.kt +++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt @@ -16,5 +16,5 @@ class Converters { fun fileFromString(value: String) = File(value) @TypeConverter - fun fileToString(file: File): String = file.absolutePath + fun fileToString(file: File): String = file.path } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt index a30063ff..60d1561d 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt @@ -11,5 +11,5 @@ import java.io.File data class DownloadedApp( @ColumnInfo(name = "package_name") val packageName: String, @ColumnInfo(name = "version") val version: String, - @ColumnInfo(name = "file") val file: File, + @ColumnInfo(name = "directory") val directory: File, ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt index 7035c6c4..fe339a2e 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt @@ -1,34 +1,61 @@ package app.revanced.manager.domain.repository +import android.app.Application +import android.content.Context import app.revanced.manager.data.room.AppDatabase +import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.apps.downloaded.DownloadedApp +import app.revanced.manager.network.downloader.AppDownloader import kotlinx.coroutines.flow.distinctUntilChanged import java.io.File class DownloadedAppRepository( + app: Application, db: AppDatabase ) { + private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE) private val dao = db.downloadedAppDao() fun getAll() = dao.getAllApps().distinctUntilChanged() - suspend fun get(packageName: String, version: String) = dao.get(packageName, version) + fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory)) + private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first() - suspend fun add( - packageName: String, - version: String, - file: File - ) = dao.insert( - DownloadedApp( - packageName = packageName, - version = version, - file = file - ) - ) + suspend fun download( + app: AppDownloader.App, + preferSplits: Boolean, + onDownload: suspend (downloadProgress: Pair?) -> Unit = {}, + ): File { + this.get(app.packageName, app.version)?.let { downloaded -> + return getApkFileForApp(downloaded) + } + + // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here. + val relativePath = File(generateUid().toString()) + val savePath = dir.resolve(relativePath).also { it.mkdirs() } + + try { + app.download(savePath, preferSplits, onDownload) + + dao.insert(DownloadedApp( + packageName = app.packageName, + version = app.version, + directory = relativePath, + )) + } catch (e: Exception) { + savePath.deleteRecursively() + throw e + } + + // Return the Apk file. + return getApkFileForDir(savePath) + } + + suspend fun get(packageName: String, version: String) = dao.get(packageName, version) suspend fun delete(downloadedApps: Collection) { downloadedApps.forEach { - it.file.deleteRecursively() + dir.resolve(it.directory).deleteRecursively() } dao.delete(downloadedApps) diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt index 3a055ef5..30c6fbee 100644 --- a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt +++ b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt @@ -171,7 +171,7 @@ class APKMirror : AppDownloader, KoinComponent { saveDirectory: File, preferSplit: Boolean, onDownload: suspend (downloadProgress: Pair?) -> Unit - ): File { + ) { val variants = httpClient.getHtml { url(apkMirror + downloadLink) } .div { withClass = "variants-table" @@ -246,18 +246,10 @@ class APKMirror : AppDownloader, KoinComponent { } } - val saveLocation = if (variant.apkType == APKType.BUNDLE) - saveDirectory.resolve(version).also { it.mkdirs() } - else - saveDirectory.resolve("$version.apk") + val targetFile = saveDirectory.resolve("base.apk") try { - val downloadLocation = if (variant.apkType == APKType.BUNDLE) - saveLocation.resolve("temp.zip") - else - saveLocation - - httpClient.download(downloadLocation) { + httpClient.download(targetFile) { url(apkMirror + downloadLink) onDownload { bytesSentTotal, contentLength -> onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10)) @@ -267,16 +259,11 @@ class APKMirror : AppDownloader, KoinComponent { if (variant.apkType == APKType.BUNDLE) { // TODO: Extract temp.zip - downloadLocation.delete() + targetFile.delete() } - } catch (e: Exception) { - saveLocation.deleteRecursively() - throw e } finally { onDownload(null) } - - return saveLocation } } diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt index a6a17622..dcefa26e 100644 --- a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt +++ b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt @@ -22,7 +22,6 @@ interface AppDownloader { saveDirectory: File, preferSplit: Boolean, onDownload: suspend (downloadProgress: Pair?) -> Unit = {} - ): File + ) } - } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index 864eb343..b6a33637 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -190,19 +190,11 @@ class PatcherWorker( val inputFile = when (val selectedApp = args.input) { is SelectedApp.Download -> { - val savePath = applicationContext.filesDir.resolve("downloaded-apps") - .resolve(args.input.packageName).also { it.mkdirs() } - - selectedApp.app.download( - savePath, + downloadedAppRepository.download( + selectedApp.app, prefs.preferSplits.get(), onDownload = { downloadProgress.emit(it) } ).also { - downloadedAppRepository.add( - args.input.packageName, - args.input.version, - it - ) args.setInputFile(it) updateProgress() // Downloading } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt index f7420131..cae25116 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt @@ -68,7 +68,7 @@ class VersionSelectorViewModel( } val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps -> - downloadedApps.filter { it.packageName == packageName }.map { SelectedApp.Local(it.packageName, it.version, it.file, false) } + downloadedApps.filter { it.packageName == packageName }.map { SelectedApp.Local(it.packageName, it.version, downloadedAppRepository.getApkFileForApp(it), false) } } init { From 8f5449527d0a5a3028d78b1b27e9413efbdbca0d Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sat, 14 Oct 2023 18:24:40 +0200 Subject: [PATCH 04/48] feat: armv7 warning --- .../app/revanced/manager/patcher/aapt/Aapt.kt | 5 + .../manager/ui/component/NotificationCard.kt | 10 +- .../manager/ui/screen/InstalledAppsScreen.kt | 93 ++++++++++++------- app/src/main/res/values/strings.xml | 2 + 4 files changed, 70 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt index 25e26be4..959768e6 100644 --- a/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt +++ b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt @@ -1,9 +1,14 @@ package app.revanced.manager.patcher.aapt import android.content.Context +import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS import java.io.File object Aapt { + private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64") + + fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty() + fun binary(context: Context): File? { return File(context.applicationInfo.nativeLibraryDir).resolveAapt() } diff --git a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt index 4c23afd4..a4e21297 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -24,13 +26,13 @@ fun NotificationCard( color: Color, icon: ImageVector, text: String, - content: @Composable () -> Unit + content: (@Composable () -> Unit)? = null, ) { Card( + colors = CardDefaults.cardColors(containerColor = color), modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(28.dp)) - .background(color) ) { Row( modifier = Modifier @@ -47,11 +49,11 @@ fun NotificationCard( contentDescription = null, ) Text( - modifier = Modifier.width(220.dp), + modifier = if (content != null) Modifier.width(220.dp) else Modifier, text = text, style = MaterialTheme.typography.bodyMedium ) - content() + content?.invoke() } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt index 82bd6f6b..2a2a2b89 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt @@ -2,10 +2,15 @@ package app.revanced.manager.ui.screen import androidx.compose.foundation.clickable 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.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -13,14 +18,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstalledApp +import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.ui.component.AppIcon import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.LoadingIndicator +import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel import org.koin.androidx.compose.getViewModel @@ -31,43 +39,56 @@ fun InstalledAppsScreen( ) { val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null) - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = installedApps?.let { if (it.isEmpty()) Arrangement.Center else Arrangement.Top } ?: Arrangement.Center - ) { - installedApps?.let { installedApps -> - - if (installedApps.isNotEmpty()) { - items( - installedApps, - key = { it.currentPackageName } - ) { installedApp -> - viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo -> - ListItem( - modifier = Modifier.clickable { onAppClick(installedApp) }, - leadingContent = { - AppIcon( - packageInfo, - contentDescription = null, - Modifier.size(36.dp) - ) - }, - headlineContent = { AppLabel(packageInfo, defaultText = null) }, - supportingContent = { Text(installedApp.currentPackageName) } - ) - - } - } - } else { - item { - Text( - text = stringResource(R.string.no_patched_apps_found), - style = MaterialTheme.typography.titleLarge - ) - } + Column { + if (!Aapt.supportsDevice()) + Box(modifier = Modifier.padding(16.dp)) { + NotificationCard( + color = MaterialTheme.colorScheme.errorContainer, + icon = Icons.Outlined.WarningAmber, + text = stringResource( + R.string.unsupported_architecture_warning + ), + ) } - } ?: item { LoadingIndicator() } + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top + ) { + installedApps?.let { installedApps -> + + if (installedApps.isNotEmpty()) { + items( + installedApps, + key = { it.currentPackageName } + ) { installedApp -> + viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo -> + ListItem( + modifier = Modifier.clickable { onAppClick(installedApp) }, + leadingContent = { + AppIcon( + packageInfo, + contentDescription = null, + Modifier.size(36.dp) + ) + }, + headlineContent = { AppLabel(packageInfo, defaultText = null) }, + supportingContent = { Text(installedApp.currentPackageName) } + ) + + } + } + } else { + item { + Text( + text = stringResource(R.string.no_patched_apps_found), + style = MaterialTheme.typography.titleLarge + ) + } + } + + } ?: item { LoadingIndicator() } + } } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b22bc46..18b5bb75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ Select an app Select patches + Patching on ARMv7 devices is not yet supported and will most likely fail. + Import Import patch bundle Bundle patches From f5b3b29d6d2457e0f5720d4e387066ebdf6b3bf8 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sat, 14 Oct 2023 18:48:07 +0200 Subject: [PATCH 05/48] feat: hide unfinished pages in release mode --- .../manager/ui/component/bundle/BaseBundleDialog.kt | 10 +++++----- .../manager/ui/screen/settings/AboutSettingsScreen.kt | 7 ++++--- app/src/main/java/app/revanced/manager/util/Util.kt | 3 +++ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt index 385c2269..33eb2d69 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt @@ -22,10 +22,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.TextInputDialog +import app.revanced.manager.util.isDebuggable @Composable fun BaseBundleDialog( @@ -159,20 +161,18 @@ fun BaseBundleDialog( ) } + val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0 BundleListItem( headlineText = stringResource(R.string.patches), supportingText = if (patchCount == 0) stringResource(R.string.no_patches) else stringResource(R.string.patches_available, patchCount), - modifier = Modifier.clickable(enabled = patchCount > 0) { - onPatchesClick() - } + modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick) ) { - if (patchCount > 0) { + if (patchesClickable) Icon( Icons.Outlined.ArrowRight, stringResource(R.string.patches) ) - } } version?.let { diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index 341c14c9..862cd6fb 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -24,6 +24,7 @@ import app.revanced.manager.BuildConfig import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.destination.SettingsDestination +import app.revanced.manager.util.isDebuggable import app.revanced.manager.util.openUrl import com.google.accompanist.drawablepainter.rememberDrawablePainter import dev.olshevski.navigation.reimagined.NavController @@ -57,15 +58,15 @@ fun AboutSettingsScreen( }), ) - val listItems = listOf( + val listItems = listOfNotNull( Triple(stringResource(R.string.submit_feedback), stringResource(R.string.submit_feedback_description), third = { context.openUrl("https://github.com/ReVanced/revanced-manager/issues/new/choose") }), Triple(stringResource(R.string.contributors), stringResource(R.string.contributors_description), - third = onContributorsClick), + third = onContributorsClick).takeIf { context.isDebuggable }, Triple(stringResource(R.string.developer_options), stringResource(R.string.developer_options_description), - third = { /*TODO*/ }), + third = { /*TODO*/ }).takeIf { context.isDebuggable }, Triple(stringResource(R.string.opensource_licenses), stringResource(R.string.opensource_licenses_description), third = onLicensesClick) ) diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index e8f38056..ed10ea6d 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -2,6 +2,7 @@ package app.revanced.manager.util import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo import android.util.Log import android.widget.Toast import androidx.annotation.StringRes @@ -22,6 +23,8 @@ import java.util.Locale typealias PatchesSelection = Map> typealias Options = Map>> +val Context.isDebuggable get() = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE + fun Context.openUrl(url: String) { startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK From 4c1ad868a929787b9f32f294e6bdce903273ba61 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 15 Oct 2023 00:22:12 +0200 Subject: [PATCH 06/48] fix: broken logo in about page on release builds --- .../ui/screen/settings/AboutSettingsScreen.kt | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index 862cd6fb..a35652ba 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.screen.settings +import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -13,22 +14,19 @@ import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.MailOutline import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource 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.ui.component.AppTopBar -import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.util.isDebuggable import app.revanced.manager.util.openUrl import com.google.accompanist.drawablepainter.rememberDrawablePainter -import dev.olshevski.navigation.reimagined.NavController -import dev.olshevski.navigation.reimagined.navigate @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,7 +36,10 @@ fun AboutSettingsScreen( onLicensesClick: () -> Unit, ) { val context = LocalContext.current - val icon = painterResource(R.drawable.ic_logo_ring) + // painterResource() is broken on release builds for some reason. + val icon = rememberDrawablePainter(drawable = remember { + AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring) + }) val filledButton = listOf( Triple(Icons.Outlined.FavoriteBorder, stringResource(R.string.donate)) { @@ -59,16 +60,24 @@ fun AboutSettingsScreen( ) val listItems = listOfNotNull( - Triple(stringResource(R.string.submit_feedback), stringResource(R.string.submit_feedback_description), + Triple(stringResource(R.string.submit_feedback), + stringResource(R.string.submit_feedback_description), third = { context.openUrl("https://github.com/ReVanced/revanced-manager/issues/new/choose") }), - Triple(stringResource(R.string.contributors), stringResource(R.string.contributors_description), - third = onContributorsClick).takeIf { context.isDebuggable }, - Triple(stringResource(R.string.developer_options), stringResource(R.string.developer_options_description), + Triple( + stringResource(R.string.contributors), + stringResource(R.string.contributors_description), + third = onContributorsClick + ).takeIf { context.isDebuggable }, + Triple(stringResource(R.string.developer_options), + stringResource(R.string.developer_options_description), third = { /*TODO*/ }).takeIf { context.isDebuggable }, - Triple(stringResource(R.string.opensource_licenses), stringResource(R.string.opensource_licenses_description), - third = onLicensesClick) + Triple( + stringResource(R.string.opensource_licenses), + stringResource(R.string.opensource_licenses_description), + third = onLicensesClick + ) ) Scaffold( @@ -94,7 +103,10 @@ fun AboutSettingsScreen( ) { Image(painter = icon, contentDescription = null) Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge) - Text( text = stringResource(R.string.version) + " " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.version) + " " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", + style = MaterialTheme.typography.bodyMedium + ) Row( modifier = Modifier.padding(top = 12.dp) ) { @@ -190,7 +202,13 @@ fun AboutSettingsScreen( .padding(8.dp) .clickable { onClick() }, headlineContent = { Text(title, style = MaterialTheme.typography.titleLarge) }, - supportingContent = { Text(description, style = MaterialTheme.typography.bodyMedium,color = MaterialTheme.colorScheme.outline) } + supportingContent = { + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } ) } } From cee2240cdc1ae004063f4686be656bf53f2702bf Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 15 Oct 2023 13:17:07 +0200 Subject: [PATCH 07/48] chore: bump compose --- app/build.gradle.kts | 4 ++-- .../app/revanced/manager/ui/screen/DashboardScreen.kt | 8 ++++++-- .../manager/ui/screen/PatchesSelectorScreen.kt | 11 +++++++---- gradle/libs.versions.toml | 8 ++++---- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5c3f484..d3a2a95d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,13 +9,13 @@ plugins { android { namespace = "app.revanced.manager" - compileSdk = 33 + compileSdk = 34 buildToolsVersion = "33.0.2" defaultConfig { applicationId = "app.revanced.manager" minSdk = 26 - targetSdk = 33 + targetSdk = 34 versionCode = 1 versionName = "0.0.1" resourceConfigurations.addAll(listOf( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index be71514b..2f3e49ec 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -65,7 +65,12 @@ fun DashboardScreen( val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val androidContext = LocalContext.current - val pagerState = rememberPagerState() + val pagerState = rememberPagerState( + initialPage = DashboardPage.DASHBOARD.ordinal, + initialPageOffsetFraction = 0f + ) { + DashboardPage.values().size + } val composableScope = rememberCoroutineScope() LaunchedEffect(pagerState.currentPage) { @@ -186,7 +191,6 @@ fun DashboardScreen( } HorizontalPager( - pageCount = pages.size, state = pagerState, userScrollEnabled = true, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 01cda7c0..47119f4b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -79,15 +79,19 @@ fun PatchesSelectorScreen( onBackClick: () -> Unit, vm: PatchesSelectorViewModel ) { - val pagerState = rememberPagerState() + val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f + ) { + bundles.size + } val composableScope = rememberCoroutineScope() var search: String? by rememberSaveable { mutableStateOf(null) } var showBottomSheet by rememberSaveable { mutableStateOf(false) } - val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList()) - if (showBottomSheet) { ModalBottomSheet( onDismissRequest = { @@ -358,7 +362,6 @@ fun PatchesSelectorScreen( } HorizontalPager( - pageCount = bundles.size, state = pagerState, userScrollEnabled = true, pageContent = { index -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c7b5e3b..1cb69f97 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] -ktx = "1.10.1" +ktx = "1.12.0" viewmodel-lifecycle = "2.6.2" splash-screen = "1.0.1" -compose-activity = "1.7.2" +compose-activity = "1.8.0" paging = "3.2.1" preferences-datastore = "1.0.0" work-runtime = "2.8.1" -compose-bom = "2023.06.01" +compose-bom = "2023.10.00" accompanist = "0.30.1" serialization = "1.6.0" collection = "0.3.5" @@ -15,7 +15,7 @@ revanced-patcher = "16.0.1" revanced-library = "1.1.1" koin-version = "3.4.3" koin-version-compose = "3.4.6" -reimagined-navigation = "1.4.0" +reimagined-navigation = "1.5.0" ktor = "2.3.3" markdown = "0.5.0" androidGradlePlugin = "8.1.1" From bf54d38c9161362db41f3177674ef505980ee90e Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 15 Oct 2023 14:03:53 +0200 Subject: [PATCH 08/48] chore: bump patcher --- gradle/libs.versions.toml | 4 ++-- settings.gradle.kts | 44 ++------------------------------------- 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cb69f97..729aa449 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,8 +11,8 @@ accompanist = "0.30.1" serialization = "1.6.0" collection = "0.3.5" room-version = "2.5.2" -revanced-patcher = "16.0.1" -revanced-library = "1.1.1" +revanced-patcher = "17.0.0" +revanced-library = "1.1.4" koin-version = "3.4.3" koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index fbde0be1..c4887269 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,59 +1,19 @@ pluginManagement { repositories { - // TODO: remove this once https://github.com/gradle/gradle/issues/23572 is fixed - val (gprUser, gprKey) = if (File(".gradle/gradle.properties").exists()) { - File(".gradle/gradle.properties").inputStream().use { - java.util.Properties().apply { load(it) }.let { - it.getProperty("gpr.user") to it.getProperty("gpr.key") - } - } - } else { - null to null - } - - fun RepositoryHandler.githubPackages(name: String) = maven { - url = uri(name) - credentials { - username = gprUser ?: providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") - password = gprKey ?: providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") - } - } - gradlePluginPortal() google() mavenCentral() maven("https://jitpack.io") - githubPackages("https://maven.pkg.github.com/revanced/revanced-patcher") - githubPackages("https://maven.pkg.github.com/revanced/revanced-library") + mavenLocal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - // TODO: remove this once https://github.com/gradle/gradle/issues/23572 is fixed - val (gprUser, gprKey) = if (File(".gradle/gradle.properties").exists()) { - File(".gradle/gradle.properties").inputStream().use { - java.util.Properties().apply { load(it) }.let { - it.getProperty("gpr.user") to it.getProperty("gpr.key") - } - } - } else { - null to null - } - - fun RepositoryHandler.githubPackages(name: String) = maven { - url = uri(name) - credentials { - username = gprUser ?: providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") - password = gprKey ?: providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") - } - } - google() mavenCentral() maven("https://jitpack.io") - githubPackages("https://maven.pkg.github.com/revanced/revanced-patcher") - githubPackages("https://maven.pkg.github.com/revanced/revanced-library") + mavenLocal() } } rootProject.name = "ReVanced Manager" From 212e55ffd8e0e8b1b6dc4990e1b20d01022d6e2a Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:39:17 -0700 Subject: [PATCH 09/48] feat: add user agent (#1382) --- app/src/main/java/app/revanced/manager/di/HttpModule.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/app/revanced/manager/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/di/HttpModule.kt index 38621b0c..1d827ce6 100644 --- a/app/src/main/java/app/revanced/manager/di/HttpModule.kt +++ b/app/src/main/java/app/revanced/manager/di/HttpModule.kt @@ -1,9 +1,11 @@ package app.revanced.manager.di import android.content.Context +import app.revanced.manager.BuildConfig import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json @@ -40,6 +42,9 @@ val httpModule = module { install(HttpTimeout) { socketTimeoutMillis = 10000 } + install(UserAgent) { + agent = "ReVanced-Manager/${BuildConfig.VERSION_CODE}" + } } fun provideJson() = Json { From 5aefb3bc59bf95ea284b976538b8bd92a5e4bd99 Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Mon, 16 Oct 2023 12:48:51 -0700 Subject: [PATCH 10/48] fix: hide patch button (#1284) --- .../ui/screen/PatchesSelectorScreen.kt | 41 +++++++++++++------ .../ui/viewmodel/PatchesSelectorViewModel.kt | 14 +++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 47119f4b..4ac41b06 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -56,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo @@ -91,7 +93,10 @@ fun PatchesSelectorScreen( mutableStateOf(null) } var showBottomSheet by rememberSaveable { mutableStateOf(false) } - + var showPatchButton by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + showPatchButton = vm.isSelectionNotEmpty() + } if (showBottomSheet) { ModalBottomSheet( onDismissRequest = { @@ -222,10 +227,17 @@ fun PatchesSelectorScreen( if (vm.selectionWarningEnabled) { vm.pendingSelectionAction = { vm.togglePatch(uid, patch) + vm.viewModelScope.launch { + showPatchButton = vm.isSelectionNotEmpty() + } } } else { vm.togglePatch(uid, patch) + vm.viewModelScope.launch { + showPatchButton = vm.isSelectionNotEmpty() + } } + }, supported = supported ) @@ -296,6 +308,7 @@ fun PatchesSelectorScreen( } } + Scaffold( topBar = { AppTopBar( @@ -319,18 +332,22 @@ fun PatchesSelectorScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.patch)) }, - icon = { Icon(Icons.Default.Build, null) }, - onClick = { - // TODO: only allow this if all required options have been set. - composableScope.launch { - val selection = vm.getSelection() - vm.saveSelection(selection).join() - onPatchClick(selection, vm.getOptions()) + if(showPatchButton) { + ExtendedFloatingActionButton( + text = { + Text(stringResource(R.string.patch)) + }, + icon = { Icon(Icons.Default.Build, null) }, + onClick = { + // TODO: only allow this if all required options have been set. + composableScope.launch { + val selection = vm.getSelection() + vm.saveSelection(selection).join() + onPatchClick(selection, vm.getOptions()) + } } - } - ) + ) + } } ) { paddingValues -> Column( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 036bc613..8e4f7246 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -175,6 +175,20 @@ class PatchesSelectorViewModel( } } + private suspend fun patchesAvailable(bundle: BundleInfo): List { + val patches = (bundle.supported + bundle.universal).toMutableList() + val removeUnsupported = !allowExperimental.get() + if (!removeUnsupported) patches += bundle.unsupported + return patches + } + + suspend fun isSelectionNotEmpty() = + bundlesFlow.first().any { bundle -> + patchesAvailable(bundle).any { patch -> + isSelected(bundle.uid, patch) + } + } + private fun getOrCreateSelection(bundle: Int) = explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf) From 7ba00cafd97eecf7e89ea94402e181ace5a7371d Mon Sep 17 00:00:00 2001 From: Ax333l Date: Tue, 17 Oct 2023 09:21:06 +0200 Subject: [PATCH 11/48] refactor: move mount code to when block --- .../ui/viewmodel/InstallerViewModel.kt | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index 1a78078a..c6e0d586 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -97,11 +97,13 @@ class InstallerViewModel( val (selectedApp, patches, options) = input - _progress = MutableStateFlow(PatcherProgressManager.generateSteps( - app, - patches.flatMap { (_, selected) -> selected }, - selectedApp - ).toImmutableList()) + _progress = MutableStateFlow( + PatcherProgressManager.generateSteps( + app, + patches.flatMap { (_, selected) -> selected }, + selectedApp + ).toImmutableList() + ) patcherWorkerId = workerRepository.launchExpedited( @@ -186,23 +188,24 @@ class InstallerViewModel( is SelectedApp.Local -> { if (selectedApp.shouldDelete) selectedApp.file.delete() } + + is SelectedApp.Installed -> { + try { + installedApp?.let { + if (it.installType == InstallType.ROOT) { + rootInstaller.mount(packageName) + } + } + } catch (e: Exception) { + Log.e(tag, "Failed to mount", e) + app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage())) + } + } + else -> {} } tempDir.deleteRecursively() - - try { - if (input.selectedApp is SelectedApp.Installed) { - installedApp?.let { - if (it.installType == InstallType.ROOT) { - rootInstaller.mount(packageName) - } - } - } - } catch (e: Exception) { - Log.e(tag, "Failed to mount", e) - app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage())) - } } private suspend fun signApk(): Boolean { @@ -239,9 +242,13 @@ class InstallerViewModel( if (!signApk()) return@launch when (installType) { - InstallType.DEFAULT -> { pm.installApp(listOf(signedFile)) } + InstallType.DEFAULT -> { + pm.installApp(listOf(signedFile)) + } - InstallType.ROOT -> { installAsRoot() } + InstallType.ROOT -> { + installAsRoot() + } } } finally { @@ -286,7 +293,8 @@ class InstallerViewModel( app.toast(app.getString(R.string.install_app_fail, e.simpleMessage())) try { rootInstaller.uninstall(packageName) - } catch (_: Exception) { } + } catch (_: Exception) { + } } } } From c3af6acb2cfb7daff75caa320ac7b5e8ee5dc922 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 19 Oct 2023 21:44:50 +0200 Subject: [PATCH 12/48] feat: selected app info page (#1395) --- .../java/app/revanced/manager/MainActivity.kt | 86 ++++-- .../revanced/manager/di/ViewModelModule.kt | 3 +- .../revanced/manager/ui/component/AppInfo.kt | 39 +++ .../manager/ui/destination/Destination.kt | 4 +- .../destination/SelectedAppInfoDestination.kt | 19 ++ .../revanced/manager/ui/model/BundleInfo.kt | 82 +++++ ...nfoScreen.kt => InstalledAppInfoScreen.kt} | 30 +- .../ui/screen/PatchesSelectorScreen.kt | 83 ++---- .../ui/screen/SelectedAppInfoScreen.kt | 218 ++++++++++++++ ...wModel.kt => InstalledAppInfoViewModel.kt} | 2 +- .../ui/viewmodel/PatchesSelectorViewModel.kt | 281 ++++++------------ .../ui/viewmodel/SelectedAppInfoViewModel.kt | 128 ++++++++ .../main/java/app/revanced/manager/util/PM.kt | 13 +- .../manager/util/saver/NullableSaver.kt | 22 ++ .../util/saver/PersistentCollectionSavers.kt | 69 +++++ app/src/main/res/values/strings.xml | 9 + 16 files changed, 779 insertions(+), 309 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt rename app/src/main/java/app/revanced/manager/ui/screen/{AppInfoScreen.kt => InstalledAppInfoScreen.kt} (90%) create mode 100644 app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt rename app/src/main/java/app/revanced/manager/ui/viewmodel/{AppInfoViewModel.kt => InstalledAppInfoViewModel.kt} (99%) create mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt create mode 100644 app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt create mode 100644 app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 944656e3..4cc8e1c6 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -19,16 +19,17 @@ import androidx.compose.runtime.setValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.destination.Destination -import app.revanced.manager.ui.screen.AppInfoScreen +import app.revanced.manager.ui.screen.InstalledAppInfoScreen import app.revanced.manager.ui.screen.AppSelectorScreen import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.InstallerScreen -import app.revanced.manager.ui.screen.PatchesSelectorScreen +import app.revanced.manager.ui.screen.SelectedAppInfoScreen import app.revanced.manager.ui.screen.SettingsScreen import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel +import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.tag import app.revanced.manager.util.toast import dev.olshevski.navigation.reimagined.AnimatedNavHost @@ -37,9 +38,9 @@ import dev.olshevski.navigation.reimagined.navigate import dev.olshevski.navigation.reimagined.pop import dev.olshevski.navigation.reimagined.popUpTo import dev.olshevski.navigation.reimagined.rememberNavController -import org.koin.androidx.compose.getViewModel +import org.koin.androidx.compose.getViewModel as getComposeViewModel +import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel import org.koin.core.parameter.parametersOf -import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel class MainActivity : ComponentActivity() { @ExperimentalAnimationApi @@ -48,7 +49,7 @@ class MainActivity : ComponentActivity() { installSplashScreen() - val vm: MainViewModel = getActivityViewModel() + val vm: MainViewModel = getAndroidViewModel() setContent { val theme by vm.prefs.theme.getAsState() @@ -102,7 +103,7 @@ class MainActivity : ComponentActivity() { } legacyActivityState = LegacyActivity.LAUNCHED - } else if (legacyActivityState == LegacyActivity.FAILED){ + } else if (legacyActivityState == LegacyActivity.FAILED) { AutoUpdatesDialog(vm::applyAutoUpdatePrefs) } } @@ -114,15 +115,26 @@ class MainActivity : ComponentActivity() { is Destination.Dashboard -> DashboardScreen( onSettingsClick = { navController.navigate(Destination.Settings) }, onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, - onAppClick = { installedApp -> navController.navigate(Destination.ApplicationInfo(installedApp)) } + onAppClick = { installedApp -> + navController.navigate( + Destination.InstalledApplicationInfo( + installedApp + ) + ) + } ) - is Destination.ApplicationInfo -> AppInfoScreen( + is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen( onPatchClick = { packageName, patchesSelection -> - navController.navigate(Destination.VersionSelector(packageName, patchesSelection)) + navController.navigate( + Destination.VersionSelector( + packageName, + patchesSelection + ) + ) }, onBackClick = { navController.pop() }, - viewModel = getViewModel { parametersOf(destination.installedApp) } + viewModel = getComposeViewModel { parametersOf(destination.installedApp) } ) is Destination.Settings -> SettingsScreen( @@ -131,7 +143,13 @@ class MainActivity : ComponentActivity() { is Destination.AppSelector -> AppSelectorScreen( onAppClick = { navController.navigate(Destination.VersionSelector(it)) }, - onStorageClick = { navController.navigate(Destination.PatchesSelector(it)) }, + onStorageClick = { + navController.navigate( + Destination.SelectedApplicationInfo( + it + ) + ) + }, onBackClick = { navController.pop() } ) @@ -139,32 +157,42 @@ class MainActivity : ComponentActivity() { onBackClick = { navController.pop() }, onAppClick = { selectedApp -> navController.navigate( - Destination.PatchesSelector( + Destination.SelectedApplicationInfo( selectedApp, + destination.patchesSelection, + ) + ) + }, + viewModel = getComposeViewModel { + parametersOf( + destination.packageName, + destination.patchesSelection + ) + } + ) + + is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( + onPatchClick = { app, patches, options -> + navController.navigate( + Destination.Installer( + app, patches, options + ) + ) + }, + onBackClick = navController::pop, + vm = getComposeViewModel { + parametersOf( + SelectedAppInfoViewModel.Params( + destination.selectedApp, destination.patchesSelection ) ) - }, - viewModel = getViewModel { parametersOf(destination.packageName, destination.patchesSelection) } - ) - - is Destination.PatchesSelector -> PatchesSelectorScreen( - onBackClick = { navController.pop() }, - onPatchClick = { patches, options -> - navController.navigate( - Destination.Installer( - destination.selectedApp, - patches, - options - ) - ) - }, - vm = getViewModel { parametersOf(destination) } + } ) is Destination.Installer -> InstallerScreen( onBackClick = { navController.popUpTo { it is Destination.Dashboard } }, - vm = getViewModel { parametersOf(destination) } + vm = getComposeViewModel { parametersOf(destination) } ) } } diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 5f12a2ad..1729f546 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -7,6 +7,7 @@ import org.koin.dsl.module val viewModelModule = module { viewModelOf(::MainViewModel) viewModelOf(::DashboardViewModel) + viewModelOf(::SelectedAppInfoViewModel) viewModelOf(::PatchesSelectorViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::AdvancedSettingsViewModel) @@ -19,5 +20,5 @@ val viewModelModule = module { viewModelOf(::ContributorViewModel) viewModelOf(::DownloadsViewModel) viewModelOf(::InstalledAppsViewModel) - viewModelOf(::AppInfoViewModel) + viewModelOf(::InstalledAppInfoViewModel) } diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt new file mode 100644 index 00000000..6d45b20b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt @@ -0,0 +1,39 @@ +package app.revanced.manager.ui.component + +import android.content.pm.PackageInfo +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AppInfo(appInfo: PackageInfo?, placeholderLabel: String? = null, extraContent: @Composable () -> Unit = {}) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppIcon( + appInfo, + contentDescription = null, + modifier = Modifier + .size(100.dp) + .padding(bottom = 5.dp) + ) + + AppLabel( + appInfo, + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.titleLarge, + defaultText = placeholderLabel + ) + + extraContent() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index e737ed8c..6c01e783 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -14,7 +14,7 @@ sealed interface Destination : Parcelable { object Dashboard : Destination @Parcelize - data class ApplicationInfo(val installedApp: InstalledApp) : Destination + data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination @Parcelize object AppSelector : Destination @@ -26,7 +26,7 @@ sealed interface Destination : Parcelable { data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination @Parcelize - data class PatchesSelector(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination + data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination @Parcelize data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt new file mode 100644 index 00000000..32036c2a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt @@ -0,0 +1,19 @@ +package app.revanced.manager.ui.destination + +import android.os.Parcelable +import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.util.Options +import app.revanced.manager.util.PatchesSelection +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +sealed interface SelectedAppInfoDestination : Parcelable { + @Parcelize + data object Main : SelectedAppInfoDestination + + @Parcelize + data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchesSelection?, val options: @RawValue Options) : SelectedAppInfoDestination + + @Parcelize + data object VersionSelector: SelectedAppInfoDestination +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt new file mode 100644 index 00000000..fd048e4b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt @@ -0,0 +1,82 @@ +package app.revanced.manager.ui.model + +import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.patcher.patch.PatchInfo +import app.revanced.manager.util.PatchesSelection +import app.revanced.manager.util.flatMapLatestAndCombine +import kotlinx.coroutines.flow.map + +/** + * A data class that contains patch bundle metadata for use by UI code. + */ +data class BundleInfo( + val name: String, + val uid: Int, + val supported: List, + val unsupported: List, + val universal: List +) { + val all = sequence { + yieldAll(supported) + yieldAll(unsupported) + yieldAll(universal) + } + + val patchCount get() = supported.size + unsupported.size + universal.size + + fun patchSequence(allowUnsupported: Boolean) = if (allowUnsupported) { + all + } else { + sequence { + yieldAll(supported) + yieldAll(universal) + } + } + + companion object Extensions { + inline fun Iterable.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchesSelection = this.associate { bundle -> + val patches = + bundle.patchSequence(allowUnsupported) + .mapNotNullTo(mutableSetOf()) { patch -> + patch.name.takeIf { + condition( + bundle.uid, + patch + ) + } + } + + bundle.uid to patches + } + + fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) = + sources.flatMapLatestAndCombine( + combiner = { it.filterNotNull() } + ) { source -> + // Regenerate bundle information whenever this source updates. + source.state.map { state -> + val bundle = state.patchBundleOrNull() ?: return@map null + + val supported = mutableListOf() + val unsupported = mutableListOf() + val universal = mutableListOf() + + bundle.patches.filter { it.compatibleWith(packageName) }.forEach { + val targetList = when { + it.compatiblePackages == null -> universal + it.supportsVersion( + packageName, + version + ) -> supported + + else -> unsupported + } + + targetList.add(it) + } + + BundleInfo(source.name, source.uid, supported, unsupported, universal) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt similarity index 90% rename from app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt rename to app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 1cd3762b..4abea5ec 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -41,18 +41,19 @@ import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.ui.component.AppIcon +import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.SegmentedButton -import app.revanced.manager.ui.viewmodel.AppInfoViewModel +import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.util.PatchesSelection @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AppInfoScreen( +fun InstalledAppInfoScreen( onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit, onBackClick: () -> Unit, - viewModel: AppInfoViewModel + viewModel: InstalledAppInfoViewModel ) { SideEffect { viewModel.onBackClick = onBackClick @@ -80,27 +81,8 @@ fun AppInfoScreen( .padding(paddingValues) .verticalScroll(rememberScrollState()) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AppIcon( - viewModel.appInfo, - contentDescription = null, - modifier = Modifier - .size(100.dp) - .padding(bottom = 5.dp) - ) - - AppLabel( - viewModel.appInfo, - style = MaterialTheme.typography.titleLarge, - defaultText = null - ) - - Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall) + AppInfo(viewModel.appInfo) { + Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) if (viewModel.installedApp.installType == InstallType.ROOT) { Text( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 4ac41b06..d33c28a5 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -15,16 +15,15 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip @@ -42,6 +41,7 @@ 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 @@ -57,7 +57,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo @@ -65,7 +64,6 @@ import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.Countdown import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel -import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.BaseSelectionMode import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED @@ -77,7 +75,7 @@ import org.koin.compose.rememberKoinInject @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun PatchesSelectorScreen( - onPatchClick: (PatchesSelection, Options) -> Unit, + onSave: (PatchesSelection?, Options) -> Unit, onBackClick: () -> Unit, vm: PatchesSelectorViewModel ) { @@ -93,10 +91,10 @@ fun PatchesSelectorScreen( mutableStateOf(null) } var showBottomSheet by rememberSaveable { mutableStateOf(false) } - var showPatchButton by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - showPatchButton = vm.isSelectionNotEmpty() + val showPatchButton by remember { + derivedStateOf { vm.selectionIsValid(bundles) } } + if (showBottomSheet) { ModalBottomSheet( onDismissRequest = { @@ -140,39 +138,12 @@ fun PatchesSelectorScreen( ) } } - - Divider() - - ListItem( - modifier = Modifier - .fillMaxWidth() - .clickable( - enabled = vm.hasPreviousSelection, - onClick = vm::switchBaseSelectionMode - ), - leadingContent = { - Checkbox( - checked = vm.baseSelectionMode == BaseSelectionMode.PREVIOUS, - onCheckedChange = { - vm.switchBaseSelectionMode() - }, - enabled = vm.hasPreviousSelection - ) - }, - headlineContent = { - Text( - "Use previous selection", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - ) } } if (vm.compatibleVersions.isNotEmpty()) UnsupportedDialog( - appVersion = vm.input.selectedApp.version, + appVersion = vm.appVersion, supportedVersions = vm.compatibleVersions, onDismissRequest = vm::dismissDialogs ) @@ -194,8 +165,6 @@ fun PatchesSelectorScreen( ) } - val allowExperimental by vm.allowExperimental.getAsState() - fun LazyListScope.patchList( uid: Int, patches: List, @@ -227,17 +196,10 @@ fun PatchesSelectorScreen( if (vm.selectionWarningEnabled) { vm.pendingSelectionAction = { vm.togglePatch(uid, patch) - vm.viewModelScope.launch { - showPatchButton = vm.isSelectionNotEmpty() - } } } else { vm.togglePatch(uid, patch) - vm.viewModelScope.launch { - showPatchButton = vm.isSelectionNotEmpty() - } } - }, supported = supported ) @@ -292,7 +254,7 @@ fun PatchesSelectorScreen( ) } - if (!allowExperimental) return@LazyColumn + if (!vm.allowExperimental) return@LazyColumn patchList( uid = bundle.uid, patches = bundle.unsupported.searched(), @@ -332,22 +294,19 @@ fun PatchesSelectorScreen( ) }, floatingActionButton = { - if(showPatchButton) { - ExtendedFloatingActionButton( - text = { - Text(stringResource(R.string.patch)) - }, - icon = { Icon(Icons.Default.Build, null) }, - onClick = { - // TODO: only allow this if all required options have been set. - composableScope.launch { - val selection = vm.getSelection() - vm.saveSelection(selection).join() - onPatchClick(selection, vm.getOptions()) - } + if (!showPatchButton) return@Scaffold + + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.save)) }, + icon = { Icon(Icons.Outlined.Save, null) }, + onClick = { + // TODO: only allow this if all required options have been set. + composableScope.launch { + vm.saveSelection() + onSave(vm.getCustomSelection(), vm.getOptions()) } - ) - } + } + ) } ) { paddingValues -> Column( @@ -407,7 +366,7 @@ fun PatchesSelectorScreen( uid = bundle.uid, patches = bundle.unsupported, filterFlag = SHOW_UNSUPPORTED, - supported = allowExperimental + supported = vm.allowExperimental ) { ListHeader( title = stringResource(R.string.unsupported_patches), diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt new file mode 100644 index 00000000..2d50e62c --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -0,0 +1,218 @@ +package app.revanced.manager.ui.screen + +import android.content.pm.PackageInfo +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowRight +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.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.revanced.manager.R +import app.revanced.manager.ui.component.AppInfo +import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.destination.SelectedAppInfoDestination +import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow +import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel +import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel +import app.revanced.manager.util.Options +import app.revanced.manager.util.PatchesSelection +import dev.olshevski.navigation.reimagined.AnimatedNavHost +import dev.olshevski.navigation.reimagined.NavBackHandler +import dev.olshevski.navigation.reimagined.navigate +import dev.olshevski.navigation.reimagined.pop +import dev.olshevski.navigation.reimagined.rememberNavController +import org.koin.androidx.compose.getViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun SelectedAppInfoScreen( + onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit, + onBackClick: () -> Unit, + vm: SelectedAppInfoViewModel +) { + val bundles by remember(vm.selectedApp.packageName, vm.selectedApp.version) { + vm.bundlesRepo.bundleInfoFlow(vm.selectedApp.packageName, vm.selectedApp.version) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val allowExperimental by vm.prefs.allowExperimental.getAsState() + val patches by remember { + derivedStateOf { + vm.getPatches(bundles, allowExperimental) + } + } + val selectedPatchCount by remember { + derivedStateOf { + patches.values.sumOf { it.size } + } + } + val availablePatchCount by remember { + derivedStateOf { + bundles.sumOf { it.patchCount } + } + } + + val navController = + rememberNavController(startDestination = SelectedAppInfoDestination.Main) + + NavBackHandler(controller = navController) + + AnimatedNavHost(controller = navController) { destination -> + when (destination) { + is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen( + onPatchClick = { + onPatchClick( + vm.selectedApp, + patches, + vm.patchOptions + ) + }, + onPatchSelectorClick = { + navController.navigate( + SelectedAppInfoDestination.PatchesSelector( + vm.selectedApp, + vm.getCustomPatches( + bundles, + allowExperimental + ), + vm.patchOptions + ) + ) + }, + onVersionSelectorClick = { + navController.navigate(SelectedAppInfoDestination.VersionSelector) + }, + onBackClick = onBackClick, + availablePatchCount = availablePatchCount, + selectedPatchCount = selectedPatchCount, + packageName = vm.selectedApp.packageName, + version = vm.selectedApp.version, + packageInfo = vm.selectedAppInfo, + ) + + is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen( + onBackClick = navController::pop, + onAppClick = { + vm.setSelectedApp(it) + navController.pop() + }, + viewModel = getViewModel { parametersOf(vm.selectedApp.packageName) } + ) + + is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( + onSave = { patches, options -> + vm.setCustomPatches(patches) + vm.patchOptions = options + navController.pop() + }, + onBackClick = navController::pop, + vm = getViewModel { + parametersOf( + PatchesSelectorViewModel.Params( + destination.app, + destination.currentSelection, + destination.options + ) + ) + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectedAppInfoScreen( + onPatchClick: () -> Unit, + onPatchSelectorClick: () -> Unit, + onVersionSelectorClick: () -> Unit, + onBackClick: () -> Unit, + availablePatchCount: Int, + selectedPatchCount: Int, + packageName: String, + version: String, + packageInfo: PackageInfo?, +) { + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.app_info), + onBackClick = onBackClick + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + AppInfo(packageInfo, placeholderLabel = packageName) { + Text( + stringResource(R.string.selected_app_meta, version, availablePatchCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + + PageItem(R.string.patch, stringResource(R.string.patch_item_description), onPatchClick) + + Text( + stringResource(R.string.advanced), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + ) + + PageItem( + R.string.patch_selector_item, + stringResource(R.string.patch_selector_item_description, selectedPatchCount), + onPatchSelectorClick + ) + PageItem( + R.string.version_selector_item, + stringResource(R.string.version_selector_item_description, version), + onVersionSelectorClick + ) + } + } +} + +@Composable +private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) { + ListItem( + modifier = Modifier + .clickable(onClick = onClick) + .padding(start = 8.dp), + headlineContent = { + Text( + stringResource(title), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge + ) + }, + supportingContent = { + Text( + description, + color = MaterialTheme.colorScheme.outline, + style = MaterialTheme.typography.bodyMedium + ) + }, + trailingContent = { + Icon(Icons.Outlined.ArrowRight, null) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt similarity index 99% rename from app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt rename to app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt index 7cf31727..3055794d 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class AppInfoViewModel( +class InstalledAppInfoViewModel( val installedApp: InstalledApp ) : ViewModel(), KoinComponent { private val app: Application by inject() diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 8e4f7246..69449f80 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -2,8 +2,8 @@ package app.revanced.manager.ui.viewmodel import android.app.Application import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf 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 @@ -20,66 +20,46 @@ import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.ui.destination.Destination +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.util.Options import app.revanced.manager.util.PatchesSelection -import app.revanced.manager.util.flatMapLatestAndCombine +import app.revanced.manager.util.saver.nullableSaver +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.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.get +import kotlinx.collections.immutable.* +import kotlinx.coroutines.withContext +import java.util.Optional @Stable @OptIn(SavedStateHandleSaveableApi::class) -class PatchesSelectorViewModel( - val input: Destination.PatchesSelector -) : ViewModel(), KoinComponent { +class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { private val app: Application = get() private val selectionRepository: PatchSelectionRepository = get() private val savedStateHandle: SavedStateHandle = get() private val prefs: PreferencesManager = get() - private val packageName = input.selectedApp.packageName + private val packageName = input.app.packageName + val appVersion = input.app.version var pendingSelectionAction by mutableStateOf<(() -> Unit)?>(null) + // TODO: this should be hoisted to the parent screen var selectionWarningEnabled by mutableStateOf(true) private set - val allowExperimental = get().allowExperimental - val bundlesFlow = get().sources.flatMapLatestAndCombine( - combiner = { it.filterNotNull() } - ) { source -> - // Regenerate bundle information whenever this source updates. - source.state.map { state -> - val bundle = state.patchBundleOrNull() ?: return@map null - - val supported = mutableListOf() - val unsupported = mutableListOf() - val universal = mutableListOf() - - bundle.patches.filter { it.compatibleWith(packageName) }.forEach { - val targetList = when { - it.compatiblePackages == null -> universal - it.supportsVersion( - input.selectedApp.packageName, - input.selectedApp.version - ) -> supported - - else -> unsupported - } - - targetList.add(it) - } - - BundleInfo(source.name, source.uid, bundle.patches, supported, unsupported, universal) - } - } + val allowExperimental = get().allowExperimental.getBlocking() + val bundlesFlow = + get().bundleInfoFlow(packageName, input.app.version) init { viewModelScope.launch { @@ -88,63 +68,28 @@ class PatchesSelectorViewModel( return@launch } - val experimental = allowExperimental.get() - fun BundleInfo.hasDefaultPatches(): Boolean { - return if (experimental) { - all.asSequence() - } else { - sequence { - yieldAll(supported) - yieldAll(universal) - } - }.any { it.include } - } + fun BundleInfo.hasDefaultPatches() = patchSequence(allowExperimental).any { it.include } // Don't show the warning if there are no default patches. selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches) } } - var baseSelectionMode by mutableStateOf(BaseSelectionMode.DEFAULT) - private set - - private val previousPatchesSelection: SnapshotStateMap> = mutableStateMapOf() - - init { - viewModelScope.launch(Dispatchers.Default) { loadPreviousSelection() } - } - - val hasPreviousSelection by derivedStateOf { - previousPatchesSelection.filterValues(Set::isNotEmpty).isNotEmpty() - } - private var hasModifiedSelection = false + private var customPatchesSelection: PersistentPatchesSelection? by savedStateHandle.saveable( + key = "selection", + stateSaver = patchesSaver, + ) { + mutableStateOf(input.currentSelection?.toPersistentPatchesSelection()) + } - private val explicitPatchesSelection: SnapshotExplicitPatchesSelection by savedStateHandle.saveable( - saver = explicitPatchesSelectionSaver, - init = ::mutableStateMapOf - ) - - private val patchOptions: SnapshotOptions by savedStateHandle.saveable( + private val patchOptions: PersistentOptions by savedStateHandle.saveable( saver = optionsSaver, - init = ::mutableStateMapOf - ) - - private val selectors by derivedStateOf> { - arrayOf( - // Patches that were explicitly selected - { bundle, patch -> - explicitPatchesSelection[bundle]?.get(patch.name) - }, - // The fallback selection. - when (baseSelectionMode) { - BaseSelectionMode.DEFAULT -> ({ _, patch -> patch.include }) - - BaseSelectionMode.PREVIOUS -> ({ bundle, patch -> - previousPatchesSelection[bundle]?.contains(patch.name) ?: false - }) - } - ) + ) { + // Convert Options to PersistentOptions + input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) -> + allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap() + } } /** @@ -154,52 +99,39 @@ class PatchesSelectorViewModel( val compatibleVersions = mutableStateListOf() - var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED) + var filter by mutableIntStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED) private set - private suspend fun loadPreviousSelection() { - val selection = (input.patchesSelection ?: selectionRepository.getSelection( - packageName - )).mapValues { (_, value) -> value.toSet() } + private suspend fun generateDefaultSelection(): PersistentPatchesSelection { + val bundles = bundlesFlow.first() + val generatedSelection = + bundles.toPatchSelection(allowExperimental) { _, patch -> patch.include } - withContext(Dispatchers.Main) { - previousPatchesSelection.putAll(selection) + return generatedSelection.toPersistentPatchesSelection() + } + + fun selectionIsValid(bundles: List) = bundles.any { bundle -> + bundle.patchSequence(allowExperimental).any { patch -> + isSelected(bundle.uid, patch) } } - fun switchBaseSelectionMode() = viewModelScope.launch { - baseSelectionMode = if (baseSelectionMode == BaseSelectionMode.DEFAULT) { - BaseSelectionMode.PREVIOUS - } else { - BaseSelectionMode.DEFAULT - } - } - - private suspend fun patchesAvailable(bundle: BundleInfo): List { - val patches = (bundle.supported + bundle.universal).toMutableList() - val removeUnsupported = !allowExperimental.get() - if (!removeUnsupported) patches += bundle.unsupported - return patches - } - - suspend fun isSelectionNotEmpty() = - bundlesFlow.first().any { bundle -> - patchesAvailable(bundle).any { patch -> - isSelected(bundle.uid, patch) - } - } - - private fun getOrCreateSelection(bundle: Int) = - explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf) - - fun isSelected(bundle: Int, patch: PatchInfo) = - selectors.firstNotNullOf { fn -> fn(bundle, patch) } - - fun togglePatch(bundle: Int, patch: PatchInfo) { - val patches = getOrCreateSelection(bundle) + fun isSelected(bundle: Int, patch: PatchInfo) = customPatchesSelection?.let { selection -> + selection[bundle]?.contains(patch.name) ?: false + } ?: patch.include + fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch { hasModifiedSelection = true - patches[patch.name] = !isSelected(bundle, patch) + + val selection = customPatchesSelection ?: generateDefaultSelection() + val newPatches = selection[bundle]?.let { patches -> + if (patch.name in patches) + patches.remove(patch.name) + else + patches.add(patch.name) + } ?: persistentSetOf(patch.name) + + customPatchesSelection = selection.put(bundle, newPatches) } fun confirmSelectionWarning(dismissPermanently: Boolean) { @@ -221,46 +153,39 @@ class PatchesSelectorViewModel( fun reset() { patchOptions.clear() - baseSelectionMode = BaseSelectionMode.DEFAULT - explicitPatchesSelection.clear() + customPatchesSelection = null hasModifiedSelection = false app.toast(app.getString(R.string.patch_selection_reset_toast)) } - suspend fun getSelection(): PatchesSelection { - val bundles = bundlesFlow.first() - val removeUnsupported = !allowExperimental.get() + fun getCustomSelection(): PatchesSelection? { + // Convert persistent collections to standard hash collections because persistent collections are not parcelable. - return bundles.associate { bundle -> - val included = - bundle.all.filter { isSelected(bundle.uid, it) }.map { it.name }.toMutableSet() - - if (removeUnsupported) { - val unsupported = bundle.unsupported.map { it.name }.toSet() - included.removeAll(unsupported) - } - - bundle.uid to included - } + return customPatchesSelection?.mapValues { (_, v) -> v.toSet() } } - suspend fun saveSelection(selection: PatchesSelection) = - viewModelScope.launch(Dispatchers.Default) { - when { - hasModifiedSelection -> selectionRepository.updateSelection(packageName, selection) - baseSelectionMode == BaseSelectionMode.DEFAULT -> selectionRepository.clearSelection( - packageName - ) + fun getOptions(): Options { + // Convert the collection for the same reasons as in getCustomSelection() - else -> {} - } - } + return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } } + } + + suspend fun saveSelection() = withContext(Dispatchers.Default) { + customPatchesSelection?.let { selectionRepository.updateSelection(packageName, it) } + ?: selectionRepository.clearSelection(packageName) + } - fun getOptions(): Options = patchOptions fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name) fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) { - patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value + // All patches + val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf) + // The key-value options of an individual patch + val patchToOpts = patchesToOpts + .getOrElse(patch.name, ::persistentMapOf) + .put(key, value) + + patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts) } fun resetOptions(bundle: Int, patch: PatchInfo) { @@ -274,7 +199,7 @@ class PatchesSelectorViewModel( fun openUnsupportedDialog(unsupportedPatches: List) { compatibleVersions.addAll(unsupportedPatches.flatMap { patch -> - patch.compatiblePackages?.find { it.packageName == input.selectedApp.packageName }?.versions.orEmpty() + patch.compatiblePackages?.find { it.packageName == packageName }?.versions.orEmpty() }) } @@ -287,50 +212,28 @@ class PatchesSelectorViewModel( const val SHOW_UNIVERSAL = 2 // 2^1 const val SHOW_UNSUPPORTED = 4 // 2^2 - private fun SnapshotStateMap>.getOrCreate(key: K) = - getOrPut(key, ::mutableStateMapOf) - - private val optionsSaver: Saver = snapshotStateMapSaver( + private val optionsSaver: Saver = snapshotStateMapSaver( // Patch name -> Options - valueSaver = snapshotStateMapSaver( + valueSaver = persistentMapSaver( // Option key -> Option value - valueSaver = snapshotStateMapSaver() + valueSaver = persistentMapSaver() ) ) - private val explicitPatchesSelectionSaver: Saver = - snapshotStateMapSaver(valueSaver = snapshotStateMapSaver()) + private val patchesSaver: Saver> = + nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver())) } - /** - * An enum for controlling the behavior of the selector. - */ - enum class BaseSelectionMode { - /** - * Selection is determined by the [PatchInfo.include] field. - */ - DEFAULT, - - /** - * Selection is determined by what the user selected previously. - * Any patch that is not part of the previous selection will be deselected. - */ - PREVIOUS - } - - data class BundleInfo( - val name: String, - val uid: Int, - val all: List, - val supported: List, - val unsupported: List, - val universal: List + data class Params( + val app: SelectedApp, + val currentSelection: PatchesSelection?, + val options: Options, ) } -private typealias Selector = (Int, PatchInfo) -> Boolean? -private typealias ExplicitPatchesSelection = Map> +// Versions of other types, but utilizing persistent/observable collection types. +private typealias PersistentOptions = SnapshotStateMap>> +private typealias PersistentPatchesSelection = PersistentMap> -// Versions of other types, but utilizing observable collection types instead. -private typealias SnapshotOptions = SnapshotStateMap>> -private typealias SnapshotExplicitPatchesSelection = SnapshotStateMap> \ No newline at end of file +private fun PatchesSelection.toPersistentPatchesSelection(): PersistentPatchesSelection = + mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap() \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt new file mode 100644 index 00000000..e66ec074 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -0,0 +1,128 @@ +package app.revanced.manager.ui.viewmodel + +import android.content.pm.PackageInfo +import android.os.Parcelable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi +import androidx.lifecycle.viewmodel.compose.saveable +import app.revanced.manager.domain.manager.PreferencesManager +import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.domain.repository.PatchSelectionRepository +import app.revanced.manager.ui.model.BundleInfo +import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection +import app.revanced.manager.ui.model.SelectedApp +import app.revanced.manager.util.Options +import app.revanced.manager.util.PM +import app.revanced.manager.util.PatchesSelection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import org.koin.core.component.KoinComponent +import org.koin.core.component.get + +@OptIn(SavedStateHandleSaveableApi::class) +class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { + val bundlesRepo: PatchBundleRepository = get() + private val selectionRepository: PatchSelectionRepository = get() + private val pm: PM = get() + private val savedStateHandle: SavedStateHandle = get() + val prefs: PreferencesManager = get() + + var selectedApp by savedStateHandle.saveable { + mutableStateOf(input.app) + } + private set + + var selectedAppInfo: PackageInfo? by mutableStateOf(null) + private set + + init { + invalidateSelectedAppInfo() + } + + var patchOptions: Options by savedStateHandle.saveable { + mutableStateOf(emptyMap()) + } + + private var selectionState by savedStateHandle.saveable { + if (input.patches != null) { + return@saveable mutableStateOf(SelectionState.Customized(input.patches)) + } + + val selection: MutableState = mutableStateOf(SelectionState.Default) + + // Get previous selection (if present). + viewModelScope.launch { + val previous = selectionRepository.getSelection(selectedApp.packageName) + + if (previous.values.sumOf { it.size } == 0) { + return@launch + } + + selection.value = SelectionState.Customized(previous) + } + + selection + } + + fun setSelectedApp(new: SelectedApp) { + selectedApp = new + invalidateSelectedAppInfo() + } + + private fun invalidateSelectedAppInfo() = viewModelScope.launch { + val info = when (val app = selectedApp) { + is SelectedApp.Download -> null + is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) } + is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) } + } + + selectedAppInfo = info + } + + fun getPatches(bundles: List, allowUnsupported: Boolean) = + selectionState.patches(bundles, allowUnsupported) + + fun getCustomPatches( + bundles: List, + allowUnsupported: Boolean + ): PatchesSelection? = + (selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported) + + fun setCustomPatches(selection: PatchesSelection?) { + selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default + } + + data class Params( + val app: SelectedApp, + val patches: PatchesSelection?, + ) +} + +private sealed interface SelectionState : Parcelable { + fun patches(bundles: List, allowUnsupported: Boolean): PatchesSelection + + @Parcelize + data class Customized(val patchesSelection: PatchesSelection) : SelectionState { + override fun patches(bundles: List, allowUnsupported: Boolean) = + bundles.toPatchSelection( + allowUnsupported + ) { uid, patch -> + patchesSelection[uid]?.contains(patch.name) ?: false + } + } + + @Parcelize + data object Default : SelectionState { + override fun patches(bundles: List, allowUnsupported: Boolean) = + bundles.toPatchSelection(allowUnsupported) { _, patch -> patch.include } + } +} + diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 45f21377..12a91a6c 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -98,7 +98,18 @@ class PM( null } - fun getPackageInfo(file: File): PackageInfo? = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0) + fun getPackageInfo(file: File): PackageInfo? { + val path = file.absolutePath + val pkgInfo = app.packageManager.getPackageArchiveInfo(path, 0) ?: return null + + // This is needed in order to load label and icon. + pkgInfo.applicationInfo.apply { + sourceDir = path + publicSourceDir = path + } + + return pkgInfo + } fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString() diff --git a/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt new file mode 100644 index 00000000..6116722a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt @@ -0,0 +1,22 @@ +package app.revanced.manager.util.saver + +import androidx.compose.runtime.saveable.Saver +import java.util.Optional +import kotlin.jvm.optionals.getOrNull + +/** + * Creates a saver that can save nullable versions of types that have custom savers. + */ +fun nullableSaver(baseSaver: Saver): Saver> = + Saver( + save = { value -> + with(baseSaver) { + save(value ?: return@Saver Optional.empty()) + }?.let { + Optional.of(it) + } + }, + restore = { + it.getOrNull()?.let(baseSaver::restore) + } + ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt b/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt new file mode 100644 index 00000000..2a1418cd --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/saver/PersistentCollectionSavers.kt @@ -0,0 +1,69 @@ +package app.revanced.manager.util.saver + +import androidx.compose.runtime.saveable.Saver +import kotlinx.collections.immutable.* + +/** + * Create a [Saver] for [PersistentList]s. + */ +fun persistentListSaver() = Saver, List>( + save = { + it.toList() + }, + restore = { + it.toPersistentList() + } +) + +/** + * Create a [Saver] for [PersistentSet]s. + */ +fun persistentSetSaver() = Saver, Set>( + save = { + it.toSet() + }, + restore = { + it.toPersistentSet() + } +) + +/** + * Create a [Saver] for [PersistentMap]s. + */ +fun persistentMapSaver() = Saver, Map>( + save = { + it.toMap() + }, + restore = { + it.toPersistentMap() + } +) + +/** + * Create a saver for [PersistentMap]s with a custom [Saver] used for the values. + * Null values will not be saved by this [Saver]. + * + * @param valueSaver The [Saver] used for the values of the [Map]. + */ +fun persistentMapSaver( + valueSaver: Saver +) = Saver, Map>( + save = { + buildMap { + it.forEach { (key, value) -> + with(valueSaver) { + save(value)?.let { + this@buildMap[key] = it + } + } + } + } + }, + restore = { + buildMap { + it.forEach { (key, value) -> + this[key] = valueSaver.restore(value) ?: return@forEach + } + }.toPersistentMap() + } +) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18b5bb75..5d49308d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,15 @@ Missing Error + + %1s • %2d available patches + + Start patching the application + Patch selection and options + %d patches selected + + Change version + %s selected Could not import legacy settings From 9df98edca5254fdf1306b9f48e7721a8f4bcf328 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 19 Oct 2023 22:01:15 +0200 Subject: [PATCH 13/48] fix: use upsert when modifying installed apps --- .../data/room/apps/installed/InstalledAppDao.kt | 13 +++++++++---- .../domain/repository/InstalledAppRepository.kt | 4 ++-- .../manager/ui/viewmodel/InstallerViewModel.kt | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt index 71172493..90d40b9f 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.MapInfo import androidx.room.Query import androidx.room.Transaction +import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @Dao @@ -24,17 +25,21 @@ interface InstalledAppDao { suspend fun getPatchesSelection(packageName: String): Map> @Transaction - suspend fun insertApp(installedApp: InstalledApp, appliedPatches: List) { - insertApp(installedApp) + suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List) { + upsertApp(installedApp) + deleteAppliedPatches(installedApp.currentPackageName) insertAppliedPatches(appliedPatches) } - @Insert - suspend fun insertApp(installedApp: InstalledApp) + @Upsert + suspend fun upsertApp(installedApp: InstalledApp) @Insert suspend fun insertAppliedPatches(appliedPatches: List) + @Query("DELETE FROM applied_patch WHERE package_name = :packageName") + suspend fun deleteAppliedPatches(packageName: String) + @Delete suspend fun delete(installedApp: InstalledApp) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt index 99a37ed9..afba73ba 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt @@ -19,14 +19,14 @@ class InstalledAppRepository( suspend fun getAppliedPatches(packageName: String): PatchesSelection = dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() } - suspend fun add( + suspend fun addOrUpdate( currentPackageName: String, originalPackageName: String, version: String, installType: InstallType, patchesSelection: PatchesSelection ) { - dao.insertApp( + dao.upsertApp( InstalledApp( currentPackageName = currentPackageName, originalPackageName = originalPackageName, diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index c6e0d586..e136eb38 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -142,7 +142,7 @@ class InstallerViewModel( installedPackageName = intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) viewModelScope.launch { - installedAppRepository.add( + installedAppRepository.addOrUpdate( installedPackageName!!, packageName, input.selectedApp.version, @@ -277,7 +277,7 @@ class InstallerViewModel( installedApp?.let { installedAppRepository.delete(it) } - installedAppRepository.add( + installedAppRepository.addOrUpdate( packageName, packageName, input.selectedApp.version, From 4b12ae1531b3eedf1078c45964287d2786c659a4 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 17:21:59 +0200 Subject: [PATCH 14/48] fix: jvm signature clash error --- .../manager/ui/screen/SelectedAppInfoScreen.kt | 2 +- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 2d50e62c..230edc7b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -107,7 +107,7 @@ fun SelectedAppInfoScreen( is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen( onBackClick = navController::pop, onAppClick = { - vm.setSelectedApp(it) + vm.selectedApp = it navController.pop() }, viewModel = getViewModel { parametersOf(vm.selectedApp.packageName) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index e66ec074..c3d9d093 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -35,13 +35,18 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { private val savedStateHandle: SavedStateHandle = get() val prefs: PreferencesManager = get() - var selectedApp by savedStateHandle.saveable { + private var _selectedApp by savedStateHandle.saveable { mutableStateOf(input.app) } - private set + + var selectedApp + get() = _selectedApp + set(value) { + invalidateSelectedAppInfo() + _selectedApp = value + } var selectedAppInfo: PackageInfo? by mutableStateOf(null) - private set init { invalidateSelectedAppInfo() @@ -72,11 +77,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { selection } - fun setSelectedApp(new: SelectedApp) { - selectedApp = new - invalidateSelectedAppInfo() - } - private fun invalidateSelectedAppInfo() = viewModelScope.launch { val info = when (val app = selectedApp) { is SelectedApp.Download -> null From 18cfb56b45ea0a5470babdd970211504cb6e795c Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 18:49:44 +0200 Subject: [PATCH 15/48] fix: bundles not loading on Android 14 --- .../domain/bundles/LocalPatchBundle.kt | 6 +++-- .../domain/bundles/PatchBundleSource.kt | 13 +++++++++- .../domain/bundles/RemotePatchBundle.kt | 17 +++++++------ .../manager/network/service/HttpService.kt | 24 +++++++++++++------ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt index 43f86e72..9b6d1d60 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt @@ -10,8 +10,10 @@ import java.nio.file.StandardCopyOption class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) { suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) { withContext(Dispatchers.IO) { - patches?.let { - Files.copy(it, patchesFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + patches?.let { inputStream -> + patchBundleOutputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } } integrations?.let { Files.copy(it, this@LocalPatchBundle.integrationsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index f8b8e74c..dedbbf5d 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf import java.io.File +import java.io.OutputStream /** * A [PatchBundle] source. @@ -23,6 +24,16 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) */ fun hasInstalled() = patchesFile.exists() + protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) { + // Android 14+ requires dex containers to be readonly. + try { + setWritable(true, true) + outputStream() + } finally { + setReadOnly() + } + } + private fun load(): State { if (!hasInstalled()) return State.Missing @@ -40,7 +51,7 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) sealed interface State { fun patchBundleOrNull(): PatchBundle? = null - object Missing : State + data object Missing : State data class Failed(val throwable: Throwable) : State data class Loaded(val bundle: PatchBundle) : State { override fun patchBundleOrNull() = bundle diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 93c945a8..240347af 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -33,16 +33,19 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo private suspend fun download(info: BundleInfo) = withContext(Dispatchers.IO) { val (patches, integrations) = info coroutineScope { - mapOf( - patches.url to patchesFile, - integrations.url to integrationsFile - ).forEach { (asset, file) -> - launch { - http.download(file) { - url(asset) + launch { + patchBundleOutputStream().use { + http.streamTo(it) { + url(patches.url) } } } + + launch { + http.download(integrationsFile) { + url(integrations.url) + } + } } saveVersion(patches.version, integrations.version) diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt index 3781c3b4..e0b69aa6 100644 --- a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt @@ -18,8 +18,11 @@ import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.core.isNotEmpty import io.ktor.utils.io.core.readBytes import it.skrape.core.htmlDocument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File +import java.io.OutputStream /** * @author Aliucord Authors, DiamondMiner88 @@ -49,7 +52,10 @@ class HttpService( null } - Log.e(tag, "Failed to fetch: API error, http status: ${response.status}, body: $body") + Log.e( + tag, + "Failed to fetch: API error, http status: ${response.status}, body: $body" + ) APIResponse.Error(APIError(response.status, body)) } } catch (t: Throwable) { @@ -59,20 +65,19 @@ class HttpService( return response } - suspend fun download( - saveLocation: File, + suspend fun streamTo( + outputStream: OutputStream, builder: HttpRequestBuilder.() -> Unit ) { http.prepareGet(builder).execute { httpResponse -> if (httpResponse.status.isSuccess()) { - - saveLocation.outputStream().use { stream -> - val channel: ByteReadChannel = httpResponse.body() + val channel: ByteReadChannel = httpResponse.body() + withContext(Dispatchers.IO) { while (!channel.isClosedForRead) { val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) while (packet.isNotEmpty) { val bytes = packet.readBytes() - stream.write(bytes) + outputStream.write(bytes) } } } @@ -83,6 +88,11 @@ class HttpService( } } + suspend fun download( + saveLocation: File, + builder: HttpRequestBuilder.() -> Unit + ) = saveLocation.outputStream().use { streamTo(it, builder) } + suspend fun getHtml(builder: HttpRequestBuilder.() -> Unit) = htmlDocument( html = http.get(builder).bodyAsText() ) From 5290713504facf0575776df353008f8434ee1fbf Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 19:24:17 +0200 Subject: [PATCH 16/48] feat(patch-selector): remove TODO about an unplanned feature --- .../revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index 69449f80..eec230af 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -53,7 +53,6 @@ class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { var pendingSelectionAction by mutableStateOf<(() -> Unit)?>(null) - // TODO: this should be hoisted to the parent screen var selectionWarningEnabled by mutableStateOf(true) private set From 32e8a37f33b3d4286bc317b4733996028eb88066 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 19:43:26 +0200 Subject: [PATCH 17/48] fix: handle exceptions when checking for bundle updates --- .../repository/PatchBundleRepository.kt | 34 +++++++++++-------- .../java/app/revanced/manager/util/Util.kt | 18 +++++++--- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index ffdf7d13..2f7a8fe3 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -3,6 +3,7 @@ package app.revanced.manager.domain.repository import android.app.Application import android.content.Context import android.util.Log +import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.domain.bundles.APIPatchBundle @@ -13,18 +14,19 @@ import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.util.tag +import app.revanced.manager.util.uiSafe import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import java.io.InputStream class PatchBundleRepository( - app: Application, + private val app: Application, private val persistenceRepo: PatchBundlePersistenceRepository, private val networkInfo: NetworkInfo, ) { @@ -124,20 +126,24 @@ class PatchBundleRepository( reload() } - suspend fun redownloadRemoteBundles() = getBundlesByType().forEach { it.downloadLatest() } + suspend fun redownloadRemoteBundles() = + getBundlesByType().forEach { it.downloadLatest() } - suspend fun updateCheck() = supervisorScope { - if (!networkInfo.isSafe()) { - Log.d(tag, "Skipping update check because the network is down or metered.") - return@supervisorScope - } + suspend fun updateCheck() = + uiSafe(app, R.string.source_download_fail, "Failed to update bundles") { + coroutineScope { + if (!networkInfo.isSafe()) { + Log.d(tag, "Skipping update check because the network is down or metered.") + return@coroutineScope + } - getBundlesByType().forEach { - launch { - if (!it.propsFlow().first().autoUpdate) return@launch - Log.d(tag, "Updating patch bundle: ${it.name}") - it.update() + getBundlesByType().forEach { + launch { + if (!it.propsFlow().first().autoUpdate) return@launch + Log.d(tag, "Updating patch bundle: ${it.name}") + it.update() + } + } } } - } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index ed10ea6d..908aa0f6 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -13,7 +13,10 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -44,16 +47,21 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { * @param logMsg The log message. * @param block The code to execute. */ +@OptIn(DelicateCoroutinesApi::class) inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) { try { block() } catch (error: Exception) { - context.toast( - context.getString( - toastMsg, - error.simpleMessage() + // You can only toast on the main thread. + GlobalScope.launch(Dispatchers.Main) { + context.toast( + context.getString( + toastMsg, + error.simpleMessage() + ) ) - ) + } + Log.e(tag, logMsg, error) } } From 64ec73d821b9c9418dc7fed41d1fe61adac801bb Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 22:59:16 +0200 Subject: [PATCH 18/48] fix: more android 34 fixes --- app/src/main/AndroidManifest.xml | 14 +++++++++++++- .../manager/patcher/worker/PatcherWorker.kt | 9 ++++++++- .../ui/viewmodel/InstalledAppInfoViewModel.kt | 13 +++++++++---- .../manager/ui/viewmodel/InstallerViewModel.kt | 5 +++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 452c968a..3acb1c04 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + + tools:targetApi="34"> + + + + = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0 + ) private fun createNotification(): Notification { val notificationIntent = Intent(applicationContext, PatcherWorker::class.java) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt index 3055794d..90fbf264 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt @@ -11,6 +11,7 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R @@ -83,8 +84,10 @@ class InstalledAppInfoViewModel( override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { UninstallService.APP_UNINSTALL_ACTION -> { - val extraStatus = intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999) - val extraStatusMessage = intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) + val extraStatus = + intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999) + val extraStatusMessage = + intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) if (extraStatus == PackageInstaller.STATUS_SUCCESS) { viewModelScope.launch { @@ -113,9 +116,11 @@ class InstalledAppInfoViewModel( } } - app.registerReceiver( + ContextCompat.registerReceiver( + app, uninstallBroadcastReceiver, - IntentFilter(UninstallService.APP_UNINSTALL_ACTION) + IntentFilter(UninstallService.APP_UNINSTALL_ACTION), + ContextCompat.RECEIVER_NOT_EXPORTED ) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index e136eb38..15b26e72 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.map import androidx.lifecycle.viewModelScope @@ -162,10 +163,10 @@ class InstallerViewModel( } init { - app.registerReceiver(installBroadcastReceiver, IntentFilter().apply { + ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply { addAction(InstallService.APP_INSTALL_ACTION) addAction(UninstallService.APP_UNINSTALL_ACTION) - }) + }, ContextCompat.RECEIVER_NOT_EXPORTED) } fun exportLogs(context: Context) { From e70c10adbd8e20f42f68b0d0b3de27aa5f84e03a Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 23:02:35 +0200 Subject: [PATCH 19/48] feat: add checkboxes to the downloaded apps page --- .../settings/DownloadsSettingsScreen.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index f13a5dfd..aef44091 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -66,12 +67,20 @@ fun DownloadsSettingsScreen( GroupHeader(stringResource(R.string.downloaded_apps)) - downloadedApps.forEach { + downloadedApps.forEach { app -> + val selected = app in viewModel.selection + ListItem( - modifier = Modifier.clickable { viewModel.toggleItem(it) }, - headlineContent = { Text(it.packageName) }, - supportingContent = { Text(it.version) }, - tonalElevation = if (viewModel.selection.contains(it)) 8.dp else 0.dp + modifier = Modifier.clickable { viewModel.toggleItem(app) }, + headlineContent = { Text(app.packageName) }, + leadingContent = (@Composable { + Checkbox( + checked = selected, + onCheckedChange = { viewModel.toggleItem(app) } + ) + }).takeIf { viewModel.selection.isNotEmpty() }, + supportingContent = { Text(app.version) }, + tonalElevation = if (selected) 8.dp else 0.dp ) } } From 65f8d38c594d6cec7de1e4e5aef2304dce77f845 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 20 Oct 2023 23:16:00 +0200 Subject: [PATCH 20/48] feat: show toast when no patches are selected --- .../manager/ui/screen/SelectedAppInfoScreen.kt | 10 +++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 230edc7b..98ee50d9 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -32,6 +33,7 @@ import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.util.Options import app.revanced.manager.util.PatchesSelection +import app.revanced.manager.util.toast import dev.olshevski.navigation.reimagined.AnimatedNavHost import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.navigate @@ -46,6 +48,7 @@ fun SelectedAppInfoScreen( onBackClick: () -> Unit, vm: SelectedAppInfoViewModel ) { + val context = LocalContext.current val bundles by remember(vm.selectedApp.packageName, vm.selectedApp.version) { vm.bundlesRepo.bundleInfoFlow(vm.selectedApp.packageName, vm.selectedApp.version) }.collectAsStateWithLifecycle(initialValue = emptyList()) @@ -74,7 +77,12 @@ fun SelectedAppInfoScreen( AnimatedNavHost(controller = navController) { destination -> when (destination) { is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen( - onPatchClick = { + onPatchClick = patchClick@{ + if (selectedPatchCount == 0) { + context.toast(context.getString(R.string.no_patches_selected)) + + return@patchClick + } onPatchClick( vm.selectedApp, patches, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d49308d..4c094773 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,6 +31,7 @@ Start patching the application Patch selection and options %d patches selected + No patches selected Change version %s selected From 50e8d1f8f4fd265fdea4fcb91f0f50cad20743b7 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sat, 21 Oct 2023 16:10:10 +0200 Subject: [PATCH 21/48] docs: clarify license --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From 757840b76fd7fd53ac62cbbc77e8639c2efad0c1 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 26 Oct 2023 09:03:26 +0200 Subject: [PATCH 22/48] feat(bundles tab): add BackHandler --- .../app/revanced/manager/ui/screen/DashboardScreen.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 2f3e49ec..8bd2cbaa 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.screen +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -203,6 +204,13 @@ fun DashboardScreen( } DashboardPage.BUNDLES -> { + BackHandler { + if (bundlesSelectable) vm.cancelSourceSelection() else composableScope.launch { + pagerState.animateScrollToPage( + DashboardPage.DASHBOARD.ordinal + ) + } + } val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) From cc897840e2f6aff358f4545dd1b3f2b81daee1bb Mon Sep 17 00:00:00 2001 From: Ax333l Date: Thu, 26 Oct 2023 09:06:42 +0200 Subject: [PATCH 23/48] fix: perform selected app operations in the correct order --- .../revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index c3d9d093..41fce83e 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -42,8 +42,8 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { var selectedApp get() = _selectedApp set(value) { - invalidateSelectedAppInfo() _selectedApp = value + invalidateSelectedAppInfo() } var selectedAppInfo: PackageInfo? by mutableStateOf(null) From 6abaac25d94995046442c58a9dcfe133c5dd745d Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Thu, 26 Oct 2023 07:25:12 -0700 Subject: [PATCH 24/48] chore: upgrade dependencies (#1401) --- app/build.gradle.kts | 2 +- .../revanced/manager/patcher/patch/PatchBundle.kt | 5 ++++- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 14 +++++++------- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3a2a95d..0c7aa133 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,7 @@ plugins { android { namespace = "app.revanced.manager" compileSdk = 34 - buildToolsVersion = "33.0.2" + buildToolsVersion = "34.0.0" defaultConfig { applicationId = "app.revanced.manager" diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt index 7c5b2ffd..6da4fab4 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt @@ -9,7 +9,10 @@ import java.io.File class PatchBundle(private val loader: Iterable>, val integrations: File?) { constructor(bundleJar: File, integrations: File?) : this( object : Iterable> { - private fun load(): Iterable> = PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null) + private fun load(): Iterable> { + bundleJar.setReadOnly() + return PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null) + } override fun iterator(): Iterator> = load().iterator() }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 729aa449..5dcc6c08 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" ktor = "2.3.3" markdown = "0.5.0" -androidGradlePlugin = "8.1.1" +androidGradlePlugin = "8.1.2" kotlinGradlePlugin = "1.9.10" devToolsGradlePlugin = "1.9.10-1.0.13" aboutLibrariesGradlePlugin = "10.8.3" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 864d6c47..48314094 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionSha256Sum=591855b517fc635b9e04de1d05d5e76ada3f89f5fc76f87978d1b245b4f69225 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0adc8e1a..1aa94a42 100755 --- a/gradlew +++ b/gradlew @@ -145,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -202,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ From 7d887c73e882af737381f364bc248d243663538e Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 27 Oct 2023 17:33:11 +0200 Subject: [PATCH 25/48] fix: use correct checksum --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 48314094..46671acb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=591855b517fc635b9e04de1d05d5e76ada3f89f5fc76f87978d1b245b4f69225 +distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true From 172604fcdbe7d02da8f63f5353d8ca7c5ad70caa Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 27 Oct 2023 23:30:45 +0200 Subject: [PATCH 26/48] feat(installer): sign apk in patcher worker --- .../patcher/worker/PatcherProgressManager.kt | 24 ++++++++++--- .../manager/patcher/worker/PatcherWorker.kt | 10 +++++- .../ui/viewmodel/InstallerViewModel.kt | 36 ++++--------------- app/src/main/res/values/strings.xml | 1 + 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt index b138b3bd..938f7b12 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt @@ -26,7 +26,12 @@ class Step( val state: State = State.WAITING ) -class PatcherProgressManager(context: Context, selectedPatches: List, selectedApp: SelectedApp, downloadProgress: StateFlow?>) { +class PatcherProgressManager( + context: Context, + selectedPatches: List, + selectedApp: SelectedApp, + downloadProgress: StateFlow?> +) { val steps = generateSteps(context, selectedPatches, selectedApp, downloadProgress) private var currentStep: StepKey? = StepKey(0, 0) @@ -87,12 +92,20 @@ class PatcherProgressManager(context: Context, selectedPatches: List, se selectedPatches.map { SubStep(it) }.toImmutableList() ) - fun generateSteps(context: Context, selectedPatches: List, selectedApp: SelectedApp, downloadProgress: StateFlow?>? = null) = mutableListOf( + fun generateSteps( + context: Context, + selectedPatches: List, + selectedApp: SelectedApp, + downloadProgress: StateFlow?>? = null + ) = mutableListOf( Step( R.string.patcher_step_group_prepare, listOfNotNull( SubStep(context.getString(R.string.patcher_step_load_patches)), - SubStep("Download apk", progress = downloadProgress).takeIf { selectedApp is SelectedApp.Download }, + SubStep( + "Download apk", + progress = downloadProgress + ).takeIf { selectedApp is SelectedApp.Download }, SubStep(context.getString(R.string.patcher_step_unpack)), SubStep(context.getString(R.string.patcher_step_integrations)) ).toImmutableList() @@ -100,7 +113,10 @@ class PatcherProgressManager(context: Context, selectedPatches: List, se generatePatchesStep(selectedPatches), Step( R.string.patcher_step_group_saving, - persistentListOf(SubStep(context.getString(R.string.patcher_step_write_patched))) + persistentListOf( + SubStep(context.getString(R.string.patcher_step_write_patched)), + SubStep(context.getString(R.string.patcher_step_sign_apk)) + ) ) ) } diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index a0dc8568..eea966cd 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -19,6 +19,7 @@ import app.revanced.manager.R import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.domain.installer.RootInstaller +import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository @@ -50,6 +51,7 @@ class PatcherWorker( private val patchBundleRepository: PatchBundleRepository by inject() private val workerRepository: WorkerRepository by inject() private val prefs: PreferencesManager by inject() + private val keystoreManager: KeystoreManager by inject() private val downloadedAppRepository: DownloadedAppRepository by inject() private val pm: PM by inject() private val fs: Filesystem by inject() @@ -159,6 +161,8 @@ class PatcherWorker( progressFlow.value = progressManager.getProgress().toImmutableList() } + val patchedApk = fs.tempDir.resolve("patched.apk") + return try { if (args.input is SelectedApp.Installed) { @@ -219,9 +223,12 @@ class PatcherWorker( inputFile, onStepSucceeded = ::updateProgress ).use { session -> - session.run(File(args.output), patches, integrations) + session.run(patchedApk, patches, integrations) } + keystoreManager.sign(patchedApk, File(args.output)) + updateProgress() // Signing + Log.i(tag, "Patching succeeded".logFmt()) progressManager.success() Result.success() @@ -231,6 +238,7 @@ class PatcherWorker( Result.failure() } finally { updateProgress(false) + patchedApk.delete() } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index 15b26e72..bbb94299 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -24,7 +24,6 @@ import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.domain.installer.RootInstaller -import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.patcher.worker.PatcherProgressManager @@ -57,7 +56,6 @@ import java.util.logging.LogRecord class InstallerViewModel( private val input: Destination.Installer ) : ViewModel(), KoinComponent { - private val keystoreManager: KeystoreManager by inject() private val app: Application by inject() private val fs: Filesystem by inject() private val pm: PM by inject() @@ -72,8 +70,6 @@ class InstallerViewModel( } private val outputFile = tempDir.resolve("output.apk") - private val signedFile = tempDir.resolve("signed.apk") - private var hasSigned = false private var inputFile: File? = null private var installedApp: InstalledApp? = null @@ -209,42 +205,22 @@ class InstallerViewModel( tempDir.deleteRecursively() } - private suspend fun signApk(): Boolean { - if (!hasSigned) { - try { - withContext(Dispatchers.Default) { - keystoreManager.sign(outputFile, signedFile) - } - } catch (e: Exception) { - Log.e(tag, "Got exception while signing", e) - app.toast(app.getString(R.string.sign_fail, e::class.simpleName)) - return false - } - } - - return true - } - fun export(uri: Uri?) = viewModelScope.launch { uri?.let { - if (signApk()) { - withContext(Dispatchers.IO) { - app.contentResolver.openOutputStream(it) - .use { stream -> Files.copy(signedFile.toPath(), stream) } - } - app.toast(app.getString(R.string.export_app_success)) + withContext(Dispatchers.IO) { + app.contentResolver.openOutputStream(it) + .use { stream -> Files.copy(outputFile.toPath(), stream) } } + app.toast(app.getString(R.string.export_app_success)) } } fun install(installType: InstallType) = viewModelScope.launch { isInstalling = true try { - if (!signApk()) return@launch - when (installType) { InstallType.DEFAULT -> { - pm.installApp(listOf(signedFile)) + pm.installApp(listOf(outputFile)) } InstallType.ROOT -> { @@ -262,7 +238,7 @@ class InstallerViewModel( private suspend fun installAsRoot() { try { val label = with(pm) { - getPackageInfo(signedFile)?.label() + getPackageInfo(outputFile)?.label() ?: throw Exception("Failed to load application info") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c094773..9894dda2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -235,6 +235,7 @@ Patching Saving Write patched Apk + Sign Apk Patching in progress… completed From 123ae3752489a06daaa15eed2dde6e88df6a499c Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Fri, 27 Oct 2023 22:54:33 -0700 Subject: [PATCH 27/48] chore: add issue template (#1432) --- .github/ISSUE_TEMPLATE/bug-issue.yml | 61 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature-issue.yml | 42 ++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-issue.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-issue.yml diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml new file mode 100644 index 00000000..73017f1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -0,0 +1,61 @@ +name: 🐞 Bug report +description: Create a new bug report. +title: 'bug: ' +labels: [bug] +body: + - type: markdown + attributes: + value: | + # ReVanced Manager bug report + + Please check for existing issues [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one. + - type: textarea + attributes: + label: Bug description + description: | + - Describe your bug in detail + - Add steps to reproduce the bug if possible (Step 1. Download some files. Step 2. ...) + - Add images and videos if possible + - List selected patches if applicable + validations: + required: true + - type: textarea + attributes: + label: Version of ReVanced Manager and version & name of application you tried to patch + validations: + required: true + - type: dropdown + attributes: + label: Installation type + options: + - Non-root + - Root + validations: + required: false + - type: textarea + attributes: + label: Device logs + description: Export logs in ReVanced Manager settings. + render: shell + validations: + required: true + - type: textarea + attributes: + label: Patcher logs + description: Export logs in "Patcher" screen. + render: shell + validations: + required: false + - type: checkboxes + attributes: + label: Acknowledgements + description: Your issue will be closed if you don't follow the checklist below! + options: + - label: This request is not a duplicate of an existing issue. + required: true + - label: I have chosen an appropriate title. + required: true + - label: All requested information has been provided properly. + required: true + - label: The issue is solely related to the ReVanced Manager + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ec4bb386 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-issue.yml b/.github/ISSUE_TEMPLATE/feature-issue.yml new file mode 100644 index 00000000..f5676dec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-issue.yml @@ -0,0 +1,42 @@ +name: ⭐ Feature request +description: Create a new feature request. +title: 'feat: <title>' +labels: [feature-request] +body: + - type: markdown + attributes: + value: | + # ReVanced Manager feature request + + Please check for existing feature requests [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one. + - type: textarea + attributes: + label: Feature description + description: Describe your feature in detail. + validations: + required: true + - type: textarea + attributes: + label: Motivation + description: Explain why the lack of it is a problem. + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: In case there is something else you want to add. + validations: + required: false + - type: checkboxes + attributes: + label: Acknowledgements + description: Your issue will be closed if you don't follow the checklist below! + options: + - label: This request is not a duplicate of an existing issue. + required: true + - label: I have chosen an appropriate title. + required: true + - label: All requested information has been provided properly. + required: true + - label: The issue is solely related to the ReVanced Manager + required: true From 7fe4724e10fb33b95c10b399667fde3d2e798034 Mon Sep 17 00:00:00 2001 From: Ax333l <main@axelen.xyz> Date: Tue, 31 Oct 2023 21:16:02 +0100 Subject: [PATCH 28/48] feat: remember patch options (#1449) --- .../1.json | 111 +++++++++++++++++- .../revanced/manager/data/room/AppDatabase.kt | 6 +- .../manager/data/room/options/Option.kt | 23 ++++ .../manager/data/room/options/OptionDao.kt | 51 ++++++++ .../manager/data/room/options/OptionGroup.kt | 24 ++++ .../data/room/selection/SelectionDao.kt | 2 +- .../revanced/manager/di/RepositoryModule.kt | 1 + .../repository/PatchOptionsRepository.kt | 89 ++++++++++++++ .../manager/patcher/worker/PatcherWorker.kt | 6 +- .../ui/screen/PatchesSelectorScreen.kt | 5 +- .../ui/screen/SelectedAppInfoScreen.kt | 23 ++-- .../settings/ImportExportSettingsScreen.kt | 109 ++++++++++++++++- .../ui/viewmodel/ImportExportViewModel.kt | 18 ++- .../ui/viewmodel/PatchesSelectorViewModel.kt | 9 -- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 63 +++++++++- app/src/main/res/values/strings.xml | 7 ++ 16 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/data/room/options/Option.kt create mode 100644 app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt create mode 100644 app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt create mode 100644 app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index 041d1dea..0fb6425d 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "371c7a84b122a2de8b660b35e6e9ce14", + "identityHash": "802fa2fda94b930bf0ebb85d195f1022", "entities": [ { "tableName": "patch_bundles", @@ -295,12 +295,119 @@ ] } ] + }, + { + "tableName": "option_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchBundle", + "columnName": "patch_bundle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_option_groups_patch_bundle_package_name", + "unique": true, + "columnNames": [ + "patch_bundle", + "package_name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)" + } + ], + "foreignKeys": [ + { + "table": "patch_bundles", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "patch_bundle" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "options", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patchName", + "columnName": "patch_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group", + "patch_name", + "key" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "option_groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group" + ], + "referencedColumns": [ + "uid" + ] + } + ] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '371c7a84b122a2de8b660b35e6e9ce14')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '802fa2fda94b930bf0ebb85d195f1022')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index e1a0f81b..0440a7c2 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -13,15 +13,19 @@ import app.revanced.manager.data.room.selection.SelectedPatch import app.revanced.manager.data.room.selection.SelectionDao import app.revanced.manager.data.room.bundles.PatchBundleDao import app.revanced.manager.data.room.bundles.PatchBundleEntity +import app.revanced.manager.data.room.options.Option +import app.revanced.manager.data.room.options.OptionDao +import app.revanced.manager.data.room.options.OptionGroup import kotlin.random.Random -@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class], version = 1) +@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class], version = 1) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun patchBundleDao(): PatchBundleDao abstract fun selectionDao(): SelectionDao abstract fun downloadedAppDao(): DownloadedAppDao abstract fun installedAppDao(): InstalledAppDao + abstract fun optionDao(): OptionDao companion object { fun generateUid() = Random.Default.nextInt() diff --git a/app/src/main/java/app/revanced/manager/data/room/options/Option.kt b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt new file mode 100644 index 00000000..3a70a9a5 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt @@ -0,0 +1,23 @@ +package app.revanced.manager.data.room.options + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "options", + primaryKeys = ["group", "patch_name", "key"], + foreignKeys = [ForeignKey( + OptionGroup::class, + parentColumns = ["uid"], + childColumns = ["group"], + onDelete = ForeignKey.CASCADE + )] +) +data class Option( + @ColumnInfo(name = "group") val group: Int, + @ColumnInfo(name = "patch_name") val patchName: String, + @ColumnInfo(name = "key") val key: String, + // Encoded as Json. + @ColumnInfo(name = "value") val value: String, +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt new file mode 100644 index 00000000..fa343a6d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt @@ -0,0 +1,51 @@ +package app.revanced.manager.data.room.options + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.MapInfo +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class OptionDao { + @Transaction + @MapInfo(keyColumn = "patch_bundle") + @Query( + "SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" + + " LEFT JOIN options ON uid = options.`group`" + + " WHERE package_name = :packageName" + ) + abstract suspend fun getOptions(packageName: String): Map<Int, List<Option>> + + @Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName") + abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int? + + @Query("SELECT package_name FROM option_groups") + abstract fun getPackagesWithOptions(): Flow<List<String>> + + @Insert + abstract suspend fun createOptionGroup(group: OptionGroup) + + @Query("DELETE FROM option_groups WHERE patch_bundle = :uid") + abstract suspend fun clearForPatchBundle(uid: Int) + + @Query("DELETE FROM option_groups WHERE package_name = :packageName") + abstract suspend fun clearForPackage(packageName: String) + + @Query("DELETE FROM option_groups") + abstract suspend fun reset() + + @Insert + protected abstract suspend fun insertOptions(patches: List<Option>) + + @Query("DELETE FROM options WHERE `group` = :groupId") + protected abstract suspend fun clearGroup(groupId: Int) + + @Transaction + open suspend fun updateOptions(options: Map<Int, List<Option>>) = + options.forEach { (groupId, options) -> + clearGroup(groupId) + insertOptions(options) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt new file mode 100644 index 00000000..df35dc99 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt @@ -0,0 +1,24 @@ +package app.revanced.manager.data.room.options + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import app.revanced.manager.data.room.bundles.PatchBundleEntity + +@Entity( + tableName = "option_groups", + foreignKeys = [ForeignKey( + PatchBundleEntity::class, + parentColumns = ["uid"], + childColumns = ["patch_bundle"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index(value = ["patch_bundle", "package_name"], unique = true)] +) +data class OptionGroup( + @PrimaryKey val uid: Int, + @ColumnInfo(name = "patch_bundle") val patchBundle: Int, + @ColumnInfo(name = "package_name") val packageName: String +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt index 630c5d66..7cf6dd79 100644 --- a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt @@ -49,7 +49,7 @@ abstract class SelectionDao { @Transaction open suspend fun updateSelections(selections: Map<Int, Set<String>>) = - selections.map { (selectionUid, patches) -> + selections.forEach { (selectionUid, patches) -> clearSelection(selectionUid) selectPatches(patches.map { SelectedPatch(selectionUid, it) }) } diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index a5420a5c..5e0f409c 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -17,6 +17,7 @@ val repositoryModule = module { singleOf(::NetworkInfo) singleOf(::PatchBundlePersistenceRepository) singleOf(::PatchSelectionRepository) + singleOf(::PatchOptionsRepository) singleOf(::PatchBundleRepository) singleOf(::WorkerRepository) singleOf(::DownloadedAppRepository) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt new file mode 100644 index 00000000..43ca3273 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt @@ -0,0 +1,89 @@ +package app.revanced.manager.domain.repository + +import app.revanced.manager.data.room.AppDatabase +import app.revanced.manager.data.room.options.Option +import app.revanced.manager.data.room.options.OptionGroup +import app.revanced.manager.util.Options +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.intOrNull + +class PatchOptionsRepository(db: AppDatabase) { + private val dao = db.optionDao() + + private suspend fun getOrCreateGroup(bundleUid: Int, packageName: String) = + dao.getGroupId(bundleUid, packageName) ?: OptionGroup( + uid = AppDatabase.generateUid(), + patchBundle = bundleUid, + packageName = packageName + ).also { dao.createOptionGroup(it) }.uid + + suspend fun getOptions(packageName: String): Options { + val options = dao.getOptions(packageName) + // Bundle -> Patches + return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) { + options.forEach { (sourceUid, bundlePatchOptionsList) -> + // Patches -> Patch options + this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option -> + val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf) + + patchOptions[option.key] = deserialize(option.value) + + bundlePatchOptions + } + } + } + } + + suspend fun saveOptions(packageName: String, options: Options) = + dao.updateOptions(options.entries.associate { (sourceUid, bundlePatchOptions) -> + val groupId = getOrCreateGroup(sourceUid, packageName) + + groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) -> + patchOptions.mapNotNull { (key, value) -> + val serialized = serialize(value) + ?: return@mapNotNull null // Don't save options that we can't serialize. + + Option(groupId, patchName, key, serialized) + } + } + }) + + fun getPackagesWithSavedOptions() = + dao.getPackagesWithOptions().map(Iterable<String>::toSet).distinctUntilChanged() + + suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName) + suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid) + suspend fun reset() = dao.reset() + + private companion object { + fun deserialize(value: String): Any? { + val primitive = Json.decodeFromString<JsonPrimitive>(value) + + return when { + primitive.isString -> primitive.content + primitive is JsonNull -> null + else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull + } + } + + fun serialize(value: Any?): String? { + val primitive = when (value) { + null -> JsonNull + is String -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + else -> return null + } + + return Json.encodeToString(primitive) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index eea966cd..ba6e3afe 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -179,11 +179,11 @@ class PatcherWorker( .mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) } // Set all patch options. - args.options.forEach { (bundle, configuredPatchOptions) -> + args.options.forEach { (bundle, bundlePatchOptions) -> val patches = allPatches[bundle] ?: return@forEach - configuredPatchOptions.forEach { (patchName, options) -> + bundlePatchOptions.forEach { (patchName, configuredPatchOptions) -> val patchOptions = patches.single { it.name == patchName }.options - options.forEach { (key, value) -> + configuredPatchOptions.forEach { (key, value) -> patchOptions[key] = value } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index d33c28a5..75b6d74b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -301,10 +301,7 @@ fun PatchesSelectorScreen( icon = { Icon(Icons.Outlined.Save, null) }, onClick = { // TODO: only allow this if all required options have been set. - composableScope.launch { - vm.saveSelection() - onSave(vm.getCustomSelection(), vm.getOptions()) - } + onSave(vm.getCustomSelection(), vm.getOptions()) } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 98ee50d9..a2978f67 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -49,9 +49,13 @@ fun SelectedAppInfoScreen( vm: SelectedAppInfoViewModel ) { val context = LocalContext.current - val bundles by remember(vm.selectedApp.packageName, vm.selectedApp.version) { - vm.bundlesRepo.bundleInfoFlow(vm.selectedApp.packageName, vm.selectedApp.version) + + 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 allowExperimental by vm.prefs.allowExperimental.getAsState() val patches by remember { derivedStateOf { @@ -86,7 +90,7 @@ fun SelectedAppInfoScreen( onPatchClick( vm.selectedApp, patches, - vm.patchOptions + vm.getOptionsFiltered(bundles) ) }, onPatchSelectorClick = { @@ -97,7 +101,7 @@ fun SelectedAppInfoScreen( bundles, allowExperimental ), - vm.patchOptions + vm.options ) ) }, @@ -107,8 +111,8 @@ fun SelectedAppInfoScreen( onBackClick = onBackClick, availablePatchCount = availablePatchCount, selectedPatchCount = selectedPatchCount, - packageName = vm.selectedApp.packageName, - version = vm.selectedApp.version, + packageName = packageName, + version = version, packageInfo = vm.selectedAppInfo, ) @@ -118,13 +122,12 @@ fun SelectedAppInfoScreen( vm.selectedApp = it navController.pop() }, - viewModel = getViewModel { parametersOf(vm.selectedApp.packageName) } + viewModel = getViewModel { parametersOf(packageName) } ) is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( onSave = { patches, options -> - vm.setCustomPatches(patches) - vm.patchOptions = options + vm.updateConfiguration(patches, options, bundles) navController.pop() }, onBackClick = navController::pop, @@ -133,7 +136,7 @@ fun SelectedAppInfoScreen( PatchesSelectorViewModel.Params( destination.app, destination.currentSelection, - destination.options + destination.options, ) ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 63fae1bf..5df573c0 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -6,7 +6,10 @@ 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.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -14,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Key import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -53,8 +57,10 @@ fun ImportExportSettingsScreen( it?.let(vm::exportKeystore) } + val patchBundles by vm.patchBundles.collectAsStateWithLifecycle(initialValue = emptyList()) + val packagesWithOptions by vm.packagesWithOptions.collectAsStateWithLifecycle(initialValue = emptySet()) + vm.selectionAction?.let { action -> - val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) val launcher = rememberLauncherForActivityResult(action.activityContract) { uri -> if (uri == null) { vm.clearSelectionAction() @@ -64,7 +70,7 @@ fun ImportExportSettingsScreen( } if (vm.selectedBundle == null) { - BundleSelector(sources) { + BundleSelector(patchBundles) { if (it == null) { vm.clearSelectionAction() } else { @@ -137,11 +143,110 @@ fun ImportExportSettingsScreen( headline = R.string.backup_patches_selection, description = R.string.backup_patches_selection_description ) + // TODO: allow resetting selection for specific bundle or package name. GroupItem( onClick = vm::resetSelection, headline = R.string.clear_patches_selection, description = R.string.clear_patches_selection_description ) + + var showPackageSelector by rememberSaveable { + mutableStateOf(false) + } + var showBundleSelector by rememberSaveable { + mutableStateOf(false) + } + + if (showPackageSelector) + PackageSelector(packages = packagesWithOptions) { selected -> + selected?.let(vm::clearOptionsForPackage) + + showPackageSelector = false + } + + if (showBundleSelector) + BundleSelector(bundles = patchBundles) { bundle -> + bundle?.let(vm::clearOptionsForBundle) + + showBundleSelector = false + } + + GroupHeader(stringResource(R.string.patch_options)) + // TODO: patch options import/export. + GroupItem( + onClick = { showPackageSelector = true }, + headline = R.string.patch_options_clear_package, + description = R.string.patch_options_clear_package_description + ) + if (patchBundles.size > 1) + GroupItem( + onClick = { showBundleSelector = true }, + headline = R.string.patch_options_clear_bundle, + description = R.string.patch_options_clear_bundle_description, + ) + GroupItem( + onClick = vm::resetOptions, + headline = R.string.patch_options_clear_all, + description = R.string.patch_options_clear_all_description, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PackageSelector(packages: Set<String>, onFinish: (String?) -> Unit) { + val context = LocalContext.current + + val noPackages = packages.isEmpty() + + LaunchedEffect(noPackages) { + if (noPackages) { + context.toast("No packages available.") + onFinish(null) + } + } + + if (noPackages) return + + ModalBottomSheet( + onDismissRequest = { onFinish(null) } + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .height(48.dp) + .fillMaxWidth() + ) { + Text( + text = "Select package", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + packages.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .height(48.dp) + .fillMaxWidth() + .clickable { + onFinish(it) + } + ) { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt index 85f36fc7..ee107163 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt @@ -16,6 +16,7 @@ import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.SerializedSelection import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.util.JSON_MIMETYPE import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe @@ -38,10 +39,11 @@ class ImportExportViewModel( private val app: Application, private val keystoreManager: KeystoreManager, private val selectionRepository: PatchSelectionRepository, + private val optionsRepository: PatchOptionsRepository, patchBundleRepository: PatchBundleRepository ) : ViewModel() { private val contentResolver = app.contentResolver - val sources = patchBundleRepository.sources + val patchBundles = patchBundleRepository.sources var selectedBundle by mutableStateOf<PatchBundleSource?>(null) private set var selectionAction by mutableStateOf<SelectionAction?>(null) @@ -49,6 +51,20 @@ class ImportExportViewModel( private var keystoreImportPath by mutableStateOf<Path?>(null) val showCredentialsDialog by derivedStateOf { keystoreImportPath != null } + val packagesWithOptions = optionsRepository.getPackagesWithSavedOptions() + + fun clearOptionsForPackage(packageName: String) = viewModelScope.launch { + optionsRepository.clearOptionsForPackage(packageName) + } + + fun clearOptionsForBundle(patchBundle: PatchBundleSource) = viewModelScope.launch { + optionsRepository.clearOptionsForPatchBundle(patchBundle.uid) + } + + fun resetOptions() = viewModelScope.launch { + optionsRepository.reset() + } + fun startKeystoreImport(content: Uri) = viewModelScope.launch { val path = withContext(Dispatchers.IO) { File.createTempFile("signing", "ks", app.cacheDir).toPath().also { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index eec230af..c9bcfdb9 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -17,7 +17,6 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.saveable import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager -import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.model.BundleInfo @@ -31,20 +30,17 @@ 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.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.get import kotlinx.collections.immutable.* -import kotlinx.coroutines.withContext import java.util.Optional @Stable @OptIn(SavedStateHandleSaveableApi::class) class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { private val app: Application = get() - private val selectionRepository: PatchSelectionRepository = get() private val savedStateHandle: SavedStateHandle = get() private val prefs: PreferencesManager = get() @@ -169,11 +165,6 @@ class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } } } - suspend fun saveSelection() = withContext(Dispatchers.Default) { - customPatchesSelection?.let { selectionRepository.updateSelection(packageName, it) } - ?: selectionRepository.clearSelection(packageName) - } - fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name) fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 41fce83e..4ab02aaa 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.saveable import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.ui.model.BundleInfo import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection @@ -31,10 +32,13 @@ import org.koin.core.component.get class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { val bundlesRepo: PatchBundleRepository = get() private val selectionRepository: PatchSelectionRepository = get() + private val optionsRepository: PatchOptionsRepository = get() private val pm: PM = get() private val savedStateHandle: SavedStateHandle = get() val prefs: PreferencesManager = get() + private val persistConfiguration = input.patches == null + private var _selectedApp by savedStateHandle.saveable { mutableStateOf(input.app) } @@ -52,9 +56,18 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { invalidateSelectedAppInfo() } - var patchOptions: Options by savedStateHandle.saveable { - mutableStateOf(emptyMap()) + var options: Options by savedStateHandle.saveable { + val state = mutableStateOf<Options>(emptyMap()) + + viewModelScope.launch(Dispatchers.Default) { + if (!persistConfiguration) return@launch // TODO: save options for patched apps. + + state.value = optionsRepository.getOptions(selectedApp.packageName) + } + + state } + private set private var selectionState by savedStateHandle.saveable { if (input.patches != null) { @@ -87,6 +100,8 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { selectedAppInfo = info } + fun getOptionsFiltered(bundles: List<BundleInfo>) = options.filtered(bundles) + fun getPatches(bundles: List<BundleInfo>, allowUnsupported: Boolean) = selectionState.patches(bundles, allowUnsupported) @@ -96,14 +111,56 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { ): PatchesSelection? = (selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported) - fun setCustomPatches(selection: PatchesSelection?) { + fun updateConfiguration( + selection: PatchesSelection?, + options: Options, + bundles: List<BundleInfo> + ) { selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default + + val filteredOptions = options.filtered(bundles) + this.options = filteredOptions + + if (!persistConfiguration) return + + val packageName = selectedApp.packageName + viewModelScope.launch(Dispatchers.Default) { + selection?.let { selectionRepository.updateSelection(packageName, it) } + ?: selectionRepository.clearSelection(packageName) + + optionsRepository.saveOptions(packageName, filteredOptions) + } } data class Params( val app: SelectedApp, val patches: PatchesSelection?, ) + + private companion object { + /** + * Returns a copy with all nonexistent options removed. + */ + private fun Options.filtered(bundles: List<BundleInfo>): Options = buildMap options@{ + bundles.forEach bundles@{ bundle -> + val bundleOptions = this@filtered[bundle.uid] ?: return@bundles + + val patches = bundle.all.associateBy { it.name } + + this@options[bundle.uid] = buildMap bundleOptions@{ + bundleOptions.forEach patch@{ (patchName, values) -> + // Get all valid option keys for the patch. + val validOptionKeys = + patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch + + this@bundleOptions[patchName] = values.filterKeys { key -> + key in validOptionKeys + } + } + } + } + } + } } private sealed interface SelectionState : Parcelable { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9894dda2..23f49159 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,13 @@ <string name="backup_patches_selection_fail">Failed to backup patches selection: %s</string> <string name="clear_patches_selection">Clear patches selection</string> <string name="clear_patches_selection_description">Clear all patches selection</string> + <string name="patch_options">Patch options</string> + <string name="patch_options_clear_package">Clear patch options for package</string> + <string name="patch_options_clear_package_description">Resets patch options for a single package</string> + <string name="patch_options_clear_bundle">Clear patch options for bundle</string> + <string name="patch_options_clear_bundle_description">Resets patch options for all patches in a bundle</string> + <string name="patch_options_clear_all">Clear all patch options</string> + <string name="patch_options_clear_all_description">Resets all patch options</string> <string name="prefer_splits">Prefer split apks</string> <string name="prefer_splits_description">Prefer split apks instead of full apks</string> <string name="prefer_universal">Prefer universal apks</string> From 25bd91debc50d74040f786bf99450c15530cea61 Mon Sep 17 00:00:00 2001 From: Ushie <ushiekane@gmail.com> Date: Wed, 1 Nov 2023 20:11:43 +0300 Subject: [PATCH 29/48] feat(Settings): use SettingsListItem consistently and overall improvements (#1427) --- .../manager/ui/component/GroupHeader.kt | 2 +- .../manager/ui/component/SettingsListItem.kt | 47 +++++ .../ui/component/settings/BooleanItem.kt | 9 +- .../ui/screen/InstalledAppInfoScreen.kt | 36 ++-- .../manager/ui/screen/SettingsScreen.kt | 23 +-- .../ui/screen/settings/AboutSettingsScreen.kt | 183 +++++++++--------- .../screen/settings/AdvancedSettingsScreen.kt | 40 ++-- .../settings/DownloadsSettingsScreen.kt | 9 +- .../screen/settings/GeneralSettingsScreen.kt | 9 +- .../settings/ImportExportSettingsScreen.kt | 7 +- .../settings/update/UpdatesSettingsScreen.kt | 22 +-- 11 files changed, 201 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt diff --git a/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt index f7ca27ae..b07b23e6 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt @@ -18,6 +18,6 @@ fun GroupHeader( text = title, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(16.dp).semantics { heading() }.then(modifier) + modifier = Modifier.padding(24.dp).semantics { heading() }.then(modifier) ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt new file mode 100644 index 00000000..cbae1282 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt @@ -0,0 +1,47 @@ +package app.revanced.manager.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.material3.ListItem + +@Composable +fun SettingsListItem( + headlineContent: String, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: String? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemDefaults.Elevation, + shadowElevation: Dp = ListItemDefaults.Elevation, +) = ListItem( + headlineContent = { + Text( + text = headlineContent, + style = MaterialTheme.typography.titleLarge + ) + }, + modifier = modifier.then(Modifier.padding(horizontal = 8.dp)), + overlineContent = overlineContent, + supportingContent = { + if (supportingContent != null) + Text( + text = supportingContent, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) else null + }, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt index 8ed78775..b12fc8fd 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt @@ -2,15 +2,14 @@ package app.revanced.manager.ui.component.settings import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.material3.ListItem import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import app.revanced.manager.domain.manager.base.Preference +import app.revanced.manager.ui.component.SettingsListItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -37,10 +36,10 @@ fun BooleanItem( onValueChange: (Boolean) -> Unit, @StringRes headline: Int, @StringRes description: Int -) = ListItem( +) = SettingsListItem( modifier = Modifier.clickable { onValueChange(!value) }, - headlineContent = { Text(stringResource(headline)) }, - supportingContent = { Text(stringResource(description)) }, + headlineContent = stringResource(headline), + supportingContent = stringResource(description), trailingContent = { Switch( checked = value, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 4abea5ec..50d1a99a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -21,7 +19,6 @@ import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.AlertDialog 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 @@ -32,7 +29,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.clip import androidx.compose.ui.res.pluralStringResource @@ -40,10 +36,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType -import app.revanced.manager.ui.component.AppIcon import app.revanced.manager.ui.component.AppInfo -import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.SettingsListItem import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.util.PatchesSelection @@ -148,38 +143,35 @@ fun InstalledAppInfoScreen( Column( modifier = Modifier.padding(vertical = 16.dp) ) { - ListItem( + SettingsListItem( modifier = Modifier.clickable { }, - headlineContent = { Text(stringResource(R.string.applied_patches)) }, - supportingContent = { - Text( + headlineContent = stringResource(R.string.applied_patches), + supportingContent = (viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let { pluralStringResource( id = R.plurals.applied_patches, it, it ) - } - ) - }, + }, trailingContent = { Icon(Icons.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) } ) - ListItem( - headlineContent = { Text(stringResource(R.string.package_name)) }, - supportingContent = { Text(viewModel.installedApp.currentPackageName) } + SettingsListItem( + headlineContent = stringResource(R.string.package_name), + supportingContent = viewModel.installedApp.currentPackageName ) if (viewModel.installedApp.originalPackageName != viewModel.installedApp.currentPackageName) { - ListItem( - headlineContent = { Text(stringResource(R.string.original_package_name)) }, - supportingContent = { Text(viewModel.installedApp.originalPackageName) } + SettingsListItem( + headlineContent = stringResource(R.string.original_package_name), + supportingContent = viewModel.installedApp.originalPackageName ) } - ListItem( - headlineContent = { Text(stringResource(R.string.install_type)) }, - supportingContent = { Text(stringResource(viewModel.installedApp.installType.stringResource)) } + SettingsListItem( + headlineContent = stringResource(R.string.install_type), + supportingContent = stringResource(viewModel.installedApp.installType.stringResource) ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index 3aaa7d44..6c26d84f 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -45,9 +45,10 @@ import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen import app.revanced.manager.ui.viewmodel.SettingsViewModel import dev.olshevski.navigation.reimagined.* import org.koin.androidx.compose.getViewModel +import app.revanced.manager.ui.component.SettingsListItem @SuppressLint("BatteryLife") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onBackClick: () -> Unit, @@ -157,8 +158,7 @@ fun SettingsScreen( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) + .verticalScroll(rememberScrollState()) ) { AnimatedVisibility(visible = showBatteryButton) { Card( @@ -197,21 +197,10 @@ fun SettingsScreen( } } settingsSections.forEach { (titleDescIcon, destination) -> - ListItem( + SettingsListItem( modifier = Modifier.clickable { navController.navigate(destination) }, - headlineContent = { - Text( - stringResource(titleDescIcon.first), - style = MaterialTheme.typography.titleLarge - ) - }, - supportingContent = { - Text( - stringResource(titleDescIcon.second), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - }, + headlineContent = stringResource(titleDescIcon.first), + supportingContent = stringResource(titleDescIcon.second), leadingContent = { Icon(titleDescIcon.third, null) } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index a35652ba..63d8837d 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -1,10 +1,19 @@ package app.revanced.manager.ui.screen.settings import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.border 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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -12,23 +21,30 @@ import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.MailOutline -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color 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.ui.component.AppTopBar +import app.revanced.manager.ui.component.SettingsListItem import app.revanced.manager.util.isDebuggable import app.revanced.manager.util.openUrl import com.google.accompanist.drawablepainter.rememberDrawablePainter -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun AboutSettingsScreen( onBackClick: () -> Unit, @@ -92,99 +108,87 @@ fun AboutSettingsScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .verticalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + Image( + modifier = Modifier.padding(top = 16.dp), + painter = icon, + contentDescription = null + ) Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Image(painter = icon, contentDescription = null) - Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge) + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineSmall + ) Text( text = stringResource(R.string.version) + " " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ")", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline ) - Row( - modifier = Modifier.padding(top = 12.dp) - ) { - filledButton.forEach { (icon, text, onClick) -> - FilledTonalButton( - onClick = onClick, - modifier = Modifier.padding(end = 8.dp) + } + FlowRow( + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + filledButton.forEach { (icon, text, onClick) -> + FilledTonalButton( + onClick = onClick + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - icon, - contentDescription = null, - modifier = Modifier - .size(28.dp) - .padding(end = 8.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text, - style = MaterialTheme.typography.labelLarge, - ) - } + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text( + text, + style = MaterialTheme.typography.labelLarge + ) } } } - - Row( - modifier = Modifier.padding(top = 12.dp) - ) { - outlinedButton.forEach { (icon, text, onClick) -> - Button( - onClick = onClick, - modifier = Modifier.padding(end = 8.dp), - colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ), - border = ButtonDefaults.outlinedButtonBorder + outlinedButton.forEach { (icon, text, onClick) -> + OutlinedButton( + onClick = onClick + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - icon, - contentDescription = null, - modifier = Modifier - .size(28.dp) - .padding(end = 8.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text( + text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) } } } } - Box( - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = MaterialTheme.shapes.medium - ) - .padding(16.dp) + OutlinedCard( + modifier = Modifier.padding(horizontal = 16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) ) { - Column { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( text = stringResource(R.string.about_revanced_manager), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.titleMedium ) Text( text = stringResource(R.string.revanced_manager_description), @@ -192,24 +196,17 @@ fun AboutSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - - listItems.forEach { (title, description, onClick) -> - ListItem( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .clickable { onClick() }, - headlineContent = { Text(title, style = MaterialTheme.typography.titleLarge) }, - supportingContent = { - Text( - description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } - ) + Column { + listItems.forEach { (title, description, onClick) -> + SettingsListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + headlineContent = title, + supportingContent = description + ) + } } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index 4af8e908..4804d632 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.outlined.Http import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -36,6 +35,7 @@ import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.SettingsListItem import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel import org.koin.androidx.compose.getViewModel @@ -79,9 +79,9 @@ fun AdvancedSettingsScreen( it?.let(vm::setApiUrl) } } - ListItem( - headlineContent = { Text(stringResource(R.string.api_url)) }, - supportingContent = { Text(apiUrl) }, + SettingsListItem( + headlineContent = stringResource(R.string.api_url), + supportingContent = apiUrl, modifier = Modifier.clickable { showApiUrlDialog = true } @@ -96,35 +96,35 @@ fun AdvancedSettingsScreen( ) GroupHeader(stringResource(R.string.patch_bundles_section)) - ListItem( - headlineContent = { Text(stringResource(R.string.patch_bundles_redownload)) }, + SettingsListItem( + headlineContent = stringResource(R.string.patch_bundles_redownload), modifier = Modifier.clickable { vm.redownloadBundles() } ) - ListItem( - headlineContent = { Text(stringResource(R.string.patch_bundles_reset)) }, + SettingsListItem( + headlineContent = stringResource(R.string.patch_bundles_reset), modifier = Modifier.clickable { vm.resetBundles() } ) GroupHeader(stringResource(R.string.device)) - ListItem( - headlineContent = { Text(stringResource(R.string.device_model)) }, - supportingContent = { Text(Build.MODEL) } + SettingsListItem( + headlineContent = stringResource(R.string.device_model), + supportingContent = Build.MODEL ) - ListItem( - headlineContent = { Text(stringResource(R.string.device_android_version)) }, - supportingContent = { Text(Build.VERSION.RELEASE) } + SettingsListItem( + headlineContent = stringResource(R.string.device_android_version), + supportingContent = Build.VERSION.RELEASE ) - ListItem( - headlineContent = { Text(stringResource(R.string.device_architectures)) }, - supportingContent = { Text(Build.SUPPORTED_ABIS.joinToString(", ")) } + SettingsListItem( + headlineContent = stringResource(R.string.device_architectures), + supportingContent = Build.SUPPORTED_ABIS.joinToString(", ") ) - ListItem( - headlineContent = { Text(stringResource(R.string.device_memory_limit)) }, - supportingContent = { Text(memoryLimit) } + SettingsListItem( + headlineContent = stringResource(R.string.device_memory_limit), + supportingContent = memoryLimit ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index aef44091..5567470a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -12,9 +12,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -24,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.SettingsListItem import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel import org.koin.androidx.compose.getViewModel @@ -70,16 +69,16 @@ fun DownloadsSettingsScreen( downloadedApps.forEach { app -> val selected = app in viewModel.selection - ListItem( + SettingsListItem( modifier = Modifier.clickable { viewModel.toggleItem(app) }, - headlineContent = { Text(app.packageName) }, + headlineContent = app.packageName, leadingContent = (@Composable { Checkbox( checked = selected, onCheckedChange = { viewModel.toggleItem(app) } ) }).takeIf { viewModel.selection.isNotEmpty() }, - supportingContent = { Text(app.version) }, + supportingContent = app.version, tonalElevation = if (selected) 8.dp else 0.dp ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt index ed25e7b2..b32dea91 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt @@ -23,6 +23,7 @@ import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.SettingsViewModel import org.koin.compose.koinInject +import app.revanced.manager.ui.component.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -58,12 +59,12 @@ fun GeneralSettingsScreen( GroupHeader(stringResource(R.string.appearance)) val theme by prefs.theme.getAsState() - ListItem( + SettingsListItem( modifier = Modifier.clickable { showThemePicker = true }, - headlineContent = { Text(stringResource(R.string.theme)) }, - supportingContent = { Text(stringResource(R.string.theme_description)) }, + headlineContent = stringResource(R.string.theme), + supportingContent = stringResource(R.string.theme_description), trailingContent = { - Button( + FilledTonalButton( onClick = { showThemePicker = true } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 5df573c0..7ff29db2 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -39,6 +39,7 @@ import app.revanced.manager.ui.component.bundle.BundleSelector import app.revanced.manager.util.toast import kotlinx.coroutines.launch import org.koin.androidx.compose.getViewModel +import app.revanced.manager.ui.component.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -253,10 +254,10 @@ private fun PackageSelector(packages: Set<String>, onFinish: (String?) -> Unit) @Composable private fun GroupItem(onClick: () -> Unit, @StringRes headline: Int, @StringRes description: Int) = - ListItem( + SettingsListItem( modifier = Modifier.clickable { onClick() }, - headlineContent = { Text(stringResource(headline)) }, - supportingContent = { Text(stringResource(description)) } + headlineContent = stringResource(headline), + supportingContent = stringResource(description) ) @Composable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt index 18b9fc97..65ebe35b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,24 +75,13 @@ fun UpdatesSettingsScreen( ) listItems.forEach { (title, description, onClick) -> - ListItem( + SettingsListItem( modifier = Modifier .fillMaxWidth() - .padding(8.dp) + .padding(horizontal = 8.dp) .clickable { onClick() }, - headlineContent = { - Text( - title, - style = MaterialTheme.typography.titleLarge - ) - }, - supportingContent = { - Text( - description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline - ) - } + headlineContent = title, + supportingContent = description ) } } @@ -124,4 +114,4 @@ fun UpdateNotification( ) } } -} \ No newline at end of file +} From 7741394c9c57d0e41aafd1e89ca12dbffa0a97c4 Mon Sep 17 00:00:00 2001 From: Ushie <ushiekane@gmail.com> Date: Wed, 1 Nov 2023 20:54:06 +0300 Subject: [PATCH 30/48] feat(NotificationCard): rewrite & consistent usage (#1426) --- .../manager/ui/component/NotificationCard.kt | 158 +++++++++++++++--- .../component/bundle/BundlePatchesDialog.kt | 13 +- .../manager/ui/screen/InstalledAppsScreen.kt | 22 +-- .../manager/ui/screen/SettingsScreen.kt | 43 +---- .../settings/update/UpdatesSettingsScreen.kt | 45 +---- app/src/main/res/values/strings.xml | 4 +- 6 files changed, 164 insertions(+), 121 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt index a4e21297..e5d5074b 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt @@ -1,59 +1,171 @@ package app.revanced.manager.ui.component -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Card -import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import app.revanced.manager.R @Composable fun NotificationCard( - color: Color, - icon: ImageVector, + isWarning: Boolean = false, + title: String? = null, text: String, - content: (@Composable () -> Unit)? = null, + icon: ImageVector, + actions: (@Composable () -> Unit)? ) { - Card( - colors = CardDefaults.cardColors(containerColor = color), - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(28.dp)) - ) { + NotificationCardInstance(isWarning = isWarning) { + Column( + modifier = Modifier.padding(if (title != null) 20.dp else 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (title != null) { + Icon( + modifier = Modifier.size(36.dp), + imageVector = icon, + contentDescription = null, + tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = icon, + contentDescription = null, + tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + actions?.invoke() + } + } +} + +@Composable +fun NotificationCard( + isWarning: Boolean = false, + title: String? = null, + text: String, + icon: ImageVector, + onDismiss: (() -> Unit)? = null, + primaryAction: (() -> Unit)? = null +) { + NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 16.dp, - Alignment.Start - ) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( + modifier = Modifier.size(if (title != null) 36.dp else 24.dp), imageVector = icon, contentDescription = null, + tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer ) - Text( - modifier = if (content != null) Modifier.width(220.dp) else Modifier, - text = text, - style = MaterialTheme.typography.bodyMedium - ) - content?.invoke() + if (title != null) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } else { + Text( + modifier = Modifier.weight(1f), + text = text, + style = MaterialTheme.typography.bodyMedium, + color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + if (onDismiss != null) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.close), + tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NotificationCardInstance( + isWarning: Boolean = false, + onClick: (() -> Unit)? = null, + content: (@Composable () -> Unit), +) { + if (onClick != null) { + Card( + onClick = onClick, + colors = CardDefaults.cardColors(containerColor = (if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(24.dp)) + ) { + content.invoke() + } + } else { + Card( + colors = CardDefaults.cardColors(containerColor = (if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(24.dp)) + ) { + content.invoke() } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt index 2ff2a555..a78cbb59 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt @@ -70,17 +70,10 @@ fun BundlePatchesDialog( item { AnimatedVisibility(visible = informationCardVisible) { NotificationCard( - color = MaterialTheme.colorScheme.secondaryContainer, icon = Icons.Outlined.Lightbulb, - text = stringResource(R.string.tap_on_patches) - ) { - IconButton(onClick = { informationCardVisible = false }) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.close), - ) - } - } + text = stringResource(R.string.tap_on_patches), + onDismiss = { informationCardVisible = false } + ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt index 2a2a2b89..c7535a6a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppsScreen.kt @@ -2,10 +2,8 @@ package app.revanced.manager.ui.screen import androidx.compose.foundation.clickable 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.LazyColumn import androidx.compose.foundation.lazy.items @@ -18,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -40,16 +37,15 @@ fun InstalledAppsScreen( val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null) Column { - if (!Aapt.supportsDevice()) - Box(modifier = Modifier.padding(16.dp)) { - NotificationCard( - color = MaterialTheme.colorScheme.errorContainer, - icon = Icons.Outlined.WarningAmber, - text = stringResource( - R.string.unsupported_architecture_warning - ), - ) - } + if (!Aapt.supportsDevice()) { + NotificationCard( + isWarning = true, + icon = Icons.Outlined.WarningAmber, + text = stringResource( + R.string.unsupported_architecture_warning + ), + ) + } LazyColumn( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index 6c26d84f..dc57665a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -7,8 +7,6 @@ import android.net.Uri import android.os.PowerManager import android.provider.Settings import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,9 +14,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BatteryAlert @@ -29,14 +25,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.screen.settings.* import app.revanced.manager.ui.screen.settings.update.ManagerUpdateChangelog @@ -161,40 +156,18 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()) ) { AnimatedVisibility(visible = showBatteryButton) { - Card( - onClick = { + NotificationCard( + isWarning = true, + icon = Icons.Default.BatteryAlert, + text = stringResource(R.string.battery_optimization_notification), + primaryAction = { context.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = Uri.parse("package:${context.packageName}") }) showBatteryButton = !pm.isIgnoringBatteryOptimizations(context.packageName) - }, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.tertiaryContainer), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Default.BatteryAlert, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(24.dp) - ) - Text( - text = stringResource(R.string.battery_optimization_notification), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) } - } + ) } settingsSections.forEach { (titleDescIcon, destination) -> SettingsListItem( @@ -209,4 +182,4 @@ fun SettingsScreen( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt index 65ebe35b..cac4acdd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt @@ -1,33 +1,26 @@ package app.revanced.manager.ui.screen.settings.update -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Update 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.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.component.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @@ -70,8 +63,10 @@ fun UpdatesSettingsScreen( .padding(paddingValues) .verticalScroll(rememberScrollState()) ) { - UpdateNotification( - onClick = onUpdateClick + NotificationCard( + text = stringResource(R.string.update_notification), + icon = Icons.Default.Update, + primaryAction = onUpdateClick ) listItems.forEach { (title, description, onClick) -> @@ -86,32 +81,4 @@ fun UpdatesSettingsScreen( } } } -} - -@Composable -fun UpdateNotification( - onClick: () -> Unit -) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { onClick() }, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon(imageVector = Icons.Default.Update, contentDescription = null) - Text( - text = stringResource(R.string.update_notification), - style = MaterialTheme.typography.bodyMedium - ) - } - } -} +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23f49159..0f6c6eda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -254,6 +254,7 @@ <string name="more">More</string> <string name="continue_">Continue</string> + <string name="dismiss">Dismiss</string> <string name="permanent_dismiss">Do not show this again</string> <string name="donate">Donate</string> <string name="website">Website</string> @@ -281,7 +282,7 @@ <string name="changelog_loading">Loading changelog</string> <string name="changelog_download_fail">Failed to download changelog: %s</string> <string name="changelog_description">Check out the latest changes in this update</string> - <string name="battery_optimization_notification">Battery optimization must be turned off in order for ReVanced Manager to work correctly in the background. Tap here to turn off.</string> + <string name="battery_optimization_notification">Battery optimization must be turned off in order for ReVanced Manager to work correctly in the background. Click here to turn off.</string> <string name="installing_manager_update">Installing update…</string> <string name="downloading_manager_update">Downloading update…</string> <string name="download_manager_failed">Failed to download update: %s</string> @@ -289,4 +290,5 @@ <string name="save">Save</string> <string name="update">Update</string> <string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string> + <string name="disable_battery_optimization">Disable battery optimization</string> </resources> \ No newline at end of file From 8df7f2992d728d700e00399b51e412663f7dd3cf Mon Sep 17 00:00:00 2001 From: Ax333l <main@axelen.xyz> Date: Wed, 1 Nov 2023 21:57:00 +0100 Subject: [PATCH 31/48] refactor(ui-components): deduplicate colors and move to settings folder --- .../manager/ui/component/NotificationCard.kt | 53 +++++++++++-------- .../ui/component/settings/BooleanItem.kt | 1 - .../{ => settings}/SettingsListItem.kt | 4 +- .../ui/screen/InstalledAppInfoScreen.kt | 2 +- .../manager/ui/screen/SettingsScreen.kt | 6 +-- .../ui/screen/settings/AboutSettingsScreen.kt | 3 +- .../screen/settings/AdvancedSettingsScreen.kt | 2 +- .../settings/DownloadsSettingsScreen.kt | 2 +- .../screen/settings/GeneralSettingsScreen.kt | 2 +- .../settings/ImportExportSettingsScreen.kt | 2 +- .../settings/update/UpdatesSettingsScreen.kt | 5 +- 11 files changed, 40 insertions(+), 42 deletions(-) rename app/src/main/java/app/revanced/manager/ui/component/{ => settings}/SettingsListItem.kt (96%) diff --git a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt index e5d5074b..3b17de5d 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt @@ -33,6 +33,9 @@ fun NotificationCard( icon: ImageVector, actions: (@Composable () -> Unit)? ) { + val color = + if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + NotificationCardInstance(isWarning = isWarning) { Column( modifier = Modifier.padding(if (title != null) 20.dp else 16.dp), @@ -43,7 +46,7 @@ fun NotificationCard( modifier = Modifier.size(36.dp), imageVector = icon, contentDescription = null, - tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + tint = color, ) Column( verticalArrangement = Arrangement.spacedBy(6.dp) @@ -51,12 +54,12 @@ fun NotificationCard( Text( text = title, style = MaterialTheme.typography.titleLarge, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) } } else { @@ -65,12 +68,12 @@ fun NotificationCard( modifier = Modifier.size(24.dp), imageVector = icon, contentDescription = null, - tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + tint = color, ) Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) } } @@ -88,6 +91,9 @@ fun NotificationCard( onDismiss: (() -> Unit)? = null, primaryAction: (() -> Unit)? = null ) { + val color = + if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) { Row( modifier = Modifier @@ -100,7 +106,7 @@ fun NotificationCard( modifier = Modifier.size(if (title != null) 36.dp else 24.dp), imageVector = icon, contentDescription = null, - tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + tint = color, ) if (title != null) { Column( @@ -110,12 +116,12 @@ fun NotificationCard( Text( text = title, style = MaterialTheme.typography.titleLarge, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) } } else { @@ -123,7 +129,7 @@ fun NotificationCard( modifier = Modifier.weight(1f), text = text, style = MaterialTheme.typography.bodyMedium, - color = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + color = color, ) } if (onDismiss != null) { @@ -131,7 +137,7 @@ fun NotificationCard( Icon( imageVector = Icons.Outlined.Close, contentDescription = stringResource(R.string.close), - tint = if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer + tint = color, ) } } @@ -144,28 +150,29 @@ fun NotificationCard( private fun NotificationCardInstance( isWarning: Boolean = false, onClick: (() -> Unit)? = null, - content: (@Composable () -> Unit), + content: @Composable () -> Unit, ) { + val colors = + CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer) + val modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(24.dp)) + if (onClick != null) { Card( onClick = onClick, - colors = CardDefaults.cardColors(containerColor = (if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) + colors = colors, + modifier = modifier ) { - content.invoke() + content() } } else { Card( - colors = CardDefaults.cardColors(containerColor = (if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(24.dp)) + colors = colors, + modifier = modifier, ) { - content.invoke() + content() } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt index b12fc8fd..5df102a1 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/BooleanItem.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import app.revanced.manager.domain.manager.base.Preference -import app.revanced.manager.ui.component.SettingsListItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch diff --git a/app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt similarity index 96% rename from app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt rename to app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt index cbae1282..2d40dda7 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/SettingsListItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt @@ -1,4 +1,4 @@ -package app.revanced.manager.ui.component +package app.revanced.manager.ui.component.settings import androidx.compose.foundation.layout.padding import androidx.compose.material3.ListItemColors @@ -37,7 +37,7 @@ fun SettingsListItem( text = supportingContent, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline - ) else null + ) }, leadingContent = leadingContent, trailingContent = trailingContent, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 50d1a99a..fe29a9f3 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -38,7 +38,7 @@ import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.util.PatchesSelection diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index dc57665a..0c2dd3d3 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -8,11 +8,8 @@ import android.os.PowerManager import android.provider.Settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -28,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.NotificationCard @@ -40,7 +36,7 @@ import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen import app.revanced.manager.ui.viewmodel.SettingsViewModel import dev.olshevski.navigation.reimagined.* import org.koin.androidx.compose.getViewModel -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem @SuppressLint("BatteryLife") @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index 63d8837d..60715e98 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -3,7 +3,6 @@ package app.revanced.manager.ui.screen.settings import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -39,7 +38,7 @@ import androidx.compose.ui.unit.dp import app.revanced.manager.BuildConfig import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.util.isDebuggable import app.revanced.manager.util.openUrl import com.google.accompanist.drawablepainter.rememberDrawablePainter diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index 4804d632..0923ce04 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -35,7 +35,7 @@ import androidx.lifecycle.viewModelScope import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.GroupHeader -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel import org.koin.androidx.compose.getViewModel diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 5567470a..881c420b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.GroupHeader -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel import org.koin.androidx.compose.getViewModel diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt index b32dea91..1224bbe3 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/GeneralSettingsScreen.kt @@ -23,7 +23,7 @@ import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.SettingsViewModel import org.koin.compose.koinInject -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 7ff29db2..627c174d 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -39,7 +39,7 @@ import app.revanced.manager.ui.component.bundle.BundleSelector import app.revanced.manager.util.toast import kotlinx.coroutines.launch import org.koin.androidx.compose.getViewModel -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt index cac4acdd..88e094e0 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdatesSettingsScreen.kt @@ -10,10 +10,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Update import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -21,7 +18,7 @@ import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.NotificationCard -import app.revanced.manager.ui.component.SettingsListItem +import app.revanced.manager.ui.component.settings.SettingsListItem @OptIn(ExperimentalMaterial3Api::class) @Composable From 5fff0a2923487639924eba0defa86409af5f559c Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Thu, 2 Nov 2023 08:46:53 -0700 Subject: [PATCH 32/48] fix: option state crash (#1456) Co-authored-by: Ax333l <main@axelen.xyz> --- .../manager/ui/viewmodel/SelectedAppInfoViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 4ab02aaa..86fca7de 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -59,10 +59,11 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { var options: Options by savedStateHandle.saveable { val state = mutableStateOf<Options>(emptyMap()) - viewModelScope.launch(Dispatchers.Default) { + viewModelScope.launch { if (!persistConfiguration) return@launch // TODO: save options for patched apps. - state.value = optionsRepository.getOptions(selectedApp.packageName) + val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes. + state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(packageName) } } state From 3c5776214fbeb4857d7ef0b4736780075badc4d9 Mon Sep 17 00:00:00 2001 From: Ushie <ushiekane@gmail.com> Date: Fri, 3 Nov 2023 12:15:17 +0300 Subject: [PATCH 33/48] feat(Installer): use BottomAppBar (#1428) --- .../manager/ui/screen/InstallerScreen.kt | 83 +++++++++---------- .../ui/viewmodel/InstallerViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 4 +- 3 files changed, 40 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt index c2a6d300..b72b944b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.outlined.MoreVert +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.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -36,8 +38,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.data.room.apps.installed.InstallType -import app.revanced.manager.patcher.worker.Step import app.revanced.manager.patcher.worker.State +import app.revanced.manager.patcher.worker.Step import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.ArrowButton @@ -59,7 +61,6 @@ fun InstallerScreen( val patcherState by vm.patcherState.observeAsState(null) val steps by vm.progress.collectAsStateWithLifecycle() val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } } - var dropdownActive by rememberSaveable { mutableStateOf(false) } var showInstallPicker by rememberSaveable { mutableStateOf(false) } if (showInstallPicker) @@ -72,23 +73,40 @@ fun InstallerScreen( topBar = { AppTopBar( title = stringResource(R.string.installer), - onBackClick = onBackClick, - actions = { - IconButton(onClick = { dropdownActive = true }) { - Icon(Icons.Outlined.MoreVert, stringResource(R.string.more)) - } - DropdownMenu( - expanded = dropdownActive, - onDismissRequest = { dropdownActive = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save_logs)) }, - onClick = { vm.exportLogs(context) }, - enabled = patcherState != null - ) - } - } + onBackClick = onBackClick ) + }, + bottomBar = { + AnimatedVisibility(patcherState != null) { + BottomAppBar( + actions = { + if (canInstall) { + IconButton(onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }) { + Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk)) + } + } + IconButton(onClick = { vm.exportLogs(context) }) { + Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs)) + } + }, + floatingActionButton = { + if (canInstall) { + ExtendedFloatingActionButton( + text = { Text(stringResource(vm.appButtonText)) }, + icon = { Icon(Icons.Outlined.FileDownload, stringResource(id = R.string.install_app)) }, + containerColor = BottomAppBarDefaults.bottomAppBarFabColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + onClick = { + if (vm.installedPackageName == null) + showInstallPicker = true + else + vm.open() + } + ) + } + } + ) + } } ) { paddingValues -> Column( @@ -100,33 +118,6 @@ fun InstallerScreen( steps.forEach { InstallStep(it) } - Spacer(modifier = Modifier.weight(1f)) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - ) { - Button( - onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }, - enabled = canInstall - ) { - Text(stringResource(R.string.export_app)) - } - - Button( - onClick = { - if (vm.installedPackageName == null) - showInstallPicker = true - else - vm.open() - }, - enabled = canInstall - ) { - Text(stringResource(vm.appButtonText)) - } - } } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index bbb94299..177a7e5f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -211,7 +211,7 @@ class InstallerViewModel( app.contentResolver.openOutputStream(it) .use { stream -> Files.copy(outputFile.toPath(), stream) } } - app.toast(app.getString(R.string.export_app_success)) + app.toast(app.getString(R.string.save_apk_success)) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f6c6eda..63f53250 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -229,8 +229,8 @@ <string name="install_app_fail">Failed to install app: %s</string> <string name="uninstall_app_fail">Failed to uninstall app: %s</string> <string name="open_app">Open</string> - <string name="export_app">Export</string> - <string name="export_app_success">Apk exported</string> + <string name="save_apk">Save APK</string> + <string name="save_apk_success">APK Saved</string> <string name="sign_fail">Failed to sign Apk: %s</string> <string name="save_logs">Save logs</string> <string name="select_install_type">Select installation type</string> From 1a83315424f3b2d50d36e2e963992b20ba9e4a5d Mon Sep 17 00:00:00 2001 From: Ushie <ushiekane@gmail.com> Date: Fri, 3 Nov 2023 21:03:14 +0300 Subject: [PATCH 34/48] feat(Changelogs): overall improvement (#1429) --- app/build.gradle.kts | 4 +- .../revanced/manager/di/ViewModelModule.kt | 2 +- .../domain/bundles/RemotePatchBundle.kt | 2 +- .../manager/network/api/ReVancedAPI.kt | 7 +- .../manager/network/dto/ReVancedRelease.kt | 6 + .../network/service/ReVancedService.kt | 10 +- .../revanced/manager/ui/component/Markdown.kt | 123 +++--------- .../ui/destination/SettingsDestination.kt | 2 +- .../manager/ui/screen/SettingsScreen.kt | 6 +- .../settings/update/ChangelogsScreen.kt | 178 ++++++++++++++++++ .../settings/update/ManagerUpdateChangelog.kt | 100 ---------- .../ui/viewmodel/ChangelogsViewModel.kt | 44 +++++ .../ManagerUpdateChangelogViewModel.kt | 53 ------ .../ui/viewmodel/UpdateProgressViewModel.kt | 4 +- .../java/app/revanced/manager/util/Util.kt | 57 ++++++ app/src/main/res/values/strings.xml | 6 + gradle/libs.versions.toml | 4 +- 17 files changed, 336 insertions(+), 272 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c7aa133..a0404b32 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,6 +156,6 @@ dependencies { implementation(libs.ktor.content.negotiation) implementation(libs.ktor.serialization) - // Markdown to HTML - implementation(libs.markdown) + // Markdown + implementation(libs.markdown.renderer) } diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 1729f546..baf66330 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -15,7 +15,7 @@ val viewModelModule = module { viewModelOf(::VersionSelectorViewModel) viewModelOf(::InstallerViewModel) viewModelOf(::UpdateProgressViewModel) - viewModelOf(::ManagerUpdateChangelogViewModel) + viewModelOf(::ChangelogsViewModel) viewModelOf(::ImportExportViewModel) viewModelOf(::ContributorViewModel) viewModelOf(::DownloadsViewModel) diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 240347af..295cc2bd 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -104,7 +104,7 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) : override suspend fun getLatestInfo() = coroutineScope { fun getAssetAsync(repo: String, mime: String) = async(Dispatchers.IO) { api - .getRelease(repo) + .getLatestRelease(repo) .getOrThrow() .let { BundleAsset(it.metadata.tag, it.findAssetByType(mime).downloadUrl) diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index 3367bcf2..1bc5fdd6 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -1,11 +1,8 @@ package app.revanced.manager.network.api import app.revanced.manager.domain.manager.PreferencesManager -import app.revanced.manager.network.dto.Asset -import app.revanced.manager.network.dto.ReVancedLatestRelease import app.revanced.manager.network.dto.ReVancedRelease import app.revanced.manager.network.service.ReVancedService -import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.network.utils.transform class ReVancedAPI( @@ -16,7 +13,9 @@ class ReVancedAPI( suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories } - suspend fun getRelease(name: String) = service.getRelease(apiUrl(), name).transform { it.release } + suspend fun getLatestRelease(name: String) = service.getLatestRelease(apiUrl(), name).transform { it.release } + + suspend fun getReleases(name: String) = service.getReleases(apiUrl(), name).transform { it.releases } companion object Extensions { fun ReVancedRelease.findAssetByType(mime: String) = assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime) diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt index 416e0629..d7fe2bbf 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedRelease.kt @@ -8,6 +8,11 @@ data class ReVancedLatestRelease( val release: ReVancedRelease, ) +@Serializable +data class ReVancedReleases( + val releases: List<ReVancedRelease> +) + @Serializable data class ReVancedRelease( val metadata: ReVancedReleaseMeta, @@ -28,6 +33,7 @@ data class ReVancedReleaseMeta( @Serializable data class Asset( val name: String, + @SerialName("download_count") val downloadCount: Int, @SerialName("browser_download_url") val downloadUrl: String, @SerialName("content_type") val contentType: String ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt b/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt index b5681afe..54516b4d 100644 --- a/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt +++ b/app/src/main/java/app/revanced/manager/network/service/ReVancedService.kt @@ -2,6 +2,7 @@ package app.revanced.manager.network.service import app.revanced.manager.network.dto.ReVancedLatestRelease import app.revanced.manager.network.dto.ReVancedGitRepositories +import app.revanced.manager.network.dto.ReVancedReleases import app.revanced.manager.network.utils.APIResponse import io.ktor.client.request.* import kotlinx.coroutines.Dispatchers @@ -10,13 +11,20 @@ import kotlinx.coroutines.withContext class ReVancedService( private val client: HttpService, ) { - suspend fun getRelease(api: String, repo: String): APIResponse<ReVancedLatestRelease> = + suspend fun getLatestRelease(api: String, repo: String): APIResponse<ReVancedLatestRelease> = withContext(Dispatchers.IO) { client.request { url("$api/v2/$repo/releases/latest") } } + suspend fun getReleases(api: String, repo: String): APIResponse<ReVancedReleases> = + withContext(Dispatchers.IO) { + client.request { + url("$api/v2/$repo/releases") + } + } + suspend fun getContributors(api: String): APIResponse<ReVancedGitRepositories> = withContext(Dispatchers.IO) { client.request { diff --git a/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt index 6773b15a..1b79d8f8 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt @@ -1,113 +1,32 @@ package app.revanced.manager.ui.component -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import android.webkit.WebResourceRequest -import android.webkit.WebView -import androidx.compose.foundation.background import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import app.revanced.manager.util.hexCode -import app.revanced.manager.util.openUrl -import com.google.accompanist.web.AccompanistWebViewClient -import com.google.accompanist.web.WebView -import com.google.accompanist.web.rememberWebViewStateWithHTMLData +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.model.markdownColor +import com.mikepenz.markdown.model.markdownTypography @Composable -@SuppressLint("ClickableViewAccessibility") fun Markdown( - text: String, - modifier: Modifier = Modifier + text: String ) { - val ctx = LocalContext.current - val state = rememberWebViewStateWithHTMLData(data = generateMdHtml(source = text)) - val client = remember { - object : AccompanistWebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - if (request != null) ctx.openUrl(request.url.toString()) - return true - } - } - } + val markdown = text.trimIndent() - WebView( - state, - modifier = Modifier - .background(Color.Transparent) - .then(modifier), - client = client, - captureBackPresses = false, - onCreated = { - it.setBackgroundColor(android.graphics.Color.TRANSPARENT) - it.isVerticalScrollBarEnabled = false - it.isHorizontalScrollBarEnabled = false - it.setOnTouchListener { _, event -> event.action == MotionEvent.ACTION_MOVE } - it.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } + Markdown( + content = markdown, + colors = markdownColor( + text = MaterialTheme.colorScheme.onSurfaceVariant, + codeBackground = MaterialTheme.colorScheme.secondaryContainer, + codeText = MaterialTheme.colorScheme.onSecondaryContainer + ), + typography = markdownTypography( + h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + h2 = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + h3 = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + text = MaterialTheme.typography.bodyMedium, + list = MaterialTheme.typography.bodyMedium + ) ) -} - -@Composable -fun generateMdHtml( - source: String, - wrap: Boolean = false, - headingColor: Color = MaterialTheme.colorScheme.onSurface, - textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - linkColor: Color = MaterialTheme.colorScheme.primary -) = remember(source, wrap, headingColor, textColor, linkColor) { - """<html> - <head> - <meta charset="utf-8" /> - <title>Markdown - - - - - $source - - """ } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt index ffdf20bc..ac3374c8 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt @@ -30,7 +30,7 @@ sealed interface SettingsDestination : Parcelable { object UpdateProgress : SettingsDestination @Parcelize - object UpdateChangelog : SettingsDestination + object Changelogs : SettingsDestination @Parcelize object Contributors: SettingsDestination diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index 0c2dd3d3..eb3464df 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -30,7 +30,7 @@ import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.screen.settings.* -import app.revanced.manager.ui.screen.settings.update.ManagerUpdateChangelog +import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen import app.revanced.manager.ui.screen.settings.update.UpdateProgressScreen import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen import app.revanced.manager.ui.viewmodel.SettingsViewModel @@ -102,7 +102,7 @@ fun SettingsScreen( is SettingsDestination.Updates -> UpdatesSettingsScreen( onBackClick = { navController.pop() }, - onChangelogClick = { navController.navigate(SettingsDestination.UpdateChangelog) }, + onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) }, onUpdateClick = { navController.navigate(SettingsDestination.UpdateProgress) } ) @@ -124,7 +124,7 @@ fun SettingsScreen( onBackClick = { navController.pop() }, ) - is SettingsDestination.UpdateChangelog -> ManagerUpdateChangelog( + is SettingsDestination.Changelogs -> ChangelogsScreen( onBackClick = { navController.pop() }, ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt new file mode 100644 index 00000000..16a9f802 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt @@ -0,0 +1,178 @@ +package app.revanced.manager.ui.screen.settings.update + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.revanced.manager.R +import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.LoadingIndicator +import app.revanced.manager.ui.component.Markdown +import app.revanced.manager.ui.viewmodel.ChangelogsViewModel +import app.revanced.manager.util.formatNumber +import app.revanced.manager.util.relativeTime +import org.koin.androidx.compose.getViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangelogsScreen( + onBackClick: () -> Unit, + vm: ChangelogsViewModel = getViewModel() +) { + val changelogs = vm.changelogs + + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.changelog), + onBackClick = onBackClick + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = if (changelogs.isNullOrEmpty()) Arrangement.Center else Arrangement.Top + ) { + if (changelogs == null) { + item { + LoadingIndicator() + } + } else if (changelogs.isEmpty()) { + item { + Text( + text = stringResource(id = R.string.no_changelogs_found), + style = MaterialTheme.typography.titleLarge + ) + } + } else { + val lastChangelog = changelogs.last() + items( + changelogs, + key = { it.version } + ) { changelog -> + ChangelogItem(changelog, lastChangelog) + } + } + } + } +} + +@Composable +fun ChangelogItem( + changelog: ChangelogsViewModel.Changelog, + lastChangelog: ChangelogsViewModel.Changelog +) { + Column(modifier = Modifier.padding(16.dp)) { + Changelog( + markdown = changelog.body.replace("`", ""), + version = changelog.version, + downloadCount = changelog.downloadCount.formatNumber(), + publishDate = changelog.publishDate.relativeTime(LocalContext.current) + ) + if (changelog != lastChangelog) { + Divider( + modifier = Modifier.padding(top = 32.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } +} + +@Composable +private fun Changelog( + markdown: String, + version: String, + downloadCount: String, + publishDate: String +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Campaign, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + ) + Text( + version.removePrefix("v"), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)), + color = MaterialTheme.colorScheme.primary, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + ) { + Tag( + Icons.Outlined.Sell, + version + ) + Tag( + Icons.Outlined.FileDownload, + downloadCount + ) + Tag( + Icons.Outlined.CalendarToday, + publishDate + ) + } + } + Markdown( + markdown, + ) +} + +@Composable +private fun Tag(icon: ImageVector, text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt deleted file mode 100644 index 5ba085e6..00000000 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ManagerUpdateChangelog.kt +++ /dev/null @@ -1,100 +0,0 @@ -package app.revanced.manager.ui.screen.settings.update - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Campaign -import androidx.compose.material.icons.outlined.FileDownload -import androidx.compose.material.icons.outlined.Sell -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import app.revanced.manager.R -import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.Markdown -import app.revanced.manager.ui.viewmodel.ManagerUpdateChangelogViewModel -import org.koin.androidx.compose.getViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ManagerUpdateChangelog( - onBackClick: () -> Unit, - vm: ManagerUpdateChangelogViewModel = getViewModel() -) { - Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.changelog), - onBackClick = onBackClick - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .verticalScroll(rememberScrollState()) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Campaign, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - ) - Text( - vm.changelog.version.removePrefix("v"), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Sell, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text( - vm.changelog.version, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - ) - } - } - Markdown( - vm.changelogHtml, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt new file mode 100644 index 00000000..61466ed2 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ChangelogsViewModel.kt @@ -0,0 +1,44 @@ +package app.revanced.manager.ui.viewmodel + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.revanced.manager.R +import app.revanced.manager.network.api.ReVancedAPI +import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType +import app.revanced.manager.network.utils.getOrNull +import app.revanced.manager.util.APK_MIMETYPE +import app.revanced.manager.util.uiSafe +import kotlinx.coroutines.launch + +class ChangelogsViewModel( + private val api: ReVancedAPI, + private val app: Application, +) : ViewModel() { + var changelogs: List? by mutableStateOf(null) + + init { + viewModelScope.launch { + uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") { + changelogs = api.getReleases("revanced-manager").getOrNull().orEmpty().map { release -> + Changelog( + release.metadata.tag, + release.findAssetByType(APK_MIMETYPE).downloadCount, + release.metadata.publishedAt, + release.metadata.body + ) + } + } + } + } + + data class Changelog( + val version: String, + val downloadCount: Int, + val publishDate: String, + val body: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt deleted file mode 100644 index 02d187ea..00000000 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ManagerUpdateChangelogViewModel.kt +++ /dev/null @@ -1,53 +0,0 @@ -package app.revanced.manager.ui.viewmodel - -import android.app.Application -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.revanced.manager.R -import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.utils.getOrThrow -import app.revanced.manager.util.uiSafe -import kotlinx.coroutines.launch -import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor -import org.intellij.markdown.html.HtmlGenerator -import org.intellij.markdown.parser.MarkdownParser - -class ManagerUpdateChangelogViewModel( - private val api: ReVancedAPI, - private val app: Application, -) : ViewModel() { - private val markdownFlavour = GFMFlavourDescriptor() - private val markdownParser = MarkdownParser(flavour = markdownFlavour) - - var changelog by mutableStateOf( - Changelog( - "...", - app.getString(R.string.changelog_loading), - ) - ) - private set - val changelogHtml by derivedStateOf { - val markdown = changelog.body - val parsedTree = markdownParser.buildMarkdownTreeFromString(markdown) - HtmlGenerator(markdown, parsedTree, markdownFlavour).generateHtml() - } - - init { - viewModelScope.launch { - uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") { - changelog = api.getRelease("revanced-manager").getOrThrow().let { - Changelog(it.metadata.tag, it.metadata.body) - } - } - } - } - - data class Changelog( - val version: String, - val body: String, - ) -} diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt index 2ee6ed33..a7d7969d 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt @@ -43,10 +43,10 @@ class UpdateProgressViewModel( private val location = File.createTempFile("updater", ".apk", app.cacheDir) private val job = viewModelScope.launch { - uiSafe(app, R.string.download_manager_failed, "Failed to download manager") { + uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { withContext(Dispatchers.IO) { val asset = reVancedAPI - .getRelease("revanced-manager") + .getLatestRelease("revanced-manager") .getOrThrow() .findAssetByType(APK_MIMETYPE) diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 908aa0f6..760d396a 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -3,6 +3,11 @@ package app.revanced.manager.util import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo +import android.icu.number.Notation +import android.icu.number.NumberFormatter +import android.icu.number.Precision +import android.icu.text.CompactDecimalFormat +import android.os.Build import android.util.Log import android.widget.Toast import androidx.annotation.StringRes @@ -12,6 +17,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import app.revanced.manager.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -21,6 +27,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +import java.time.Duration +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import java.util.Locale typealias PatchesSelection = Map> @@ -108,4 +119,50 @@ suspend fun Flow>.collectEach(block: suspend (T) -> Unit) { block(it) } } +} + +fun Int.formatNumber(): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + NumberFormatter.with() + .notation(Notation.compactShort()) + .decimal(NumberFormatter.DecimalSeparatorDisplay.ALWAYS) + .precision(Precision.fixedFraction(1)) + .locale(Locale.getDefault()) + .format(this) + .toString() + } else { + val compact = CompactDecimalFormat.getInstance( + Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT + ) + compact.maximumFractionDigits = 1 + compact.format(this) + } +} + +fun String.relativeTime(context: Context): String { + try { + val currentTime = ZonedDateTime.now(ZoneId.of("UTC")) + val inputDateTime = ZonedDateTime.parse(this) + val duration = Duration.between(inputDateTime, currentTime) + + return when { + duration.toMinutes() < 1 -> context.getString(R.string.just_now) + duration.toMinutes() < 60 -> context.getString(R.string.minutes_ago, duration.toMinutes().toString()) + duration.toHours() < 24 -> context.getString(R.string.hours_ago, duration.toHours().toString()) + duration.toDays() < 30 -> context.getString(R.string.days_ago, duration.toDays().toString()) + else -> { + val formatter = DateTimeFormatter.ofPattern("MMM d") + val formattedDate = inputDateTime.format(formatter) + if (inputDateTime.year != currentTime.year) { + val yearFormatter = DateTimeFormatter.ofPattern(", yyyy") + val formattedYear = inputDateTime.format(yearFormatter) + "$formattedDate$formattedYear" + } else { + formattedDate + } + } + } + } catch (e: DateTimeParseException) { + return context.getString(R.string.invalid_date) + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63f53250..ea55f13c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,5 +290,11 @@ Save Update Tap on Update when prompted. \n ReVanced Manager will close when updating. + No changelogs found + Just now + %sm ago + %sh ago + %sd ago + Invalid date Disable battery optimization \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5dcc6c08..dc60f15a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ koin-version = "3.4.3" koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" ktor = "2.3.3" -markdown = "0.5.0" +markdown-renderer = "0.8.0" androidGradlePlugin = "8.1.2" kotlinGradlePlugin = "1.9.10" devToolsGradlePlugin = "1.9.10-1.0.13" @@ -93,7 +93,7 @@ skrapeit-dsl = { group = "it.skrape", name = "skrapeit-dsl", version.ref = "skra skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version.ref = "skrapeit" } # Markdown -markdown = { group = "org.jetbrains", name = "markdown", version.ref = "markdown" } +markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" } # LibSU libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } From 1dc41badd9636362c72f0c1f9738c72aa926b286 Mon Sep 17 00:00:00 2001 From: Robert <72943079+CnC-Robert@users.noreply.github.com> Date: Sun, 5 Nov 2023 12:19:55 +0000 Subject: [PATCH 35/48] feat: check for updates on startup (#1462) --- .../java/app/revanced/manager/MainActivity.kt | 95 +++++++------------ .../manager/ui/destination/Destination.kt | 6 +- .../manager/ui/screen/SettingsScreen.kt | 32 ++++--- .../manager/ui/viewmodel/MainViewModel.kt | 84 +++++++++++++++- app/src/main/res/values/strings.xml | 5 + 5 files changed, 144 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 4cc8e1c6..6aa1eee8 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -1,27 +1,25 @@ package app.revanced.manager -import android.content.ActivityNotFoundException -import android.content.Intent import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Update +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.destination.Destination -import app.revanced.manager.ui.screen.InstalledAppInfoScreen +import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.screen.AppSelectorScreen import app.revanced.manager.ui.screen.DashboardScreen +import app.revanced.manager.ui.screen.InstalledAppInfoScreen import app.revanced.manager.ui.screen.InstallerScreen import app.revanced.manager.ui.screen.SelectedAppInfoScreen import app.revanced.manager.ui.screen.SettingsScreen @@ -30,17 +28,15 @@ import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.viewmodel.MainViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel -import app.revanced.manager.util.tag -import app.revanced.manager.util.toast import dev.olshevski.navigation.reimagined.AnimatedNavHost import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.navigate import dev.olshevski.navigation.reimagined.pop import dev.olshevski.navigation.reimagined.popUpTo import dev.olshevski.navigation.reimagined.rememberNavController +import org.koin.core.parameter.parametersOf import org.koin.androidx.compose.getViewModel as getComposeViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel -import org.koin.core.parameter.parametersOf class MainActivity : ComponentActivity() { @ExperimentalAnimationApi @@ -51,6 +47,8 @@ class MainActivity : ComponentActivity() { val vm: MainViewModel = getAndroidViewModel() + vm.importLegacySettings(this) + setContent { val theme by vm.prefs.theme.getAsState() val dynamicColor by vm.prefs.dynamicColor.getAsState() @@ -66,46 +64,30 @@ class MainActivity : ComponentActivity() { val firstLaunch by vm.prefs.firstLaunch.getAsState() - if (firstLaunch) { - var legacyActivityState by rememberSaveable { mutableStateOf(LegacyActivity.NOT_LAUNCHED) } - if (legacyActivityState == LegacyActivity.NOT_LAUNCHED) { - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result: ActivityResult -> - if (result.resultCode == RESULT_OK) { - if (result.data != null) { - val jsonData = result.data!!.getStringExtra("data")!! - vm.applyLegacySettings(jsonData) + if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs) + + vm.updatedManagerVersion?.let { + AlertDialog( + onDismissRequest = vm::dismissUpdateDialog, + confirmButton = { + TextButton( + onClick = { + vm.dismissUpdateDialog() + navController.navigate(Destination.Settings(SettingsDestination.UpdateProgress)) } - } else { - legacyActivityState = LegacyActivity.FAILED - toast(getString(R.string.legacy_import_failed)) + ) { + Text(stringResource(R.string.update)) } - } - - val intent = Intent().apply { - setClassName( - "app.revanced.manager.flutter", - "app.revanced.manager.flutter.ExportSettingsActivity" - ) - } - - LaunchedEffect(Unit) { - try { - launcher.launch(intent) - } catch (e: Exception) { - if (e !is ActivityNotFoundException) { - toast(getString(R.string.legacy_import_failed)) - Log.e(tag, "Failed to launch legacy import activity: $e") - } - legacyActivityState = LegacyActivity.FAILED + }, + dismissButton = { + TextButton(onClick = vm::dismissUpdateDialog) { + Text(stringResource(R.string.dismiss_temporary)) } - } - - legacyActivityState = LegacyActivity.LAUNCHED - } else if (legacyActivityState == LegacyActivity.FAILED) { - AutoUpdatesDialog(vm::applyAutoUpdatePrefs) - } + }, + icon = { Icon(Icons.Outlined.Update, null) }, + title = { Text(stringResource(R.string.update_available)) }, + text = { Text(stringResource(R.string.update_available_description, it)) } + ) } AnimatedNavHost( @@ -113,7 +95,7 @@ class MainActivity : ComponentActivity() { ) { destination -> when (destination) { is Destination.Dashboard -> DashboardScreen( - onSettingsClick = { navController.navigate(Destination.Settings) }, + onSettingsClick = { navController.navigate(Destination.Settings()) }, onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, onAppClick = { installedApp -> navController.navigate( @@ -138,7 +120,8 @@ class MainActivity : ComponentActivity() { ) is Destination.Settings -> SettingsScreen( - onBackClick = { navController.pop() } + onBackClick = { navController.pop() }, + startDestination = destination.startDestination ) is Destination.AppSelector -> AppSelectorScreen( @@ -199,10 +182,4 @@ class MainActivity : ComponentActivity() { } } } - - private enum class LegacyActivity { - NOT_LAUNCHED, - LAUNCHED, - FAILED - } } diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index 6c01e783..a7712532 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -11,16 +11,16 @@ import kotlinx.parcelize.RawValue sealed interface Destination : Parcelable { @Parcelize - object Dashboard : Destination + data object Dashboard : Destination @Parcelize data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination @Parcelize - object AppSelector : Destination + data object AppSelector : Destination @Parcelize - object Settings : Destination + data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination @Parcelize data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index eb3464df..76bff712 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -43,10 +43,16 @@ import app.revanced.manager.ui.component.settings.SettingsListItem @Composable fun SettingsScreen( onBackClick: () -> Unit, + startDestination: SettingsDestination, viewModel: SettingsViewModel = getViewModel() ) { - val navController = - rememberNavController(startDestination = SettingsDestination.Settings) + val navController = rememberNavController(startDestination) + + val backClick: () -> Unit = { + if (navController.backstack.entries.size == 1) + onBackClick() + else navController.pop() + } val context = LocalContext.current val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -92,48 +98,48 @@ fun SettingsScreen( when (destination) { is SettingsDestination.General -> GeneralSettingsScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, viewModel = viewModel ) is SettingsDestination.Advanced -> AdvancedSettingsScreen( - onBackClick = { navController.pop() } + onBackClick = backClick ) is SettingsDestination.Updates -> UpdatesSettingsScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) }, onUpdateClick = { navController.navigate(SettingsDestination.UpdateProgress) } ) is SettingsDestination.Downloads -> DownloadsSettingsScreen( - onBackClick = { navController.pop() } + onBackClick = backClick ) is SettingsDestination.ImportExport -> ImportExportSettingsScreen( - onBackClick = { navController.pop() } + onBackClick = backClick ) is SettingsDestination.About -> AboutSettingsScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, onContributorsClick = { navController.navigate(SettingsDestination.Contributors) }, onLicensesClick = { navController.navigate(SettingsDestination.Licenses) } ) is SettingsDestination.UpdateProgress -> UpdateProgressScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, ) is SettingsDestination.Changelogs -> ChangelogsScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, ) is SettingsDestination.Contributors -> ContributorScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, ) is SettingsDestination.Licenses -> LicensesScreen( - onBackClick = { navController.pop() }, + onBackClick = backClick, ) is SettingsDestination.Settings -> { @@ -141,7 +147,7 @@ fun SettingsScreen( topBar = { AppTopBar( title = stringResource(R.string.settings), - onBackClick = onBackClick, + onBackClick = backClick, ) } ) { paddingValues -> diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt index baf017b1..0fcfedfd 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt @@ -1,15 +1,32 @@ package app.revanced.manager.ui.viewmodel +import android.app.Application +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build 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.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.revanced.manager.R +import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.SerializedSelection +import app.revanced.manager.network.api.ReVancedAPI +import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.ui.theme.Theme +import app.revanced.manager.util.tag +import app.revanced.manager.util.toast import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -19,12 +36,42 @@ class MainViewModel( private val patchBundleRepository: PatchBundleRepository, private val patchSelectionRepository: PatchSelectionRepository, private val keystoreManager: KeystoreManager, + private val reVancedAPI: ReVancedAPI, + private val app: Application, + private val networkInfo: NetworkInfo, val prefs: PreferencesManager ) : ViewModel() { + var updatedManagerVersion: String? by mutableStateOf(null) + private set + + init { + viewModelScope.launch { checkForManagerUpdates() } + } + + fun dismissUpdateDialog() { + updatedManagerVersion = null + } + + private suspend fun checkForManagerUpdates() { + if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return + + try { + reVancedAPI.getLatestRelease("revanced-manager").getOrThrow().let { release -> + updatedManagerVersion = release.metadata.tag.takeIf { it != Build.VERSION.RELEASE } + } + } catch (e: Exception) { + app.toast(app.getString(R.string.failed_to_check_updates)) + Log.e(tag, "Failed to check for updates", e) + } + } + fun applyAutoUpdatePrefs(manager: Boolean, patches: Boolean) = viewModelScope.launch { prefs.firstLaunch.update(false) prefs.managerAutoUpdates.update(manager) + + if (manager) checkForManagerUpdates() + if (patches) { with(patchBundleRepository) { sources @@ -38,7 +85,39 @@ class MainViewModel( } } - fun applyLegacySettings(data: String) = viewModelScope.launch { + fun importLegacySettings(componentActivity: ComponentActivity) { + if (!prefs.firstLaunch.getBlocking()) return + + try { + val launcher = componentActivity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result: ActivityResult -> + if (result.resultCode == ComponentActivity.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 { + setClassName( + "app.revanced.manager.flutter", + "app.revanced.manager.flutter.ExportSettingsActivity" + ) + } + + launcher.launch(intent) + } catch (e: Exception) { + if (e !is ActivityNotFoundException) { + app.toast(app.getString(R.string.legacy_import_failed)) + Log.e(tag, "Failed to launch legacy import activity: $e") + } + } + } + + private fun applyLegacySettings(data: String) = viewModelScope.launch { val json = Json { ignoreUnknownKeys = true } val settings = json.decodeFromString(data) @@ -48,7 +127,7 @@ class MainViewModel( 1 to Theme.LIGHT, 2 to Theme.DARK ) - prefs.theme.update(themeMap[theme]!!) + prefs.theme.update(themeMap[theme] ?: Theme.SYSTEM) } settings.useDynamicTheme?.let { dynamicColor -> prefs.dynamicColor.update(dynamicColor) @@ -84,7 +163,6 @@ class MainViewModel( settings.patches?.let { selection -> patchSelectionRepository.import(0, selection) } - prefs.firstLaunch.update(false) } @Serializable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea55f13c..d4a07d1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,4 +297,9 @@ %sd ago Invalid date Disable battery optimization + + Failed to check for updates + Not now + New update available + A new version (%s) is available for download. \ No newline at end of file From 59daceef99984207970bea19651fb849f801fb0a Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 6 Nov 2023 19:33:06 +0100 Subject: [PATCH 36/48] chore: bump patcher --- .../domain/bundles/PatchBundleSource.kt | 3 + .../manager/patcher/patch/PatchInfo.kt | 8 +- .../ui/component/patches/OptionFields.kt | 109 +++++++++--------- .../ui/screen/PatchesSelectorScreen.kt | 2 +- gradle/libs.versions.toml | 4 +- 5 files changed, 64 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index dedbbf5d..ebff077c 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -1,7 +1,9 @@ package app.revanced.manager.domain.bundles +import android.util.Log import androidx.compose.runtime.Stable import app.revanced.manager.patcher.patch.PatchBundle +import app.revanced.manager.util.tag import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf @@ -40,6 +42,7 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) return try { State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists))) } catch (t: Throwable) { + Log.e(tag, "Failed to load patch bundle $name", t) State.Failed(t) } } diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt index 8002fa99..4914a07a 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt @@ -56,15 +56,15 @@ data class Option( val key: String, val description: String, val required: Boolean, - val type: Class>, - val defaultValue: Any? + val type: String, + val default: Any? ) { constructor(option: PatchOption<*>) : this( option.title ?: option.key, option.key, option.description.orEmpty(), option.required, - option::class.java, - option.value + option.valueType, + option.default, ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt index e8fb4e4a..8f871afa 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt @@ -29,7 +29,6 @@ import app.revanced.manager.R import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.patcher.patch.Option import app.revanced.manager.util.toast -import app.revanced.patcher.patch.options.types.* import org.koin.compose.rememberKoinInject // Composable functions do not support function references, so we have to use composable lambdas instead. @@ -136,69 +135,69 @@ private fun StringOptionDialog( ) } -private val StringOption: OptionImpl = { option, value, setValue -> - var showInputDialog by rememberSaveable { mutableStateOf(false) } - fun showInputDialog() { - showInputDialog = true - } - - fun dismissInputDialog() { - showInputDialog = false - } - - if (showInputDialog) { - StringOptionDialog( - name = option.title, - value = value as? String, - onSubmit = { - dismissInputDialog() - setValue(it) - }, - onDismissRequest = ::dismissInputDialog - ) - } - - OptionListItem( - option = option, - onClick = ::showInputDialog - ) { - IconButton(onClick = ::showInputDialog) { - Icon( - Icons.Outlined.Edit, - contentDescription = stringResource(R.string.string_option_icon_description) - ) - } - } -} - -private val BooleanOption: OptionImpl = { option, value, setValue -> - val current = (value as? Boolean) ?: false - - OptionListItem( - option = option, - onClick = { setValue(!current) } - ) { - Switch(checked = current, onCheckedChange = setValue) - } -} - -private val UnknownOption: OptionImpl = { option, _, _ -> +private val unknownOption: OptionImpl = { option, _, _ -> val context = LocalContext.current OptionListItem( option = option, - onClick = { context.toast("Unknown type: ${option.type.name}") }, + onClick = { context.toast("Unknown type: ${option.type}") }, trailingContent = {}) } +private val optionImplementations = mapOf( + // These are the only two types that are currently used by the official patches + "Boolean" to { option, value, setValue -> + val current = (value as? Boolean) ?: false + + OptionListItem( + option = option, + onClick = { setValue(!current) } + ) { + Switch(checked = current, onCheckedChange = setValue) + } + }, + "String" to { option, value, setValue -> + var showInputDialog by rememberSaveable { mutableStateOf(false) } + fun showInputDialog() { + showInputDialog = true + } + + fun dismissInputDialog() { + showInputDialog = false + } + + if (showInputDialog) { + StringOptionDialog( + name = option.title, + value = value as? String, + onSubmit = { + dismissInputDialog() + setValue(it) + }, + onDismissRequest = ::dismissInputDialog + ) + } + + OptionListItem( + option = option, + onClick = ::showInputDialog + ) { + IconButton(onClick = ::showInputDialog) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.string_option_icon_description) + ) + } + } + } +) + @Composable fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) { val implementation = remember(option.type) { - when (option.type) { - // These are the only two types that are currently used by the official patches. - StringPatchOption::class.java -> StringOption - BooleanPatchOption::class.java -> BooleanOption - else -> UnknownOption - } + optionImplementations.getOrDefault( + option.type, + unknownOption + ) } implementation(option, value, setValue) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 75b6d74b..b91cef03 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -571,7 +571,7 @@ fun OptionsDialog( items(patch.options, key = { it.key }) { option -> val key = option.key val value = - if (values == null || !values.contains(key)) option.defaultValue else values[key] + if (values == null || !values.contains(key)) option.default else values[key] OptionItem(option = option, value = value, setValue = { set(key, it) }) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc60f15a..c7f41e2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,8 +11,8 @@ accompanist = "0.30.1" serialization = "1.6.0" collection = "0.3.5" room-version = "2.5.2" -revanced-patcher = "17.0.0" -revanced-library = "1.1.4" +revanced-patcher = "19.0.0" +revanced-library = "1.2.0" koin-version = "3.4.3" koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" From ac561e7acaf190605d02ff22ba49df6cd655a8e9 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Tue, 7 Nov 2023 23:59:33 +0100 Subject: [PATCH 37/48] feat: Use correct casing in module description --- app/src/main/assets/root/module.prop | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/root/module.prop b/app/src/main/assets/root/module.prop index 17f4a7b2..05a5a159 100644 --- a/app/src/main/assets/root/module.prop +++ b/app/src/main/assets/root/module.prop @@ -3,4 +3,4 @@ name=__LABEL__ ReVanced version=__VERSION__ versionCode=0 author=ReVanced -description=Mounts the patched apk on top of the original apk \ No newline at end of file +description=Mounts the patched APK on top of the original one \ No newline at end of file From 2bd84636d67f4c5ed77843cfd8b58bef5177c04e Mon Sep 17 00:00:00 2001 From: Ax333l Date: Wed, 15 Nov 2023 21:32:54 +0100 Subject: [PATCH 38/48] fix: parcel error for nullable types --- .../ui/viewmodel/PatchesSelectorViewModel.kt | 3 ++- .../manager/util/saver/NullableSaver.kt | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index c9bcfdb9..f3df1c4d 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -25,6 +25,7 @@ import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.util.Options import app.revanced.manager.util.PatchesSelection +import app.revanced.manager.util.saver.Nullable import app.revanced.manager.util.saver.nullableSaver import app.revanced.manager.util.saver.persistentMapSaver import app.revanced.manager.util.saver.persistentSetSaver @@ -210,7 +211,7 @@ class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent { ) ) - private val patchesSaver: Saver> = + private val patchesSaver: Saver> = nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver())) } diff --git a/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt index 6116722a..a94a5388 100644 --- a/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt +++ b/app/src/main/java/app/revanced/manager/util/saver/NullableSaver.kt @@ -1,22 +1,24 @@ package app.revanced.manager.util.saver +import android.os.Parcelable import androidx.compose.runtime.saveable.Saver -import java.util.Optional -import kotlin.jvm.optionals.getOrNull +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +class Nullable(val inner: @RawValue T?) : Parcelable /** * Creates a saver that can save nullable versions of types that have custom savers. */ -fun nullableSaver(baseSaver: Saver): Saver> = +fun nullableSaver(baseSaver: Saver): Saver> = Saver( save = { value -> with(baseSaver) { - save(value ?: return@Saver Optional.empty()) - }?.let { - Optional.of(it) - } + save(value ?: return@Saver Nullable(null)) + }?.let(::Nullable) }, restore = { - it.getOrNull()?.let(baseSaver::restore) + it.inner?.let(baseSaver::restore) } ) \ No newline at end of file From 62a5fce66ccec88c839e12a5e1cd0de55c42beff Mon Sep 17 00:00:00 2001 From: Ushie Date: Sun, 19 Nov 2023 23:27:13 +0300 Subject: [PATCH 39/48] feat(Contributors Screen): implement design from Figma (#1465) Co-authored-by: Robert <72943079+CnC-Robert@users.noreply.github.com> Co-authored-by: Ax333l --- .../ui/screen/settings/AboutSettingsScreen.kt | 2 +- .../ui/screen/settings/ContributorScreen.kt | 204 +++++++++++------- .../ui/viewmodel/ContributorViewModel.kt | 12 +- app/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 2 +- 5 files changed, 143 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt index 60715e98..63908faa 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AboutSettingsScreen.kt @@ -84,7 +84,7 @@ fun AboutSettingsScreen( stringResource(R.string.contributors), stringResource(R.string.contributors_description), third = onContributorsClick - ).takeIf { context.isDebuggable }, + ), Triple(stringResource(R.string.developer_options), stringResource(R.string.developer_options_description), third = { /*TODO*/ }).takeIf { context.isDebuggable }, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt index 313eec24..e5fa5742 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ContributorScreen.kt @@ -1,35 +1,39 @@ package app.revanced.manager.ui.screen.settings +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.border import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.ArrowDropUp -import androidx.compose.material3.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import app.revanced.manager.R import app.revanced.manager.network.dto.ReVancedContributor import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.viewmodel.ContributorViewModel import coil.compose.AsyncImage import org.koin.androidx.compose.getViewModel - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContributorScreen( @@ -45,92 +49,148 @@ fun ContributorScreen( ) }, ) { paddingValues -> - Column( + LazyColumn( modifier = Modifier .fillMaxHeight() .padding(paddingValues) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = if (repositories.isNullOrEmpty()) Arrangement.Center else Arrangement.spacedBy( + 24.dp + ) ) { - if(repositories.isEmpty()) { - LoadingIndicator() - } - repositories.forEach { - ExpandableListCard( - title = it.name, - contributors = it.contributors - ) - } + repositories?.let { repositories -> + if (repositories.isEmpty()) { + item { + Text( + text = stringResource(id = R.string.no_contributors_found), + style = MaterialTheme.typography.titleLarge + ) + } + } else { + items( + items = repositories, + key = { it.name } + ) { + ContributorsCard( + title = it.name, + contributors = it.contributors + ) + } + } + } ?: item { LoadingIndicator() } } } } -@OptIn(ExperimentalLayoutApi::class) + +@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @Composable -fun ExpandableListCard( +fun ContributorsCard( title: String, - contributors: List + contributors: List, + itemsPerPage: Int = 12, + numberOfRows: Int = 2 ) { - var expanded by remember { mutableStateOf(false) } + val itemsPerRow = (itemsPerPage / numberOfRows) + + // Create a list of contributors grouped by itemsPerPage + val contributorsByPage = remember(itemsPerPage, contributors) { + contributors.chunked(itemsPerPage) + } + val pagerState = rememberPagerState { contributorsByPage.size } + Card( - shape = RoundedCornerShape(30.dp), - elevation = CardDefaults.outlinedCardElevation(), modifier = Modifier .fillMaxWidth() - .padding(16.dp) .border( - width = 2.dp, - color = MaterialTheme.colorScheme.outline, + width = 1.dp, + color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.medium ), - colors = CardDefaults.outlinedCardColors(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) ) { - Column() { - Row() { - ListItem( - headlineContent = { - Text( - text = processHeadlineText(title), - style = MaterialTheme.typography.titleMedium - ) - }, - trailingContent = { - if (contributors.isNotEmpty()) { - ArrowButton( - expanded = expanded, - onClick = { expanded = !expanded } - ) - } - }, + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = processHeadlineText(title), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Medium) + ) + Text( + text = "(${(pagerState.currentPage + 1)}/${pagerState.pageCount})", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold) ) } - if (expanded) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(8.dp), - ) { - contributors.forEach { - AsyncImage( - model = it.avatarUrl, - contentDescription = it.avatarUrl, - contentScale = ContentScale.Crop, - modifier = Modifier - .padding(16.dp) - .size(45.dp) - .clip(CircleShape) - ) + HorizontalPager( + state = pagerState, + userScrollEnabled = true, + modifier = Modifier.fillMaxSize(), + ) { page -> + BoxWithConstraints { + val spaceBetween = 16.dp + val maxWidth = this.maxWidth + val itemSize = (maxWidth - (itemsPerRow - 1) * spaceBetween) / itemsPerRow + val itemSpacing = (maxWidth - itemSize * 6) / (itemsPerRow - 1) + FlowRow( + maxItemsInEachRow = itemsPerRow, + horizontalArrangement = Arrangement.spacedBy(itemSpacing), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + contributorsByPage[page].forEach { + if (itemSize > 100.dp) { + Row( + modifier = Modifier.width(itemSize - 1.dp), // we delete 1.dp to account for not-so divisible numbers + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AsyncImage( + model = it.avatarUrl, + contentDescription = it.avatarUrl, + contentScale = ContentScale.Crop, + modifier = Modifier + .size((itemSize / 3).coerceAtMost(40.dp)) + .clip(CircleShape) + ) + Text( + text = it.username, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + Box( + modifier = Modifier.width(itemSize - 1.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = it.avatarUrl, + contentDescription = it.avatarUrl, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size = (itemSize - 1.dp).coerceAtMost(50.dp)) // we delete 1.dp to account for not-so divisible numbers + .clip(CircleShape) + ) + } + } + } } } } } } } + fun processHeadlineText(repositoryName: String): String { - return "Revanced " + repositoryName.replace("revanced/revanced-", "") + return "ReVanced " + repositoryName.replace("revanced/revanced-", "") .replace("-", " ") - .split(" ") - .map { if (it.length > 3) it else it.uppercase() } - .joinToString(" ") + .split(" ").joinToString(" ") { if (it.length > 3) it else it.uppercase() } .replaceFirstChar { it.uppercase() } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt index 3230c011..72fbfd7d 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ContributorViewModel.kt @@ -1,6 +1,9 @@ package app.revanced.manager.ui.viewmodel +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.network.api.ReVancedAPI @@ -11,13 +14,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ContributorViewModel(private val reVancedAPI: ReVancedAPI) : ViewModel() { - val repositories = mutableStateListOf() + var repositories: List? by mutableStateOf(null) + private set init { viewModelScope.launch { - withContext(Dispatchers.IO) { reVancedAPI.getContributors().getOrNull() }?.let( - repositories::addAll - ) + repositories = withContext(Dispatchers.IO) { + reVancedAPI.getContributors().getOrNull() + } } } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4a07d1f..31e7be4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,4 +302,5 @@ Not now New update available A new version (%s) is available for download. + No contributors found \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7f41e2a..d8e6ed55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,7 +43,7 @@ compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } -compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.2.0-alpha10"} compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } # Coil From bd9778a3d16ff7e97c65660045711a8e00923c04 Mon Sep 17 00:00:00 2001 From: Ushie Date: Sun, 19 Nov 2023 23:28:28 +0300 Subject: [PATCH 40/48] feat(Update Screen): changelogs & handle states (#1464) Co-authored-by: Ax333l --- app/build.gradle.kts | 3 + .../java/app/revanced/manager/MainActivity.kt | 6 +- .../revanced/manager/di/ViewModelModule.kt | 2 +- .../manager/service/InstallService.kt | 1 + .../manager/service/UninstallService.kt | 2 +- .../ui/component/settings/Changelog.kt | 95 +++++++ .../ui/destination/SettingsDestination.kt | 3 +- .../manager/ui/screen/SettingsScreen.kt | 16 +- .../settings/update/ChangelogsScreen.kt | 86 +------ .../settings/update/UpdateProgressScreen.kt | 98 ------- .../ui/screen/settings/update/UpdateScreen.kt | 239 ++++++++++++++++++ .../ui/viewmodel/UpdateProgressViewModel.kt | 75 ------ .../manager/ui/viewmodel/UpdateViewModel.kt | 170 +++++++++++++ app/src/main/res/values/strings.xml | 18 +- gradle/libs.versions.toml | 4 + 15 files changed, 545 insertions(+), 273 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt delete mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0404b32..4c28bbc5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -158,4 +158,7 @@ dependencies { // Markdown implementation(libs.markdown.renderer) + + // Fading Edges + implementation(libs.fading.edges) } diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 6aa1eee8..5c714a93 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -73,7 +73,7 @@ class MainActivity : ComponentActivity() { TextButton( onClick = { vm.dismissUpdateDialog() - navController.navigate(Destination.Settings(SettingsDestination.UpdateProgress)) + navController.navigate(Destination.Settings(SettingsDestination.Update(false))) } ) { Text(stringResource(R.string.update)) @@ -85,8 +85,8 @@ class MainActivity : ComponentActivity() { } }, icon = { Icon(Icons.Outlined.Update, null) }, - title = { Text(stringResource(R.string.update_available)) }, - text = { Text(stringResource(R.string.update_available_description, it)) } + title = { Text(stringResource(R.string.update_available_dialog_title)) }, + text = { Text(stringResource(R.string.update_available_dialog_description, it)) } ) } diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index baf66330..dbcba6a0 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -14,7 +14,7 @@ val viewModelModule = module { viewModelOf(::AppSelectorViewModel) viewModelOf(::VersionSelectorViewModel) viewModelOf(::InstallerViewModel) - viewModelOf(::UpdateProgressViewModel) + viewModelOf(::UpdateViewModel) viewModelOf(::ChangelogsViewModel) viewModelOf(::ImportExportViewModel) viewModelOf(::ContributorViewModel) diff --git a/app/src/main/java/app/revanced/manager/service/InstallService.kt b/app/src/main/java/app/revanced/manager/service/InstallService.kt index 420a5dc0..7bf2d213 100644 --- a/app/src/main/java/app/revanced/manager/service/InstallService.kt +++ b/app/src/main/java/app/revanced/manager/service/InstallService.kt @@ -29,6 +29,7 @@ class InstallService : Service() { else -> { sendBroadcast(Intent().apply { action = APP_INSTALL_ACTION + `package` = packageName putExtra(EXTRA_INSTALL_STATUS, extraStatus) putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage) putExtra(EXTRA_PACKAGE_NAME, extraPackageName) diff --git a/app/src/main/java/app/revanced/manager/service/UninstallService.kt b/app/src/main/java/app/revanced/manager/service/UninstallService.kt index cefd3528..6bb4d4fd 100644 --- a/app/src/main/java/app/revanced/manager/service/UninstallService.kt +++ b/app/src/main/java/app/revanced/manager/service/UninstallService.kt @@ -31,7 +31,7 @@ class UninstallService : Service() { else -> { sendBroadcast(Intent().apply { action = APP_UNINSTALL_ACTION - + `package` = packageName putExtra(EXTRA_UNINSTALL_STATUS, extraStatus) putExtra(EXTRA_UNINSTALL_STATUS_MESSAGE, extraStatusMessage) }) diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt new file mode 100644 index 00000000..0a609e78 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/Changelog.kt @@ -0,0 +1,95 @@ +package app.revanced.manager.ui.component.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.revanced.manager.ui.component.Markdown + +@Composable +fun Changelog( + markdown: String, + version: String, + downloadCount: String, + publishDate: String +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Campaign, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + ) + Text( + version.removePrefix("v"), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)), + color = MaterialTheme.colorScheme.primary, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + ) { + Tag( + Icons.Outlined.Sell, + version + ) + Tag( + Icons.Outlined.FileDownload, + downloadCount + ) + Tag( + Icons.Outlined.CalendarToday, + publishDate + ) + } + } + Markdown( + markdown, + ) +} + +@Composable +private fun Tag(icon: ImageVector, text: String) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.outline, + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt index ac3374c8..5b6e59ee 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/SettingsDestination.kt @@ -27,7 +27,7 @@ sealed interface SettingsDestination : Parcelable { object About : SettingsDestination @Parcelize - object UpdateProgress : SettingsDestination + data class Update(val downloadOnScreenEntry: Boolean) : SettingsDestination @Parcelize object Changelogs : SettingsDestination @@ -37,5 +37,4 @@ sealed interface SettingsDestination : Parcelable { @Parcelize object Licenses: SettingsDestination - } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt index 76bff712..e26602f4 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SettingsScreen.kt @@ -28,15 +28,17 @@ import androidx.compose.ui.res.stringResource import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.NotificationCard +import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.screen.settings.* import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen -import app.revanced.manager.ui.screen.settings.update.UpdateProgressScreen +import app.revanced.manager.ui.screen.settings.update.UpdateScreen import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen import app.revanced.manager.ui.viewmodel.SettingsViewModel import dev.olshevski.navigation.reimagined.* import org.koin.androidx.compose.getViewModel -import app.revanced.manager.ui.component.settings.SettingsListItem +import org.koin.core.parameter.parametersOf +import org.koin.androidx.compose.getViewModel as getComposeViewModel @SuppressLint("BatteryLife") @OptIn(ExperimentalMaterial3Api::class) @@ -96,7 +98,6 @@ fun SettingsScreen( controller = navController ) { destination -> when (destination) { - is SettingsDestination.General -> GeneralSettingsScreen( onBackClick = backClick, viewModel = viewModel @@ -109,7 +110,7 @@ fun SettingsScreen( is SettingsDestination.Updates -> UpdatesSettingsScreen( onBackClick = backClick, onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) }, - onUpdateClick = { navController.navigate(SettingsDestination.UpdateProgress) } + onUpdateClick = { navController.navigate(SettingsDestination.Update(false)) } ) is SettingsDestination.Downloads -> DownloadsSettingsScreen( @@ -126,8 +127,13 @@ fun SettingsScreen( onLicensesClick = { navController.navigate(SettingsDestination.Licenses) } ) - is SettingsDestination.UpdateProgress -> UpdateProgressScreen( + is SettingsDestination.Update -> UpdateScreen( onBackClick = backClick, + vm = getComposeViewModel { + parametersOf( + destination.downloadOnScreenEntry + ) + } ) is SettingsDestination.Changelogs -> ChangelogsScreen( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt index 16a9f802..a1ecd92b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/ChangelogsScreen.kt @@ -1,37 +1,27 @@ package app.revanced.manager.ui.screen.settings.update + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -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.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.revanced.manager.R import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.LoadingIndicator -import app.revanced.manager.ui.component.Markdown +import app.revanced.manager.ui.component.settings.Changelog import app.revanced.manager.ui.viewmodel.ChangelogsViewModel import app.revanced.manager.util.formatNumber import app.revanced.manager.util.relativeTime @@ -103,76 +93,4 @@ fun ChangelogItem( ) } } -} - -@Composable -private fun Changelog( - markdown: String, - version: String, - downloadCount: String, - publishDate: String -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 0.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Campaign, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - ) - Text( - version.removePrefix("v"), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)), - color = MaterialTheme.colorScheme.primary, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - ) { - Tag( - Icons.Outlined.Sell, - version - ) - Tag( - Icons.Outlined.FileDownload, - downloadCount - ) - Tag( - Icons.Outlined.CalendarToday, - publishDate - ) - } - } - Markdown( - markdown, - ) -} - -@Composable -private fun Tag(icon: ImageVector, text: String) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.outline, - ) - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - ) - } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt deleted file mode 100644 index b1a5f152..00000000 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateProgressScreen.kt +++ /dev/null @@ -1,98 +0,0 @@ -package app.revanced.manager.ui.screen.settings.update - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -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.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import app.revanced.manager.R -import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.viewmodel.UpdateProgressViewModel -import org.koin.androidx.compose.getViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -@Stable -fun UpdateProgressScreen( - onBackClick: () -> Unit, - vm: UpdateProgressViewModel = getViewModel() -) { - Scaffold( - topBar = { - AppTopBar( - title = stringResource(R.string.updates), - onBackClick = onBackClick - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(vertical = 16.dp, horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = if (vm.isInstalling) stringResource(R.string.installing_manager_update) else stringResource( - R.string.downloading_manager_update - ), style = MaterialTheme.typography.headlineMedium - ) - LinearProgressIndicator( - progress = vm.downloadProgress, - modifier = Modifier - .padding(vertical = 16.dp) - .fillMaxWidth() - ) - Text( - text = if (!vm.isInstalling) "${vm.downloadedSize.div(1000000)} MB / ${ - vm.totalSize.div( - 1000000 - ) - } MB (${vm.downloadProgress.times(100).toInt()}%)" else stringResource(R.string.installing_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.outline, - modifier = Modifier.align(Alignment.CenterHorizontally), - textAlign = TextAlign.Center - ) - Text( - text = "This update adds many functionality and fixes many issues in Manager. New experiment toggles are also added, they can be found in Settings > Advanced. Please submit some feedback in Settings > About > Submit issues or feedback. Thank you, everyone!", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 32.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.weight(1f)) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - TextButton( - onClick = onBackClick, - ) { - Text(text = stringResource(R.string.cancel)) - } - Button(onClick = vm::installUpdate, enabled = vm.finished) { - Text(text = stringResource(R.string.update)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt new file mode 100644 index 00000000..29ca28fd --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/update/UpdateScreen.kt @@ -0,0 +1,239 @@ +package app.revanced.manager.ui.screen.settings.update + +import androidx.compose.animation.AnimatedVisibility +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.Update +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +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.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.ui.component.AppTopBar +import app.revanced.manager.ui.component.settings.Changelog +import app.revanced.manager.ui.viewmodel.UpdateViewModel +import app.revanced.manager.ui.viewmodel.UpdateViewModel.Changelog +import app.revanced.manager.ui.viewmodel.UpdateViewModel.State +import app.revanced.manager.util.formatNumber +import app.revanced.manager.util.relativeTime +import com.gigamole.composefadingedges.content.FadingEdgesContentType +import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig +import com.gigamole.composefadingedges.fill.FadingEdgesFillType +import com.gigamole.composefadingedges.verticalFadingEdges +import org.koin.androidx.compose.getViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Stable +fun UpdateScreen( + onBackClick: () -> Unit, + vm: UpdateViewModel = getViewModel() +) { + Scaffold( + topBar = { + AppTopBar( + title = stringResource(R.string.updates), + onBackClick = onBackClick + ) + } + ) { 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) + ) { + Header( + vm.state, + vm.changelog, + DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize) + ) + vm.changelog?.let { changelog -> + Divider() + Changelog(changelog) + } ?: Spacer(modifier = Modifier.weight(1f)) + Buttons(vm.state, vm::downloadUpdate, vm::installUpdate, onBackClick) + } + } +} + +@Composable +private fun MeteredDownloadConfirmationDialog( + onDismiss: () -> Unit, + onDownloadAnyways: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + dismissButton = { + TextButton(onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + onDismiss() + onDownloadAnyways() + } + ) { + Text(stringResource(R.string.download)) + } + }, + title = { Text(stringResource(R.string.download_update_confirmation)) }, + icon = { Icon(Icons.Outlined.Update, null) }, + text = { Text(stringResource(R.string.download_confirmation_metered)) } + ) +} + +@Composable +private fun Header(state: State, changelog: Changelog?, 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 + ) + changelog?.let { changelog -> + Text( + text = stringResource( + id = R.string.new_version, + changelog.version.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(changelog: Changelog) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(scrollState) + .verticalFadingEdges( + fillType = FadingEdgesFillType.FadeColor( + color = MaterialTheme.colorScheme.background, + fillStops = Triple(0F, 0.55F, 1F), + secondStopAlpha = 1F + ), + contentType = FadingEdgesContentType.Dynamic.Scroll( + state = scrollState, + scrollConfig = FadingEdgesScrollConfig.Dynamic( + animationSpec = spring(), + isLerpByDifferenceForPartialContent = true, + scrollFactor = 1.25F + ) + ), + length = 350.dp + ) + ) { + Changelog( + markdown = changelog.body.replace("`", ""), + version = changelog.version, + downloadCount = changelog.downloadCount.formatNumber(), + publishDate = changelog.publishDate.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 +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt deleted file mode 100644 index a7d7969d..00000000 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateProgressViewModel.kt +++ /dev/null @@ -1,75 +0,0 @@ -package app.revanced.manager.ui.viewmodel - -import android.app.Application -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.revanced.manager.R -import app.revanced.manager.network.api.ReVancedAPI -import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType -import app.revanced.manager.network.service.HttpService -import app.revanced.manager.network.utils.getOrThrow -import app.revanced.manager.util.APK_MIMETYPE -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import app.revanced.manager.util.PM -import app.revanced.manager.util.uiSafe -import io.ktor.client.plugins.onDownload -import io.ktor.client.request.url -import kotlinx.coroutines.withContext -import java.io.File - -class UpdateProgressViewModel( - app: Application, - private val reVancedAPI: ReVancedAPI, - private val http: HttpService, - private val pm: PM -) : ViewModel() { - var downloadedSize by mutableStateOf(0L) - private set - var totalSize by mutableStateOf(0L) - private set - val downloadProgress by derivedStateOf { - if (downloadedSize == 0L || totalSize == 0L) return@derivedStateOf 0f - - downloadedSize.toFloat() / totalSize.toFloat() - } - val isInstalling by derivedStateOf { downloadProgress >= 1 } - var finished by mutableStateOf(false) - private set - - private val location = File.createTempFile("updater", ".apk", app.cacheDir) - private val job = viewModelScope.launch { - uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { - withContext(Dispatchers.IO) { - val asset = reVancedAPI - .getLatestRelease("revanced-manager") - .getOrThrow() - .findAssetByType(APK_MIMETYPE) - - http.download(location) { - url(asset.downloadUrl) - onDownload { bytesSentTotal, contentLength -> - downloadedSize = bytesSentTotal - totalSize = contentLength - } - } - } - finished = true - } - } - - fun installUpdate() = viewModelScope.launch { - pm.installApp(listOf(location)) - } - - override fun onCleared() { - super.onCleared() - - job.cancel() - location.delete() - } -} diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt new file mode 100644 index 00000000..d8b26b22 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -0,0 +1,170 @@ +package app.revanced.manager.ui.viewmodel + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.revanced.manager.R +import app.revanced.manager.data.platform.NetworkInfo +import app.revanced.manager.network.api.ReVancedAPI +import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType +import app.revanced.manager.network.dto.ReVancedRelease +import app.revanced.manager.network.service.HttpService +import app.revanced.manager.network.utils.getOrThrow +import app.revanced.manager.service.InstallService +import app.revanced.manager.service.UninstallService +import app.revanced.manager.util.APK_MIMETYPE +import app.revanced.manager.util.PM +import app.revanced.manager.util.simpleMessage +import app.revanced.manager.util.tag +import app.revanced.manager.util.toast +import app.revanced.manager.util.uiSafe +import io.ktor.client.plugins.onDownload +import io.ktor.client.request.url +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.File + +class UpdateViewModel( + private val downloadOnScreenEntry: Boolean +) : ViewModel(), KoinComponent { + private val app: Application by inject() + private val reVancedAPI: ReVancedAPI by inject() + private val http: HttpService by inject() + private val pm: PM by inject() + private val networkInfo: NetworkInfo by inject() + + var downloadedSize by mutableStateOf(0L) + private set + var totalSize by mutableStateOf(0L) + private set + val downloadProgress by derivedStateOf { + if (downloadedSize == 0L || totalSize == 0L) return@derivedStateOf 0f + + downloadedSize.toFloat() / totalSize.toFloat() + } + var showInternetCheckDialog by mutableStateOf(false) + var state by mutableStateOf(State.CAN_DOWNLOAD) + private set + + var installError by mutableStateOf("") + + var changelog: Changelog? by mutableStateOf(null) + + private val location = File.createTempFile("updater", ".apk", app.cacheDir) + private var release: ReVancedRelease? = null + private val job = viewModelScope.launch { + uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { + withContext(Dispatchers.IO) { + val response = reVancedAPI + .getLatestRelease("revanced-manager") + .getOrThrow() + release = response + changelog = Changelog( + response.metadata.tag, + response.findAssetByType(APK_MIMETYPE).downloadCount, + response.metadata.publishedAt, + response.metadata.body + ) + } + if (downloadOnScreenEntry) { + downloadUpdate() + } else { + state = State.CAN_DOWNLOAD + } + } + } + + fun downloadUpdate(ignoreInternetCheck: Boolean = false) = viewModelScope.launch { + uiSafe(app, R.string.failed_to_download_update, "Failed to download update") { + withContext(Dispatchers.IO) { + if (!networkInfo.isSafe() && !ignoreInternetCheck) { + showInternetCheckDialog = true + } else { + state = State.DOWNLOADING + val asset = release?.findAssetByType(APK_MIMETYPE) + ?: throw Exception("couldn't find asset to download") + + http.download(location) { + url(asset.downloadUrl) + onDownload { bytesSentTotal, contentLength -> + downloadedSize = bytesSentTotal + totalSize = contentLength + } + } + state = State.CAN_INSTALL + } + } + } + } + + fun installUpdate() = viewModelScope.launch { + state = State.INSTALLING + + pm.installApp(listOf(location)) + } + + private val installBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let { + val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999) + val extra = + intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!! + + 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)) + } + } + } + } + + init { + ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply { + addAction(InstallService.APP_INSTALL_ACTION) + }, ContextCompat.RECEIVER_NOT_EXPORTED) + } + + override fun onCleared() { + super.onCleared() + app.unregisterReceiver(installBroadcastReceiver) + + job.cancel() + location.delete() + } + + data class Changelog( + val version: String, + val downloadCount: Int, + val publishDate: String, + val body: String, + ) + + enum class State(@StringRes val title: Int, val showCancel: Boolean = false) { + CAN_DOWNLOAD(R.string.update_available), + DOWNLOADING(R.string.downloading_manager_update, true), + CAN_INSTALL(R.string.ready_to_install_update, true), + INSTALLING(R.string.installing_manager_update), + FAILED(R.string.install_update_manager_failed), + SUCCESS(R.string.update_completed) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31e7be4d..37f33002 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,10 +32,10 @@ Patch selection and options %d patches selected No patches selected - + Change version %s selected - + Could not import legacy settings Select updates to receive @@ -273,6 +273,12 @@ Choose the type of bundle you want About ReVanced Manager ReVanced Manager is an application designed to work with ReVanced Patcher, which allows for long-lasting patches to be created for Android apps. The patching system is designed to automatically work with new versions of apps with minimal maintenance. + An update is available + Current version: %s + New version: %s + Ready to install update + Update installed + Failed to install update A minor update for ReVanced Manager is available. Click here to update and get the latest features and fixes! Update channel Stable @@ -300,7 +306,11 @@ Failed to check for updates Not now - New update available - A new version (%s) is available for download. + New update available + A new version (%s) is available for download. + Failed to download update: %s + Download + You are currently on a metered connection, and data charges from your service provider may apply.\n\nDo you still want to continue? + Download update? No contributors found \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8e6ed55..3baad062 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" ktor = "2.3.3" markdown-renderer = "0.8.0" +fading-edges = "1.0.4" androidGradlePlugin = "8.1.2" kotlinGradlePlugin = "1.9.10" devToolsGradlePlugin = "1.9.10-1.0.13" @@ -95,6 +96,9 @@ skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version. # Markdown markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdown-renderer" } +# Fading Edges +fading-edges = { group = "com.github.GIGAMOLE", name = "ComposeFadingEdges", version.ref = "fading-edges"} + # LibSU libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" } libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" } From 9cab91959e85808317342084aafb821247813b68 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Wed, 29 Nov 2023 22:11:57 +0100 Subject: [PATCH 41/48] fix: load patch bundles earlier --- .../main/java/app/revanced/manager/di/RepositoryModule.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index 5e0f409c..df2d7018 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -18,7 +18,10 @@ val repositoryModule = module { singleOf(::PatchBundlePersistenceRepository) singleOf(::PatchSelectionRepository) singleOf(::PatchOptionsRepository) - singleOf(::PatchBundleRepository) + singleOf(::PatchBundleRepository) { + // It is best to load patch bundles ASAP + createdAtStart() + } singleOf(::WorkerRepository) singleOf(::DownloadedAppRepository) singleOf(::InstalledAppRepository) From 12b00e5c8d796a6043bac209264342874786e9da Mon Sep 17 00:00:00 2001 From: Benjamin <73490201+BenjaminHalko@users.noreply.github.com> Date: Wed, 29 Nov 2023 13:33:00 -0800 Subject: [PATCH 42/48] fix: specify `multithreadingDexFileWriter` in `PatcherOptions` (#1402) Co-authored-by: Ax333l --- .../manager/domain/manager/PreferencesManager.kt | 1 + .../main/java/app/revanced/manager/patcher/Session.kt | 4 +++- .../revanced/manager/patcher/worker/PatcherWorker.kt | 1 + .../ui/screen/settings/AdvancedSettingsScreen.kt | 10 ++++++++-- .../manager/ui/viewmodel/AdvancedSettingsViewModel.kt | 9 +++------ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 44f61794..34a41ab8 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -12,6 +12,7 @@ class PreferencesManager( val api = stringPreference("api_url", "https://api.revanced.app") + val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true) val allowExperimental = booleanPreference("allow_experimental", false) val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT) diff --git a/app/src/main/java/app/revanced/manager/patcher/Session.kt b/app/src/main/java/app/revanced/manager/patcher/Session.kt index 35b80e5e..337bd195 100644 --- a/app/src/main/java/app/revanced/manager/patcher/Session.kt +++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt @@ -20,6 +20,7 @@ class Session( cacheDir: String, frameworkDir: String, aaptPath: String, + multithreadingDexFileWriter: Boolean, private val logger: ManagerLogger, private val input: File, private val onStepSucceeded: suspend () -> Unit @@ -30,7 +31,8 @@ class Session( inputFile = input, resourceCachePath = tempDir.resolve("aapt-resources"), frameworkFileDirectory = frameworkDir, - aaptBinaryPath = aaptPath + aaptBinaryPath = aaptPath, + multithreadingDexFileWriter = multithreadingDexFileWriter, ) ) diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index ba6e3afe..4779677a 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -219,6 +219,7 @@ class PatcherWorker( fs.tempDir.absolutePath, frameworkPath, aaptPath, + prefs.multithreadingDexFileWriter.get(), args.logger, inputFile, onStepSucceeded = ::updateProgress diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt index 0923ce04..f1dc35b1 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/AdvancedSettingsScreen.kt @@ -70,7 +70,7 @@ fun AdvancedSettingsScreen( .padding(paddingValues) .verticalScroll(rememberScrollState()) ) { - val apiUrl by vm.apiUrl.getAsState() + val apiUrl by vm.prefs.api.getAsState() var showApiUrlDialog by rememberSaveable { mutableStateOf(false) } if (showApiUrlDialog) { @@ -89,11 +89,17 @@ fun AdvancedSettingsScreen( GroupHeader(stringResource(R.string.patcher)) BooleanItem( - preference = vm.allowExperimental, + preference = vm.prefs.allowExperimental, coroutineScope = vm.viewModelScope, headline = R.string.experimental_patches, description = R.string.experimental_patches_description ) + BooleanItem( + preference = vm.prefs.multithreadingDexFileWriter, + coroutineScope = vm.viewModelScope, + headline = R.string.multithreaded_dex_file_writer, + description = R.string.multithreaded_dex_file_writer_description, + ) GroupHeader(stringResource(R.string.patch_bundles_section)) SettingsListItem( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt index 6d7d79b8..9efed1dd 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AdvancedSettingsViewModel.kt @@ -12,17 +12,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class AdvancedSettingsViewModel( - prefs: PreferencesManager, + val prefs: PreferencesManager, private val app: Application, private val patchBundleRepository: PatchBundleRepository ) : ViewModel() { - val apiUrl = prefs.api - val allowExperimental = prefs.allowExperimental - fun setApiUrl(value: String) = viewModelScope.launch(Dispatchers.Default) { - if (value == apiUrl.get()) return@launch + if (value == prefs.api.get()) return@launch - apiUrl.update(value) + prefs.api.update(value) patchBundleRepository.reloadApiBundles() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37f33002..e1cdfaf2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,8 @@ Adapt colors to the wallpaper Theme Choose between light or dark theme + Multi-threaded DEX file writer + Use multiple cores to write DEX files. This is faster, but uses more memory Allow experimental patches Allow patching incompatible patches with experimental versions, something may break Import keystore From de4e616dcc15ba7f0735ec87c7ddc95c50a622bd Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 1 Dec 2023 13:21:20 +0100 Subject: [PATCH 43/48] chore(deps): bump revanced patcher and library --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3baad062..aa2e6c44 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,8 +11,8 @@ accompanist = "0.30.1" serialization = "1.6.0" collection = "0.3.5" room-version = "2.5.2" -revanced-patcher = "19.0.0" -revanced-library = "1.2.0" +revanced-patcher = "19.1.0" +revanced-library = "1.4.0" koin-version = "3.4.3" koin-version-compose = "3.4.6" reimagined-navigation = "1.5.0" From a17a05995a49e7b13de219d8d258eef6dc71c9a9 Mon Sep 17 00:00:00 2001 From: validcube Date: Sat, 2 Dec 2023 16:55:04 +0700 Subject: [PATCH 44/48] ci: caching with `gradle-build-action` Allow for automatic capture of buildscan in job summary, and smarter caching than the one provided by `setup-java`. --- .github/workflows/pr-build.yml | 15 ++++++--------- .github/workflows/release-build.yml | 12 ++++++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 7fd76dc2..e4a04351 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -17,17 +17,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up cache - uses: actions/cache@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - path: | - ${{ runner.home }}/.gradle/caches - ${{ runner.home }}/.gradle/wrapper - .gradle - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + java-version: '17' + distribution: 'temurin' - - name: Set up Java - run: echo "JAVA_HOME=$JAVA_HOME_17_X64" >> $GITHUB_ENV + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 - name: Build with Gradle env: diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ec1e0714..8e273987 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -14,8 +14,16 @@ jobs: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Set up Java - run: echo "JAVA_HOME=$JAVA_HOME_17_X64" >> $GITHUB_ENV + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + with: + cache-disabled: true - name: Build with Gradle env: From 8cd617e32de3eb54431c19b21c1502a78f647206 Mon Sep 17 00:00:00 2001 From: validcube Date: Sat, 2 Dec 2023 16:58:47 +0700 Subject: [PATCH 45/48] chore(template): update label name for feature --- .github/ISSUE_TEMPLATE/feature-issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature-issue.yml b/.github/ISSUE_TEMPLATE/feature-issue.yml index f5676dec..ca76ef00 100644 --- a/.github/ISSUE_TEMPLATE/feature-issue.yml +++ b/.github/ISSUE_TEMPLATE/feature-issue.yml @@ -1,7 +1,7 @@ name: ⭐ Feature request description: Create a new feature request. title: 'feat: ' -labels: [feature-request] +labels: [feature request] body: - type: markdown attributes: From d9eb1c42bcb917745a84e14cb248b9eb1fde30cc Mon Sep 17 00:00:00 2001 From: validcube <pun.butrach@gmail.com> Date: Sat, 2 Dec 2023 17:00:52 +0700 Subject: [PATCH 46/48] refactor: slight formatting of `build.gradle.kts` --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c28bbc5..36daff74 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,7 @@ android { includeInApk = false includeInBundle = false } + packaging { resources.excludes.addAll(listOf( "/prebuilt/**", From 55c7800f396c8f2cc836f00ea4814f3fef81bf4c Mon Sep 17 00:00:00 2001 From: validcube <pun.butrach@gmail.com> Date: Sat, 2 Dec 2023 17:15:50 +0700 Subject: [PATCH 47/48] build: bump Gradle to v8.5 build: update Gradle wrapper --- gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 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 literal 63721 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鯊%1Et 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⁢ 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 46671acb..db8c3baa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionSha256Sum=9d926787066a081739e8200858338b4a69e837c3a821a33aca9db09dd4a41026 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From a8b9d9316f7255f88abd342af907e25ac71aa3f0 Mon Sep 17 00:00:00 2001 From: validcube <pun.butrach@gmail.com> Date: Sat, 2 Dec 2023 17:18:21 +0700 Subject: [PATCH 48/48] docs: update revanced url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a486841..c742bcab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub license](https://img.shields.io/github/license/revanced/revanced-manager)](../../blob/main/LICENSE) [![GitHub last commit](https://img.shields.io/github/last-commit/revanced/revanced-manager/compose-dev)](https://github.com/ReVanced/revanced-manager/commits/compose-dev) -[![GitHub commit activity](https://img.shields.io/github/commit-activity/w/revanced/revanced-manager/compose-dev)](https://github.com/ReVanced/revanced-manager-compose/commits/compose-dev) +[![GitHub commit activity](https://img.shields.io/github/commit-activity/w/revanced/revanced-manager/compose-dev)](https://github.com/ReVanced/revanced-manager/commits/compose-dev) _(Yet another)_ rewrite of the ReVanced Manager using Kotlin and Jetpack Compose.