From de2f3f0ec6bbb02e9775f466fcbdf28fa19a470e Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 28 Nov 2025 16:54:04 -0300 Subject: [PATCH] Add InstalledAppsCache and show apps with issues in the My Apps list, we are now showing apps with issues such as incompatible signer. The install cache can be used to indicate that apps are installed. --- .../org/fdroid/install/InstalledAppsCache.kt | 108 ++++++++ .../main/kotlin/org/fdroid/ui/BottomBar.kt | 39 ++- next/src/main/kotlin/org/fdroid/ui/Main.kt | 3 + .../org/fdroid/ui/apps/InstalledAppRow.kt | 35 ++- .../kotlin/org/fdroid/ui/apps/MyAppItem.kt | 18 +- .../main/kotlin/org/fdroid/ui/apps/MyApps.kt | 208 ++------------- .../kotlin/org/fdroid/ui/apps/MyAppsInfo.kt | 1 + .../kotlin/org/fdroid/ui/apps/MyAppsList.kt | 237 ++++++++++++++++++ .../org/fdroid/ui/apps/MyAppsPresenter.kt | 18 +- .../org/fdroid/ui/apps/MyAppsViewModel.kt | 67 +++-- .../org/fdroid/ui/apps/UpdatableAppRow.kt | 7 +- .../org/fdroid/ui/details/AppDetails.kt | 4 +- .../org/fdroid/ui/details/AppDetailsItem.kt | 14 +- .../fdroid/ui/details/AppDetailsViewModel.kt | 12 +- .../fdroid/ui/details/AppDetailsWarnings.kt | 74 +++++- .../org/fdroid/ui/details/DetailsPresenter.kt | 16 +- .../kotlin/org/fdroid/ui/discover/Discover.kt | 13 +- .../fdroid/ui/discover/DiscoverViewModel.kt | 1 + .../ui/repositories/RepositoriesViewModel.kt | 3 + .../org/fdroid/ui/utils/PreviewUtils.kt | 82 +++++- .../org/fdroid/updates/UpdatesManager.kt | 72 ++++-- .../org/fdroid/updates/UpdatesModule.kt | 8 +- next/src/main/res/values/strings-next.xml | 7 +- 23 files changed, 734 insertions(+), 313 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt create mode 100644 next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt diff --git a/next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt b/next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt new file mode 100644 index 000000000..9ab988fd5 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt @@ -0,0 +1,108 @@ +package org.fdroid.install + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.Intent.EXTRA_REPLACING +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.GET_SIGNATURES +import androidx.annotation.UiThread +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.fdroid.utils.IoDispatcher +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InstalledAppsCache @Inject constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val ioScope: CoroutineScope, +) : BroadcastReceiver() { + + private val log = KotlinLogging.logger { } + private val packageManager = context.packageManager + private val _installedApps = MutableStateFlow>(emptyMap()) + val installedApps = _installedApps.asStateFlow() + private var loadJob: Job? = null + + init { + val intentFilter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + } + context.registerReceiver(this, intentFilter) + loadInstalledApps() + } + + @UiThread + private fun loadInstalledApps() { + if (loadJob?.isActive == true) { + // TODO this may give us a stale cache if an app was changed + // while the system had already assembled the data, but we didn't return yet + log.warn { "Already loading apps, not loading again." } + return + } + loadJob = ioScope.launch { + log.info { "Loading installed apps..." } + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) + _installedApps.update { installedPackages.associateBy { it.packageName } } + } + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.`package` != null) { + // we have seen duplicate intents on Android 15, need to check other versions + log.warn { "Ignoring intent with package: $intent" } + return + } + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED -> onPackageAdded(intent) + Intent.ACTION_PACKAGE_REMOVED -> onPackageRemoved(intent) + else -> log.error { "Unknown broadcast received: $intent" } + } + } + + private fun onPackageAdded(intent: Intent) { + val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) + log.info { "onPackageAdded($intent) ${intent.data} replacing: $replacing" } + val packageName = intent.data?.schemeSpecificPart + ?: error("No package name in ACTION_PACKAGE_ADDED") + + try { + @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken + val packageInfo = packageManager.getPackageInfo(packageName, GET_SIGNATURES) + // even if the app got replaced, we need to update packageInfo for new version code + _installedApps.update { + it.toMutableMap().apply { + put(packageName, packageInfo) + } + } + } catch (e: PackageManager.NameNotFoundException) { + // Broadcasts don't always get delivered on time. So when this broadcast arrives, + // the user may already have uninstalled the app. + log.warn(e) { "Maybe broadcast was late? App not installed anymore: " } + } + } + + private fun onPackageRemoved(intent: Intent) { + val replacing = intent.getBooleanExtra(EXTRA_REPLACING, false) + log.info { "onPackageRemoved($intent) ${intent.data} replacing: $replacing" } + val packageName = intent.data?.schemeSpecificPart + ?: error("No package name in ACTION_PACKAGE_REMOVED") + if (!replacing) _installedApps.update { apps -> + apps.toMutableMap().apply { + remove(packageName) + } + } + } +} diff --git a/next/src/main/kotlin/org/fdroid/ui/BottomBar.kt b/next/src/main/kotlin/org/fdroid/ui/BottomBar.kt index 5a501a0bf..93772c358 100644 --- a/next/src/main/kotlin/org/fdroid/ui/BottomBar.kt +++ b/next/src/main/kotlin/org/fdroid/ui/BottomBar.kt @@ -1,26 +1,43 @@ package org.fdroid.ui +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.NavKey +import org.fdroid.R @Composable -fun BottomBar(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) -> Unit) { +fun BottomBar( + numUpdates: Int, + hasIssues: Boolean, + currentNavKey: NavKey, + onNav: (NavigationKey) -> Unit, +) { NavigationBar { BottomNavDestinations.entries.forEach { dest -> NavigationBarItem( - icon = { NavIcon(dest, numUpdates) }, + icon = { NavIcon(dest, numUpdates, hasIssues) }, label = { Text(stringResource(dest.label)) }, selected = dest.key == currentNavKey, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), onClick = { if (dest.key != currentNavKey) onNav(dest.key) }, @@ -32,6 +49,7 @@ fun BottomBar(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) -> @Composable fun NavigationRail( numUpdates: Int, + hasIssues: Boolean, currentNavKey: NavKey, onNav: (NavigationKey) -> Unit, modifier: Modifier, @@ -39,9 +57,14 @@ fun NavigationRail( NavigationRail(modifier) { BottomNavDestinations.entries.forEach { dest -> NavigationRailItem( - icon = { NavIcon(dest, numUpdates) }, + icon = { NavIcon(dest, numUpdates, hasIssues) }, label = { Text(stringResource(dest.label)) }, selected = dest.key == currentNavKey, + colors = NavigationRailItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = contentColorFor(MaterialTheme.colorScheme.primary), + ), onClick = { if (dest.key != currentNavKey) onNav(dest.key) }, @@ -51,13 +74,19 @@ fun NavigationRail( } @Composable -private fun NavIcon(dest: BottomNavDestinations, numUpdates: Int) { +private fun NavIcon(dest: BottomNavDestinations, numUpdates: Int, hasIssues: Boolean) { BadgedBox( badge = { if (dest == BottomNavDestinations.MY_APPS && numUpdates > 0) { - Badge { + Badge(containerColor = MaterialTheme.colorScheme.secondary) { Text(text = numUpdates.toString()) } + } else if (dest == BottomNavDestinations.MY_APPS && hasIssues) { + Icon( + imageVector = Icons.Default.Error, + tint = MaterialTheme.colorScheme.error, + contentDescription = stringResource(R.string.my_apps_header_apps_with_issue) + ) } } ) { diff --git a/next/src/main/kotlin/org/fdroid/ui/Main.kt b/next/src/main/kotlin/org/fdroid/ui/Main.kt index 9b7b7afc4..1721509f6 100644 --- a/next/src/main/kotlin/org/fdroid/ui/Main.kt +++ b/next/src/main/kotlin/org/fdroid/ui/Main.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneSt import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -86,6 +87,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) { ) { val viewModel = hiltViewModel() val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle(0).value + val hasIssues = viewModel.hasIssues.collectAsState(false).value Discover( discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value, onListTap = { @@ -96,6 +98,7 @@ fun Main(onListeningForIntent: () -> Unit = {}) { }, onNav = { backStack.add(it) }, numUpdates = numUpdates, + hasIssues = hasIssues, isBigScreen = isBigScreen, onSearch = viewModel::search, onSearchCleared = viewModel::onSearchCleared, diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt b/next/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt index d0676bab7..2f93a2ee0 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/InstalledAppRow.kt @@ -2,6 +2,10 @@ package org.fdroid.ui.apps import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme @@ -10,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview @@ -23,21 +28,32 @@ import org.fdroid.ui.utils.Names @Composable fun InstalledAppRow( - app: InstalledAppItem, + app: MyInstalledAppItem, isSelected: Boolean, modifier: Modifier = Modifier, + hasIssue: Boolean = false, ) { Column(modifier = modifier) { ListItem( leadingContent = { - AsyncShimmerImage( - model = PackageName(app.packageName, app.iconModel as? DownloadRequest), - error = painterResource(R.drawable.ic_repo_app_default), - contentDescription = null, - modifier = Modifier - .size(48.dp) - .semantics { hideFromAccessibility() }, - ) + BadgedBox(badge = { + if (hasIssue) Icon( + imageVector = Icons.Filled.Error, + tint = MaterialTheme.colorScheme.error, + contentDescription = + stringResource(R.string.notification_title_single_update_available), + modifier = Modifier.size(24.dp), + ) + }) { + AsyncShimmerImage( + model = PackageName(app.packageName, app.iconModel as? DownloadRequest), + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .semantics { hideFromAccessibility() }, + ) + } }, headlineContent = { Text(app.name) @@ -70,6 +86,7 @@ fun InstalledAppRowPreview() { Column { InstalledAppRow(app, false) InstalledAppRow(app, true) + InstalledAppRow(app, false, hasIssue = true) } } } diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt index af1176d3f..4be3cb271 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt @@ -1,5 +1,6 @@ package org.fdroid.ui.apps +import org.fdroid.database.AppIssue import org.fdroid.download.DownloadRequest import org.fdroid.index.v2.PackageVersion import org.fdroid.install.InstallStateWithInfo @@ -32,10 +33,23 @@ data class AppUpdateItem( override val lastUpdated: Long = update.added } +data class AppWithIssueItem( + override val packageName: String, + override val name: String, + override val installedVersionName: String, + val issue: AppIssue, + override val lastUpdated: Long, + override val iconModel: Any? = null, +) : MyInstalledAppItem() + data class InstalledAppItem( override val packageName: String, override val name: String, - val installedVersionName: String, + override val installedVersionName: String, override val lastUpdated: Long, override val iconModel: Any? = null, -) : MyAppItem() +) : MyInstalledAppItem() + +abstract class MyInstalledAppItem : MyAppItem() { + abstract val installedVersionName: String +} diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt index e1b766abc..3ab7378ac 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -3,28 +3,20 @@ package org.fdroid.ui.apps import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.RestrictTo -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SortByAlpha -import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,7 +31,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource @@ -55,15 +46,12 @@ import org.fdroid.database.AppListSortOrder import org.fdroid.database.AppListSortOrder.LAST_UPDATED import org.fdroid.fdroid.ui.theme.FDroidContent import org.fdroid.install.InstallConfirmationState -import org.fdroid.install.InstallState import org.fdroid.ui.BottomBar import org.fdroid.ui.NavigationKey import org.fdroid.ui.lists.TopSearchBar import org.fdroid.ui.utils.BigLoadingIndicator -import org.fdroid.ui.utils.Names import org.fdroid.ui.utils.getMyAppsInfo -import org.fdroid.ui.utils.getPreviewVersion -import java.util.concurrent.TimeUnit.DAYS +import org.fdroid.ui.utils.myAppsModel @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -172,7 +160,12 @@ fun MyApps( ) }, bottomBar = { - if (!isBigScreen) BottomBar(updatableApps?.size ?: 0, NavigationKey.MyApps, onNav) + if (!isBigScreen) BottomBar( + numUpdates = updatableApps?.size ?: 0, + hasIssues = !myAppsModel.appsWithIssue.isNullOrEmpty(), + currentNavKey = NavigationKey.MyApps, + onNav = onNav, + ) }, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> @@ -195,131 +188,13 @@ fun MyApps( .padding(16.dp), ) } else { - var showUpdateAllButton by remember(updatableApps) { - mutableStateOf(true) - } - LazyColumn( - state = lazyListState, - modifier = modifier - .padding(paddingValues) - .then( - if (currentPackageName == null) Modifier - else Modifier.selectableGroup() - ), - ) { - // Updates header with Update all button (only show when there's a list below) - if (!updatableApps.isNullOrEmpty()) { - item(key = "A", contentType = "header") { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.updates), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(16.dp) - .weight(1f), - ) - if (showUpdateAllButton) Button( - onClick = { - myAppsInfo.updateAll() - showUpdateAllButton = false - }, - modifier = Modifier.padding(end = 16.dp), - ) { - Text(stringResource(R.string.update_all)) - } - } - } - // List of updatable apps - items( - items = updatableApps, - key = { it.packageName }, - contentType = { "A" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier.Companion - .animateItem() - .then(interactionModifier) - UpdatableAppRow(app, isSelected, modifier) - } - } - // Apps currently installing header - if (installingApps.isNotEmpty()) { - item(key = "B", contentType = "header") { - Text( - text = stringResource(R.string.notification_title_summary_installing), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .padding(16.dp) - ) - } - // List of currently installing apps - items( - items = installingApps, - key = { it.packageName }, - contentType = { "B" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier.Companion - .animateItem() - .then(interactionModifier) - InstallingAppRow(app, isSelected, modifier) - } - } - // Installed apps header (only show when we have non-empty lists above) - if ((installingApps.isNotEmpty() || !updatableApps.isNullOrEmpty()) && - !installedApps.isNullOrEmpty() - ) { - item(key = "C", contentType = "header") { - Text( - text = stringResource(R.string.installed_apps__activity_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), - ) - } - } - // List of installed apps - if (installedApps != null) items( - items = installedApps, - key = { it.packageName }, - contentType = { "C" }, - ) { app -> - val isSelected = app.packageName == currentPackageName - val interactionModifier = if (currentPackageName == null) { - Modifier.clickable( - onClick = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier - .animateItem() - .then(interactionModifier) - InstalledAppRow(app, isSelected, modifier) - } - } + MyAppsList( + myAppsInfo = myAppsInfo, + currentPackageName = currentPackageName, + lazyListState = lazyListState, + onAppItemClick = onAppItemClick, + modifier = modifier.padding(paddingValues), + ) } } } @@ -349,61 +224,8 @@ fun MyAppsLoadingPreview() { @RestrictTo(RestrictTo.Scope.TESTS) fun MyAppsPreview() { FDroidContent { - val installingApp1 = InstallingAppItem( - packageName = "A1", - installState = InstallState.Downloading( - name = "Installing App 1", - versionName = "1.0.4", - currentVersionName = null, - lastUpdated = 23, - iconDownloadRequest = null, - downloadedBytes = 25, - totalBytes = 100, - startMillis = System.currentTimeMillis(), - ) - ) - val app1 = AppUpdateItem( - repoId = 1, - packageName = "B1", - name = "App Update 123", - installedVersionName = "1.0.1", - update = getPreviewVersion("1.1.0", 123456789), - whatsNew = "This is new, all is new, nothing old.", - ) - val app2 = AppUpdateItem( - repoId = 2, - packageName = "B2", - name = Names.randomName, - installedVersionName = "3.0.1", - update = getPreviewVersion("3.1.0", 9876543), - whatsNew = null, - ) - val installedApp1 = InstalledAppItem( - packageName = "C1", - name = Names.randomName, - installedVersionName = "1", - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1) - ) - val installedApp2 = InstalledAppItem( - packageName = "C2", - name = Names.randomName, - installedVersionName = "2", - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2) - ) - val installedApp3 = InstalledAppItem( - packageName = "C3", - name = Names.randomName, - installedVersionName = "3", - lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3) - ) - val model = MyAppsModel( - installingApps = listOf(installingApp1), - appUpdates = listOf(app1, app2), - installedApps = listOf(installedApp1, installedApp2, installedApp3), - sortOrder = AppListSortOrder.NAME, - ) MyApps( - myAppsInfo = getMyAppsInfo(model), + myAppsInfo = getMyAppsInfo(myAppsModel), currentPackageName = null, onAppItemClick = {}, onNav = {}, diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt index df28d9957..fc8e531a2 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -15,6 +15,7 @@ interface MyAppsInfo { data class MyAppsModel( val installingApps: List, val appUpdates: List? = null, + val appsWithIssue: List? = null, val installedApps: List? = null, val sortOrder: AppListSortOrder = AppListSortOrder.NAME, ) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt new file mode 100644 index 000000000..4f2f08425 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt @@ -0,0 +1,237 @@ +package org.fdroid.ui.apps + +import androidx.annotation.RestrictTo +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.fdroid.R +import org.fdroid.database.NotAvailable +import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.ui.utils.getMyAppsInfo +import org.fdroid.ui.utils.myAppsModel + +@Composable +fun MyAppsList( + myAppsInfo: MyAppsInfo, + currentPackageName: String?, + lazyListState: LazyListState, + onAppItemClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val updatableApps = myAppsInfo.model.appUpdates + val installingApps = myAppsInfo.model.installingApps + val appsWithIssue = myAppsInfo.model.appsWithIssue + val installedApps = myAppsInfo.model.installedApps + // allow us to hide "update all" button to avoid user pressing it twice + var showUpdateAllButton by remember(updatableApps) { + mutableStateOf(true) + } + LazyColumn( + state = lazyListState, + modifier = modifier + .then( + if (currentPackageName == null) Modifier + else Modifier.selectableGroup() + ), + ) { + // Updates header with Update all button (only show when there's a list below) + if (!updatableApps.isNullOrEmpty()) { + item(key = "A", contentType = "header") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.updates), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(16.dp) + .weight(1f), + ) + if (showUpdateAllButton) Button( + onClick = { + myAppsInfo.updateAll() + showUpdateAllButton = false + }, + modifier = Modifier.padding(end = 16.dp), + ) { + Text(stringResource(R.string.update_all)) + } + } + } + // List of updatable apps + items( + items = updatableApps, + key = { it.packageName }, + contentType = { "A" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) + } + val modifier = Modifier.Companion + .animateItem() + .then(interactionModifier) + UpdatableAppRow(app, isSelected, modifier) + } + } + // Apps currently installing header + if (installingApps.isNotEmpty()) { + item(key = "B", contentType = "header") { + Text( + text = stringResource(R.string.notification_title_summary_installing), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(16.dp) + ) + } + // List of currently installing apps + items( + items = installingApps, + key = { it.packageName }, + contentType = { "B" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) + } + val modifier = Modifier.Companion + .animateItem() + .then(interactionModifier) + InstallingAppRow(app, isSelected, modifier) + } + } + // Apps with issues + if (!appsWithIssue.isNullOrEmpty()) { + // header + item(key = "C", contentType = "header") { + Text( + text = stringResource(R.string.my_apps_header_apps_with_issue), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + // list of apps with issues + items( + items = appsWithIssue, + key = { it.packageName }, + contentType = { "C" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + var showNotAvailableDialog by remember { mutableStateOf(false) } + val onClick = { + if (app.issue is NotAvailable) { + showNotAvailableDialog = true + } else { + onAppItemClick(app.packageName) + } + } + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = onClick, + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = onClick, + ) + } + val modifier = Modifier + .animateItem() + .then(interactionModifier) + InstalledAppRow(app, isSelected, modifier, hasIssue = true) + if (showNotAvailableDialog) AlertDialog( + onDismissRequest = { showNotAvailableDialog = false }, + title = { Text(text = stringResource(R.string.app_issue_not_available_title)) }, + text = { Text(text = stringResource(R.string.app_issue_not_available_text)) }, + confirmButton = { + TextButton( + onClick = { showNotAvailableDialog = false } + ) { Text(stringResource(R.string.ok)) } + }, + ) + } + } + // Installed apps header (only show when we have non-empty lists above) + val aboveNonEmpty = installingApps.isNotEmpty() || + !updatableApps.isNullOrEmpty() || + !appsWithIssue.isNullOrEmpty() + if (aboveNonEmpty && !installedApps.isNullOrEmpty()) { + item(key = "D", contentType = "header") { + Text( + text = stringResource(R.string.installed_apps__activity_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + // List of installed apps + if (installedApps != null) items( + items = installedApps, + key = { it.packageName }, + contentType = { "D" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) + } + val modifier = Modifier + .animateItem() + .then(interactionModifier) + InstalledAppRow(app, isSelected, modifier) + } + } +} + +@Preview +@Composable +@RestrictTo(RestrictTo.Scope.TESTS) +private fun MyAppsListPreview() { + FDroidContent { + MyApps( + myAppsInfo = getMyAppsInfo(myAppsModel), + currentPackageName = null, + onAppItemClick = {}, + onNav = {}, + isBigScreen = false, + ) + } +} diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt index ad5b83fce..b751a4f22 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -4,6 +4,7 @@ package org.fdroid.ui.apps import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.fdroid.database.AppListSortOrder import org.fdroid.install.InstallState @@ -17,13 +18,15 @@ import java.util.Locale fun MyAppsPresenter( appUpdatesFlow: StateFlow?>, appInstallStatesFlow: StateFlow>, - installedAppsFlow: StateFlow?>, + appsWithIssuesFlow: StateFlow?>, + installedAppsFlow: Flow>, searchQueryFlow: StateFlow, sortOrderFlow: StateFlow, ): MyAppsModel { val appUpdates = appUpdatesFlow.collectAsState().value val appInstallStates = appInstallStatesFlow.collectAsState().value - val installedApps = installedAppsFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value + val installedApps = installedAppsFlow.collectAsState(null).value val searchQuery = searchQueryFlow.collectAsState().value.normalize() val sortOrder = sortOrderFlow.collectAsState().value val processedPackageNames = mutableSetOf() @@ -52,6 +55,16 @@ fun MyAppsPresenter( if (keep) processedPackageNames.add(it.packageName) keep } + val withIssues = appsWithIssues?.filter { + val keep = if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames + } else { + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) + } + if (keep) processedPackageNames.add(it.packageName) + keep + } val installed = installedApps?.filter { if (searchQuery.isBlank()) { it.packageName !in processedPackageNames @@ -63,6 +76,7 @@ fun MyAppsPresenter( return MyAppsModel( installingApps = installingApps.sort(sortOrder), appUpdates = updates?.sort(sortOrder), + appsWithIssue = withIssues?.sort(sortOrder), installedApps = installed?.sort(sortOrder), sortOrder = sortOrder, ) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt index 46b1b3636..a9e8c32ec 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -3,9 +3,7 @@ package org.fdroid.ui.apps import android.app.Application import androidx.core.os.LocaleListCompat import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.Observer import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import app.cash.molecule.AndroidUiDispatcher import app.cash.molecule.RecompositionMode.ContextClock @@ -13,11 +11,12 @@ import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import mu.KotlinLogging -import org.fdroid.database.AppListItem import org.fdroid.database.AppListSortOrder import org.fdroid.database.FDroidDatabase import org.fdroid.download.getImageModel @@ -25,6 +24,7 @@ import org.fdroid.index.RepoManager import org.fdroid.install.AppInstallManager import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState +import org.fdroid.install.InstalledAppsCache import org.fdroid.settings.SettingsManager import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher @@ -37,6 +37,7 @@ class MyAppsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val db: FDroidDatabase, private val settingsManager: SettingsManager, + private val installedAppsCache: InstalledAppsCache, private val appInstallManager: AppInstallManager, private val updatesManager: UpdatesManager, private val repoManager: RepoManager, @@ -48,43 +49,39 @@ class MyAppsViewModel @Inject constructor( CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) private val updates = updatesManager.updates - private val installedApps = MutableStateFlow?>(null) - private var installedAppsLiveData = - db.getAppDao().getInstalledAppListItems(application.packageManager) - private val installedAppsObserver = Observer> { list -> - val proxyConfig = settingsManager.proxyConfig - installedApps.value = list.map { app -> - InstalledAppItem( - packageName = app.packageName, - name = app.name ?: "Unknown app", - installedVersionName = app.installedVersionName ?: "???", - lastUpdated = app.lastUpdated, - iconModel = repoManager.getRepository(app.repoId)?.let { repo -> - app.getIcon(localeList)?.getImageModel(repo, proxyConfig) - }, - ) + + @OptIn(ExperimentalCoroutinesApi::class) + private val installedAppItems = + installedAppsCache.installedApps.flatMapLatest { installedApps -> + val proxyConfig = settingsManager.proxyConfig + db.getAppDao().getInstalledAppListItems(installedApps).map { list -> + list.map { app -> + InstalledAppItem( + packageName = app.packageName, + name = app.name ?: "Unknown app", + installedVersionName = app.installedVersionName ?: "???", + lastUpdated = app.lastUpdated, + iconModel = repoManager.getRepository(app.repoId)?.let { repo -> + app.getIcon(localeList)?.getImageModel(repo, proxyConfig) + }, + ) + } + } } - } - private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") + + private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME) val myAppsModel: StateFlow = moleculeScope.launchMolecule(mode = ContextClock) { MyAppsPresenter( appUpdatesFlow = updates, appInstallStatesFlow = appInstallManager.appInstallStates, - installedAppsFlow = installedApps, + appsWithIssuesFlow = updatesManager.appsWithIssues, + installedAppsFlow = installedAppItems, searchQueryFlow = searchQuery, sortOrderFlow = sortOrder, ) } - init { - installedAppsLiveData.observeForever(installedAppsObserver) - } - - override fun onCleared() { - installedAppsLiveData.removeObserver(installedAppsObserver) - } - fun updateAll() { scope.launch { updatesManager.updateAll() @@ -100,14 +97,8 @@ class MyAppsViewModel @Inject constructor( } fun refresh() { - updatesManager.loadUpdates() - - // need to get new liveData from the DB, so it re-queries installed packages - installedAppsLiveData.removeObserver(installedAppsObserver) - installedAppsLiveData = - db.getAppDao().getInstalledAppListItems(application.packageManager).apply { - observeForever(installedAppsObserver) - } + // TODO check if really not needed anymore and if so remove + // updatesManager.loadUpdates() } fun confirmAppInstall(packageName: String, state: InstallConfirmationState) { diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt b/next/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt index fed0f7735..6c7a0c9dd 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/UpdatableAppRow.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview @@ -52,7 +53,7 @@ fun UpdatableAppRow( BadgedBox(badge = { Icon( imageVector = Icons.Filled.NewReleases, - tint = MaterialTheme.colorScheme.error, + tint = MaterialTheme.colorScheme.secondary, contentDescription = stringResource(R.string.notification_title_single_update_available), modifier = Modifier.size(24.dp), @@ -62,7 +63,9 @@ fun UpdatableAppRow( model = PackageName(app.packageName, app.iconModel as? DownloadRequest), error = painterResource(R.drawable.ic_repo_app_default), contentDescription = null, - modifier = Modifier.size(48.dp), + modifier = Modifier + .size(48.dp) + .semantics { hideFromAccessibility() }, ) } }, diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt index e8d3f87f1..d4f25b741 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt @@ -110,7 +110,9 @@ fun AppDetails( ) { // Header is taking care of top innerPadding AppDetailsHeader(item, innerPadding) - AnimatedVisibility(item.showWarnings) { AppDetailsWarnings(item) } + AnimatedVisibility(item.showWarnings) { + AppDetailsWarnings(item, Modifier.padding(horizontal = 16.dp)) + } // What's New if (item.installedVersion != null && (item.whatsNew != null || item.app.changelog != null) diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt index aebd5cc35..d580fd027 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt @@ -7,6 +7,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.os.LocaleListCompat import io.ktor.client.engine.ProxyConfig import org.fdroid.database.App +import org.fdroid.database.AppIssue import org.fdroid.database.AppMetadata import org.fdroid.database.AppPrefs import org.fdroid.database.AppVersion @@ -56,12 +57,7 @@ data class AppDetailsItem( val appPrefs: AppPrefs? = null, val whatsNew: String? = null, val antiFeatures: List? = null, - /** - * true if this app from this repository has no versions with a - * compatible signer. This means that the app is installed, but does not receive updates either - * because the signer in the repo has changed or a wrong repo is set as preferred. - */ - val noUpdatesBecauseDifferentSigner: Boolean = false, + val issue: AppIssue? = null, val authorHasMoreThanOneApp: Boolean = false, val proxy: ProxyConfig?, ) { @@ -78,7 +74,7 @@ data class AppDetailsItem( suggestedVersion: AppVersion?, possibleUpdate: AppVersion?, appPrefs: AppPrefs?, - noUpdatesBecauseDifferentSigner: Boolean, + issue: AppIssue?, authorHasMoreThanOneApp: Boolean, localeList: LocaleListCompat, proxy: ProxyConfig?, @@ -117,7 +113,7 @@ data class AppDetailsItem( localeList = localeList, proxy = proxy, ), - noUpdatesBecauseDifferentSigner = noUpdatesBecauseDifferentSigner, + issue = issue, authorHasMoreThanOneApp = authorHasMoreThanOneApp, proxy = proxy, ) @@ -171,7 +167,7 @@ data class AppDetailsItem( * True if this app has warnings, we need to show to the user. */ val showWarnings: Boolean - get() = isIncompatible || oldTargetSdk || noUpdatesBecauseDifferentSigner + get() = isIncompatible || oldTargetSdk || issue != null /** * True if the targetSdk of the suggested version is so old diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 3b20736c3..079a38c1b 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -56,18 +56,19 @@ class AppDetailsViewModel @Inject constructor( private val currentRepoIdFlow = MutableStateFlow(null) val appDetails: StateFlow = viewModelScope.launchMolecule( - context = Dispatchers.IO, mode = Immediate, + context = scope.coroutineContext, mode = Immediate, ) { DetailsPresenter( db = db, repoManager = repoManager, repoPreLoader = repoPreLoader, updateChecker = updateChecker, + settingsManager = settingsManager, appInstallManager = appInstallManager, viewModel = this, packageInfoFlow = packageInfoFlow, currentRepoIdFlow = currentRepoIdFlow, - settingsManager = settingsManager, + appsWithIssuesFlow = updatesManager.appsWithIssues, ) } @@ -107,8 +108,6 @@ class AppDetailsViewModel @Inject constructor( if (result is InstallState.Installed) { // to reload packageInfoFlow with fresh packageInfo loadPackageInfoFlow(appMetadata.packageName) - // load updates as there may be less now (removes/updates notification) - updatesManager.loadUpdates() } } } @@ -160,7 +159,10 @@ class AppDetailsViewModel @Inject constructor( @UiThread fun onPreferredRepoChanged(repoId: Long) { val packageName = packageInfoFlow.value?.packageName ?: error("Had not package name") - repoManager.setPreferredRepoId(packageName, repoId) + scope.launch { + repoManager.setPreferredRepoId(packageName, repoId).join() + updatesManager.loadUpdates() + } } override fun onCleared() { diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt index 593e8af3c..e87c4adf6 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsWarnings.kt @@ -18,40 +18,79 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat import org.fdroid.R +import org.fdroid.database.AppVersion +import org.fdroid.database.KnownVulnerability +import org.fdroid.database.NoCompatibleSigner +import org.fdroid.database.NotAvailable +import org.fdroid.database.UpdateInOtherRepo import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.index.v2.ANTI_FEATURE_KNOWN_VULNERABILITY import org.fdroid.ui.utils.testApp @Composable fun AppDetailsWarnings( item: AppDetailsItem, + modifier: Modifier = Modifier, ) { - val (color, stringRes) = when { + val (color, string) = when { + // app issues take priority + item.issue != null -> when (item.issue) { + // apps has a known security vulnerability + is KnownVulnerability -> { + val details = item.versions?.firstNotNullOfOrNull { versionItem -> + (versionItem.version as? AppVersion)?.getAntiFeatureReason( + antiFeatureKey = ANTI_FEATURE_KNOWN_VULNERABILITY, + localeList = LocaleListCompat.getDefault(), + ) + } + Pair( + MaterialTheme.colorScheme.errorContainer, + if (details.isNullOrBlank()) { + stringResource(R.string.antiknownvulnlist) + } else { + stringResource(R.string.antiknownvulnlist) + ":\n\n" + details + }, + ) + } + is NoCompatibleSigner -> Pair( + MaterialTheme.colorScheme.errorContainer, + if (item.issue.repoIdWithCompatibleSigner == null) { + stringResource(R.string.app_no_compatible_signer) + } else { + stringResource(R.string.app_no_compatible_signer_in_this_repo) + }, + ) + is UpdateInOtherRepo -> Pair( + MaterialTheme.colorScheme.inverseSurface, + stringResource(R.string.app_issue_update_other_repo), + ) + NotAvailable -> Pair( + MaterialTheme.colorScheme.errorContainer, + stringResource(R.string.error), + ) + } // app is outright incompatible item.isIncompatible -> Pair( MaterialTheme.colorScheme.errorContainer, - R.string.app_no_compatible_versions, - ) - // app is installed, but can't receive updates, because current repo has different signer - item.noUpdatesBecauseDifferentSigner -> Pair( - MaterialTheme.colorScheme.errorContainer, - R.string.app_no_compatible_signer, + stringResource(R.string.app_no_compatible_versions), ) // app targets old targetSdk, not a deal breaker, but worth flagging, no auto-update item.oldTargetSdk -> Pair( MaterialTheme.colorScheme.inverseSurface, - R.string.app_no_auto_update, + stringResource(R.string.app_no_auto_update), ) else -> return } ElevatedCard( colors = CardDefaults.elevatedCardColors(containerColor = color), - modifier = Modifier + modifier = modifier .fillMaxWidth() - .padding(8.dp), + .padding(vertical = 8.dp), ) { WarningRow( - text = stringResource(stringRes), + text = string, ) } } @@ -59,7 +98,7 @@ fun AppDetailsWarnings( @Composable private fun WarningRow(text: String) { Row( - horizontalArrangement = spacedBy(8.dp), + horizontalArrangement = spacedBy(16.dp), verticalAlignment = CenterVertically, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) { @@ -78,6 +117,17 @@ fun AppDetailsWarningsPreview() { } } +@Preview +@Composable +private fun KnownVulnPreview() { + FDroidContent { + Column { + AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(true))) + AppDetailsWarnings(testApp.copy(issue = KnownVulnerability(false))) + } + } +} + @Preview @Composable private fun IncompatiblePreview() { diff --git a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt index 590aa817a..95341045d 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt @@ -20,6 +20,7 @@ import org.fdroid.install.AppInstallManager import org.fdroid.install.InstallState import org.fdroid.repo.RepoPreLoader import org.fdroid.settings.SettingsManager +import org.fdroid.ui.apps.AppWithIssueItem import org.fdroid.utils.sha256 private const val TAG = "DetailsPresenter" @@ -37,11 +38,13 @@ fun DetailsPresenter( viewModel: AppDetailsViewModel, packageInfoFlow: StateFlow, currentRepoIdFlow: StateFlow, + appsWithIssuesFlow: StateFlow?>, ): AppDetailsItem? { val packagePair = packageInfoFlow.collectAsState().value ?: return null val packageName = packagePair.packageName val packageInfo = packagePair.packageInfo val currentRepoId = currentRepoIdFlow.collectAsState().value + val appsWithIssues = appsWithIssuesFlow.collectAsState().value val app = if (currentRepoId == null) { val flow = remember { db.getAppDao().getApp(packageName).asFlow() @@ -118,14 +121,6 @@ fun DetailsPresenter( val installedVersion = packageInfo?.let { versions?.find { it.versionCode == installedVersionCode } } - val noUpdatesBecauseDifferentSigner = if (packageInfo != null && versions != null) { - // return true of no version has same signer - versions.none { version -> - version.manifest.signer?.sha256?.get(0) == installedSigner - } - } else { - false - } val authorName = app.authorName val authorHasMoreThanOneApp = if (authorName == null) false else { val flow = remember(authorName) { @@ -133,6 +128,9 @@ fun DetailsPresenter( } flow.collectAsState(false).value } + val issue = remember(appsWithIssues) { + appsWithIssues?.find { it.packageName == packageName }?.issue + } val locales = LocaleListCompat.getDefault() Log.d(TAG, "Presenting app details:") Log.d(TAG, " app '${app.name}' ($packageName) in ${repo.address}") @@ -202,7 +200,7 @@ fun DetailsPresenter( suggestedVersion = suggestedVersion, possibleUpdate = possibleUpdate, appPrefs = appPrefs, - noUpdatesBecauseDifferentSigner = noUpdatesBecauseDifferentSigner, + issue = issue, authorHasMoreThanOneApp = authorHasMoreThanOneApp, localeList = locales, proxy = settingsManager.proxyConfig, diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt b/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt index b16493b54..6084c48c3 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/Discover.kt @@ -54,6 +54,7 @@ import org.fdroid.ui.utils.BigLoadingIndicator fun Discover( discoverModel: DiscoverModel, numUpdates: Int, + hasIssues: Boolean, isBigScreen: Boolean, onSearch: suspend (String) -> Unit, onSearchCleared: () -> Unit, @@ -97,7 +98,7 @@ fun Discover( ) }, bottomBar = { - if (!isBigScreen) BottomBar(numUpdates, NavigationKey.Discover, onNav) + if (!isBigScreen) BottomBar(numUpdates, hasIssues, NavigationKey.Discover, onNav) }, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> @@ -205,12 +206,13 @@ fun LoadingDiscoverPreview() { Discover( discoverModel = LoadingDiscoverModel(true), numUpdates = 23, + hasIssues = true, isBigScreen = false, + onSearch = {}, + onSearchCleared = {}, onListTap = {}, onAppTap = {}, onNav = {}, - onSearch = {}, - onSearchCleared = {}, ) } } @@ -222,12 +224,13 @@ private fun NoEnabledReposPreview() { Discover( discoverModel = NoEnabledReposDiscoverModel, numUpdates = 0, + hasIssues = true, isBigScreen = false, + onSearch = {}, + onSearchCleared = {}, onListTap = {}, onAppTap = {}, onNav = {}, - onSearch = {}, - onSearchCleared = {}, ) } } diff --git a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt index 3d239a051..77d1552c0 100644 --- a/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/discover/DiscoverViewModel.kt @@ -52,6 +52,7 @@ class DiscoverViewModel @Inject constructor( private val collator = Collator.getInstance(Locale.getDefault()) val numUpdates = updatesManager.numUpdates + val hasIssues = updatesManager.appsWithIssues.map { !it.isNullOrEmpty() } val newApps = db.getAppDao().getNewAppsFlow().map { list -> val proxyConfig = settingsManager.proxyConfig list.mapNotNull { diff --git a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt index fe64391ae..4eddeb9e7 100644 --- a/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/repositories/RepositoriesViewModel.kt @@ -22,6 +22,7 @@ import org.fdroid.index.RepoManager import org.fdroid.repo.RepoUpdateWorker import org.fdroid.settings.OnboardingManager import org.fdroid.settings.SettingsManager +import org.fdroid.updates.UpdatesManager import org.fdroid.utils.IoDispatcher import javax.inject.Inject @@ -29,6 +30,7 @@ import javax.inject.Inject class RepositoriesViewModel @Inject constructor( app: Application, private val repoManager: RepoManager, + private val updateManager: UpdatesManager, private val settingsManager: SettingsManager, private val onboardingManager: OnboardingManager, @param:IoDispatcher private val ioScope: CoroutineScope, @@ -82,6 +84,7 @@ class RepositoriesViewModel @Inject constructor( fun onRepositoryEnabled(repoId: Long, enabled: Boolean) { ioScope.launch { repoManager.setRepositoryEnabled(repoId, enabled) + updateManager.loadUpdates() if (enabled) withContext(Dispatchers.Main) { RepoUpdateWorker.updateNow(application, repoId) } diff --git a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index 6f3319071..210495df8 100644 --- a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -1,10 +1,13 @@ package org.fdroid.ui.utils import android.content.Intent +import androidx.annotation.RestrictTo import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import org.fdroid.database.AppListSortOrder import org.fdroid.database.AppMetadata import org.fdroid.database.AppPrefs +import org.fdroid.database.KnownVulnerability +import org.fdroid.database.NotAvailable import org.fdroid.database.Repository import org.fdroid.download.Mirror import org.fdroid.index.IndexFormatVersion @@ -13,6 +16,10 @@ import org.fdroid.index.v2.PackageVersion import org.fdroid.index.v2.SignerV2 import org.fdroid.install.InstallConfirmationState import org.fdroid.install.InstallState +import org.fdroid.ui.apps.AppUpdateItem +import org.fdroid.ui.apps.AppWithIssueItem +import org.fdroid.ui.apps.InstalledAppItem +import org.fdroid.ui.apps.InstallingAppItem import org.fdroid.ui.apps.MyAppsInfo import org.fdroid.ui.apps.MyAppsModel import org.fdroid.ui.categories.CategoryItem @@ -176,7 +183,6 @@ val testApp = AppDetailsItem( "and in the long run the SABR video protocol needs to be implemented, " + "but TeamNewPipe members are currently busy so any help would be greatly appreciated! " + "https://github.com/TeamNewPipe/NewPipe/issues/12248", - noUpdatesBecauseDifferentSigner = true, authorHasMoreThanOneApp = true, versions = listOf( VersionItem( @@ -264,6 +270,80 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { override fun confirmAppInstall(packageName: String, state: InstallConfirmationState) {} } +@RestrictTo(RestrictTo.Scope.TESTS) +internal val myAppsModel = MyAppsModel( + appUpdates = listOf( + AppUpdateItem( + repoId = 1, + packageName = "B1", + name = "App Update 123", + installedVersionName = "1.0.1", + update = getPreviewVersion("1.1.0", 123456789), + whatsNew = "This is new, all is new, nothing old.", + ), + AppUpdateItem( + repoId = 2, + packageName = "B2", + name = Names.randomName, + installedVersionName = "3.0.1", + update = getPreviewVersion("3.1.0", 9876543), + whatsNew = null, + ) + ), + installingApps = listOf( + InstallingAppItem( + packageName = "A1", + installState = InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconDownloadRequest = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ) + ) + ), + appsWithIssue = listOf( + AppWithIssueItem( + packageName = "C1", + name = Names.randomName, + installedVersionName = "1", + issue = KnownVulnerability(true), + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(5) + ), + AppWithIssueItem( + packageName = "C2", + name = Names.randomName, + installedVersionName = "2", + issue = NotAvailable, + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(7) + ), + ), + installedApps = listOf( + InstalledAppItem( + packageName = "D1", + name = Names.randomName, + installedVersionName = "1", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1) + ), + InstalledAppItem( + packageName = "D2", + name = Names.randomName, + installedVersionName = "2", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2) + ), + InstalledAppItem( + packageName = "D3", + name = Names.randomName, + installedVersionName = "3", + lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3) + ) + ), + sortOrder = AppListSortOrder.NAME, +) + fun getRepositoriesInfo( model: RepositoryModel, currentRepositoryId: Long? = null, diff --git a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt index 5a65cd5a4..6e0937b11 100644 --- a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt +++ b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt @@ -1,6 +1,7 @@ package org.fdroid.updates import android.content.Context +import android.content.pm.PackageInfo import androidx.core.os.LocaleListCompat import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -17,14 +18,18 @@ import kotlinx.coroutines.sync.withPermit import mu.KotlinLogging import org.fdroid.NotificationManager import org.fdroid.database.AppVersion -import org.fdroid.database.DbUpdateChecker +import org.fdroid.database.AvailableAppWithIssue +import org.fdroid.database.DbAppChecker import org.fdroid.database.FDroidDatabase +import org.fdroid.database.UnavailableAppWithIssue import org.fdroid.download.getImageModel import org.fdroid.index.RepoManager import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstalledAppsCache import org.fdroid.repo.RepoUpdateWorker import org.fdroid.settings.SettingsManager import org.fdroid.ui.apps.AppUpdateItem +import org.fdroid.ui.apps.AppWithIssueItem import org.fdroid.utils.IoDispatcher import javax.inject.Inject import javax.inject.Singleton @@ -34,10 +39,11 @@ import kotlin.math.min class UpdatesManager @Inject constructor( @param:ApplicationContext private val context: Context, private val db: FDroidDatabase, - private val dbUpdateChecker: DbUpdateChecker, + private val dbAppChecker: DbAppChecker, private val settingsManager: SettingsManager, private val repoManager: RepoManager, private val appInstallManager: AppInstallManager, + private val installedAppsCache: InstalledAppsCache, private val notificationManager: NotificationManager, @param:IoDispatcher private val coroutineScope: CoroutineScope, ) { @@ -45,6 +51,8 @@ class UpdatesManager @Inject constructor( private val _updates = MutableStateFlow?>(null) val updates = _updates.asStateFlow() + private val _appsWithIssues = MutableStateFlow?>(null) + val appsWithIssues = _appsWithIssues.asStateFlow() private val _numUpdates = MutableStateFlow(0) val numUpdates = _numUpdates.asStateFlow() @@ -77,16 +85,24 @@ class UpdatesManager @Inject constructor( ) init { - loadUpdates() + coroutineScope.launch { + // refresh updates whenever installed apps change + installedAppsCache.installedApps.collect { + loadUpdates(it) + } + } } - fun loadUpdates() = coroutineScope.launch { - // TODO (includeKnownVulnerabilities = true) and show in AppDetails + fun loadUpdates( + packageInfoMap: Map = installedAppsCache.installedApps.value, + ) = coroutineScope.launch { + if (packageInfoMap.isEmpty()) return@launch val localeList = LocaleListCompat.getDefault() - val updates = try { - log.info { "Checking for updates..." } + try { + log.info { "Checking for updates (${packageInfoMap.size} apps)..." } val proxyConfig = settingsManager.proxyConfig - dbUpdateChecker.getUpdatableApps(onlyFromPreferredRepo = true).map { update -> + val apps = dbAppChecker.getApps(packageInfoMap = packageInfoMap) + val updates = apps.updates.map { update -> AppUpdateItem( repoId = update.repoId, packageName = update.packageName, @@ -99,17 +115,43 @@ class UpdatesManager @Inject constructor( }, ) } + _updates.value = updates + _numUpdates.value = updates.size + // update 'update available' notification, if it is currently showing + if (notificationManager.isAppUpdatesAvailableNotificationShowing) { + if (updates.isEmpty()) notificationManager.cancelAppUpdatesAvailableNotification() + else notificationManager.showAppUpdatesAvailableNotification(notificationStates) + } + + val issueItems = apps.issues.map { app -> + when (app) { + is AvailableAppWithIssue -> AppWithIssueItem( + packageName = app.app.packageName, + name = app.app.getName(localeList) ?: "Unknown app", + installedVersionName = app.installVersionName, + issue = app.issue, + lastUpdated = app.app.lastUpdated, + iconModel = PackageName( + packageName = app.app.packageName, + iconDownloadRequest = repoManager.getRepository(app.app.repoId)?.let { + app.app.getIcon(localeList)?.getImageModel(it, proxyConfig) + } as? DownloadRequest), + ) + is UnavailableAppWithIssue -> AppWithIssueItem( + packageName = app.packageName, + name = app.name.toString(), + installedVersionName = app.installVersionName, + issue = app.issue, + lastUpdated = -1, + iconModel = PackageName(app.packageName, null), + ) + } + } + _appsWithIssues.value = issueItems } catch (e: Exception) { log.error(e) { "Error loading updates: " } return@launch } - _updates.value = updates - _numUpdates.value = updates.size - // update 'update available' notification, if it is currently showing - if (notificationManager.isAppUpdatesAvailableNotificationShowing) { - if (updates.isEmpty()) notificationManager.cancelAppUpdatesAvailableNotification() - else notificationManager.showAppUpdatesAvailableNotification(notificationStates) - } } suspend fun updateAll(): List { diff --git a/next/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt b/next/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt index e95adbaf9..0d404f95e 100644 --- a/next/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt +++ b/next/src/main/kotlin/org/fdroid/updates/UpdatesModule.kt @@ -9,7 +9,7 @@ import dagger.hilt.components.SingletonComponent import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.UpdateChecker -import org.fdroid.database.DbUpdateChecker +import org.fdroid.database.DbAppChecker import org.fdroid.database.FDroidDatabase import javax.inject.Singleton @@ -30,12 +30,12 @@ object UpdatesModule { @Provides @Singleton - fun provideDbUpdateChecker( + fun provideDbAppChecker( @ApplicationContext context: Context, db: FDroidDatabase, updateChecker: UpdateChecker, compatibilityChecker: CompatibilityChecker, - ): DbUpdateChecker { - return DbUpdateChecker(db, context.packageManager, compatibilityChecker, updateChecker) + ): DbAppChecker { + return DbAppChecker(db, context, compatibilityChecker, updateChecker) } } diff --git a/next/src/main/res/values/strings-next.xml b/next/src/main/res/values/strings-next.xml index 112b4f57a..b4c530abe 100644 --- a/next/src/main/res/values/strings-next.xml +++ b/next/src/main/res/values/strings-next.xml @@ -14,6 +14,7 @@ No repository selected No apps installed.\n\nInstall apps and they will appear here. + Apps with issues New apps Recently updated @@ -57,8 +58,12 @@ Developer contact Copy link This app is not compatible with your device. - Can not update this app, because no compatible versions available in repository. + Can not update this app, because all versions have an incompatible signature.\n\nIf you don\'t receive updates through other means, you may need to uninstall and then reinstall this app. The app\'s data will be lost. + Can not update this app, because there are no compatible versions in the preferred repository.\n\nTry changing the preferred repository. Auto-update not available, because app targets old version of Android. + An update is available in another repository, but will not get installed, because that repository is not preferred. + No longer available + This app is not receiving updates, because it is no longer in any enabled repository.\n\nIt may have been in a repository you removed or disabled. Or it was simply removed. Added %1$s Size: %1$s SDK versions: %1$s