diff --git a/basic/src/main/AndroidManifest.xml b/basic/src/main/AndroidManifest.xml index 12044e4a3..f779415c6 100644 --- a/basic/src/main/AndroidManifest.xml +++ b/basic/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - + @@ -31,8 +31,7 @@ - + tools:node="remove" /> diff --git a/basic/src/main/java/org/fdroid/basic/App.kt b/basic/src/main/java/org/fdroid/basic/App.kt index 10afa220f..4af5014d5 100644 --- a/basic/src/main/java/org/fdroid/basic/App.kt +++ b/basic/src/main/java/org/fdroid/basic/App.kt @@ -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 { + val downloadRequestKeyer = object : Keyer { 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 { + override fun key(data: PackageName, options: Options): String = data.packageName + } + add(packageNameKeyer) + add(ApplicationIconFetcher.Factory(this@App.applicationContext, downloadRequestFetcherFactory)) } .memoryCache { MemoryCache.Builder() diff --git a/basic/src/main/java/org/fdroid/basic/MainViewModel.kt b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt index a087f8bf1..717102bae 100644 --- a/basic/src/main/java/org/fdroid/basic/MainViewModel.kt +++ b/basic/src/main/java/org/fdroid/basic/MainViewModel.kt @@ -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 } } diff --git a/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt b/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt index 2171e9c6b..b0865296e 100644 --- a/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt +++ b/basic/src/main/java/org/fdroid/basic/details/AppDetailsItem.kt @@ -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? = 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, dbApp: App, + actions: AppDetailsActions, versions: List?, 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, ) diff --git a/basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt b/basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt similarity index 51% rename from basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt rename to basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt index ead848ede..ee6237c44 100644 --- a/basic/src/main/java/org/fdroid/basic/details/AppDetailsManager.kt +++ b/basic/src/main/java/org/fdroid/basic/details/AppDetailsViewModel.kt @@ -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(null) val appDetails: StateFlow = 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( diff --git a/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt b/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt index 628c6d26b..46ad81710 100644 --- a/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt +++ b/basic/src/main/java/org/fdroid/basic/details/DetailsPresenter.kt @@ -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, ): 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) +} diff --git a/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt b/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt index bbdc7737b..02cb7c615 100644 --- a/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt +++ b/basic/src/main/java/org/fdroid/basic/manager/MyAppsManager.kt @@ -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>(emptyList()) +class MyAppsManager @Inject constructor( + private val dbUpdateChecker: DbUpdateChecker, + @IoDispatcher private val coroutineScope: CoroutineScope, + private val repositoryManager: RepositoryManager, +) { + private val _updates = MutableStateFlow?>(null) val updates = _updates.asStateFlow() - private val _installed = MutableStateFlow>(installedApps) - val installed = _installed.asStateFlow() - val numUpdates = _updates.map { it.size } - private val _sortBy = MutableStateFlow(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 } - } diff --git a/basic/src/main/java/org/fdroid/basic/ui/Utils.kt b/basic/src/main/java/org/fdroid/basic/ui/Utils.kt index e13df4e44..2e0f56645 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/Utils.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/Utils.kt @@ -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? = null + override val packageManifest: PackageManifest = object : PackageManifest { + override val minSdkVersion: Int? = null + override val maxSdkVersion: Int? = null + override val featureNames: List? = null + override val nativecode: List? = null + override val targetSdkVersion: Int? = null + } + override val hasKnownVulnerability: Boolean = false +} fun Long.asRelativeTimeString(): String { return DateUtils.getRelativeTimeSpanString( diff --git a/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt b/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt new file mode 100644 index 000000000..be7d717d2 --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/icons/ApplicationIconFetcher.kt @@ -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 { + 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) + }, + ) + } +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt b/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt index 9d2d1a6d7..97f2576b8 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/BottomBar.kt @@ -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) }, diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt index 4698e9020..43fff63dd 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/Main.kt @@ -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()//(directive = directive) + val listDetailStrategy = rememberListDetailSceneStrategy(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() + 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() + 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( metadata = ListDetailSceneStrategy.detailPane("appdetails") ) { - val currentItem = viewModel.appDetails.collectAsStateWithLifecycle().value - it.packageName // TODO use? + val appDetailsViewModel = hiltViewModel() + 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() diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt index 10b0fce51..7c464373e 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledApp.kt @@ -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 diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt index 4e4cf100a..77e33a784 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/InstalledAppRow.kt @@ -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 { diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt index f1ad3a1e5..d98b39652 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyApps.kt @@ -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, - installedApps: List, - 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 = {}, ) } } diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt new file mode 100644 index 000000000..6e9035d0c --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsPresenter.kt @@ -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?>, + installedAppsFlow: StateFlow?>, + sortOrderFlow: StateFlow, +): 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? = null, + val installedApps: List? = null, + val sortOrder: Sort = Sort.NAME, +) diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt new file mode 100644 index 000000000..3df9ff41e --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/MyAppsViewModel.kt @@ -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?>(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 -> + 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 = 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) + } + } +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt deleted file mode 100644 index 0cb7d1b6a..000000000 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableApp.kt +++ /dev/null @@ -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 diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt index 2e97b109e..f4311d4ad 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/apps/UpdatableAppRow.kt @@ -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) } } } diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt index 9d1593697..dc1fb0e39 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetails.kt @@ -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 diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt index 08683df6d..40863c5c7 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsHeader.kt @@ -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) ) { diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt index 1ac0e9483..912a1d5c0 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsMenu.kt @@ -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) {} + } +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt new file mode 100644 index 000000000..b7052a146 --- /dev/null +++ b/basic/src/main/java/org/fdroid/basic/ui/main/details/AppDetailsTopAppBar.kt @@ -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, + ) +} diff --git a/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt b/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt index a25148b41..14b7d3b75 100644 --- a/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt +++ b/basic/src/main/java/org/fdroid/basic/ui/main/lists/AppList.kt @@ -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) } )