From 9948edc7833982993b1f76bc44b0cb748da9130a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 2 Oct 2025 17:28:20 -0300 Subject: [PATCH] Show install states in notification and My apps screen --- .../kotlin/org/fdroid/NotificationManager.kt | 69 +++-- .../org/fdroid/download/LocalIconFetcher.kt | 8 +- .../org/fdroid/install/AppInstallManager.kt | 199 +++++++++++--- .../install/InstallNotificationState.kt | 108 ++++++++ .../kotlin/org/fdroid/install/InstallState.kt | 80 +++++- .../fdroid/install/SessionInstallManager.kt | 36 ++- .../org/fdroid/repo/RepoUpdateWorker.kt | 8 +- .../main/kotlin/org/fdroid/ui/IntentRouter.kt | 10 +- next/src/main/kotlin/org/fdroid/ui/Main.kt | 5 + .../org/fdroid/ui/apps/AppUpdateItem.kt | 13 - .../org/fdroid/ui/apps/InstallingAppRow.kt | 112 ++++++++ .../kotlin/org/fdroid/ui/apps/MyAppItem.kt | 40 +++ .../main/kotlin/org/fdroid/ui/apps/MyApps.kt | 259 +++++++++++------- .../kotlin/org/fdroid/ui/apps/MyAppsInfo.kt | 3 + .../org/fdroid/ui/apps/MyAppsPresenter.kt | 69 +++-- .../org/fdroid/ui/apps/MyAppsViewModel.kt | 31 ++- .../org/fdroid/ui/details/AppDetailsHeader.kt | 9 +- .../org/fdroid/ui/details/AppDetailsItem.kt | 2 +- .../fdroid/ui/details/AppDetailsViewModel.kt | 21 +- .../org/fdroid/ui/details/DetailsPresenter.kt | 4 +- .../kotlin/org/fdroid/ui/details/Versions.kt | 2 +- .../org/fdroid/ui/utils/PreviewUtils.kt | 16 +- .../org/fdroid/updates/UpdatesManager.kt | 31 ++- next/src/main/res/values/strings-next.xml | 12 + 24 files changed, 907 insertions(+), 240 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt delete mode 100644 next/src/main/kotlin/org/fdroid/ui/apps/AppUpdateItem.kt create mode 100644 next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt create mode 100644 next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt diff --git a/next/src/main/kotlin/org/fdroid/NotificationManager.kt b/next/src/main/kotlin/org/fdroid/NotificationManager.kt index 73cbb851d..75fa9a4c9 100644 --- a/next/src/main/kotlin/org/fdroid/NotificationManager.kt +++ b/next/src/main/kotlin/org/fdroid/NotificationManager.kt @@ -3,34 +3,48 @@ package org.fdroid import android.Manifest.permission.POST_NOTIFICATIONS import android.content.Context import android.content.pm.PackageManager.PERMISSION_GRANTED +import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.BigTextStyle import androidx.core.app.NotificationCompat.CATEGORY_SERVICE import androidx.core.app.NotificationCompat.PRIORITY_HIGH import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.IMPORTANCE_LOW import androidx.core.content.ContextCompat.checkSelfPermission import dagger.hilt.android.qualifiers.ApplicationContext +import org.fdroid.install.InstallNotificationState import org.fdroid.next.R import javax.inject.Inject const val NOTIFICATION_ID_REPO_UPDATE: Int = 0 +const val NOTIFICATION_ID_APP_INSTALLS: Int = 1 const val CHANNEL_UPDATES = "update-channel" +const val CHANNEL_INSTALLS = "install-channel" class NotificationManager @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, ) { private val nm = NotificationManagerCompat.from(context) private var lastRepoUpdateNotification = 0L init { - val updateChannel = NotificationChannelCompat.Builder( - CHANNEL_UPDATES, IMPORTANCE_LOW - ).setName(context.getString(R.string.notification_channel_updates_title)) - .setDescription(context.getString(R.string.notification_channel_updates_description)) - .build() - nm.createNotificationChannel(updateChannel) + createNotificationChannels() + } + + private fun createNotificationChannels() { + val channels = listOf( + NotificationChannelCompat.Builder(CHANNEL_UPDATES, IMPORTANCE_LOW) + .setName(s(R.string.notification_channel_updates_title)) + .setDescription(s(R.string.notification_channel_updates_description)) + .build(), + NotificationChannelCompat.Builder(CHANNEL_INSTALLS, IMPORTANCE_LOW) + .setName(s(R.string.notification_channel_installs_title)) + .setDescription(s(R.string.notification_channel_installs_description)) + .build(), + ) + nm.createNotificationChannelsCompat(channels) } fun showUpdateRepoNotification(msg: String, throttle: Boolean = true, progress: Int? = null) { @@ -58,19 +72,36 @@ class NotificationManager @Inject constructor( .setOngoing(true) .setProgress(100, progress ?: 0, progress == null) - fun getAppUpdateNotification( - msg: String? = null, - ) = NotificationCompat.Builder(context, CHANNEL_UPDATES) - .setSmallIcon(R.drawable.ic_refresh) - .setCategory(CATEGORY_SERVICE) - .setContentTitle(context.getString(R.string.banner_updating_apps)) - .setContentText(msg) - .setOngoing(true) - .setProgress(100, 0, true) + fun showAppInstallNotification(installNotificationState: InstallNotificationState) { + // TODO we may need some throttling when many apps download at the same time + val n = getAppInstallNotification(installNotificationState).build() + if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + nm.notify(NOTIFICATION_ID_APP_INSTALLS, n) + } + } + fun getAppInstallNotification(state: InstallNotificationState): NotificationCompat.Builder { + val pi = state.getPendingIntent(context) + val builder = NotificationCompat.Builder(context, CHANNEL_INSTALLS) + .setSmallIcon(R.drawable.ic_notification) + .setCategory(CATEGORY_SERVICE) + .setContentTitle(state.getTitle(context)) + .setStyle(BigTextStyle().bigText(state.getBigText(context))) + .setContentIntent(pi) + .setOngoing(true) + .setProgress(100, state.percent ?: 0, state.percent == null) + return builder + } + + fun cancelAppInstallNotification() { + nm.cancel(NOTIFICATION_ID_APP_INSTALLS) + } + + // TODO pass in bigText with apps and their version changes fun showAppUpdatesAvailableNotification(numUpdates: Int) { val n = getAppUpdatesAvailableNotification(numUpdates).build() if (checkSelfPermission(context, POST_NOTIFICATIONS) == PERMISSION_GRANTED) { + // TODO different ID nm.notify(NOTIFICATION_ID_REPO_UPDATE, n) } } @@ -81,7 +112,7 @@ class NotificationManager @Inject constructor( numUpdates, numUpdates, ) val text = context.getString(R.string.notification_title_summary_app_update_available) - + // TODO different channel return NotificationCompat.Builder(context, CHANNEL_UPDATES) .setSmallIcon(R.drawable.ic_notification) .setPriority(PRIORITY_HIGH) @@ -90,4 +121,8 @@ class NotificationManager @Inject constructor( .setOngoing(false) .setAutoCancel(true) } + + private fun s(@StringRes id: Int): String { + return context.getString(id) + } } diff --git a/next/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt b/next/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt index ec30fe5b6..73af4b010 100644 --- a/next/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt +++ b/next/src/main/kotlin/org/fdroid/download/LocalIconFetcher.kt @@ -14,7 +14,11 @@ import mu.KotlinLogging import org.fdroid.download.coil.DownloadRequestFetcher import javax.inject.Inject -data class PackageName(val packageName: String, val iconDownloadRequest: DownloadRequest?) +data class PackageName( + val packageName: String, + val iconDownloadRequest: DownloadRequest?, + val warnOnError: Boolean = false, +) class LocalIconFetcher( private val packageManager: PackageManager, @@ -29,7 +33,7 @@ class LocalIconFetcher( val info = packageManager.getApplicationInfo(data.packageName, 0) info.loadUnbadgedIcon(packageManager) } catch (e: PackageManager.NameNotFoundException) { - log.error(e) { "Error getting icon from packageManager: " } + if (data.warnOnError) log.error(e) { "Error getting icon from packageManager: " } return downloadRequestFetcher?.fetch() } diff --git a/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt index f8ee23be4..7421d0e14 100644 --- a/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt +++ b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt @@ -4,9 +4,11 @@ import android.app.Activity import android.content.Context import android.content.Intent.ACTION_DELETE import android.graphics.Bitmap +import android.net.Uri import androidx.activity.result.ActivityResult import androidx.annotation.UiThread import androidx.annotation.WorkerThread +import androidx.core.os.LocaleListCompat import coil3.SingletonImageLoader import coil3.memory.MemoryCache import coil3.request.ImageRequest @@ -19,10 +21,15 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import mu.KotlinLogging +import org.fdroid.LocaleChooser.getBestLocale +import org.fdroid.NotificationManager import org.fdroid.database.AppMetadata import org.fdroid.database.AppVersion import org.fdroid.database.Repository @@ -32,7 +39,6 @@ import org.fdroid.getCacheKey import org.fdroid.utils.IoDispatcher import java.io.File import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue import javax.inject.Inject import javax.inject.Singleton @@ -41,35 +47,33 @@ class AppInstallManager @Inject constructor( @param:ApplicationContext private val context: Context, private val downloaderFactory: DownloaderFactory, private val sessionInstallManager: SessionInstallManager, + private val notificationManager: NotificationManager, @param:IoDispatcher private val scope: CoroutineScope, ) { private val log = KotlinLogging.logger { } - private val queue = ConcurrentLinkedQueue() - private val apps = ConcurrentHashMap>() + private val apps = MutableStateFlow>(emptyMap()) private val jobs = ConcurrentHashMap() + val appInstallStates = apps.asStateFlow() - fun getAppFlow(packageName: String): StateFlow { - return apps.getOrPut(packageName) { - MutableStateFlow(InstallState.Unknown) - } + fun getAppFlow(packageName: String): Flow { + return apps.map { it[packageName] ?: InstallState.Unknown } } @UiThread suspend fun install( appMetadata: AppMetadata, version: AppVersion, + currentVersionName: String?, repo: Repository, iconDownloadRequest: DownloadRequest?, ): InstallState { - val flow = apps.getOrPut(appMetadata.packageName) { - MutableStateFlow(InstallState.Starting) - } + val packageName = appMetadata.packageName val job = scope.async { - installInt(flow, appMetadata, version, repo, iconDownloadRequest) + installInt(appMetadata, version, currentVersionName, repo, iconDownloadRequest) } // keep track of this job, in case we want to cancel it - jobs.put(appMetadata.packageName, job) + jobs.put(packageName, job) // wait for job to return val result = try { job.await() @@ -77,21 +81,31 @@ class AppInstallManager @Inject constructor( InstallState.UserAborted } finally { // remove job as it has completed - jobs.remove(appMetadata.packageName) + jobs.remove(packageName) } - flow.update { result } + apps.updateApp(packageName) { result } + onStatesUpdated() return result } @WorkerThread private suspend fun installInt( - flow: MutableStateFlow, appMetadata: AppMetadata, version: AppVersion, + currentVersionName: String?, repo: Repository, iconDownloadRequest: DownloadRequest?, ): InstallState { - flow.update { InstallState.Starting } + apps.updateApp(appMetadata.packageName) { + InstallState.Starting( + name = appMetadata.name.getBestLocale(LocaleListCompat.getDefault()) ?: "Unknown", + versionName = version.versionName, + currentVersionName = currentVersionName, + lastUpdated = version.added, + iconDownloadRequest = iconDownloadRequest, + ) + } + onStatesUpdated() val coroutineContext = currentCoroutineContext() // get the icon for pre-approval (usually in memory cache, so should be quick) coroutineContext.ensureActive() @@ -104,18 +118,38 @@ class AppInstallManager @Inject constructor( is PreApprovalResult.Error -> InstallState.Error(preApprovalResult.errorMsg) is PreApprovalResult.UserAborted -> InstallState.UserAborted else -> { - flow.update { InstallState.PreApproved(preApprovalResult) } + apps.checkAndUpdateApp(appMetadata.packageName) { + InstallState.PreApproved( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconDownloadRequest = it.iconDownloadRequest, + result = preApprovalResult, + ) + } val sessionId = (preApprovalResult as? PreApprovalResult.Success)?.sessionId coroutineContext.ensureActive() // download file val file = File(context.cacheDir, version.file.sha256) val downloader = - downloaderFactory.create(repo, android.net.Uri.EMPTY, version.file, file) + downloaderFactory.create(repo, Uri.EMPTY, version.file, file) + val now = System.currentTimeMillis() downloader.setListener { bytesRead, totalBytes -> coroutineContext.ensureActive() - flow.update { - InstallState.Downloading(sessionId, bytesRead, totalBytes) + apps.checkAndUpdateApp(appMetadata.packageName) { + InstallState.Downloading( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconDownloadRequest = it.iconDownloadRequest, + downloadedBytes = bytesRead, + totalBytes = totalBytes, + startMillis = now, + ) } + onStatesUpdated() } try { downloader.download() @@ -127,12 +161,23 @@ class AppInstallManager @Inject constructor( return InstallState.Error(msg) } coroutineContext.ensureActive() - flow.update { InstallState.Installing(sessionId) } - val result = sessionInstallManager.install(sessionId, version.packageName, file) - if (result is InstallState.PreApprovalFailed) { + val newState = apps.checkAndUpdateApp(appMetadata.packageName) { + InstallState.Installing( + name = it.name, + versionName = it.versionName, + currentVersionName = it.currentVersionName, + lastUpdated = it.lastUpdated, + iconDownloadRequest = it.iconDownloadRequest, + ) + } + val result = + sessionInstallManager.install(sessionId, version.packageName, newState, file) + if (result is InstallState.PreApproved && + result.result is PreApprovalResult.Error + ) { // if pre-approval failed (e.g. due to app label mismatch), // then try to install again, this time not using the pre-approved session - sessionInstallManager.install(null, version.packageName, file) + sessionInstallManager.install(null, version.packageName, newState, file) } else { result } @@ -148,13 +193,16 @@ class AppInstallManager @Inject constructor( packageName: String, installState: InstallState.UserConfirmationNeeded, ): InstallState? { - val flow = apps[packageName] ?: error("No state for $packageName $installState") - if (flow.value !is InstallState.UserConfirmationNeeded) { - log.error { "Unexpected state: ${flow.value}" } + val state = apps.value[packageName] ?: error("No state for $packageName $installState") + if (state !is InstallState.UserConfirmationNeeded) { + log.error { "Unexpected state: $state" } return null } + log.info { "Requesting user confirmation for $packageName" } val result = sessionInstallManager.requestUserConfirmation(installState) - flow.update { result } + log.info { "User confirmation for $packageName $result" } + apps.updateApp(packageName) { result } + onStatesUpdated() return result } @@ -170,9 +218,9 @@ class AppInstallManager @Inject constructor( packageName: String, installState: InstallState.UserConfirmationNeeded, ) { - val flow = apps[packageName] ?: error("No state for $packageName $installState") - if (flow.value !is InstallState.UserConfirmationNeeded) { - log.debug { "State has changed. Now: ${flow.value}" } + val state = apps.value[packageName] ?: error("No state for $packageName $installState") + if (state !is InstallState.UserConfirmationNeeded) { + log.debug { "State has changed. Now: $state" } return } val sessionInfo = @@ -210,9 +258,6 @@ class AppInstallManager @Inject constructor( */ @UiThread fun onUninstallResult(packageName: String, activityResult: ActivityResult): InstallState { - val flow = apps.getOrPut(packageName) { - MutableStateFlow(InstallState.Unknown) - } val result = when (activityResult.resultCode) { Activity.RESULT_OK -> InstallState.Uninstalled Activity.RESULT_FIRST_USER -> InstallState.UserAborted @@ -220,17 +265,67 @@ class AppInstallManager @Inject constructor( } val code = activityResult.data?.getIntExtra("android.intent.extra.INSTALL_RESULT", -1) log.info { "Uninstall result received: ${activityResult.resultCode} => $result ($code)" } - flow.update { result } + apps.updateApp(packageName) { result } return result } @UiThread fun cleanUp(packageName: String) { - val flow = apps[packageName] ?: return - if (!flow.value.showProgress) { - log.info { "Cleaning up state for $packageName ${flow.value}" } + val state = apps.value[packageName] ?: return + if (!state.showProgress) { + log.info { "Cleaning up state for $packageName $state" } jobs.remove(packageName)?.cancel() - apps.remove(packageName) + apps.update { oldApps -> + oldApps.toMutableMap().apply { + remove(packageName) + } + } + } + } + + private fun onStatesUpdated() { + val appStates = mutableListOf() + var numBytesDownloaded = 0L + var numTotalBytes = 0L + // go throw all apps that have active state + apps.value.toMap().forEach { packageName, state -> + // assign a category to each in progress state + val appStateCategory = when (state) { + is InstallState.Installing, is InstallState.PreApproved, + is InstallState.Starting -> AppStateCategory.INSTALLING + is InstallState.Downloading -> { + numBytesDownloaded += state.downloadedBytes + numTotalBytes += state.totalBytes + AppStateCategory.INSTALLING + } + is InstallState.Installed -> AppStateCategory.INSTALLED + is InstallState.UserConfirmationNeeded -> AppStateCategory.NEEDS_CONFIRMATION + else -> null + } + // track app state for in progress apps + val appState = appStateCategory?.let { + // all states that get a category above must be InstallStateWithInfo + state as InstallStateWithInfo + AppState( + packageName = packageName, + category = it, + name = state.name, + installVersionName = state.versionName, + currentVersionName = state.currentVersionName, + ) + } + if (appState != null) appStates.add(appState) + } + val notificationState = InstallNotificationState( + apps = appStates, + numBytesDownloaded = numBytesDownloaded, + numTotalBytes = numTotalBytes, + ) + if (notificationState.isInProgress) { + notificationManager.showAppInstallNotification(notificationState) + } else { + // cancel notification if no more apps are in progress + notificationManager.cancelAppInstallNotification() } } @@ -255,4 +350,28 @@ class AppInstallManager @Inject constructor( } } + private fun MutableStateFlow>.updateApp( + packageName: String, + function: (InstallState) -> InstallState, + ) = update { oldMap -> + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown) + newMap + } + + private fun MutableStateFlow>.checkAndUpdateApp( + packageName: String, + function: (InstallStateWithInfo) -> InstallStateWithInfo, + ): InstallStateWithInfo { + return updateAndGet { oldMap -> + val oldState = oldMap[packageName] + check(oldState is InstallStateWithInfo) { + "State for $packageName was $oldState" + } + val newMap = oldMap.toMutableMap() + newMap[packageName] = function(oldState) + newMap + }[packageName] as InstallStateWithInfo + } + } diff --git a/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt b/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt new file mode 100644 index 000000000..55c3bfd8d --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/InstallNotificationState.kt @@ -0,0 +1,108 @@ +package org.fdroid.install + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import org.fdroid.MainActivity +import org.fdroid.next.R +import org.fdroid.ui.IntentRouter.Companion.ACTION_MY_APPS +import kotlin.math.roundToInt + +data class InstallNotificationState( + val apps: List, + val numBytesDownloaded: Long, + val numTotalBytes: Long, +) { + val percent: Int? = if (numTotalBytes > 0) { + ((numBytesDownloaded.toFloat() / numTotalBytes) * 100).roundToInt() + } else { + null + } + val needsConfirmation: Boolean + get() = apps.find { it.category == AppStateCategory.NEEDS_CONFIRMATION } != null + val isInProgress: Boolean = apps.any { it.category != AppStateCategory.INSTALLED } + + fun getTitle(context: Context): String { + val numActiveApps: Int = apps.count { it.category != AppStateCategory.INSTALLED } + val installTitle = context.resources.getQuantityString( + R.plurals.notification_installing_title, + numActiveApps, + numActiveApps, + ) + val needsUserConfirmation = + apps.find { it.category == AppStateCategory.NEEDS_CONFIRMATION } != null + return if (needsUserConfirmation) { + val s = context.getString(R.string.notification_installing_confirmation) + "$s $installTitle" + } else { + installTitle + } + } + + fun getBigText(context: Context): String { + // split app apps into their categories + val installing = mutableListOf() + val toConfirm = mutableListOf() + val installed = mutableListOf() + apps.forEach { appState -> + when (appState.category) { + AppStateCategory.INSTALLING -> installing.add(appState) + AppStateCategory.NEEDS_CONFIRMATION -> toConfirm.add(appState) + AppStateCategory.INSTALLED -> installed.add(appState) + } + } + val sb = StringBuilder() + fun printApps(@StringRes titleRes: Int, list: List, showTitle: Boolean = true) { + if (list.isEmpty()) return + if (showTitle) { + if (sb.isNotEmpty()) sb.append("\n⠀\n") + sb.append(context.getString(titleRes)) + } + sb.append("\n") + list.forEach { appState -> + sb.append("• ").append(appState.displayStr).append("\n") + } + } + + val showInstallTitle = toConfirm.isNotEmpty() || installed.isNotEmpty() + printApps(R.string.notification_installing_section_confirmation, toConfirm) + printApps(R.string.notification_installing_section_installing, installing, showInstallTitle) + printApps(R.string.notification_installing_section_installed, installed) + return sb.toString() + } + + fun getPendingIntent(context: Context): PendingIntent { + val i = Intent(ACTION_MY_APPS).apply { + setClass(context, MainActivity::class.java) + } + val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + return PendingIntent.getActivity(context, 0, i, flags) + } +} + +data class AppState( + val packageName: String, + val category: AppStateCategory, + val name: String, + val installVersionName: String, + val currentVersionName: String?, +) { + val displayStr: String + get() { + val versionStr = if (currentVersionName == null) { + installVersionName + } else { + "$currentVersionName → $installVersionName" + } + return "$name $versionStr" + } +} + +enum class AppStateCategory { + INSTALLING, + NEEDS_CONFIRMATION, + INSTALLED +} diff --git a/next/src/main/kotlin/org/fdroid/install/InstallState.kt b/next/src/main/kotlin/org/fdroid/install/InstallState.kt index a6a2b8712..09790b905 100644 --- a/next/src/main/kotlin/org/fdroid/install/InstallState.kt +++ b/next/src/main/kotlin/org/fdroid/install/InstallState.kt @@ -1,29 +1,93 @@ package org.fdroid.install import android.app.PendingIntent +import org.fdroid.download.DownloadRequest sealed class InstallState(val showProgress: Boolean) { data object Unknown : InstallState(false) - data object Starting : InstallState(true) - data class PreApproved(val result: PreApprovalResult) : InstallState(true) + data class Starting( + override val name: String, + override val versionName: String, + override val currentVersionName: String? = null, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest? = null, + ) : InstallStateWithInfo(true) + + data class PreApproved( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest?, + val result: PreApprovalResult, + ) : InstallStateWithInfo(true) + data class Downloading( - val sessionId: Int?, + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest?, val downloadedBytes: Long, val totalBytes: Long, - ) : InstallState(true) + val startMillis: Long, + ) : InstallStateWithInfo(true) { + val progress: Float get() = downloadedBytes / totalBytes.toFloat() + } + + data class Installing( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest?, + ) : InstallStateWithInfo(true) - data class Installing(val sessionId: Int?) : InstallState(true) data class UserConfirmationNeeded( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest?, val sessionId: Int, val intent: PendingIntent, val progress: Float, - ) : InstallState(true) + ) : InstallStateWithInfo(true) { + constructor( + state: InstallStateWithInfo, + sessionId: Int, + intent: PendingIntent, + progress: Float + ) : this( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconDownloadRequest = state.iconDownloadRequest, + sessionId = sessionId, + intent = intent, + progress = progress + ) + } - data object PreApprovalFailed : InstallState(true) + data class Installed( + override val name: String, + override val versionName: String, + override val currentVersionName: String?, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest?, + ) : InstallStateWithInfo(false) - data object Installed : InstallState(false) data object UserAborted : InstallState(false) data class Error(val msg: String?) : InstallState(false) data object Uninstalled : InstallState(false) } + +sealed class InstallStateWithInfo(showProgress: Boolean) : InstallState(showProgress) { + abstract val name: String + abstract val versionName: String + abstract val currentVersionName: String? + abstract val lastUpdated: Long + abstract val iconDownloadRequest: DownloadRequest? +} diff --git a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt index a3d97c9a8..e7c2340e6 100644 --- a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt +++ b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -164,6 +164,7 @@ class SessionInstallManager @Inject constructor( suspend fun install( sessionId: Int?, packageName: String, + state: InstallStateWithInfo, apkFile: File, ): InstallState = suspendCancellableCoroutine { cont -> val size = apkFile.length() @@ -186,7 +187,14 @@ class SessionInstallManager @Inject constructor( context.unregisterReceiver(this) when (status) { PackageInstaller.STATUS_SUCCESS -> { - cont.resume(InstallState.Installed) + val newState = InstallState.Installed( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconDownloadRequest = state.iconDownloadRequest, + ) + cont.resume(newState) } PackageInstaller.STATUS_PENDING_USER_ACTION -> { val flags = if (SDK_INT >= 31) { @@ -199,7 +207,12 @@ class SessionInstallManager @Inject constructor( val progress = installer.getSessionInfo(sessionId)?.progress ?: error("No session info for $sessionId") cont.resume( - InstallState.UserConfirmationNeeded(sessionId, pendingIntent, progress) + InstallState.UserConfirmationNeeded( + state = state, + sessionId = sessionId, + intent = pendingIntent, + progress = progress, + ) ) } else -> { @@ -209,7 +222,15 @@ class SessionInstallManager @Inject constructor( msg != null && msg.contains("PreapprovalDetails") ) { - cont.resume(InstallState.PreApprovalFailed) + val newState = InstallState.PreApproved( + name = state.name, + versionName = state.versionName, + currentVersionName = state.currentVersionName, + lastUpdated = state.lastUpdated, + iconDownloadRequest = state.iconDownloadRequest, + result = PreApprovalResult.Error(msg), + ) + cont.resume(newState) } else { cont.resume(InstallState.Error(msg)) } @@ -258,7 +279,14 @@ class SessionInstallManager @Inject constructor( context.unregisterReceiver(this) when (status) { PackageInstaller.STATUS_SUCCESS -> { - cont.resume(InstallState.Installed) + val newState = InstallState.Installed( + name = installState.name, + versionName = installState.versionName, + currentVersionName = installState.currentVersionName, + lastUpdated = installState.lastUpdated, + iconDownloadRequest = installState.iconDownloadRequest, + ) + cont.resume(newState) } PackageInstaller.STATUS_PENDING_USER_ACTION -> { error("Got STATUS_PENDING_USER_ACTION again") diff --git a/next/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt b/next/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt index cfa26f388..37c959044 100644 --- a/next/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt +++ b/next/src/main/kotlin/org/fdroid/repo/RepoUpdateWorker.kt @@ -22,6 +22,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import mu.KotlinLogging import org.fdroid.NOTIFICATION_ID_REPO_UPDATE import org.fdroid.NotificationManager import java.util.concurrent.TimeUnit @@ -93,11 +94,14 @@ class RepoUpdateWorker @AssistedInject constructor( } } + private val log = KotlinLogging.logger { } + override suspend fun doWork(): Result { + log.info { "Starting RepoUpdateWorker... $runAttemptCount" } try { setForeground(getForegroundInfo()) } catch (e: Exception) { - Log.e(TAG, "Error while running setForeground", e) + log.error(e) { "Error while running setForeground" } } val repoId = inputData.getLong("repoId", -1) return try { @@ -105,7 +109,7 @@ class RepoUpdateWorker @AssistedInject constructor( else repoUpdateManager.updateRepos() Result.success() } catch (e: Exception) { - Log.e(TAG, "Error updating repos", e) + log.error(e) { "Error updating repos" } Result.failure() } } diff --git a/next/src/main/kotlin/org/fdroid/ui/IntentRouter.kt b/next/src/main/kotlin/org/fdroid/ui/IntentRouter.kt index 7c8ebfa30..9ad1c4ed7 100644 --- a/next/src/main/kotlin/org/fdroid/ui/IntentRouter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/IntentRouter.kt @@ -14,13 +14,17 @@ class IntentRouter(private val backStack: NavBackStack) : Consumer= 24 && ACTION_SHOW_APP_INFO == intent.action) { // App Details + } else if (ACTION_SHOW_APP_INFO == intent.action) { // App Details val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: return if (packageName.matches(packageNameRegex)) { backStack.add(NavigationKey.AppDetails(packageName)) @@ -40,6 +44,10 @@ class IntentRouter(private val backStack: NavBackStack) : Consumer Unit = {}) { myAppsViewModel.changeSortOrder(sort) override fun search(query: String) = myAppsViewModel.search(query) + override fun confirmAppInstall( + packageName: String, + state: InstallState.UserConfirmationNeeded, + ) = myAppsViewModel.confirmAppInstall(packageName, state) } MyApps( myAppsInfo = myAppsInfo, diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/AppUpdateItem.kt b/next/src/main/kotlin/org/fdroid/ui/apps/AppUpdateItem.kt deleted file mode 100644 index 1a528fc25..000000000 --- a/next/src/main/kotlin/org/fdroid/ui/apps/AppUpdateItem.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.fdroid.ui.apps - -import org.fdroid.download.DownloadRequest -import org.fdroid.index.v2.PackageVersion - -data class AppUpdateItem( - val packageName: String, - val name: String, - val installedVersionName: String, - val update: PackageVersion, - val whatsNew: String?, - val iconDownloadRequest: DownloadRequest? = null, -) diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt b/next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt new file mode 100644 index 000000000..6e500f047 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt @@ -0,0 +1,112 @@ +package org.fdroid.ui.apps + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CircularProgressIndicator +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.graphics.Color +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 +import org.fdroid.download.PackageName +import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.install.InstallState +import org.fdroid.next.R +import org.fdroid.ui.utils.AsyncShimmerImage + +@Composable +fun InstallingAppRow( + app: InstallingAppItem, + isSelected: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + ListItem( + leadingContent = { + AsyncShimmerImage( + model = PackageName(app.packageName, app.iconDownloadRequest, false), + error = painterResource(R.drawable.ic_repo_app_default), + contentDescription = null, + modifier = Modifier.size(48.dp), + ) + }, + headlineContent = { + Text(app.name) + }, + supportingContent = { + val currentVersionName = app.installState.currentVersionName + if (currentVersionName == null) { + Text(app.installState.versionName) + } else { + Text("$currentVersionName → ${app.installState.versionName}") + } + }, + trailingContent = { + if (app.installState is InstallState.Installed) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.app_installed), + tint = MaterialTheme.colorScheme.secondary, + ) + } else { + if (app.installState is InstallState.Downloading) { + CircularProgressIndicator(progress = { app.installState.progress }) + } else { + CircularProgressIndicator() + } + } + }, + colors = ListItemDefaults.colors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.surfaceVariant + } else { + Color.Transparent + } + ), + modifier = modifier, + ) + } +} + +@Preview +@Composable +private fun Preview() { + val installingApp1 = InstallingAppItem( + packageName = "A1", + installState = InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconDownloadRequest = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ) + ) + val installingApp2 = InstallingAppItem( + packageName = "A2", + installState = InstallState.Installed( + name = "Installing App 2", + versionName = "2.0.1", + currentVersionName = null, + lastUpdated = 13, + iconDownloadRequest = null, + ) + ) + FDroidContent { + Column { + InstallingAppRow(installingApp1, false) + InstallingAppRow(installingApp2, true) + } + } +} diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt new file mode 100644 index 000000000..2e4d86ede --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt @@ -0,0 +1,40 @@ +package org.fdroid.ui.apps + +import org.fdroid.download.DownloadRequest +import org.fdroid.index.v2.PackageVersion +import org.fdroid.install.InstallStateWithInfo + +sealed class MyAppItem { + abstract val packageName: String + abstract val name: String + abstract val lastUpdated: Long + abstract val iconDownloadRequest: DownloadRequest? +} + +data class InstallingAppItem( + override val packageName: String, + val installState: InstallStateWithInfo, +) : MyAppItem() { + override val name: String = installState.name + override val lastUpdated: Long = installState.lastUpdated + override val iconDownloadRequest: DownloadRequest? = installState.iconDownloadRequest +} + +data class AppUpdateItem( + override val packageName: String, + override val name: String, + val installedVersionName: String, + val update: PackageVersion, + val whatsNew: String?, + override val iconDownloadRequest: DownloadRequest? = null, +) : MyAppItem() { + override val lastUpdated: Long = update.added +} + +data class InstalledAppItem( + override val packageName: String, + override val name: String, + val installedVersionName: String, + override val lastUpdated: Long, + override val iconDownloadRequest: DownloadRequest? = null, +) : MyAppItem() diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt index 9a5af700d..fdcbe0477 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyApps.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons @@ -31,6 +32,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,17 +46,21 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation3.runtime.NavKey import org.fdroid.database.AppListSortOrder import org.fdroid.database.AppListSortOrder.LAST_UPDATED import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.install.InstallState import org.fdroid.next.R import org.fdroid.ui.BottomBar import org.fdroid.ui.NavigationKey import org.fdroid.ui.lists.TopSearchBar import org.fdroid.ui.utils.BigLoadingIndicator import org.fdroid.ui.utils.Names +import org.fdroid.ui.utils.getMyAppsInfo import org.fdroid.ui.utils.getPreviewVersion import java.util.concurrent.TimeUnit.DAYS @@ -68,10 +75,28 @@ fun MyApps( modifier: Modifier = Modifier, ) { val myAppsModel = myAppsInfo.model - LifecycleStartEffect(myAppsModel) { + val appToConfirm by remember(myAppsInfo.model.installingApps) { + derivedStateOf { + myAppsInfo.model.installingApps.find { app -> + app.installState is InstallState.UserConfirmationNeeded + } + } + } + LifecycleStartEffect(Unit) { myAppsInfo.refresh() onStopOrDispose { } } + // Ask user to confirm appToConfirm whenever it changes and we are in STARTED state. + // In tests, waiting for RESUME didn't work, because the LaunchedEffect ran before. + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(appToConfirm) { + val app = appToConfirm + if (app != null && lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + val state = app.installState as InstallState.UserConfirmationNeeded + myAppsInfo.confirmAppInstall(app.packageName, state) + } + } + val installingApps = myAppsModel.installingApps val updatableApps = myAppsModel.appUpdates val installedApps = myAppsModel.installedApps val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) @@ -150,8 +175,12 @@ fun MyApps( }, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> + val lazyListState = rememberLazyListState() if (updatableApps == null && installedApps == null) BigLoadingIndicator() - else if (updatableApps.isNullOrEmpty() && installedApps.isNullOrEmpty()) { + else if (installingApps.isEmpty() && + updatableApps.isNullOrEmpty() && + installedApps.isNullOrEmpty() + ) { Text( text = if (searchActive) { stringResource(R.string.search_my_apps_no_results) @@ -164,84 +193,126 @@ fun MyApps( .fillMaxSize() .padding(16.dp), ) - } else LazyColumn( - modifier - .padding(paddingValues) - .then( - if (currentPackageName == null) Modifier - else Modifier.selectableGroup() - ), - ) { - if (updatableApps == null || updatableApps.isNotEmpty()) { - item(key = "A", contentType = "header") { - Row(verticalAlignment = Alignment.CenterVertically) { + } else { + LazyColumn( + state = lazyListState, + modifier = modifier + .padding(paddingValues) + .then( + if (currentPackageName == null) Modifier + else Modifier.selectableGroup() + ), + ) { + // Updates header with Update all button (only show when there's a list below) + if (!updatableApps.isNullOrEmpty()) { + item(key = "A", contentType = "header") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.updates), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(16.dp) + .weight(1f), + ) + Button( + onClick = {}, + modifier = Modifier.padding(end = 16.dp), + ) { + Text(stringResource(R.string.update_all)) + } + } + } + // List of updatable apps + items( + items = updatableApps, + key = { it.packageName }, + contentType = { "A" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) + } + val modifier = Modifier.Companion + .animateItem() + .then(interactionModifier) + UpdatableAppRow(app, isSelected, modifier) + } + } + // Apps currently installing header + if (installingApps.isNotEmpty()) { + item(key = "B", contentType = "header") { Text( - text = stringResource(R.string.updates), + text = stringResource(R.string.notification_title_summary_installing), style = MaterialTheme.typography.titleMedium, modifier = Modifier .padding(16.dp) - .weight(1f), ) - if (updatableApps?.isNotEmpty() == true) Button( - onClick = {}, - modifier = Modifier.padding(end = 16.dp), - ) { - Text(stringResource(R.string.update_all)) + } + // List of currently installing apps + items( + items = installingApps, + key = { it.packageName }, + contentType = { "B" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) } + val modifier = Modifier.Companion + .animateItem() + .then(interactionModifier) + InstallingAppRow(app, isSelected, modifier) } } - } - 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 = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) + // Installed apps header (only show when we have non-empty lists above) + if ((installingApps.isNotEmpty() || !updatableApps.isNullOrEmpty()) && + !installedApps.isNullOrEmpty() + ) { + item(key = "C", contentType = "header") { + Text( + text = stringResource(R.string.installed_apps__activity_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + } } - val modifier = Modifier.Companion - .animateItem() - .then(interactionModifier) - UpdatableAppRow(app, isSelected, modifier) - } - 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), - ) + // List of installed apps + if (installedApps != null) items( + items = installedApps, + key = { it.packageName }, + contentType = { "C" }, + ) { app -> + val isSelected = app.packageName == currentPackageName + val interactionModifier = if (currentPackageName == null) { + Modifier.clickable( + onClick = { onAppItemClick(app.packageName) } + ) + } else { + Modifier.selectable( + selected = isSelected, + onClick = { onAppItemClick(app.packageName) } + ) + } + val modifier = Modifier + .animateItem() + .then(interactionModifier) + InstalledAppRow(app, isSelected, modifier) } } - 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 = { onAppItemClick(app.packageName) } - ) - } else { - Modifier.selectable( - selected = isSelected, - onClick = { onAppItemClick(app.packageName) } - ) - } - val modifier = Modifier - .animateItem() - .then(interactionModifier) - InstalledAppRow(app, isSelected, modifier) - } } } } @@ -249,20 +320,15 @@ fun MyApps( @Preview @Composable fun MyAppsLoadingPreview() { - val info = object : MyAppsInfo { - override val model = MyAppsModel( - appUpdates = null, - installedApps = null, - sortOrder = AppListSortOrder.NAME, - ) - - override fun refresh() {} - override fun changeSortOrder(sort: AppListSortOrder) {} - override fun search(query: String) {} - } + val model = MyAppsModel( + installingApps = emptyList(), + appUpdates = null, + installedApps = null, + sortOrder = AppListSortOrder.NAME, + ) FDroidContent { MyApps( - myAppsInfo = info, + myAppsInfo = getMyAppsInfo(model), currentPackageName = null, onAppItemClick = {}, onNav = {}, @@ -276,50 +342,59 @@ fun MyAppsLoadingPreview() { @RestrictTo(RestrictTo.Scope.TESTS) fun MyAppsPreview() { FDroidContent { + val installingApp1 = InstallingAppItem( + packageName = "A1", + installState = InstallState.Downloading( + name = "Installing App 1", + versionName = "1.0.4", + currentVersionName = null, + lastUpdated = 23, + iconDownloadRequest = null, + downloadedBytes = 25, + totalBytes = 100, + startMillis = System.currentTimeMillis(), + ) + ) val app1 = AppUpdateItem( - packageName = "AX", + packageName = "B1", name = "App Update 123", installedVersionName = "1.0.1", update = getPreviewVersion("1.1.0", 123456789), whatsNew = "This is new, all is new, nothing old.", ) val app2 = AppUpdateItem( - packageName = "BX", + packageName = "B2", name = Names.randomName, installedVersionName = "3.0.1", update = getPreviewVersion("3.1.0", 9876543), whatsNew = null, ) val installedApp1 = InstalledAppItem( - packageName = "1", + packageName = "C1", name = Names.randomName, installedVersionName = "1", lastUpdated = System.currentTimeMillis() - DAYS.toMillis(1) ) val installedApp2 = InstalledAppItem( - packageName = "2", + packageName = "C2", name = Names.randomName, installedVersionName = "2", lastUpdated = System.currentTimeMillis() - DAYS.toMillis(2) ) val installedApp3 = InstalledAppItem( - packageName = "3", + packageName = "C3", name = Names.randomName, installedVersionName = "3", lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3) ) val model = MyAppsModel( + installingApps = listOf(installingApp1), appUpdates = listOf(app1, app2), installedApps = listOf(installedApp1, installedApp2, installedApp3), sortOrder = AppListSortOrder.NAME, ) MyApps( - myAppsInfo = object : MyAppsInfo { - override val model = model - override fun refresh() {} - override fun changeSortOrder(sort: AppListSortOrder) {} - override fun search(query: String) {} - }, + myAppsInfo = getMyAppsInfo(model), currentPackageName = null, onAppItemClick = {}, onNav = {}, diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt index d60f19d0e..c34c22756 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsInfo.kt @@ -1,15 +1,18 @@ package org.fdroid.ui.apps import org.fdroid.database.AppListSortOrder +import org.fdroid.install.InstallState interface MyAppsInfo { val model: MyAppsModel fun refresh() fun changeSortOrder(sort: AppListSortOrder) fun search(query: String) + fun confirmAppInstall(packageName: String, state: InstallState.UserConfirmationNeeded) } data class MyAppsModel( + val installingApps: List, val appUpdates: List? = null, val installedApps: List? = null, val sortOrder: AppListSortOrder = AppListSortOrder.NAME, diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt index d86695390..aea9b3075 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsPresenter.kt @@ -6,50 +6,71 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import kotlinx.coroutines.flow.StateFlow import org.fdroid.database.AppListSortOrder +import org.fdroid.install.InstallState +import org.fdroid.install.InstallStateWithInfo import org.fdroid.ui.utils.normalize import java.text.Collator import java.util.Locale @Composable fun MyAppsPresenter( + appInstallStatesFlow: StateFlow>, appUpdatesFlow: StateFlow?>, installedAppsFlow: StateFlow?>, searchQueryFlow: StateFlow, sortOrderFlow: StateFlow, ): MyAppsModel { + val appInstallStates = appInstallStatesFlow.collectAsState().value val appUpdates = appUpdatesFlow.collectAsState().value val installedApps = installedAppsFlow.collectAsState().value val searchQuery = searchQueryFlow.collectAsState().value.normalize() val sortOrder = sortOrderFlow.collectAsState().value - val packageNames = appUpdates?.map { it.packageName } ?: emptyList() - val collator = Collator.getInstance(Locale.getDefault()) + val processedPackageNames = mutableSetOf() - val updates = if (searchQuery.isBlank()) appUpdates else appUpdates?.filter { + val installingApps = appInstallStates.mapNotNull { (packageName, state) -> + if (state is InstallStateWithInfo) { + processedPackageNames.add(packageName) + InstallingAppItem(packageName, state) + } else { + null + } + } + val installing = if (searchQuery.isBlank()) installingApps else installingApps.filter { it.name.normalize().contains(searchQuery, ignoreCase = true) } - val installed = if (searchQuery.isBlank()) installedApps else installedApps?.filter { - it.name.normalize().contains(searchQuery, ignoreCase = true) + val updates = appUpdates?.filter { + val keep = if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames + } else { + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) + } + processedPackageNames.add(it.packageName) + keep + } + val installed = installedApps?.filter { + if (searchQuery.isBlank()) { + it.packageName !in processedPackageNames + } else { + it.packageName !in processedPackageNames && + it.name.normalize().contains(searchQuery, ignoreCase = true) + } } return MyAppsModel( - appUpdates = when (sortOrder) { - AppListSortOrder.NAME -> updates?.sortedWith { a1, a2 -> - // storing collator.getCollationKey() and using that could be an optimization - collator.compare(a1.name, a2.name) - } - AppListSortOrder.LAST_UPDATED -> updates?.sortedByDescending { it.update.added } - }, - installedApps = installed?.filter { - // filter out apps already in updates - it.packageName !in packageNames - }?.let { apps -> - when (sortOrder) { - AppListSortOrder.NAME -> apps.sortedWith { a1, a2 -> - // storing collator.getCollationKey() and using that could be an optimization - collator.compare(a1.name, a2.name) - } - AppListSortOrder.LAST_UPDATED -> apps.sortedByDescending { it.lastUpdated } - } - }, + installingApps = installing.sort(sortOrder), + appUpdates = updates?.sort(sortOrder), + installedApps = installed?.sort(sortOrder), sortOrder = sortOrder, ) } + +private fun List.sort(sortOrder: AppListSortOrder): List { + val collator = Collator.getInstance(Locale.getDefault()) + return when (sortOrder) { + AppListSortOrder.NAME -> sortedWith { a1, a2 -> + // storing collator.getCollationKey() and using that could be an optimization + collator.compare(a1.name, a2.name) + } + AppListSortOrder.LAST_UPDATED -> sortedByDescending { it.lastUpdated } + } +} diff --git a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt index 54ff6c936..78c125572 100644 --- a/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/apps/MyAppsViewModel.kt @@ -12,36 +12,37 @@ import app.cash.molecule.RecompositionMode.ContextClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import mu.KotlinLogging import org.fdroid.database.AppListItem import org.fdroid.database.AppListSortOrder import org.fdroid.database.FDroidDatabase -import org.fdroid.download.DownloadRequest import org.fdroid.download.getDownloadRequest import org.fdroid.index.RepoManager +import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstallState import org.fdroid.updates.UpdatesManager +import org.fdroid.utils.IoDispatcher 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, + @param:IoDispatcher private val scope: CoroutineScope, savedStateHandle: SavedStateHandle, private val db: FDroidDatabase, + private val appInstallManager: AppInstallManager, private val updatesManager: UpdatesManager, private val repoManager: RepoManager, ) : AndroidViewModel(app) { + private val log = KotlinLogging.logger { } private val localeList = LocaleListCompat.getDefault() - private val scope = CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) + private val moleculeScope = + CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main) private val updates = updatesManager.updates private val installedApps = MutableStateFlow?>(null) @@ -62,8 +63,9 @@ class MyAppsViewModel @Inject constructor( } private val searchQuery = savedStateHandle.getMutableStateFlow("query", "") private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME) - val myAppsModel: StateFlow = scope.launchMolecule(mode = ContextClock) { + val myAppsModel: StateFlow = moleculeScope.launchMolecule(mode = ContextClock) { MyAppsPresenter( + appInstallStatesFlow = appInstallManager.appInstallStates, appUpdatesFlow = updates, installedAppsFlow = installedApps, searchQueryFlow = searchQuery, @@ -97,4 +99,11 @@ class MyAppsViewModel @Inject constructor( observeForever(installedAppsObserver) } } + + fun confirmAppInstall(packageName: String, state: InstallState.UserConfirmationNeeded) { + log.info { "Asking user to confirm install of $packageName..." } + scope.launch(Dispatchers.Main) { + appInstallManager.requestUserConfirmation(packageName, state) + } + } } diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt index 365223d36..f319f5c5b 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt @@ -196,7 +196,7 @@ fun AppDetailsHeader( modifier = Modifier.weight(1f) ) { val strRes = when (item.installState) { - InstallState.Starting -> R.string.status_install_preparing + is InstallState.Starting -> R.string.status_install_preparing is InstallState.PreApproved -> R.string.status_install_preparing is InstallState.Downloading -> R.string.downloading is InstallState.Installing -> R.string.installing @@ -209,8 +209,7 @@ fun AppDetailsHeader( ) if (item.installState is InstallState.Downloading) { val animatedProgress by animateFloatAsState( - targetValue = item.installState.downloadedBytes / - item.installState.totalBytes.toFloat(), + targetValue = item.installState.progress, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, ) LinearWavyProgressIndicator( @@ -260,7 +259,7 @@ fun AppDetailsHeader( require(item.suggestedVersion != null) { "suggestedVersion was null" } - item.actions.installAction(item.app, item.suggestedVersion) + item.actions.installAction(item.app, item.suggestedVersion, item.icon) }, modifier = Modifier.weight(1f) ) { @@ -289,7 +288,7 @@ fun AppDetailsHeaderPreview() { private fun PreviewProgress() { FDroidContent { Column { - val app = testApp.copy(installState = InstallState.Starting) + val app = testApp.copy(installState = InstallState.Starting("", "", "", 23)) AppDetailsHeader(app, PaddingValues()) } } diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt index f4ab8874d..1667c2bd0 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt @@ -189,7 +189,7 @@ data class AppDetailsItem( } class AppDetailsActions( - val installAction: (AppMetadata, AppVersion) -> Unit, + val installAction: (AppMetadata, AppVersion, DownloadRequest?) -> Unit, val requestUserConfirmation: (String, InstallState.UserConfirmationNeeded) -> Unit, /** * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 5c7689233..68fdc4024 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -24,6 +24,7 @@ import org.fdroid.UpdateChecker import org.fdroid.database.AppMetadata import org.fdroid.database.AppVersion import org.fdroid.database.FDroidDatabase +import org.fdroid.download.DownloadRequest import org.fdroid.index.RELEASE_CHANNEL_BETA import org.fdroid.index.RepoManager import org.fdroid.install.AppInstallManager @@ -45,7 +46,7 @@ class AppDetailsViewModel @Inject constructor( private val log = KotlinLogging.logger { } private val packageInfoFlow = MutableStateFlow(null) - val appDetails: StateFlow = scope.launchMolecule( + val appDetails: StateFlow = viewModelScope.launchMolecule( context = Dispatchers.IO, mode = Immediate, ) { DetailsPresenter( @@ -81,11 +82,19 @@ class AppDetailsViewModel @Inject constructor( } @UiThread - fun install(appMetadata: AppMetadata, version: AppVersion) { - val repo = repoManager.getRepository(version.repoId) ?: return // TODO - val icon = appDetails.value?.icon - viewModelScope.launch(Dispatchers.Main) { - val result = appInstallManager.install(appMetadata, version, repo, icon) + fun install( + appMetadata: AppMetadata, + version: AppVersion, + iconDownloadRequest: DownloadRequest?, + ) { + scope.launch(Dispatchers.Main) { + val result = appInstallManager.install( + appMetadata = appMetadata, + version = version, + currentVersionName = packageInfoFlow.value?.packageInfo?.versionName, + repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO + iconDownloadRequest = iconDownloadRequest, + ) if (result is InstallState.Installed) { // to reload packageInfoFlow with fresh packageInfo loadPackageInfoFlow(appMetadata.packageName) diff --git a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt index 65781911d..9f4c96e26 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt @@ -16,6 +16,7 @@ import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository import org.fdroid.index.RepoManager import org.fdroid.install.AppInstallManager +import org.fdroid.install.InstallState import org.fdroid.utils.sha256 private const val TAG = "DetailsPresenter" @@ -44,7 +45,8 @@ fun DetailsPresenter( repoManager.getRepository(repoId) } } - val installState = appInstallManager.getAppFlow(packageName).collectAsState().value + val installState = + appInstallManager.getAppFlow(packageName).collectAsState(InstallState.Unknown).value val versions = db.getVersionDao().getAppVersions(packageName).asFlow().collectAsState(null).value diff --git a/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt b/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt index b7be2a384..eee8789d5 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt @@ -68,7 +68,7 @@ fun Versions( } }, installAction = { version: AppVersion -> - item.actions.installAction(item.app, version) + item.actions.installAction(item.app, version, item.icon) }, scrollUp = scrollUp, ) diff --git a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index d51118b03..44dca49a8 100644 --- a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -9,6 +9,8 @@ import org.fdroid.index.v2.PackageManifest import org.fdroid.index.v2.PackageVersion import org.fdroid.index.v2.SignerV2 import org.fdroid.install.InstallState +import org.fdroid.ui.apps.MyAppsInfo +import org.fdroid.ui.apps.MyAppsModel import org.fdroid.ui.categories.CategoryItem import org.fdroid.ui.details.AntiFeature import org.fdroid.ui.details.AppDetailsActions @@ -113,7 +115,7 @@ val testApp = AppDetailsItem( isCompatible = true, ), actions = AppDetailsActions( - installAction = { _, _ -> }, + installAction = { _, _, _ -> }, requestUserConfirmation = { _, _ -> }, checkUserConfirmation = { _, _ -> }, cancelInstall = {}, @@ -204,3 +206,15 @@ fun getAppListInfo(model: AppListModel) = object : AppListInfo { override val showFilters: Boolean = false override val showOnboarding: Boolean = false } + +fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo { + override val model = model + override fun refresh() {} + override fun changeSortOrder(sort: AppListSortOrder) {} + override fun search(query: String) {} + override fun confirmAppInstall( + packageName: String, + state: InstallState.UserConfirmationNeeded, + ) { + } +} diff --git a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt index 55ff83855..39938eb14 100644 --- a/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt +++ b/next/src/main/kotlin/org/fdroid/updates/UpdatesManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import mu.KotlinLogging import org.fdroid.database.DbUpdateChecker import org.fdroid.download.getDownloadRequest import org.fdroid.index.RepoManager @@ -19,6 +20,8 @@ class UpdatesManager @Inject constructor( @IoDispatcher private val coroutineScope: CoroutineScope, private val repoManager: RepoManager, ) { + private val log = KotlinLogging.logger { } + private val _updates = MutableStateFlow?>(null) val updates = _updates.asStateFlow() private val _numUpdates = MutableStateFlow(0) @@ -31,17 +34,23 @@ class UpdatesManager @Inject constructor( 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 = repoManager.getRepository(update.repoId)?.let { repo -> - update.getIcon(localeList)?.getDownloadRequest(repo) - }, - ) + val updates = try { + log.info { "Checking for 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 = repoManager.getRepository(update.repoId)?.let { repo -> + update.getIcon(localeList)?.getDownloadRequest(repo) + }, + ) + } + } catch (e: Exception) { + log.error(e) { "Error loading updates: " } + return@launch } _updates.value = updates _numUpdates.value = updates.size diff --git a/next/src/main/res/values/strings-next.xml b/next/src/main/res/values/strings-next.xml index 2d9f95c26..2689ed80a 100644 --- a/next/src/main/res/values/strings-next.xml +++ b/next/src/main/res/values/strings-next.xml @@ -62,6 +62,18 @@ Size: %1$s Signer: %1$s Architectures: %1$s + + Installing %1$d app… + Installing %1$d apps… + + + Updating %1$d app… + Updating %1$d apps… + + Installing: + Needs user confirmation: + Installed: + Tap to confirm. Filter Here you can apply filters to the list of apps, e.g. showing only apps within a certain category or repository. Changing the sort order is also possible.