Show install states in notification and My apps screen

This commit is contained in:
Torsten Grote
2025-10-02 17:28:20 -03:00
parent 34049b2f9c
commit 9948edc783
24 changed files with 907 additions and 240 deletions

View File

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

View File

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

View File

@@ -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<AppVersion>()
private val apps = ConcurrentHashMap<String, MutableStateFlow<InstallState>>()
private val apps = MutableStateFlow<Map<String, InstallState>>(emptyMap())
private val jobs = ConcurrentHashMap<String, Job>()
val appInstallStates = apps.asStateFlow()
fun getAppFlow(packageName: String): StateFlow<InstallState> {
return apps.getOrPut(packageName) {
MutableStateFlow(InstallState.Unknown)
}
fun getAppFlow(packageName: String): Flow<InstallState> {
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<InstallState>,
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<AppState>()
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<Map<String, InstallState>>.updateApp(
packageName: String,
function: (InstallState) -> InstallState,
) = update { oldMap ->
val newMap = oldMap.toMutableMap()
newMap[packageName] = function(newMap[packageName] ?: InstallState.Unknown)
newMap
}
private fun MutableStateFlow<Map<String, InstallState>>.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
}
}

View File

@@ -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<AppState>,
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<AppState>()
val toConfirm = mutableListOf<AppState>()
val installed = mutableListOf<AppState>()
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<AppState>, 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
}

View File

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

View File

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

View File

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

View File

@@ -14,13 +14,17 @@ class IntentRouter(private val backStack: NavBackStack<NavKey>) : Consumer<Inten
private val log = KotlinLogging.logger { }
private val packageNameRegex = "[A-Za-z\\d_.]+".toRegex()
companion object {
const val ACTION_MY_APPS = "org.fdroid.action.MY_APPS"
}
override fun accept(value: Intent) {
val intent = value
log.info { "Incoming intent: $intent" }
val uri = intent.data
if (ACTION_MAIN == intent.action) {
// launcher intent, do nothing
} else if (SDK_INT >= 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<NavKey>) : Consumer<Inten
val packageName = uri.lastPathSegment ?: return
backStack.add(NavigationKey.AppDetails(packageName))
}
} else if (ACTION_MY_APPS == intent.action) {
if (backStack.lastOrNull() !is NavigationKey.MyApps) {
backStack.add(NavigationKey.MyApps)
}
} else {
log.warn { "Unknown intent: $intent - uri: $uri $SDK_INT" }
}

View File

@@ -31,6 +31,7 @@ import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import org.fdroid.database.AppListSortOrder
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.install.InstallState
import org.fdroid.next.R
import org.fdroid.ui.apps.MyApps
import org.fdroid.ui.apps.MyAppsInfo
@@ -116,6 +117,10 @@ fun Main(onListeningForIntent: () -> 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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<InstallingAppItem>,
val appUpdates: List<AppUpdateItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,

View File

@@ -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<Map<String, InstallState>>,
appUpdatesFlow: StateFlow<List<AppUpdateItem>?>,
installedAppsFlow: StateFlow<List<InstalledAppItem>?>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
): 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<String>()
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 <T : MyAppItem> List<T>.sort(sortOrder: AppListSortOrder): List<T> {
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 }
}
}

View File

@@ -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<List<InstalledAppItem>?>(null)
@@ -62,8 +63,9 @@ class MyAppsViewModel @Inject constructor(
}
private val searchQuery = savedStateHandle.getMutableStateFlow<String>("query", "")
private val sortOrder = savedStateHandle.getMutableStateFlow("sort", AppListSortOrder.NAME)
val myAppsModel: StateFlow<MyAppsModel> = scope.launchMolecule(mode = ContextClock) {
val myAppsModel: StateFlow<MyAppsModel> = 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)
}
}
}

View File

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

View File

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

View File

@@ -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<AppInfo?>(null)
val appDetails: StateFlow<AppDetailsItem?> = scope.launchMolecule(
val appDetails: StateFlow<AppDetailsItem?> = 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)

View File

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

View File

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

View File

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

View File

@@ -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<List<AppUpdateItem>?>(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

View File

@@ -62,6 +62,18 @@
<string name="size_colon">Size: %1$s</string>
<string name="signer_colon">Signer: %1$s</string>
<string name="architectures_colon">Architectures: %1$s</string>
<plurals name="notification_installing_title">
<item quantity="one">Installing %1$d app…</item>
<item quantity="other">Installing %1$d apps…</item>
</plurals>
<plurals name="notification_updating_title">
<item quantity="one">Updating %1$d app…</item>
<item quantity="other">Updating %1$d apps…</item>
</plurals>
<string name="notification_installing_section_installing">Installing:</string>
<string name="notification_installing_section_confirmation">Needs user confirmation:</string>
<string name="notification_installing_section_installed">Installed:</string>
<string name="notification_installing_confirmation">Tap to confirm.</string>
<string name="onboarding_app_list_filter_title">Filter</string>
<string name="onboarding_app_list_filter_message">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.</string>