mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-05-24 16:35:49 -04:00
Show install states in notification and My apps screen
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
112
next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt
Normal file
112
next/src/main/kotlin/org/fdroid/ui/apps/InstallingAppRow.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt
Normal file
40
next/src/main/kotlin/org/fdroid/ui/apps/MyAppItem.kt
Normal 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()
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user