mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-02-08 22:22:40 -05:00
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:
108
next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt
Normal file
108
next/src/main/kotlin/org/fdroid/install/InstalledAppsCache.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
237
next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt
Normal file
237
next/src/main/kotlin/org/fdroid/ui/apps/MyAppsList.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user