Use real data for 'My apps' and make app details menu functional

This commit is contained in:
Torsten Grote
2025-07-17 17:39:38 -03:00
parent acbb4320c2
commit ffb762016f
23 changed files with 866 additions and 500 deletions

View File

@@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -31,8 +31,7 @@
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
</provider>
tools:node="remove" />
</application>
</manifest>

View File

@@ -15,6 +15,8 @@ import coil3.request.crossfade
import coil3.util.DebugLogger
import dagger.hilt.android.HiltAndroidApp
import org.fdroid.basic.repo.RepoUpdateWorker
import org.fdroid.basic.ui.icons.ApplicationIconFetcher
import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.download.DownloadRequest
import org.fdroid.download.coil.DownloadRequestFetcher
import javax.inject.Inject
@@ -24,6 +26,7 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var downloadRequestFetcherFactory: DownloadRequestFetcher.Factory
@@ -41,7 +44,7 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
return ImageLoader.Builder(context)
.crossfade(true)
.components {
val keyer = object : Keyer<DownloadRequest> {
val downloadRequestKeyer = object : Keyer<DownloadRequest> {
override fun key(
data: DownloadRequest,
options: Options
@@ -50,8 +53,14 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
?: (data.mirrors[0].baseUrl + data.indexFile.name)
}
}
add(keyer)
add(downloadRequestKeyer)
add(downloadRequestFetcherFactory)
val packageNameKeyer = object : Keyer<PackageName> {
override fun key(data: PackageName, options: Options): String = data.packageName
}
add(packageNameKeyer)
add(ApplicationIconFetcher.Factory(this@App.applicationContext, downloadRequestFetcherFactory))
}
.memoryCache {
MemoryCache.Builder()

View File

@@ -16,19 +16,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.fdroid.basic.details.AppDetailsManager
import org.fdroid.basic.download.getDownloadRequest
import org.fdroid.basic.manager.MyAppsManager
import org.fdroid.basic.repo.RepositoryManager
import org.fdroid.basic.ui.categories.Category
import org.fdroid.basic.ui.main.apps.MinimalApp
import org.fdroid.basic.ui.main.discover.AppNavigationItem
import org.fdroid.basic.ui.main.discover.DiscoverModel
import org.fdroid.basic.ui.main.discover.DiscoverPresenter
import org.fdroid.basic.ui.main.lists.AppList
import org.fdroid.basic.ui.main.lists.FilterModel
import org.fdroid.basic.ui.main.lists.FilterPresenter
import org.fdroid.basic.ui.main.lists.Sort
import org.fdroid.basic.ui.main.lists.AppList
import org.fdroid.database.FDroidDatabase
import java.text.Collator
import java.util.Locale
@@ -38,9 +35,7 @@ import javax.inject.Inject
class MainViewModel @Inject constructor(
app: Application,
savedStateHandle: SavedStateHandle,
val myAppsManager: MyAppsManager,
private val db: FDroidDatabase,
private val appDetailsManager: AppDetailsManager,
db: FDroidDatabase,
val repositoryManager: RepositoryManager,
) : AndroidViewModel(app) {
@@ -97,19 +92,11 @@ class MainViewModel @Inject constructor(
addedCategoriesFlow = addedCategories,
)
}
val updates = myAppsManager.updates
val installed = myAppsManager.installed
val numUpdates = myAppsManager.numUpdates
val appDetails = appDetailsManager.appDetails
fun setAppList(appList: AppList) {
_currentList.value = appList
}
fun setAppDetails(app: MinimalApp) {
appDetailsManager.setAppDetails(app)
}
fun toggleListFilterVisibility() {
_showFilters.update { !it }
}

View File

@@ -12,6 +12,7 @@ import org.fdroid.database.AppPrefs
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersion
import org.fdroid.index.v2.SignerV2
@@ -21,8 +22,19 @@ enum class MainButtonState {
NONE, INSTALL, UPDATE
}
class AppDetailsActions(
val allowBetaVersions: () -> Unit,
val ignoreAllUpdates: (() -> Unit)? = null,
val ignoreThisUpdate: (() -> Unit)? = null,
val shareApk: (() -> Unit)? = null,
val uninstallApp: (() -> Unit)? = null,
val launchIntent: Intent? = null,
val shareIntent: Intent? = null,
)
data class AppDetailsItem(
val app: AppMetadata,
val actions: AppDetailsActions,
/**
* The ID of the repo that is currently set as preferred.
* Note that the repository ID of this [app] may be different.
@@ -45,11 +57,18 @@ data class AppDetailsItem(
* Needed, because the [installedVersion] may not be available, e.g. too old.
*/
val installedVersionCode: Long? = null,
/**
* The currently suggested version for installation.
*/
val suggestedVersion: PackageVersion? = null,
/**
* Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions.
* This is useful for (un-)ignoring this version.
*/
val possibleUpdate: PackageVersion? = null,
val appPrefs: AppPrefs? = null,
val whatsNew: String? = null,
val antiFeatures: List<AntiFeature>? = null,
val launchIntent: Intent? = 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
@@ -63,17 +82,19 @@ data class AppDetailsItem(
preferredRepoId: Long,
repositories: List<Repository>,
dbApp: App,
actions: AppDetailsActions,
versions: List<AppVersion>?,
installedVersion: AppVersion?,
installedVersionCode: Long?,
suggestedVersion: AppVersion?,
possibleUpdate: AppVersion?,
appPrefs: AppPrefs?,
launchIntent: Intent?,
noCompatibleVersions: Boolean,
authorHasMoreThanOneApp: Boolean,
localeList: LocaleListCompat,
) : this(
app = dbApp.metadata,
actions = actions,
preferredRepoId = preferredRepoId,
repositories = repositories,
name = dbApp.name ?: "Unknown App",
@@ -93,12 +114,12 @@ data class AppDetailsItem(
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
suggestedVersion = suggestedVersion,
possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
whatsNew = installedVersion?.getWhatsNew(localeList),
antiFeatures = installedVersion?.getAntiFeatures(repository, localeList)
?: suggestedVersion?.getAntiFeatures(repository, localeList)
?: versions?.first()?.getAntiFeatures(repository, localeList),
launchIntent = launchIntent,
noCompatibleVersions = noCompatibleVersions,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
)
@@ -107,7 +128,23 @@ data class AppDetailsItem(
* True if the app is installed (and has a launch intent)
* and thus the 'Open' button should be shown.
*/
val showOpenButton: Boolean get() = launchIntent != null
val showOpenButton: Boolean get() = actions.launchIntent != null
val allowsBetaVersions: Boolean
get() = appPrefs?.releaseChannels?.contains(RELEASE_CHANNEL_BETA) == true
val ignoresAllUpdates: Boolean get() = appPrefs?.ignoreAllUpdates == true
/**
* True if the update from [possibleUpdate] is being ignored
* and not already ignoring all updates anyway.
*/
val ignoresCurrentUpdate: Boolean
get() {
if (ignoresAllUpdates) return false
val prefs = appPrefs ?: return false
val updateVersionCode = possibleUpdate?.versionCode ?: return false
return actions.ignoreThisUpdate != null && prefs.shouldIgnoreUpdate(updateVersionCode)
}
/**
* Specifies what main button should be shown.
@@ -244,6 +281,8 @@ val testApp = AppDetailsItem(
categories = listOf("Internet", "Multimedia"),
isCompatible = true,
),
actions = AppDetailsActions({}, {}, {}, {}, {}, Intent(), Intent()),
appPrefs = AppPrefs("org.schabi.newpipe"),
name = "New Pipe",
summary = "Lightweight YouTube frontend",
description = "NewPipe does not use any Google framework libraries, or the YouTube API. " +
@@ -277,4 +316,5 @@ val testApp = AppDetailsItem(
),
installedVersion = testVersion2,
suggestedVersion = testVersion1,
possibleUpdate = testVersion1,
)

View File

@@ -1,34 +1,37 @@
package org.fdroid.basic.details
import android.content.Context
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import androidx.annotation.UiThread
import androidx.lifecycle.AndroidViewModel
import app.cash.molecule.RecompositionMode.Immediate
import app.cash.molecule.launchMolecule
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.fdroid.UpdateChecker
import org.fdroid.basic.ui.main.apps.MinimalApp
import org.fdroid.basic.manager.MyAppsManager
import org.fdroid.basic.utils.IoDispatcher
import org.fdroid.database.FDroidDatabase
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.RepoManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppDetailsManager @Inject constructor(
@ApplicationContext private val context: Context,
@HiltViewModel
class AppDetailsViewModel @Inject constructor(
private val app: Application,
@IoDispatcher private val scope: CoroutineScope,
private val db: FDroidDatabase,
private val repoManager: RepoManager,
private val updateChecker: UpdateChecker,
) {
private val myAppsManager: MyAppsManager,
) : AndroidViewModel(app) {
private val packageInfoFlow = MutableStateFlow<AppInfo?>(null)
val appDetails: StateFlow<AppDetailsItem?> = scope.launchMolecule(
@@ -38,27 +41,56 @@ class AppDetailsManager @Inject constructor(
db = db,
repoManager = repoManager,
updateChecker = updateChecker,
viewModel = this,
packageInfoFlow = packageInfoFlow,
)
}
fun setAppDetails(minimalApp: MinimalApp?) {
fun setAppDetails(packageName: String) {
packageInfoFlow.value = null
val packageManager = context.packageManager
if (minimalApp != null) scope.launch {
val packageManager = app.packageManager
scope.launch {
val packageInfo = try {
packageManager.getPackageInfo(minimalApp.packageName, GET_SIGNATURES)
packageManager.getPackageInfo(packageName, GET_SIGNATURES)
} catch (_: PackageManager.NameNotFoundException) {
null
}
packageInfoFlow.value = if (packageInfo == null) {
AppInfo(minimalApp.packageName)
AppInfo(packageName)
} else {
val intent = packageManager.getLaunchIntentForPackage(minimalApp.packageName)
AppInfo(minimalApp.packageName, packageInfo, intent)
val intent = packageManager.getLaunchIntentForPackage(packageName)
AppInfo(packageName, packageInfo, intent)
}
}
}
@UiThread
fun allowBetaUpdates() {
val appPrefs = appDetails.value?.appPrefs ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA))
myAppsManager.loadUpdates()
}
}
@UiThread
fun ignoreAllUpdates() {
val appPrefs = appDetails.value?.appPrefs ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates())
myAppsManager.loadUpdates()
}
}
@UiThread
fun ignoreThisUpdate() {
val appPrefs = appDetails.value?.appPrefs ?: return
val versionCode = appDetails.value?.possibleUpdate?.versionCode ?: return
scope.launch {
db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode))
myAppsManager.loadUpdates()
}
}
}
class AppInfo(

View File

@@ -1,10 +1,12 @@
package org.fdroid.basic.details
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode
import androidx.core.net.toUri
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +24,7 @@ fun DetailsPresenter(
db: FDroidDatabase,
repoManager: RepoManager,
updateChecker: UpdateChecker,
viewModel: AppDetailsViewModel,
packageInfoFlow: StateFlow<AppInfo?>,
): AppDetailsItem? {
val packagePair = packageInfoFlow.collectAsState().value ?: return null
@@ -47,25 +50,33 @@ fun DetailsPresenter(
updateChecker.getSuggestedVersion(
versions = versions,
preferredSigner = app.metadata.preferredSigner,
// TODO releaseChannels beta
releaseChannels = null,
releaseChannels = appPrefs.releaseChannels,
preferencesGetter = { appPrefs },
)
}
val possibleUpdate = if (versions == null || appPrefs == null) {
null
} else {
updateChecker.getUpdate(
versions = versions,
allowedSignersGetter = app.metadata.preferredSigner?.let { { setOf(it) } },
allowedReleaseChannels = appPrefs.releaseChannels,
preferencesGetter = null, // ignoring existing preferences to include ignored versions
)
}
val installedVersionCode = packagePair.packageInfo?.let {
getLongVersionCode(packagePair.packageInfo)
}
val installedVersion = packagePair.packageInfo?.let {
versions?.find { it.versionCode == installedVersionCode }
}
val installedSigner = packagePair.packageInfo?.signatures?.get(0)?.let {
sha256(it.toByteArray())
}
val noCompatibleVersions = if (packagePair.packageInfo != null && versions != null) {
// get installed signer
val signer = packagePair.packageInfo.signatures?.get(0)?.let {
sha256(it.toByteArray())
}
// return true of no version has same signer
versions.none { version ->
version.manifest.signer?.sha256?.get(0) == signer
version.manifest.signer?.sha256?.get(0) == installedSigner
}
} else {
false
@@ -84,14 +95,49 @@ fun DetailsPresenter(
preferredRepoId = preferredRepoId,
repositories = repositories, // TODO maybe use emptyList() when only in F-Droid repo
dbApp = app,
actions = AppDetailsActions(
allowBetaVersions = viewModel::allowBetaUpdates,
ignoreAllUpdates = if (installedVersionCode == null) {
null
} else {
viewModel::ignoreAllUpdates
},
ignoreThisUpdate = if (installedVersionCode == null || possibleUpdate == null ||
possibleUpdate.versionCode <= installedVersionCode
) {
null
} else {
viewModel::ignoreThisUpdate
},
shareApk = null, // TODO
uninstallApp = null, // TODO
launchIntent = packagePair.launchIntent,
shareIntent = getShareIntent(repo, packageName, app.name ?: ""),
),
versions = versions,
installedVersion = installedVersion,
installedVersionCode = installedVersionCode,
suggestedVersion = suggestedVersion,
possibleUpdate = possibleUpdate,
appPrefs = appPrefs,
launchIntent = packagePair.launchIntent,
noCompatibleVersions = noCompatibleVersions,
authorHasMoreThanOneApp = authorHasMoreThanOneApp,
localeList = locales,
)
}
private fun getShareIntent(
repo: org.fdroid.database.Repository,
packageName: String,
appName: String,
): Intent? {
val webBaseUrl = repo.webBaseUrl ?: return null
val shareUri = webBaseUrl.toUri().buildUpon().appendPath(packageName).build()
val uriIntent = Intent(Intent.ACTION_SEND).apply {
setType("text/plain")
putExtra(Intent.EXTRA_SUBJECT, appName)
putExtra(Intent.EXTRA_TITLE, appName)
putExtra(Intent.EXTRA_TEXT, shareUri.toString())
}
return Intent.createChooser(uriIntent, appName)
}

View File

@@ -1,143 +1,59 @@
package org.fdroid.basic.manager
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.fdroid.basic.ui.Icons
import org.fdroid.basic.ui.Names
import org.fdroid.basic.ui.main.apps.InstalledApp
import org.fdroid.basic.ui.main.apps.UpdatableApp
import org.fdroid.basic.ui.main.lists.Sort
import java.util.Locale
import org.fdroid.basic.download.getDownloadRequest
import org.fdroid.basic.repo.RepositoryManager
import org.fdroid.basic.utils.IoDispatcher
import org.fdroid.database.DbUpdateChecker
import org.fdroid.download.DownloadRequest
import org.fdroid.index.v2.PackageVersion
import javax.inject.Inject
import javax.inject.Singleton
data class AppUpdateItem(
val packageName: String,
val name: String,
val installedVersionName: String,
val update: PackageVersion,
val whatsNew: String?,
val iconDownloadRequest: DownloadRequest? = null,
)
@Singleton
class MyAppsManager @Inject constructor() {
private val _updates = MutableStateFlow<List<UpdatableApp>>(emptyList())
class MyAppsManager @Inject constructor(
private val dbUpdateChecker: DbUpdateChecker,
@IoDispatcher private val coroutineScope: CoroutineScope,
private val repositoryManager: RepositoryManager,
) {
private val _updates = MutableStateFlow<List<AppUpdateItem>?>(null)
val updates = _updates.asStateFlow()
private val _installed = MutableStateFlow<List<InstalledApp>>(installedApps)
val installed = _installed.asStateFlow()
val numUpdates = _updates.map { it.size }
private val _sortBy = MutableStateFlow<Sort>(Sort.NAME)
val sortBy = _sortBy.asStateFlow()
companion object {
val installedApps = listOf(
InstalledApp(
packageName = "1000",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "1.0.1",
),
InstalledApp(
packageName = "1001",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "0.1",
),
InstalledApp(
packageName = "1002",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "3.0.1",
),
InstalledApp(
packageName = "1003",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "0.2.1",
),
InstalledApp(
packageName = "1004",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "0.0.1",
),
InstalledApp(
packageName = "1005",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "1.1.1",
),
InstalledApp(
packageName = "1006",
name = Names.randomName,
icon = Icons.randomIcon,
versionName = "2.0.1",
),
).sortedBy { it.name.lowercase(Locale.getDefault()) }
}
private val _numUpdates = MutableStateFlow(0)
val numUpdates = _numUpdates.asStateFlow()
init {
GlobalScope.launch {
delay(5_000)
_updates.update {
listOf(
UpdatableApp(
packageName = "2000",
name = Names.randomName,
icon = Icons.randomIcon,
currentVersionName = "1.0.1",
updateVersionName = "1.1.0",
size = 123456789,
whatsNew = "Lots of changes in this version!\nThey are all awesome.\n" +
"Only the best changes."
),
UpdatableApp(
packageName = "2001",
name = Names.randomName,
icon = Icons.randomIcon,
currentVersionName = "3.0.1",
updateVersionName = "3.1.0",
size = 9876543,
),
UpdatableApp(
packageName = "2002",
name = Names.randomName,
currentVersionName = "4.0.1",
updateVersionName = "4.3.0",
size = 4561237,
whatsNew = "This new version is super fast and aimed at fixing some bugs and enhancing your experience even more. So take the chance to update your app and always enjoy the best of Inter. In addition to the exciting new features in the latest version, we regularly release new versions to improve what you are already using on our app. To keep making your life simpler, keep your app up to date and take advantage of everything we prepare for you. "
),
UpdatableApp(
packageName = "2003",
name = Names.randomName,
icon = Icons.randomIcon,
currentVersionName = "3.0.1",
updateVersionName = "3.1.0",
size = 9876543,
),
).sortedBy { it.name.lowercase(Locale.getDefault()) }
}
}
loadUpdates()
}
fun sortBy(sort: Sort) {
when (sort) {
Sort.NAME -> {
_updates.update {
it.sortedBy { it.name.lowercase(Locale.getDefault()) }
}
_installed.update {
it.sortedBy { it.name.lowercase(Locale.getDefault()) }
}
}
Sort.LATEST -> {
_updates.update {
it.sortedBy { it.packageName }
}
_installed.update {
it.sortedBy { it.packageName }
}
}
fun loadUpdates() = coroutineScope.launch {
// TODO (includeKnownVulnerabilities = true) and show in AppDetails
val localeList = LocaleListCompat.getDefault()
val updates = dbUpdateChecker.getUpdatableApps(onlyFromPreferredRepo = true).map { update ->
AppUpdateItem(
packageName = update.packageName,
name = update.name ?: "Unknown app",
installedVersionName = update.installedVersionName,
update = update.update,
whatsNew = update.update.getWhatsNew(localeList),
iconDownloadRequest = repositoryManager.getRepository(update.repoId)?.let { repo ->
update.getIcon(localeList)?.getDownloadRequest(repo)
},
)
}
_sortBy.value = sort
_updates.value = updates
_numUpdates.value = updates.size
}
}

View File

@@ -1,8 +1,29 @@
package org.fdroid.basic.ui
import android.text.format.DateUtils
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersion
import org.fdroid.index.v2.SignerV2
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.concurrent.TimeUnit
fun getPreviewVersion(versionName: String, size: Long? = null) = object : PackageVersion {
override val versionCode: Long = 23
override val versionName: String = versionName
override val added: Long = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3)
override val size: Long? = size
override val signer: SignerV2? = null
override val releaseChannels: List<String>? = null
override val packageManifest: PackageManifest = object : PackageManifest {
override val minSdkVersion: Int? = null
override val maxSdkVersion: Int? = null
override val featureNames: List<String>? = null
override val nativecode: List<String>? = null
override val targetSdkVersion: Int? = null
}
override val hasKnownVulnerability: Boolean = false
}
fun Long.asRelativeTimeString(): String {
return DateUtils.getRelativeTimeSpanString(

View File

@@ -0,0 +1,65 @@
package org.fdroid.basic.ui.icons
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build.VERSION.SDK_INT
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import mu.KotlinLogging
import org.fdroid.download.DownloadRequest
import org.fdroid.download.coil.DownloadRequestFetcher
import javax.inject.Inject
data class PackageName(val packageName: String, val iconDownloadRequest: DownloadRequest?)
class ApplicationIconFetcher(
private val packageManager: PackageManager,
private val data: PackageName,
private val downloadRequestFetcher: Fetcher?,
) : Fetcher {
private val log = KotlinLogging.logger { }
override suspend fun fetch(): FetchResult? {
val drawable = try {
val info = packageManager.getApplicationInfo(data.packageName, 0)
info.loadUnbadgedIcon(packageManager)
} catch (e: PackageManager.NameNotFoundException) {
log.error(e) { "Error getting icon from packageManager: " }
return downloadRequestFetcher?.fetch()
}
if (SDK_INT >= 30 && packageManager.isDefaultApplicationIcon(drawable)) {
log.warn {
"Could not extract image for ${data.packageName}"
}
return downloadRequestFetcher?.fetch()
}
return ImageFetchResult(
image = drawable.asImage(),
isSampled = false,
dataSource = DataSource.DISK,
)
}
class Factory @Inject constructor(
private val context: Context,
private val downloadRequestFetcherFactory: DownloadRequestFetcher.Factory,
) : Fetcher.Factory<PackageName> {
override fun create(
data: PackageName,
options: coil3.request.Options,
imageLoader: ImageLoader,
): Fetcher? = ApplicationIconFetcher(
packageManager = context.packageManager,
data = data,
downloadRequestFetcher = data.iconDownloadRequest?.let {
downloadRequestFetcherFactory.create(it, options, imageLoader)
},
)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavKey
import org.fdroid.basic.ui.BottomNavDestinations
@@ -31,8 +32,13 @@ fun BottomBar(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) ->
}
@Composable
fun NavigationRail(numUpdates: Int, currentNavKey: NavKey, onNav: (NavigationKey) -> Unit) {
NavigationRail {
fun NavigationRail(
numUpdates: Int,
currentNavKey: NavKey,
onNav: (NavigationKey) -> Unit,
modifier: Modifier,
) {
NavigationRail(modifier) {
BottomNavDestinations.entries.forEach { dest ->
NavigationRailItem(
icon = { NavIcon(dest, numUpdates) },

View File

@@ -16,6 +16,7 @@ import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -31,9 +32,11 @@ import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import org.fdroid.basic.MainViewModel
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsViewModel
import org.fdroid.basic.ui.NavigationKey
import org.fdroid.basic.ui.icons.PackageVariant
import org.fdroid.basic.ui.main.apps.MyApps
import org.fdroid.basic.ui.main.apps.MyAppsViewModel
import org.fdroid.basic.ui.main.details.AppDetails
import org.fdroid.basic.ui.main.discover.Discover
import org.fdroid.basic.ui.main.lists.AppList
@@ -78,7 +81,7 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
val isBigScreen = remember(windowAdaptiveInfo) {
windowAdaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)
}
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()//(directive = directive)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>(directive = directive)
FDroidContent {
NavDisplay(
backStack = backStack,
@@ -97,7 +100,8 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
Text("No app selected")
},
) {
val numUpdates = viewModel.numUpdates.collectAsStateWithLifecycle(0).value
val myAppsViewModel = hiltViewModel<MyAppsViewModel>()
val numUpdates = myAppsViewModel.numUpdates.collectAsStateWithLifecycle(0).value
Discover(
discoverModel = viewModel.discoverModel.collectAsStateWithLifecycle().value,
onTitleTap = {
@@ -105,7 +109,6 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
backStack.add(NavigationKey.AppList)
},
onAppTap = {
viewModel.setAppDetails(it)
backStack.add(NavigationKey.AppDetails(it.packageName))
},
onNav = { backStack.add(it) },
@@ -119,31 +122,35 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
Text("No app selected")
},
) {
val updates = viewModel.updates.collectAsStateWithLifecycle().value
val installed = viewModel.installed.collectAsStateWithLifecycle().value
val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
val myAppsViewModel = hiltViewModel<MyAppsViewModel>()
val myAppsModel =
myAppsViewModel.myAppsModel.collectAsStateWithLifecycle().value
MyApps(
updatableApps = updates,
installedApps = installed,
currentItem = currentItem,
onItemClick = {
viewModel.setAppDetails(it)
backStack.add(NavigationKey.AppDetails(it.packageName))
myAppsModel = myAppsModel,
currentPackageName = if (isBigScreen) {
(backStack.last() as? NavigationKey.AppDetails)?.packageName
} else null,
onAppItemClick = {
backStack.add(NavigationKey.AppDetails(it))
},
onNav = { backStack.add(it) },
sortBy = viewModel.myAppsManager.sortBy.collectAsStateWithLifecycle().value,
onRefresh = myAppsViewModel::refresh,
isBigScreen = isBigScreen,
onSortChanged = viewModel.myAppsManager::sortBy,
onSortChanged = myAppsViewModel::changeSortOrder,
)
}
entry<NavigationKey.AppDetails>(
metadata = ListDetailSceneStrategy.detailPane("appdetails")
) {
val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
it.packageName // TODO use?
val appDetailsViewModel = hiltViewModel<AppDetailsViewModel>()
LaunchedEffect(it.packageName) {
appDetailsViewModel.setAppDetails(it.packageName)
}
AppDetails(
appItem = currentItem,
onBackNav = { backStack.removeLastOrNull() },
item = appDetailsViewModel.appDetails.collectAsStateWithLifecycle().value,
onBackNav = if (isBigScreen) null else {
{ backStack.removeLastOrNull() }
},
modifier = Modifier,
)
}
@@ -165,15 +172,15 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
override fun removeCategory(category: String) =
viewModel.removeCategory(category)
}
val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value
AppList(
appList = viewModel.currentList.collectAsStateWithLifecycle().value,
filterInfo = filterInfo,
currentItem = currentItem,
currentPackageName = if (isBigScreen) {
(backStack.last() as? NavigationKey.AppDetails)?.packageName
} else null,
onBackClicked = { backStack.removeLastOrNull() },
modifier = Modifier,
) {
viewModel.setAppDetails(it)
backStack.add(NavigationKey.AppDetails(it.packageName))
}
}
@@ -192,7 +199,7 @@ fun Main(viewModel: MainViewModel = hiltViewModel()) {
onRepositorySelected = {
repositoryManager.setVisibleRepository(it)
backStack.add(NavigationKey.RepoDetails(it.repoId))
} ,
},
onAddRepo = repositoryManager::addRepo,
) {
backStack.removeLastOrNull()

View File

@@ -1,18 +1,8 @@
package org.fdroid.basic.ui.main.apps
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
// TODO remove
interface MinimalApp {
val packageName: String
val name: String?
val icon: String?
}
@Parcelize
data class InstalledApp(
override val packageName: String,
override val icon: String? = null,
override val name: String,
val versionName: String,
) : MinimalApp, Parcelable

View File

@@ -1,57 +1,44 @@
package org.fdroid.basic.ui.main.apps
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.fdroid.basic.R
import org.fdroid.basic.ui.Names
import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
fun InstalledAppRow(
app: InstalledApp,
app: InstalledAppItem,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
ListItem(
leadingContent = {
app.icon?.let {
AsyncImage(
model = app.icon,
contentDescription = null,
modifier = Modifier.size(48.dp),
)
} ?: Icon(
Icons.Filled.Android,
tint = MaterialTheme.colorScheme.secondary,
AsyncImage(
model = PackageName(app.packageName, app.iconDownloadRequest),
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.size(48.dp)
.background(Color.White)
.padding(8.dp),
modifier = Modifier.size(48.dp),
)
},
headlineContent = {
Text(app.name)
},
supportingContent = {
Text(app.versionName)
Text(app.installedVersionName)
},
colors = ListItemDefaults.colors(
containerColor = if (isSelected) {
@@ -68,10 +55,11 @@ fun InstalledAppRow(
@Preview
@Composable
fun InstalledAppRowPreview() {
val app = InstalledApp(
val app = InstalledAppItem(
packageName = "",
name = Names.randomName,
versionName = "1.0.1",
installedVersionName = "1.0.1",
lastUpdated = System.currentTimeMillis() - 5000,
)
FDroidContent {
Column {

View File

@@ -1,7 +1,9 @@
package org.fdroid.basic.ui.main.apps
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -10,16 +12,18 @@ 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.ArrowDropDown
import androidx.compose.material.icons.filled.MoreVert
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.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -30,135 +34,146 @@ 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.navigation3.runtime.NavKey
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.manager.AppUpdateItem
import org.fdroid.basic.ui.Names
import org.fdroid.basic.ui.NavigationKey
import org.fdroid.basic.ui.getPreviewVersion
import org.fdroid.basic.ui.main.BottomBar
import org.fdroid.basic.ui.main.lists.Sort
import org.fdroid.fdroid.ui.theme.FDroidContent
import java.util.concurrent.TimeUnit.DAYS
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
fun MyApps(
updatableApps: List<UpdatableApp>,
installedApps: List<InstalledApp>,
currentItem: AppDetailsItem?,
onItemClick: (MinimalApp) -> Unit,
myAppsModel: MyAppsModel,
currentPackageName: String?,
onAppItemClick: (String) -> Unit,
onNav: (NavKey) -> Unit,
sortBy: Sort,
onSortChanged: (Sort) -> Unit,
onRefresh: () -> Unit,
isBigScreen: Boolean,
modifier: Modifier = Modifier,
) {
LifecycleStartEffect(myAppsModel) {
onRefresh()
onStopOrDispose { }
}
val updatableApps = myAppsModel.appUpdates
val installedApps = myAppsModel.installedApps
val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
TopAppBar(
title = {
var sortByMenuExpanded by remember { mutableStateOf(false) }
Column {
Text("My apps")
FilterChip(
selected = false,
leadingIcon = {
val vector = when (sortBy) {
Sort.NAME -> Icons.Filled.SortByAlpha
Sort.LATEST -> Icons.Filled.AccessTime
}
Icon(
vector,
null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
},
trailingIcon = {
Icon(Icons.Filled.ArrowDropDown, null)
},
label = {
val s = when (sortBy) {
Sort.NAME -> "Sort by name"
Sort.LATEST -> "Sort by latest"
}
Text(s)
DropdownMenu(
expanded = sortByMenuExpanded,
onDismissRequest = { sortByMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Sort by name") },
leadingIcon = {
Icon(Icons.Filled.SortByAlpha, null)
},
onClick = {
onSortChanged(Sort.NAME)
sortByMenuExpanded = false
},
)
DropdownMenuItem(
text = { Text("Sort by latest") },
leadingIcon = {
Icon(Icons.Filled.AccessTime, null)
},
onClick = {
onSortChanged(Sort.LATEST)
sortByMenuExpanded = false
},
)
}
},
onClick = { sortByMenuExpanded = !sortByMenuExpanded },
)
}
Text("My apps")
},
actions = {
if (updatableApps.isNotEmpty()) Button(
onClick = {},
modifier = Modifier.padding(end = 16.dp),
var sortByMenuExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { sortByMenuExpanded = !sortByMenuExpanded }) {
Icon(Icons.Filled.MoreVert, null)
}
DropdownMenu(
expanded = sortByMenuExpanded,
onDismissRequest = { sortByMenuExpanded = false },
) {
Text("Update all")
DropdownMenuItem(
text = { Text("Sort by name") },
leadingIcon = {
Icon(Icons.Filled.SortByAlpha, null)
},
trailingIcon = {
RadioButton(
selected = myAppsModel.sortOrder == Sort.NAME,
onClick = null,
)
},
onClick = {
onSortChanged(Sort.NAME)
sortByMenuExpanded = false
},
)
DropdownMenuItem(
text = { Text("Sort by latest") },
leadingIcon = {
Icon(Icons.Filled.AccessTime, null)
},
trailingIcon = {
RadioButton(
selected = myAppsModel.sortOrder == Sort.LATEST,
onClick = null,
)
},
onClick = {
onSortChanged(Sort.LATEST)
sortByMenuExpanded = false
},
)
}
},
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
if (!isBigScreen) BottomBar(updatableApps.size, NavigationKey.MyApps, onNav)
if (!isBigScreen) BottomBar(updatableApps?.size ?: 0, NavigationKey.MyApps, onNav)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
LazyColumn(
if (updatableApps == null && installedApps == null) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
LoadingIndicator(Modifier.size(128.dp))
} else LazyColumn(
modifier
.padding(paddingValues)
.then(
if (currentItem == null) Modifier
if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
if (updatableApps.isNotEmpty()) item(key = "A", contentType = "header") {
Text(
text = stringResource(R.string.updates),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
if (updatableApps == null || updatableApps.isNotEmpty()) {
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 (updatableApps?.isNotEmpty() == true) Button(
onClick = {},
modifier = Modifier.padding(end = 16.dp),
) {
Text("Update all")
}
}
}
}
items(updatableApps, key = { it.packageName }, contentType = { "A" }) { app ->
val isSelected = app.packageName == currentItem?.app?.packageName
val interactionModifier = if (currentItem == null) {
if (updatableApps != null) items(
items = updatableApps,
key = { it.packageName },
contentType = { "A" },
) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onItemClick(app) }
onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onItemClick(app) }
onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier
@@ -166,23 +181,29 @@ fun MyApps(
.then(interactionModifier)
UpdatableAppRow(app, isSelected, modifier)
}
if (updatableApps.isNotEmpty()) item(key = "B", contentType = "header") {
Text(
text = stringResource(R.string.installed_apps__activity_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
if (!updatableApps.isNullOrEmpty() && !installedApps.isNullOrEmpty()) {
item(key = "B", contentType = "header") {
Text(
text = stringResource(R.string.installed_apps__activity_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
}
}
items(installedApps, key = { it.packageName }, contentType = { "B" }) { app ->
val isSelected = app.packageName == currentItem?.app?.packageName
val interactionModifier = if (currentItem == null) {
if (installedApps != null) items(
items = installedApps,
key = { it.packageName },
contentType = { "B" },
) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onItemClick(app) }
onClick = { onAppItemClick(app.packageName) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onItemClick(app) }
onClick = { onAppItemClick(app.packageName) }
)
}
val modifier = Modifier
@@ -195,40 +216,74 @@ fun MyApps(
}
@Preview
@PreviewScreenSizes
@Composable
fun MyAppsScaffoldPreview() {
fun MyAppsLoadingPreview() {
FDroidContent {
val app1 = UpdatableApp(
packageName = "A",
name = Names.randomName,
currentVersionName = "1.0.1",
updateVersionName = "1.1.0",
size = 123456789,
)
val app2 = UpdatableApp(
packageName = "B",
name = Names.randomName,
currentVersionName = "3.0.1",
updateVersionName = "3.1.0",
size = 9876543,
)
val installedApp1 =
InstalledApp("1", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "3.0.1")
val installedApp2 =
InstalledApp("2", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "1.0")
val installedApp3 =
InstalledApp("3", Names.randomName, org.fdroid.basic.ui.Icons.randomIcon, "0.1")
var sortBy by remember { mutableStateOf(Sort.NAME) }
MyApps(
updatableApps = listOf(app1, app2),
installedApps = listOf(installedApp1, installedApp2, installedApp3),
currentItem = null,
onItemClick = {},
myAppsModel = MyAppsModel(
appUpdates = null,
installedApps = null,
sortOrder = Sort.NAME,
),
currentPackageName = null,
onAppItemClick = {},
onNav = {},
sortBy = sortBy,
onSortChanged = { sortBy = it },
onSortChanged = { },
isBigScreen = false,
onRefresh = {},
)
}
}
@Preview
@Composable
fun MyAppsPreview() {
FDroidContent {
val app1 = AppUpdateItem(
packageName = "AX",
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(
packageName = "BX",
name = Names.randomName,
installedVersionName = "3.0.1",
update = getPreviewVersion("3.1.0", 9876543),
whatsNew = null,
)
val installedApp1 = InstalledAppItem(
packageName = "1",
name = Names.randomName,
installedVersionName = "1",
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1)
)
val installedApp2 = InstalledAppItem(
packageName = "2",
name = Names.randomName,
installedVersionName = "2",
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2)
)
val installedApp3 = InstalledAppItem(
packageName = "3",
name = Names.randomName,
installedVersionName = "3",
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3)
)
val model = MyAppsModel(
appUpdates = listOf(app1, app2),
installedApps = listOf(installedApp1, installedApp2, installedApp3),
sortOrder = Sort.NAME,
)
MyApps(
myAppsModel = model,
currentPackageName = null,
onAppItemClick = {},
onNav = {},
onSortChanged = { },
isBigScreen = false,
onRefresh = {},
)
}
}

View File

@@ -0,0 +1,44 @@
package org.fdroid.basic.ui.main.apps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.StateFlow
import org.fdroid.basic.manager.AppUpdateItem
import org.fdroid.basic.ui.main.lists.Sort
import java.text.Collator
import java.util.Locale
@Composable
fun MyAppsPresenter(
appUpdatesFlow: StateFlow<List<AppUpdateItem>?>,
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
sortOrderFlow: StateFlow<Sort>,
): MyAppsModel {
val appUpdates = appUpdatesFlow.collectAsState().value
val installedApps = installedAppsFlow.collectAsState().value
val sortOrder = sortOrderFlow.collectAsState().value
val packageNames = appUpdates?.map { it.packageName } ?: emptyList()
val collator = Collator.getInstance(Locale.getDefault())
return MyAppsModel(
appUpdates = when (sortOrder) {
Sort.NAME -> appUpdates?.sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) }
Sort.LATEST -> appUpdates?.sortedByDescending { it.update.added }
},
installedApps = installedApps?.filter {
// filter out apps already in updates
it.packageName !in packageNames
}?.let { apps ->
when (sortOrder) {
Sort.NAME -> apps.sortedWith { a1, a2 -> collator.compare(a1.name, a2.name) }
Sort.LATEST -> apps.sortedByDescending { it.lastUpdated }
}
},
sortOrder = sortOrder,
)
}
data class MyAppsModel(
val appUpdates: List<AppUpdateItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: Sort = Sort.NAME,
)

View File

@@ -0,0 +1,94 @@
package org.fdroid.basic.ui.main.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
import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.fdroid.basic.download.getDownloadRequest
import org.fdroid.basic.manager.MyAppsManager
import org.fdroid.basic.repo.RepositoryManager
import org.fdroid.basic.ui.main.lists.Sort
import org.fdroid.database.AppListItem
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloadRequest
import javax.inject.Inject
data class InstalledAppItem(
val packageName: String,
val name: String,
val installedVersionName: String,
val lastUpdated: Long,
val iconDownloadRequest: DownloadRequest? = null,
)
@HiltViewModel
class MyAppsViewModel @Inject constructor(
app: Application,
savedStateHandle: SavedStateHandle,
private val db: FDroidDatabase,
private val myAppsManager: MyAppsManager,
private val repositoryManager: RepositoryManager,
) : AndroidViewModel(app) {
val updates = myAppsManager.updates
val numUpdates = myAppsManager.numUpdates
private val installedApps = MutableStateFlow<List<InstalledAppItem>?>(null)
private var installedAppsLiveData =
db.getAppDao().getInstalledAppListItems(application.packageManager)
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", Sort.NAME)
private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
private val localeList = LocaleListCompat.getDefault()
private val installedAppsObserver = Observer<List<AppListItem>> { list ->
installedApps.value = list.map { app ->
InstalledAppItem(
packageName = app.packageName,
name = app.name ?: "Unknown app",
installedVersionName = app.installedVersionName ?: "???",
lastUpdated = app.lastUpdated,
iconDownloadRequest = repositoryManager.getRepository(app.repoId)?.let { repo ->
app.getIcon(localeList)?.getDownloadRequest(repo)
},
)
}
}
val myAppsModel: StateFlow<MyAppsModel> = scope.launchMolecule(mode = ContextClock) {
MyAppsPresenter(
appUpdatesFlow = updates,
installedAppsFlow = installedApps,
sortOrderFlow = sortOrder,
)
}
init {
installedAppsLiveData.observeForever(installedAppsObserver)
}
override fun onCleared() {
installedAppsLiveData.removeObserver(installedAppsObserver)
}
fun changeSortOrder(sort: Sort) {
sortOrder.value = sort
}
fun refresh() {
myAppsManager.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)
}
}
}

View File

@@ -1,15 +0,0 @@
package org.fdroid.basic.ui.main.apps
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class UpdatableApp(
override val packageName: String,
override val name: String,
val currentVersionName: String,
val updateVersionName: String,
val size: Long,
override val icon: String? = null,
val whatsNew: String? = null,
): MinimalApp, Parcelable

View File

@@ -2,17 +2,16 @@ package org.fdroid.basic.ui.main.apps
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.NewReleases
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -25,17 +24,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.fdroid.basic.R
import org.fdroid.basic.manager.AppUpdateItem
import org.fdroid.basic.ui.getPreviewVersion
import org.fdroid.basic.ui.icons.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
fun UpdatableAppRow(
app: UpdatableApp,
app: AppUpdateItem,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
@@ -50,21 +53,11 @@ fun UpdatableAppRow(
contentDescription = null, modifier = Modifier.size(24.dp),
)
}) {
app.icon?.let {
AsyncImage(
model = app.icon,
contentDescription = null,
modifier = Modifier.size(48.dp),
)
} ?: Icon(
Icons.Filled.Android,
tint = MaterialTheme.colorScheme.secondary,
AsyncImage(
model = PackageName(app.packageName, app.iconDownloadRequest),
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.size(48.dp)
.background(Color.White)
.padding(8.dp),
modifier = Modifier.size(48.dp),
)
}
},
@@ -72,10 +65,10 @@ fun UpdatableAppRow(
Text(app.name)
},
supportingContent = {
val size = app.size.let {
val size = app.update.size?.let {
Formatter.formatFileSize(LocalContext.current, it)
}
Text("${app.currentVersionName}${app.updateVersionName}$size")
Text("${app.installedVersionName}${app.update.versionName}$size")
},
trailingContent = {
if (app.whatsNew != null) IconButton(onClick = { isExpanded = !isExpanded }) {
@@ -96,13 +89,13 @@ fun UpdatableAppRow(
modifier = modifier,
)
AnimatedVisibility(visible = isExpanded, modifier = Modifier.padding(8.dp)) {
Text(
text = app.whatsNew ?: "",
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxWidth()
.padding(8.dp)
)
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = app.whatsNew ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
@@ -110,18 +103,24 @@ fun UpdatableAppRow(
@Preview
@Composable
fun UpdatableAppRowPreview() {
val app = UpdatableApp(
packageName = "",
val app1 = AppUpdateItem(
packageName = "A",
name = "App Update 123",
currentVersionName = "1.0.1",
updateVersionName = "1.1.0",
size = 123456789,
installedVersionName = "1.0.1",
update = getPreviewVersion("1.1.0", 123456789),
whatsNew = "This is new, all is new, nothing old.",
)
val app2 = AppUpdateItem(
packageName = "B",
name = "App Update 456",
installedVersionName = "1.0.1",
update = getPreviewVersion("1.1.0", 123456789),
whatsNew = "This is new, all is new, nothing old.",
)
FDroidContent {
Column {
UpdatableAppRow(app, false)
UpdatableAppRow(app, true)
UpdatableAppRow(app1, false)
UpdatableAppRow(app2, true)
}
}
}

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChangeHistory
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CurrencyBitcoin
@@ -25,23 +24,19 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OndemandVideo
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
@@ -55,7 +50,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
@@ -69,7 +63,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import coil3.compose.AsyncImage
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.details.testApp
@@ -83,13 +79,13 @@ import org.fdroid.fdroid.ui.theme.FDroidContent
ExperimentalSharedTransitionApi::class
)
fun AppDetails(
appItem: AppDetailsItem?,
item: AppDetailsItem?,
onBackNav: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState)
if (appItem == null) Box(
if (item == null) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) {
@@ -97,44 +93,9 @@ fun AppDetails(
} else Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
title = {
if (topAppBarState.overlappedFraction == 1f) {
Text(appItem.name)
}
},
navigationIcon = {
if (onBackNav != null) IconButton(onClick = onBackNav) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Localized description",
)
}
},
actions = {
IconButton(onClick = { /* do something */ }) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Localized description",
)
}
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Localized description",
)
}
AppDetailsMenu(expanded) { expanded = false }
},
scrollBehavior = scrollBehavior,
)
AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav)
},
) { innerPadding ->
val item = appItem
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
@@ -356,6 +317,13 @@ fun AppDetails(
url = sourceCode,
)
}
item.app.video?.getBestLocale(LocaleListCompat.getDefault())?.let { video ->
AppDetailsLink(
icon = Icons.Default.OndemandVideo,
title = stringResource(R.string.menu_video),
url = video,
)
}
}
}
// Versions

View File

@@ -1,7 +1,6 @@
package org.fdroid.basic.ui.main.details
import android.text.format.Formatter
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@@ -36,6 +34,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -91,17 +90,10 @@ fun AppDetailsHeader(
horizontalArrangement = spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
item.icon?.let { icon ->
AsyncImage(
model = icon,
contentDescription = "",
placeholder = rememberVectorPainter(Icons.Default.Image),
error = rememberVectorPainter(Icons.Default.Error),
modifier = Modifier.size(64.dp),
)
} ?: Image(
painter = rememberVectorPainter(Icons.Default.Image),
contentDescription = null,
AsyncImage(
model = item.icon,
contentDescription = "",
error = painterResource(R.drawable.ic_repo_app_default),
modifier = Modifier.size(64.dp),
)
Column {
@@ -166,7 +158,7 @@ fun AppDetailsHeader(
val context = LocalContext.current
OutlinedButton(
onClick = {
context.startActivity(item.launchIntent)
context.startActivity(item.actions.launchIntent)
},
modifier = Modifier.weight(1f)
) {

View File

@@ -1,5 +1,6 @@
package org.fdroid.basic.ui.main.details
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Preview
@@ -11,56 +12,106 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.details.testApp
import org.fdroid.fdroid.ui.theme.FDroidContent
@Composable
fun AppDetailsMenu(expanded: Boolean, onDismiss: () -> Unit) {
fun AppDetailsMenu(
item: AppDetailsItem,
expanded: Boolean,
onDismiss: () -> Unit,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text("Ignore all updates") },
trailingIcon = {
Checkbox(false, null)
},
onClick = { /* Do something... */ },
)
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text("Ignore this updates") },
trailingIcon = {
Checkbox(false, null)
},
onClick = { /* Do something... */ },
)
DropdownMenuItem(
if (item.appPrefs != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Preview, null)
},
text = { Text("Allow beta updates") },
text = { Text(stringResource(R.string.menu_release_channel_beta)) },
trailingIcon = {
Checkbox(false, null)
Checkbox(
checked = item.allowsBetaVersions,
onCheckedChange = null,
enabled = !item.ignoresAllUpdates,
)
},
enabled = !item.ignoresAllUpdates,
onClick = {
item.actions.allowBetaVersions()
onDismiss()
},
onClick = { /* Do something... */ },
)
DropdownMenuItem(
if (item.actions.ignoreAllUpdates != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text(stringResource(R.string.menu_ignore_all)) },
trailingIcon = {
Checkbox(item.ignoresAllUpdates, null)
},
onClick = {
item.actions.ignoreAllUpdates()
onDismiss()
},
)
if (item.actions.ignoreThisUpdate != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.UpdateDisabled, null)
},
text = { Text(stringResource(R.string.menu_ignore_this)) },
trailingIcon = {
Checkbox(
checked = item.ignoresCurrentUpdate,
onCheckedChange = null,
enabled = !item.ignoresAllUpdates,
)
},
enabled = !item.ignoresAllUpdates,
onClick = {
item.actions.ignoreThisUpdate()
onDismiss()
},
)
if (item.actions.shareApk != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Share, null)
},
text = { Text("Share APK") },
onClick = { /* Do something... */ },
text = { Text(stringResource(R.string.menu_share_apk)) },
onClick = {
item.actions.shareApk()
onDismiss()
},
)
DropdownMenuItem(
if (item.actions.uninstallApp != null) DropdownMenuItem(
leadingIcon = {
Icon(Icons.Default.Delete, null)
},
text = { Text("Uninstall app") },
onClick = { /* Do something... */ },
text = { Text(stringResource(R.string.menu_uninstall)) },
onClick = {
item.actions.uninstallApp()
onDismiss()
},
)
}
}
@Preview
@Composable
fun AppDetailsMenuPreview() {
AppDetailsMenu(testApp, true) {}
}
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun AppDetailsMenuAllIgnoredPreview() {
val appPrefs = testApp.appPrefs!!.toggleIgnoreAllUpdates()
FDroidContent {
AppDetailsMenu(testApp.copy(appPrefs = appPrefs), true) {}
}
}

View File

@@ -0,0 +1,73 @@
package org.fdroid.basic.ui.main.details
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import org.fdroid.basic.R
import org.fdroid.basic.details.AppDetailsItem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDetailsTopAppBar(
item: AppDetailsItem,
topAppBarState: TopAppBarState,
scrollBehavior: TopAppBarScrollBehavior,
onBackNav: (() -> Unit)?,
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
title = {
if (topAppBarState.overlappedFraction == 1f) {
Text(item.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
},
navigationIcon = {
if (onBackNav != null) IconButton(onClick = onBackNav) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
val context = LocalContext.current
item.actions.shareIntent?.let { shareIntent ->
IconButton(onClick = { context.startActivity(shareIntent) }) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.menu_share),
)
}
}
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "Localized description",
)
}
AppDetailsMenu(item, expanded) { expanded = false }
},
scrollBehavior = scrollBehavior,
)
}

View File

@@ -36,7 +36,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import org.fdroid.basic.details.AppDetailsItem
import org.fdroid.basic.ui.main.apps.MinimalApp
sealed class AppList(val title: String) {
@@ -50,7 +49,7 @@ sealed class AppList(val title: String) {
fun AppList(
appList: AppList,
filterInfo: FilterInfo,
currentItem: AppDetailsItem?,
currentPackageName: String?,
modifier: Modifier = Modifier,
onBackClicked: () -> Unit,
onItemClick: (MinimalApp) -> Unit,
@@ -100,14 +99,14 @@ fun AppList(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.then(
if (currentItem == null) Modifier
if (currentPackageName == null) Modifier
else Modifier.selectableGroup()
),
) {
val apps = filterInfo.model.apps
items(apps, key = { it.packageName }, contentType = { "A" }) { navItem ->
val isSelected = currentItem?.app?.packageName == navItem.packageName
val interactionModifier = if (currentItem == null) {
val isSelected = currentPackageName == navItem.packageName
val interactionModifier = if (currentPackageName == null) {
Modifier.clickable(
onClick = { onItemClick(navItem) }
)