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.
This commit is contained in:
Torsten Grote
2025-11-28 16:54:04 -03:00
parent 2a6736096c
commit de2f3f0ec6
23 changed files with 734 additions and 313 deletions

View File

@@ -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<Map<String, PackageInfo>>(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)
}
}
}
}

View File

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

View File

@@ -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<DiscoverViewModel>()
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,

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ interface MyAppsInfo {
data class MyAppsModel(
val installingApps: List<InstallingAppItem>,
val appUpdates: List<AppUpdateItem>? = null,
val appsWithIssue: List<AppWithIssueItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
)

View File

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

View File

@@ -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<List<AppUpdateItem>?>,
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
installedAppsFlow: Flow<List<InstalledAppItem>>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
): 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<String>()
@@ -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,
)

View File

@@ -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<List<InstalledAppItem>?>(null)
private var installedAppsLiveData =
db.getAppDao().getInstalledAppListItems(application.packageManager)
private val installedAppsObserver = Observer<List<AppListItem>> { 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<String>("query", "")
private val searchQuery = savedStateHandle.getMutableStateFlow("query", "")
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME)
val myAppsModel: StateFlow<MyAppsModel> = 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) {

View File

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

View File

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

View File

@@ -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<AntiFeature>? = 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

View File

@@ -56,18 +56,19 @@ class AppDetailsViewModel @Inject constructor(
private val currentRepoIdFlow = MutableStateFlow<Long?>(null)
val appDetails: StateFlow<AppDetailsItem?> = 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() {

View File

@@ -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() {

View File

@@ -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<AppInfo?>,
currentRepoIdFlow: StateFlow<Long?>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
): 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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<List<AppUpdateItem>?>(null)
val updates = _updates.asStateFlow()
private val _appsWithIssues = MutableStateFlow<List<AppWithIssueItem>?>(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<String, PackageInfo> = 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<Job> {

View File

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

View File

@@ -14,6 +14,7 @@
<string name="no_repository_selected">No repository selected</string>
<string name="my_apps_empty">No apps installed.\n\nInstall apps and they will appear here.</string>
<string name="my_apps_header_apps_with_issue">Apps with issues</string>
<string name="app_list_new">New apps</string>
<string name="app_list_recently_updated">Recently updated</string>
@@ -57,8 +58,12 @@
<string name="developer_contact">Developer contact</string>
<string name="copy_link">Copy link</string>
<string name="app_no_compatible_versions">This app is not compatible with your device.</string>
<string name="app_no_compatible_signer">Can not update this app, because no compatible versions available in repository.</string>
<string name="app_no_compatible_signer">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.</string>
<string name="app_no_compatible_signer_in_this_repo">Can not update this app, because there are no compatible versions in the preferred repository.\n\nTry changing the preferred repository.</string>
<string name="app_no_auto_update">Auto-update not available, because app targets old version of Android.</string>
<string name="app_issue_update_other_repo">An update is available in another repository, but will not get installed, because that repository is not preferred.</string>
<string name="app_issue_not_available_title">No longer available</string>
<string name="app_issue_not_available_text">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.</string>
<string name="added_x_ago">Added %1$s</string>
<string name="size_colon">Size: %1$s</string>
<string name="sdk_versions_colon">SDK versions: %1$s</string>