chore: Merge branch dev to main (#3161)

This commit is contained in:
Ushie
2026-03-23 03:03:57 +03:00
committed by GitHub
29 changed files with 1005 additions and 1469 deletions

View File

@@ -1,3 +1,59 @@
# app [2.4.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/v2.4.0-dev.1...v2.4.0-dev.2) (2026-03-23)
### Bug Fixes
* Load app info from installed apps by default ([e7cff33](https://github.com/ReVanced/revanced-manager/commit/e7cff333182acb140883ee763b8be70730ab798f))
# app [2.4.0-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.6...v2.4.0-dev.1) (2026-03-22)
### Features
* Speed up loading app list ([f0727b2](https://github.com/ReVanced/revanced-manager/commit/f0727b27debef4cebc3c8f9339330a39c4e92d9a))
## app [2.3.1-dev.6](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.5...v2.3.1-dev.6) (2026-03-22)
### Bug Fixes
* Hide apps with no explicit compatibility when safeguard is on ([#3174](https://github.com/ReVanced/revanced-manager/issues/3174)) ([be91aa1](https://github.com/ReVanced/revanced-manager/commit/be91aa1c67a7896bfb3c789961ae4411140f0b0e))
## app [2.3.1-dev.5](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.4...v2.3.1-dev.5) (2026-03-22)
### Bug Fixes
* Show patchable section header only when it's not the only section ([#3173](https://github.com/ReVanced/revanced-manager/issues/3173)) ([65b9e95](https://github.com/ReVanced/revanced-manager/commit/65b9e95c60abe9e974a6e4c67b827dcf4f429ee8))
## app [2.3.1-dev.4](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.3...v2.3.1-dev.4) (2026-03-22)
### Bug Fixes
* Improve dialogs ([#3165](https://github.com/ReVanced/revanced-manager/issues/3165)) ([2fb13cd](https://github.com/ReVanced/revanced-manager/commit/2fb13cdcc73ec5741a3dca94d93a951f88012769))
## app [2.3.1-dev.3](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.2...v2.3.1-dev.3) (2026-03-22)
### Bug Fixes
* Improve notification cards ([653c14e](https://github.com/ReVanced/revanced-manager/commit/653c14ea5d1eeaf986a0ce2a4ab2e0c7dcf1d53b))
## app [2.3.1-dev.2](https://github.com/ReVanced/revanced-manager/compare/v2.3.1-dev.1...v2.3.1-dev.2) (2026-03-22)
### Bug Fixes
* Handle prerelease status when fetching changelogs ([742e6e8](https://github.com/ReVanced/revanced-manager/commit/742e6e8e781a320ea711ef5dd4c66b8144b0e3c8))
## app [2.3.1-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.3.0...v2.3.1-dev.1) (2026-03-21)
### Bug Fixes
* Apply M3 style guide to UI strings ([#3114](https://github.com/ReVanced/revanced-manager/issues/3114)) ([8f773bc](https://github.com/ReVanced/revanced-manager/commit/8f773bc7ab61e3e475de2fd2e3df9aab7d9984f3))
# app [2.3.0](https://github.com/ReVanced/revanced-manager/compare/v2.2.3...v2.3.0) (2026-03-20)

View File

@@ -38,6 +38,7 @@ dependencies {
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3)
implementation(libs.navigation.compose)
implementation(libs.paging3)
// Accompanist
implementation(libs.accompanist.drawablepainter)

View File

@@ -1 +1 @@
version = 2.3.0
version = 2.4.0-dev.2

View File

@@ -30,7 +30,6 @@ import androidx.navigation.toRoute
import app.revanced.manager.domain.repository.ChangelogSource
import app.revanced.manager.ui.model.navigation.Announcement
import app.revanced.manager.ui.model.navigation.Announcements
import app.revanced.manager.ui.model.navigation.AppSelector
import app.revanced.manager.ui.model.navigation.BundleInformation
import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
@@ -42,7 +41,6 @@ import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AnnouncementScreen
import app.revanced.manager.ui.screen.AnnouncementsScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.BundleInformationScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
@@ -166,9 +164,6 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<Dashboard> {
DashboardScreen(
onSettingsClick = { navController.navigateSafe(Settings) },
onAppSelectorClick = {
navController.navigateSafe(AppSelector)
},
onUpdateClick = {
navController.navigateSafe(Update())
},
@@ -215,14 +210,6 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<AppSelector> {
AppSelectorScreen(
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onBackClick = navController::popBackStackSafe
)
}
composable<Patcher> {
PatcherScreen(
onBackClick = {

View File

@@ -12,7 +12,6 @@ val viewModelModule = module {
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::GeneralSettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel)
viewModelOf(::AnnouncementsViewModel)
@@ -22,7 +21,7 @@ val viewModelModule = module {
viewModelOf(::DeveloperOptionsViewModel)
viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel)
viewModelOf(::InstalledAppsViewModel)
viewModelOf(::AppsViewModel)
viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.domain.repository
import android.os.Parcelable
import androidx.core.net.toUri
import androidx.paging.PagingSource
import androidx.paging.PagingState
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAssetHistory
import app.revanced.manager.network.utils.getOrThrow
@@ -11,58 +13,36 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
sealed interface ChangelogSource : Parcelable {
data object Manager : ChangelogSource {
}
data class Patches(val url: String) : ChangelogSource {
val baseUrl get() = url.toUri().let { "${it.scheme}://${it.host}" }
data object Manager : ChangelogSource
data class Patches(val url: String, val prerelease: Boolean) : ChangelogSource {
val baseUrl by lazy { url.toUri().let { "${it.scheme}://${it.host}" } }
}
}
class ChangelogsRepository(
private val api: ReVancedAPI
) {
private var all: List<ReVancedAssetHistory> = emptyList()
private var page = 0
private val api: ReVancedAPI,
private val source: ChangelogSource,
) : PagingSource<Int, ReVancedAssetHistory>() {
suspend fun loadInitial(
source: ChangelogSource,
pageSize: Int
): PageResult<ReVancedAssetHistory> {
all = when (source) {
is ChangelogSource.Manager ->
api.getAppHistory().getOrThrow()
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ReVancedAssetHistory> {
return try {
val items = when (source) {
is ChangelogSource.Manager ->
api.getAppHistory().getOrThrow()
is ChangelogSource.Patches ->
api.getPatchesHistory(source.baseUrl).getOrThrow()
is ChangelogSource.Patches ->
api.getPatchesHistory(source.baseUrl, source.prerelease).getOrThrow()
}
LoadResult.Page(
data = items,
prevKey = null,
nextKey = null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
page = 1
val items = all.take(pageSize)
return PageResult(
items = items,
hasMore = hasMore(pageSize)
)
}
fun loadNext(pageSize: Int): PageResult<ReVancedAssetHistory> {
val items = all
.drop(page * pageSize)
.take(pageSize)
page++
return PageResult(
items = items,
hasMore = hasMore(pageSize)
)
}
private fun hasMore(pageSize: Int) =
page * pageSize < all.size
}
data class PageResult<T>(
val items: List<T>,
val hasMore: Boolean
)
override fun getRefreshKey(state: PagingState<Int, ReVancedAssetHistory>): Int? = null
}

View File

@@ -49,12 +49,12 @@ class ReVancedAPI(
suspend fun getLatestAppInfo() =
request<ReVancedAsset>("manager${prefs.useManagerPrereleases.prereleaseString()}")
suspend fun getAppHistory() = request<List<ReVancedAssetHistory>>("manager/history")
suspend fun getAppHistory() = request<List<ReVancedAssetHistory>>("manager/history${prefs.useManagerPrereleases.prereleaseString()}")
suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches${prefs.usePatchesPrereleases.prereleaseString()}")
suspend fun getPatchesHistory(apiUrl: String) =
request<List<ReVancedAssetHistory>>(apiUrl, defaultApiVersion, "patches/history")
suspend fun getPatchesHistory(apiUrl: String, prerelease: Boolean) =
request<List<ReVancedAssetHistory>>(apiUrl, defaultApiVersion, "patches/history${prerelease.prereleaseString()}")
suspend fun getDownloaderUpdate() = request<ReVancedAsset>("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}")
@@ -64,5 +64,6 @@ class ReVancedAPI(
private companion object {
suspend fun Preference<Boolean>.prereleaseString() = if (get()) "/prerelease" else ""
fun Boolean.prereleaseString() = if (this) "/prerelease" else ""
}
}

View File

@@ -31,85 +31,65 @@ 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 androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAssetHistory
import app.revanced.manager.util.relativeTime
sealed interface ChangelogUiState {
data object Loading : ChangelogUiState
data class Error(val error: String) : ChangelogUiState
data class Success(
val changelogs: List<ReVancedAssetHistory>,
val hasMore: Boolean = false,
val isLoadingMore: Boolean = false,
) : ChangelogUiState
}
@Composable
fun ChangelogList(
state: ChangelogUiState,
onLoadMore: () -> Unit,
changelogs: LazyPagingItems<ReVancedAssetHistory>,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val total = listState.layoutInfo.totalItemsCount
val canScroll = listState.canScrollForward || listState.canScrollBackward
(lastVisible >= total - 2 || !canScroll) && total > 0
}
}
LaunchedEffect(shouldLoadMore, state) {
if (shouldLoadMore) onLoadMore()
}
Box(
modifier = modifier.then(Modifier.fillMaxSize()),
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
when (state) {
is ChangelogUiState.Loading -> LoadingIndicator()
when {
changelogs.loadState.refresh is LoadState.Loading -> LoadingIndicator()
is ChangelogUiState.Error -> Text(
text = state.error,
changelogs.loadState.refresh is LoadState.Error -> {
val error = changelogs.loadState.refresh as LoadState.Error
Text(
text = error.error.message ?: stringResource(R.string.changelog_download_fail),
style = MaterialTheme.typography.titleLarge
)
}
changelogs.itemCount == 0 -> Text(
text = stringResource(R.string.no_changelogs_found),
style = MaterialTheme.typography.titleLarge
)
is ChangelogUiState.Success -> {
if (state.changelogs.isEmpty()) {
Text(
text = stringResource(R.string.no_changelogs_found),
style = MaterialTheme.typography.titleLarge
)
} else {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = listState
) {
items(
items = state.changelogs,
key = { it.version }
) { changelog ->
else -> {
val listState = rememberLazyListState()
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = listState
) {
items(
count = changelogs.itemCount,
key = { changelogs.peek(it)?.version ?: it }
) { index ->
changelogs[index]?.let { changelog ->
ChangelogItem(
changelog = changelog,
showDivider = changelog != state.changelogs.last()
showDivider = index < changelogs.itemCount - 1
)
}
}
if (state.isLoadingMore) {
item(key = "loading_more") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
if (changelogs.loadState.append is LoadState.Loading) {
item(key = "loading_more") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}

View File

@@ -2,26 +2,17 @@ package app.revanced.manager.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.model.InstallerModel
import com.github.materiiapps.enumutil.FromValue
@@ -43,9 +34,6 @@ fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss
dismissButton = {
dialogKind.dismissButton?.invoke(model, onDismiss)
},
icon = {
Icon(dialogKind.icon, null)
},
title = {
Text(
text = stringResource(dialogKind.title),
@@ -54,12 +42,7 @@ fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss
)
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(dialogKind.contentStringResId))
}
Text(stringResource(dialogKind.contentStringResId))
}
)
}
@@ -85,7 +68,6 @@ enum class DialogKind(
val flag: Int,
val title: Int,
@param:StringRes val contentStringResId: Int,
val icon: ImageVector = Icons.Outlined.ErrorOutline,
val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
val dismissButton: InstallerStatusDialogButton? = null,
) {
@@ -93,58 +75,47 @@ enum class DialogKind(
flag = PackageInstaller.STATUS_FAILURE,
title = R.string.installation_failed_dialog_title,
contentStringResId = R.string.installation_failed_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
model.install()
}
),
FAILURE_ABORTED(
flag = PackageInstaller.STATUS_FAILURE_ABORTED,
title = R.string.installation_cancelled_dialog_title,
contentStringResId = R.string.installation_aborted_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
model.install()
}
confirmButton = installerStatusDialogButton(R.string.try_again) { it.install() },
dismissButton = installerStatusDialogButton(R.string.cancel),
),
FAILURE_BLOCKED(
flag = PackageInstaller.STATUS_FAILURE_BLOCKED,
title = R.string.installation_blocked_dialog_title,
contentStringResId = R.string.installation_blocked_description,
dismissButton = installerStatusDialogButton(R.string.cancel),
),
FAILURE_CONFLICT(
flag = PackageInstaller.STATUS_FAILURE_CONFLICT,
title = R.string.installation_conflict_dialog_title,
contentStringResId = R.string.installation_conflict_description,
confirmButton = installerStatusDialogButton(R.string.reinstall) { model ->
model.reinstall()
},
confirmButton = installerStatusDialogButton(R.string.reinstall) { it.reinstall() },
dismissButton = installerStatusDialogButton(R.string.cancel),
),
FAILURE_INCOMPATIBLE(
flag = PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
title = R.string.installation_incompatible_dialog_title,
contentStringResId = R.string.installation_incompatible_description,
dismissButton = installerStatusDialogButton(R.string.ok),
),
FAILURE_INVALID(
flag = PackageInstaller.STATUS_FAILURE_INVALID,
title = R.string.installation_invalid_dialog_title,
contentStringResId = R.string.installation_invalid_description,
confirmButton = installerStatusDialogButton(R.string.reinstall) { model ->
model.reinstall()
},
confirmButton = installerStatusDialogButton(R.string.reinstall) { it.reinstall() },
dismissButton = installerStatusDialogButton(R.string.cancel),
),
FAILURE_STORAGE(
flag = PackageInstaller.STATUS_FAILURE_STORAGE,
title = R.string.installation_storage_issue_dialog_title,
contentStringResId = R.string.installation_storage_issue_description,
dismissButton = installerStatusDialogButton(R.string.ok),
),
FAILURE_TIMEOUT(
flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT,
title = R.string.installation_timeout_dialog_title,
contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
model.install()
},
confirmButton = installerStatusDialogButton(R.string.try_again) { it.install() },
dismissButton = installerStatusDialogButton(R.string.cancel),
);
// Needed due to the @FromValue annotation.

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@@ -57,7 +58,7 @@ fun NotificationCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top,
verticalAlignment = if (actions != null) Alignment.Top else Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
@@ -74,18 +75,21 @@ fun NotificationCard(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
title?.let {
Column {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMediumEmphasized.merge(fontWeight = FontWeight.SemiBold),
color = LocalContentColor.current,
)
}
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
text = text,
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current,
)
}
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = LocalContentColor.current,
)
actions?.let {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -95,11 +99,14 @@ fun NotificationCard(
}
}
if (onDismiss != null) {
// @TODO: Use xsmall icon buttons when that is available in Compose
TooltipIconButton(
modifier = Modifier.size(32.dp),
onClick = onDismiss,
tooltip = stringResource(R.string.close),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.close),
)

View File

@@ -37,7 +37,6 @@ private enum class SourceType {
enum class ImportSourceDialogStrings(
val title: Int,
val type_description: Int,
val type_remote_description: Int,
val type_local_description: Int,
val import_local: Int,
@@ -45,7 +44,6 @@ enum class ImportSourceDialogStrings(
) {
PATCHES(
R.string.add_patches,
R.string.select_patches_type_dialog_description,
R.string.remote_patches_description,
R.string.local_patches_description,
R.string.patches,
@@ -53,7 +51,6 @@ enum class ImportSourceDialogStrings(
),
DOWNLOADERS(
R.string.downloader_add,
R.string.select_downloader_type_dialog_description,
R.string.remote_downloaders_description,
R.string.local_downloaders_description,
R.string.downloaders,
@@ -117,7 +114,7 @@ fun ImportSourceDialog(
AlertDialogExtended(
onDismissRequest = onDismiss,
title = {
Text(stringResource(if (currentStep == 0) R.string.select else strings.title))
Text(stringResource(strings.title))
},
text = {
steps[currentStep]()
@@ -167,10 +164,6 @@ private fun SelectSourceTypeStep(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(strings.type_description)
)
Column {
ListItem(
modifier = Modifier.clickable(

View File

@@ -18,9 +18,6 @@ object Onboarding
@Serializable
object Dashboard
@Serializable
object AppSelector
@Serializable
data class InstalledApplicationInfo(val packageName: String)

View File

@@ -1,262 +0,0 @@
package app.revanced.manager.ui.screen
import android.content.ActivityNotFoundException
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.component.TooltipIconButton
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppSelectorScreen(
onSelect: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {
val context = LocalContext.current
EventEffect(flow = vm.storageSelectionFlow) {
onStorageSelect(it)
}
val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let(vm::handleStorageResult)
}
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle()
var search by rememberSaveable { mutableStateOf(false) }
val appList by vm.apps.collectAsStateWithLifecycle()
val appsListFiltered by vm.filteredApps.collectAsStateWithLifecycle()
vm.nonSuggestedVersionDialogSubject?.let {
NonSuggestedVersionDialog(
suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
onDismiss = vm::dismissNonSuggestedVersionDialog
)
}
if (search) {
val filterText by vm.filterText.collectAsState()
SearchView(
query = filterText,
onQueryChange = vm::setFilterText,
onActiveChange = { search = it },
placeholder = { Text(stringResource(R.string.search_apps)) }
) {
val appsFiltered = appsListFiltered
if (!appsFiltered.isNullOrEmpty() && filterText.isNotEmpty()) {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
items(
items = appsFiltered,
key = { it.packageName }
) { app ->
ListItem(
modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = {
AppIcon(
packageInfo = app.packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = { AppLabel(app.packageInfo) },
supportingContent = { Text(app.packageName) },
trailingContent = app.patches?.let {
{
Text(
pluralStringResource(
R.plurals.patch_count,
it,
it
)
)
}
},
colors = transparentListItemColors
)
}
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.search),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.type_anything),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_app),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
actions = {
TooltipIconButton(
onClick = { search = true },
tooltip = stringResource(R.string.search)
) {
Icon(Icons.Filled.Search, stringResource(R.string.search))
}
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
ListItem(
modifier = Modifier.clickable {
try {
pickApkLauncher.launch(APK_MIMETYPE)
} catch (_: ActivityNotFoundException) {
context.toast(R.string.no_file_picker_found)
}
},
leadingContent = {
Box(Modifier.size(36.dp), Alignment.Center) {
Icon(
Icons.Default.Storage,
null,
modifier = Modifier.size(24.dp)
)
}
},
headlineContent = { Text(stringResource(R.string.select_from_storage)) },
supportingContent = {
Text(stringResource(R.string.select_from_storage_description))
}
)
HorizontalDivider()
}
val apps = appList
if (apps == null) {
item(key = "LOADING") {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
}
} else if (apps.isNotEmpty()) {
items(
items = apps,
key = { "APP-" + it.packageName },
contentType = { "APP" },
) { app ->
ListItem(
modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = {
AppIcon(
packageInfo = app.packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = {
AppLabel(
app.packageInfo,
defaultText = app.packageName
)
},
supportingContent = {
suggestedVersions[app.packageName]?.let {
Text(stringResource(R.string.suggested_version_info, it))
}
},
trailingContent = app.patches?.let {
{
Text(
pluralStringResource(
R.plurals.patch_count,
it,
it
)
)
}
}
)
}
}
}
}
}

View File

@@ -47,8 +47,7 @@ import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.SearchBar
import app.revanced.manager.ui.component.TooltipIconButton
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
import app.revanced.manager.ui.viewmodel.AppsViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
@@ -57,25 +56,24 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun InstalledAppsScreen(
fun AppsScreen(
onAppClick: (InstalledApp) -> Unit,
onPatchableAppClick: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
viewModel: InstalledAppsViewModel = koinViewModel(),
selectorVm: AppSelectorViewModel = koinViewModel()
viewModel: AppsViewModel = koinViewModel()
) {
val context = LocalContext.current
EventEffect(flow = selectorVm.storageSelectionFlow) {
EventEffect(flow = viewModel.storageSelectionFlow) {
onStorageSelect(it)
}
val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let(selectorVm::handleStorageResult)
uri?.let(viewModel::handleStorageResult)
}
val installedApps by viewModel.apps.collectAsStateWithLifecycle()
val patchableApps by selectorVm.apps.collectAsStateWithLifecycle()
val installedApps by viewModel.installedApps.collectAsStateWithLifecycle()
val patchableApps by viewModel.patchableApps.collectAsStateWithLifecycle()
fun patchedPackageNames(apps: List<InstalledApp>?): Set<String> =
apps
@@ -89,7 +87,7 @@ fun InstalledAppsScreen(
val packageInfo = viewModel.packageInfoMap[currentPackageName]
return currentPackageName.contains(query, ignoreCase = true) ||
originalPackageName.contains(query, ignoreCase = true) ||
selectorVm.loadLabel(packageInfo).contains(query, ignoreCase = true)
viewModel.loadLabel(packageInfo).contains(query, ignoreCase = true)
}
fun patchableMatchesQuery(packageName: String, label: String?, query: String): Boolean {
@@ -100,7 +98,7 @@ fun InstalledAppsScreen(
}
var searchExpanded by rememberSaveable { mutableStateOf(false) }
val filterText by selectorVm.filterText.collectAsStateWithLifecycle()
val filterText by viewModel.filterText.collectAsStateWithLifecycle()
val TITLE_HORIZONTAL = 16.dp
val TITLE_VERTICAL = 8.dp
@@ -110,7 +108,7 @@ fun InstalledAppsScreen(
Box(modifier = Modifier.padding(horizontal = if (searchExpanded) 0.dp else 16.dp)) {
SearchBar(
query = filterText,
onQueryChange = selectorVm::setFilterText,
onQueryChange = viewModel::setFilterText,
expanded = searchExpanded,
onExpandedChange = { searchExpanded = it },
placeholder = { Text(stringResource(R.string.search_apps)) },
@@ -120,7 +118,7 @@ fun InstalledAppsScreen(
onClick = {
if (searchExpanded) {
searchExpanded = false
selectorVm.setFilterText("")
viewModel.setFilterText("")
}
},
tooltip = if (searchExpanded) stringResource(R.string.back) else stringResource(R.string.search),
@@ -139,7 +137,7 @@ fun InstalledAppsScreen(
trailingIcon = {
if (searchExpanded && filterText.isNotEmpty()) {
TooltipIconButton(
onClick = { selectorVm.setFilterText("") },
onClick = { viewModel.setFilterText("") },
tooltip = stringResource(R.string.clear),
) { contentDescription ->
Icon(
@@ -162,7 +160,7 @@ fun InstalledAppsScreen(
app.packageName !in patchedPkgNames &&
patchableMatchesQuery(
packageName = app.packageName,
label = selectorVm.loadLabel(app.packageInfo),
label = viewModel.loadLabel(app.packageInfo),
query = query
)
}
@@ -211,7 +209,7 @@ fun InstalledAppsScreen(
ListItem(
modifier = Modifier.clickable {
searchExpanded = false
selectorVm.setFilterText("")
viewModel.setFilterText("")
onAppClick(installedApp)
},
leadingContent = {
@@ -252,7 +250,7 @@ fun InstalledAppsScreen(
ListItem(
modifier = Modifier.clickable {
searchExpanded = false
selectorVm.setFilterText("")
viewModel.setFilterText("")
onPatchableAppClick(app.packageName)
},
leadingContent = {
@@ -357,15 +355,17 @@ fun InstalledAppsScreen(
}
}
item(key = "HEADER_PATCHABLE") {
Text(
text = stringResource(R.string.patchable_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
)
if (patched.isNotEmpty()) {
item(key = "HEADER_PATCHABLE") {
Text(
text = stringResource(R.string.patchable_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
)
}
}
item(key = "PATCHABLE_STORAGE") {
@@ -431,4 +431,4 @@ fun InstalledAppsScreen(
}
}
}
}
}

View File

@@ -315,7 +315,18 @@ fun BundleInformationScreen(
SettingsListItem(
headlineContent = stringResource(R.string.changelog),
onClick = {
onChangelogClick(ChangelogSource.Patches(if (src.isDefault) viewModel.prefs.api.getBlocking() else endpoint))
val source = if (src.isDefault) {
ChangelogSource.Patches(
url = viewModel.prefs.api.getBlocking(),
prerelease = viewModel.prefs.usePatchesPrereleases.getBlocking()
)
} else {
ChangelogSource.Patches(
url = endpoint,
prerelease = false
)
}
onChangelogClick(source)
},
)
}

View File

@@ -1,14 +1,16 @@
package app.revanced.manager.ui.screen
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -36,15 +38,16 @@ import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
@@ -70,10 +73,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.LocalContentColor
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
@@ -85,9 +84,9 @@ import app.revanced.manager.ui.component.NotificationCardType
import app.revanced.manager.ui.component.PillTab
import app.revanced.manager.ui.component.PillTabBar
import app.revanced.manager.ui.component.TooltipIconButton
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.sources.ImportSourceDialog
import app.revanced.manager.ui.component.sources.ImportSourceDialogStrings
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.viewmodel.DashboardViewModel
@@ -97,7 +96,6 @@ import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.collections.emptyList
enum class DashboardPage(
val titleResId: Int,
@@ -112,7 +110,6 @@ enum class DashboardPage(
@Composable
fun DashboardScreen(
vm: DashboardViewModel = koinViewModel(),
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit,
onAnnouncementsClick: () -> Unit,
@@ -122,7 +119,6 @@ fun DashboardScreen(
onStorageSelect: (SelectedApp.Local) -> Unit,
onBundleClick: (Int) -> Unit
) {
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val bundleDownloadError by vm.bundleDownloadError.collectAsStateWithLifecycle(null)
val sourcesNotDownloaded by vm.sourcesNotDownloaded.collectAsStateWithLifecycle(false)
val sourceUpdatesAvailable by vm.sourceUpdatesAvailable.collectAsStateWithLifecycle(false)
@@ -220,23 +216,15 @@ fun DashboardScreen(
)
}
var pendingAppSelectorLaunch by rememberSaveable { mutableStateOf(false) }
var pendingPatchablePackage by rememberSaveable { mutableStateOf<String?>(null) }
var pendingStorageSelection by rememberSaveable { mutableStateOf<SelectedApp.Local?>(null) }
fun clearPendingSelection() {
pendingAppSelectorLaunch = false
pendingPatchablePackage = null
pendingStorageSelection = null
}
fun resumePendingSelection() {
if (pendingAppSelectorLaunch) {
clearPendingSelection()
onAppSelectorClick()
return
}
pendingPatchablePackage?.let {
clearPendingSelection()
onPatchableAppClick(it)
@@ -462,36 +450,15 @@ fun DashboardScreen(
vm.unreadAnnouncement?.let { announcement ->
{
NotificationCard(
text = stringResource(R.string.new_announcement, announcement.title),
title = stringResource(R.string.new_announcement),
text = announcement.title,
icon = Icons.Filled.Notifications,
actions = {
val colors = ButtonDefaults.textButtonColors(
contentColor = LocalContentColor.current
)
TextButton(
onClick = vm::markUnreadAnnouncementRead,
shapes = ButtonDefaults.shapes(),
colors = colors
) {
Text(stringResource(R.string.dismiss))
}
TextButton(
onClick = {
vm.markUnreadAnnouncementRead()
onAnnouncementClick(announcement)
},
shapes = ButtonDefaults.shapes(),
colors = colors
) {
Text(stringResource(R.string.view_announcement))
}
},
type = if (announcement.level > 0) NotificationCardType.ERROR else NotificationCardType.NORMAL,
onClick = {
vm.markUnreadAnnouncementRead()
onAnnouncementClick(announcement)
}
},
onDismiss = vm::markUnreadAnnouncementRead
)
}
}
@@ -504,7 +471,7 @@ fun DashboardScreen(
) { index ->
when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> {
InstalledAppsScreen(
AppsScreen(
onAppClick = { onAppClick(it.currentPackageName) },
onPatchableAppClick = ::onPatchableSelection,
onStorageSelect = { selectedApp -> onStorageSelection(selectedApp) }

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.ui.screen
import android.app.Activity
import android.os.Build
import android.content.pm.PackageInstaller
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -150,6 +150,8 @@ fun PatcherScreen(
}
viewModel.packageInstallerStatus?.let {
// Don't show when the user cancels the installation (they can click Install again anyway)
if (it == PackageInstaller.STATUS_FAILURE_ABORTED) return@let
InstallerStatusDialog(it, viewModel, viewModel::dismissPackageInstallerDialog)
}

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.compose.collectAsLazyPagingItems
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.BottomContentBar
@@ -48,6 +49,7 @@ fun UpdateScreen(
vm: UpdateViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val changelogs = vm.changelogs.collectAsLazyPagingItems()
val buttonConfig = when (vm.state) {
State.CAN_DOWNLOAD -> Triple(
@@ -123,10 +125,7 @@ fun UpdateScreen(
)
}
ChangelogList(
state = vm.changelogsState,
onLoadMore = vm::loadNextPage,
)
ChangelogList(changelogs = changelogs, modifier = Modifier.padding(paddingValues))
}
}
}

View File

@@ -193,11 +193,6 @@ private fun APIUrlDialog(currentUrl: String, defaultUrl: String, onSubmit: (Stri
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.api_url_dialog_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.api_url_dialog_warning),
style = MaterialTheme.typography.bodyMedium,

View File

@@ -8,6 +8,7 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.paging.compose.collectAsLazyPagingItems
import app.revanced.manager.domain.repository.ChangelogSource
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ChangelogList
@@ -23,6 +24,7 @@ fun ChangelogsSettingsScreen(
vm: ChangelogsViewModel = koinViewModel { parametersOf(source) }
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val changelogs = vm.changelogs.collectAsLazyPagingItems()
Scaffold(
topBar = {
@@ -34,6 +36,6 @@ fun ChangelogsSettingsScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ChangelogList(modifier = Modifier.padding(paddingValues), state = vm.state, onLoadMore =vm::loadNextPage)
ChangelogList(changelogs = changelogs, modifier = Modifier.padding(paddingValues))
}
}

View File

@@ -1,129 +0,0 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.pm.PackageInfo
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
// TODO: delete this viewmodel and the screen.
@OptIn(SavedStateHandleSaveableApi::class)
class AppSelectorViewModel(
private val app: Application,
private val pm: PM,
fs: Filesystem,
patchBundleRepository: PatchBundleRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
File(
fs.uiTempDir,
"input.apk"
).also(File::delete)
}
val filterTextFlow = MutableStateFlow("")
val filterText: StateFlow<String> = filterTextFlow
val apps = pm.appList.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null,
)
val filteredApps = filterText
.combine(apps) { filter, apps ->
if (apps == null || filter.isBlank()) {
apps
} else {
apps.filter { app ->
app.packageName.contains(filter, true) ||
loadLabel(app.packageInfo).contains(filter)
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 0,
replayExpirationMillis = 10_000,
),
initialValue = null,
)
private val storageSelectionChannel = Channel<SelectedApp.Local>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyMap(),
)
var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
private set
fun setFilterText(filter: String) {
filterTextFlow.value = filter
}
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
fun dismissNonSuggestedVersionDialog() {
nonSuggestedVersionDialogSubject = null
}
fun handleStorageResult(uri: Uri) = viewModelScope.launch {
val selectedApp = withContext(Dispatchers.IO) {
loadSelectedFile(uri)
}
if (selectedApp == null) {
app.toast(app.getString(R.string.failed_to_load_apk))
return@launch
}
storageSelectionChannel.send(selectedApp)
}
private fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)?.use { stream ->
with(inputFile) {
delete()
Files.copy(stream, toPath())
pm.getPackageInfo(this)?.let { packageInfo ->
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName!!,
file = this,
temporary = true
)
}
}
}
}

View File

@@ -0,0 +1,131 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.pm.PackageInfo
import android.net.Uri
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.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.installer.RootServiceException
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.file.Files
@OptIn(SavedStateHandleSaveableApi::class)
class AppsViewModel(
private val app: Application,
private val installedAppsRepository: InstalledAppRepository,
private val pm: PM,
private val rootInstaller: RootInstaller,
fs: Filesystem,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
File(fs.uiTempDir, "input.apk").also(File::delete)
}
val filterTextFlow = MutableStateFlow("")
val filterText: StateFlow<String> = filterTextFlow
val patchableApps = pm.appList.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null,
)
val installedApps = installedAppsRepository.getAll().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null,
)
val packageInfoMap = mutableStateMapOf<String, PackageInfo?>()
private val storageSelectionChannel = Channel<SelectedApp.Local>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
init {
viewModelScope.launch {
installedApps.filterNotNull().collectLatest(::fetchPackageInfos)
}
}
fun setFilterText(filter: String) {
filterTextFlow.value = filter
}
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
fun handleStorageResult(uri: Uri) = viewModelScope.launch {
val selectedApp = withContext(Dispatchers.IO) { loadSelectedFile(uri) }
if (selectedApp == null) {
app.toast(app.getString(R.string.failed_to_load_apk))
return@launch
}
storageSelectionChannel.send(selectedApp)
}
private suspend fun fetchPackageInfos(apps: List<InstalledApp>) {
for (app in apps) {
packageInfoMap[app.currentPackageName] = withContext(Dispatchers.IO) {
if (app.installType == InstallType.MOUNT) {
try {
if (!rootInstaller.isAppInstalled(app.currentPackageName)) {
installedAppsRepository.delete(app)
return@withContext null
}
} catch (_: RootServiceException) { }
}
val packageInfo = pm.getPackageInfo(app.currentPackageName)
if (packageInfo == null && app.installType != InstallType.MOUNT) {
installedAppsRepository.delete(app)
return@withContext null
}
packageInfo
}
}
}
private fun loadSelectedFile(uri: Uri): SelectedApp.Local? =
app.contentResolver.openInputStream(uri)?.use { stream ->
inputFile.delete()
Files.copy(stream, inputFile.toPath())
pm.getPackageInfo(inputFile)?.let { packageInfo ->
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName!!,
file = inputFile,
temporary = true,
)
}
}
}

View File

@@ -1,60 +1,26 @@
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 androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import app.revanced.manager.domain.repository.ChangelogSource
import app.revanced.manager.domain.repository.ChangelogsRepository
import app.revanced.manager.ui.component.ChangelogUiState
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAssetHistory
import kotlinx.coroutines.flow.Flow
class ChangelogsViewModel(
private val repository: ChangelogsRepository,
private val app: Application,
private val api: ReVancedAPI,
private val source: ChangelogSource,
) : ViewModel() {
var state: ChangelogUiState by mutableStateOf(ChangelogUiState.Loading)
private set
private val pageSize = 2
init {
viewModelScope.launch {
uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") {
val result = repository.loadInitial(source, pageSize)
state = ChangelogUiState.Success(
changelogs = result.items,
hasMore = result.hasMore
)
}
if (state is ChangelogUiState.Loading) {
state = ChangelogUiState.Error(
app.getString(R.string.changelog_download_fail)
)
}
}
}
fun loadNextPage() {
val current = state as? ChangelogUiState.Success ?: return
if (current.isLoadingMore || !current.hasMore) return
state = current.copy(isLoadingMore = true)
val result = repository.loadNext(pageSize)
state = current.copy(
changelogs = current.changelogs + result.items,
isLoadingMore = false,
hasMore = result.hasMore
)
}
val changelogs: Flow<PagingData<ReVancedAssetHistory>> = Pager(
config = PagingConfig(
pageSize = 10,
enablePlaceholders = false
),
pagingSourceFactory = { ChangelogsRepository(api, source) }
).flow.cachedIn(viewModelScope)
}

View File

@@ -1,65 +0,0 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.installer.RootServiceException
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class InstalledAppsViewModel(
private val installedAppsRepository: InstalledAppRepository,
private val pm: PM,
private val rootInstaller: RootInstaller
) : ViewModel() {
val apps = installedAppsRepository.getAll()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null,
)
val packageInfoMap = mutableStateMapOf<String, PackageInfo?>()
init {
viewModelScope.launch {
apps.filterNotNull().collectLatest(::fetchPackageInfos)
}
}
private suspend fun fetchPackageInfos(apps: List<InstalledApp>) {
for (app in apps) {
packageInfoMap[app.currentPackageName] = withContext(Dispatchers.IO) {
try {
if (app.installType == InstallType.MOUNT &&
!rootInstaller.isAppInstalled(app.currentPackageName)
) {
installedAppsRepository.delete(app)
return@withContext null
}
} catch (_: RootServiceException) {
}
val packageInfo = pm.getPackageInfo(app.currentPackageName)
if (packageInfo == null && app.installType != InstallType.MOUNT) {
installedAppsRepository.delete(app)
return@withContext null
}
packageInfo
}
}
}
}

View File

@@ -369,8 +369,7 @@ class SelectedAppInfoViewModel(
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) {
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
else -> null
else -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
}
selectedAppInfo = info

View File

@@ -10,6 +10,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.platform.NetworkInfo
@@ -17,13 +21,14 @@ import app.revanced.manager.domain.repository.ChangelogSource
import app.revanced.manager.domain.repository.ChangelogsRepository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.dto.ReVancedAssetHistory
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.ui.component.ChangelogUiState
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.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
@@ -37,7 +42,8 @@ import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
class UpdateViewModel(
private val changelogsRepository: ChangelogsRepository,
private val api: ReVancedAPI,
private val source: ChangelogSource,
private val downloadOnScreenEntry: Boolean
) : ViewModel(), KoinComponent {
private val app: Application by inject()
@@ -66,8 +72,13 @@ class UpdateViewModel(
var releaseInfo: ReVancedAsset? by mutableStateOf(null)
private set
var changelogsState: ChangelogUiState by mutableStateOf(ChangelogUiState.Loading)
private val changelogsPageSize = 2
val changelogs: Flow<PagingData<ReVancedAssetHistory>> = Pager(
config = PagingConfig(
pageSize = 10,
enablePlaceholders = false
),
pagingSourceFactory = { ChangelogsRepository(api, source) }
).flow.cachedIn(viewModelScope)
private val location = fs.tempDir.resolve("updater.apk")
@@ -77,44 +88,14 @@ class UpdateViewModel(
releaseInfo = reVancedAPI.getAppUpdate()
?: throw Exception("No update available")
val result = changelogsRepository.loadInitial(
ChangelogSource.Manager,
changelogsPageSize
)
changelogsState = ChangelogUiState.Success(
changelogs = result.items,
hasMore = result.hasMore
)
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
state = State.CAN_DOWNLOAD
}
}
if (changelogsState is ChangelogUiState.Loading) {
changelogsState = ChangelogUiState.Error(
app.getString(R.string.changelog_download_fail)
)
}
}
}
fun loadNextPage() {
val current = changelogsState as? ChangelogUiState.Success ?: return
if (current.isLoadingMore || !current.hasMore) return
changelogsState = current.copy(isLoadingMore = true)
val result = changelogsRepository.loadNext(changelogsPageSize)
changelogsState = current.copy(
changelogs = current.changelogs + result.items,
isLoadingMore = false,
hasMore = result.hasMore
)
}
fun downloadUpdate(ignoreInternetCheck: Boolean = false) = viewModelScope.launch {
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {

View File

@@ -4,18 +4,19 @@ import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.PackageInfoFlags
import android.content.pm.PackageManager.NameNotFoundException
import androidx.core.content.pm.PackageInfoCompat
import android.content.pm.PackageManager.PackageInfoFlags
import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import androidx.core.content.pm.PackageInfoCompat
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import ru.solrudev.ackpine.session.await
@@ -37,11 +38,15 @@ data class AppInfo(
class PM(
private val app: Application,
patchBundleRepository: PatchBundleRepository,
prefs: PreferencesManager,
private val uninstaller: PackageUninstaller
) {
private val scope = CoroutineScope(Dispatchers.IO)
val appList = patchBundleRepository.bundleInfoFlow.map { bundles ->
val appList = combine(
patchBundleRepository.bundleInfoFlow,
prefs.disableUniversalPatchCheck.flow
) { bundles, showInstalled ->
val compatibleApps = scope.async {
val compatiblePackages = bundles
.flatMap { (_, bundle) -> bundle.patches }
@@ -64,28 +69,32 @@ class PM(
}
}
val installedApps = scope.async {
getInstalledPackages().map { packageInfo ->
AppInfo(
packageInfo.packageName,
0,
packageInfo
)
val installedApps = if (showInstalled) {
scope.async {
getInstalledPackages().map { packageInfo ->
AppInfo(packageInfo.packageName, 0, packageInfo)
}
}
}
} else null
if (compatibleApps.await().isNotEmpty()) {
(compatibleApps.await() + installedApps.await())
.distinctBy { it.packageName }
.sortedWith(
compareByDescending<AppInfo> {
it.packageInfo != null && (it.patches ?: 0) > 0
}.thenByDescending {
it.patches
}.thenBy {
it.packageInfo?.label()
}.thenBy { it.packageName }
)
val compatible = compatibleApps.await()
if (compatible.isNotEmpty()) {
val base = if (installedApps != null) {
(compatible + installedApps.await()).distinctBy { it.packageName }
} else {
compatible
}
base.sortedWith(
compareByDescending<AppInfo> {
it.packageInfo != null && (it.patches ?: 0) > 0
}.thenByDescending {
it.patches
}.thenBy {
it.packageInfo?.label()
}.thenBy { it.packageName }
)
} else {
emptyList()
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ binary-compatibility-validator = "0.18.1"
semver-parser = "3.0.0"
ackpine = "0.20.6"
foundation-layout = "1.10.5"
paging3 = "3.4.2"
[libraries]
# AndroidX Core
@@ -62,6 +63,7 @@ compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedat
compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
paging3 = { group = "androidx.paging", name = "paging-compose", version.ref = "paging3" }
# Coil
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }