From f24ff287d8efb106d029e4bc622a3ad74a0ef4ac Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 22 Sep 2025 11:54:28 -0300 Subject: [PATCH] Implement installation of apps and updates --- next/src/main/AndroidManifest.xml | 14 +- next/src/main/kotlin/org/fdroid/App.kt | 10 +- .../org/fdroid/install/AppInstallListener.kt | 10 + .../org/fdroid/install/AppInstallManager.kt | 230 ++++++++++++ .../install/InstallBroadcastReceiver.kt | 48 +++ .../kotlin/org/fdroid/install/InstallState.kt | 27 ++ .../org/fdroid/install/PreApprovalResult.kt | 8 + .../fdroid/install/SessionInstallManager.kt | 334 +++++++++++++++++- .../org/fdroid/ui/details/AppDetails.kt | 25 +- .../org/fdroid/ui/details/AppDetailsHeader.kt | 118 ++++++- .../org/fdroid/ui/details/AppDetailsItem.kt | 23 +- .../fdroid/ui/details/AppDetailsViewModel.kt | 68 +++- .../org/fdroid/ui/details/DetailsPresenter.kt | 9 + .../kotlin/org/fdroid/ui/details/Versions.kt | 40 ++- .../org/fdroid/ui/utils/PreviewUtils.kt | 18 +- next/src/main/res/values/strings-next.xml | 1 + 16 files changed, 938 insertions(+), 45 deletions(-) create mode 100644 next/src/main/kotlin/org/fdroid/install/AppInstallListener.kt create mode 100644 next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt create mode 100644 next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt create mode 100644 next/src/main/kotlin/org/fdroid/install/InstallState.kt create mode 100644 next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt diff --git a/next/src/main/AndroidManifest.xml b/next/src/main/AndroidManifest.xml index 57f43d9e0..9014a5f99 100644 --- a/next/src/main/AndroidManifest.xml +++ b/next/src/main/AndroidManifest.xml @@ -6,16 +6,26 @@ - + + + + + + android:theme="@style/Theme.Fdroidclient" + tools:targetApi="33"> { - override fun key( - data: DownloadRequest, - options: Options - ): String { - return data.indexFile.sha256 - ?: (data.mirrors[0].baseUrl + data.indexFile.name) + override fun key(data: DownloadRequest, options: Options): String { + return data.getCacheKey() } } add(downloadRequestKeyer) @@ -84,3 +80,5 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory .build() } } + +fun DownloadRequest.getCacheKey() = indexFile.sha256 ?: (mirrors[0].baseUrl + indexFile.name) diff --git a/next/src/main/kotlin/org/fdroid/install/AppInstallListener.kt b/next/src/main/kotlin/org/fdroid/install/AppInstallListener.kt new file mode 100644 index 000000000..ed5d9f7b6 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/AppInstallListener.kt @@ -0,0 +1,10 @@ +package org.fdroid.install + +import android.app.PendingIntent + +interface AppInstallListener { + fun onStartInstall(sessionId: Int) + fun onUserConfirmationNeeded(sessionId: Int, intent: PendingIntent) + fun onInstalled() + fun onInstallError(msg: String?) +} diff --git a/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt new file mode 100644 index 000000000..520e37605 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt @@ -0,0 +1,230 @@ +package org.fdroid.install + +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import coil3.SingletonImageLoader +import coil3.memory.MemoryCache +import coil3.request.ImageRequest +import coil3.size.Size +import coil3.toBitmap +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import mu.KotlinLogging +import org.fdroid.database.AppMetadata +import org.fdroid.database.AppVersion +import org.fdroid.database.Repository +import org.fdroid.download.DownloadRequest +import org.fdroid.download.DownloaderFactory +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 + +@Singleton +class AppInstallManager @Inject constructor( + @param:ApplicationContext private val context: Context, + private val downloaderFactory: DownloaderFactory, + private val sessionInstallManager: SessionInstallManager, + @param:IoDispatcher private val scope: CoroutineScope, +) { + + private val log = KotlinLogging.logger { } + private val queue = ConcurrentLinkedQueue() + private val apps = ConcurrentHashMap>() + private val jobs = ConcurrentHashMap() + + fun getAppFlow(packageName: String): StateFlow { + return apps.getOrPut(packageName) { + MutableStateFlow(InstallState.Unknown) + } + } + + @UiThread + suspend fun install( + appMetadata: AppMetadata, + version: AppVersion, + repo: Repository, + iconDownloadRequest: DownloadRequest?, + ): InstallState? { + val flow = apps.getOrPut(appMetadata.packageName) { + MutableStateFlow(InstallState.Starting) + } + val job = scope.async { + installInt(flow, appMetadata, version, repo, iconDownloadRequest) + } + // keep track of this job, in case we want to cancel it + jobs.put(appMetadata.packageName, job) + // wait for job to return + val result = try { + job.await() + } catch (_: CancellationException) { + InstallState.UserAborted + } finally { + // remove job as it has completed + jobs.remove(appMetadata.packageName) + } + flow.update { result } + return result + } + + @WorkerThread + private suspend fun installInt( + flow: MutableStateFlow, + appMetadata: AppMetadata, + version: AppVersion, + repo: Repository, + iconDownloadRequest: DownloadRequest?, + ): InstallState { + flow.update { InstallState.Starting } + val coroutineContext = currentCoroutineContext() + // get the icon for pre-approval (usually in memory cache, so should be quick) + coroutineContext.ensureActive() + val icon = getIcon(iconDownloadRequest) + // request pre-approval from user (if available) + coroutineContext.ensureActive() + val preApprovalResult = sessionInstallManager.requestPreapproval(appMetadata, icon) + // continue depending on result, abort early if no approval was given + return when (preApprovalResult) { + is PreApprovalResult.Error -> InstallState.Error(preApprovalResult.errorMsg) + is PreApprovalResult.UserAborted -> InstallState.UserAborted + else -> { + flow.update { InstallState.PreApproved(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) + downloader.setListener { bytesRead, totalBytes -> + coroutineContext.ensureActive() + flow.update { + InstallState.Downloading(sessionId, bytesRead, totalBytes) + } + } + try { + downloader.download() + log.debug { "Download completed" } + } catch (e: Exception) { + if (e is CancellationException) throw e + log.error(e) { "Error downloading ${version.file}" } + val msg = "Download failed: ${e::class.java.simpleName} ${e.message}" + return InstallState.Error(msg) + } + coroutineContext.ensureActive() + flow.update { InstallState.Installing(sessionId) } + val result = sessionInstallManager.install(sessionId, version.packageName, file) + if (result is InstallState.PreApprovalFailed) { + // 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) + } else { + result + } + } + } + } + + /** + * Request user confirmation for installation and suspend until we get a result. + */ + @UiThread + suspend fun requestUserConfirmation( + 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}" } + return null + } + val result = sessionInstallManager.requestUserConfirmation(installState) + flow.update { result } + return result + } + + /** + * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog + * dismisses it without any feedback for us. + * So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded] + * we need to call this method, so we can manually check if our session progressed or not. + * If it didn't progress and the state hasn't changed, we fire up the confirmation intent again. + */ + @UiThread + fun checkUserConfirmation( + 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}" } + return + } + val sessionInfo = + context.packageManager.packageInstaller.getSessionInfo(installState.sessionId) + ?: run { + log.error { "Session ${installState.sessionId} does not exist anymore" } + return + } + if (sessionInfo.progress <= installState.progress) { + log.info { + "Session did not progress: ${sessionInfo.progress} <= ${installState.progress}" + } + // we fire up intent again to force the user to do a proper yes/no decision, + // so our session and our coroutine above don't get stuck + installState.intent.send() + } else { + log.debug { "Session has progressed, doing nothing" } + } + } + + fun cancel(packageName: String) { + val job = jobs[packageName] + log.debug { "Canceling job for $packageName $job" } + job?.cancel() + } + + @UiThread + fun cleanUp(packageName: String) { + val flow = apps[packageName] ?: return + if (!flow.value.showProgress) { + log.info { "Cleaning up state for $packageName ${flow.value}" } + jobs.remove(packageName)?.cancel() + apps.remove(packageName) + } + } + + /** + * Gets icon for preapproval from memory cache. + * In the unlikely event, that the icon isn't in the cache, + * we we download it with the given [iconDownloadRequest]. + */ + private suspend fun getIcon(iconDownloadRequest: DownloadRequest?): Bitmap? { + return iconDownloadRequest?.let { downloadRequest -> + // try memory cache first and download, if not found + val memoryCache = SingletonImageLoader.get(context).memoryCache + val key = downloadRequest.getCacheKey() + memoryCache?.get(MemoryCache.Key(key))?.image?.toBitmap() ?: run { + // not found in cache, download icon + val request = ImageRequest.Builder(context) + .data(downloadRequest) + .size(Size.ORIGINAL) + .build() + SingletonImageLoader.get(context).execute(request).image?.toBitmap() + } + } + } + +} diff --git a/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt b/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt new file mode 100644 index 000000000..6878d05cc --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/InstallBroadcastReceiver.kt @@ -0,0 +1,48 @@ +package org.fdroid.install + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.Intent.EXTRA_INTENT +import android.content.pm.PackageInstaller.EXTRA_PACKAGE_NAME +import android.content.pm.PackageInstaller.EXTRA_SESSION_ID +import android.content.pm.PackageInstaller.EXTRA_STATUS +import android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE +import androidx.core.content.IntentCompat.getParcelableExtra +import mu.KotlinLogging + +class InstallBroadcastReceiver( + private val sessionId: Int, + private val listener: InstallBroadcastReceiver.( + status: Int, + confirmIntent: Intent?, + msg: String?, + ) -> Unit, +) : BroadcastReceiver() { + + private val log = KotlinLogging.logger { } + + override fun onReceive(context: Context, intent: Intent) { + val receivedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -1) + if (receivedSessionId != sessionId) { + log.warn { + "Received intent for session $receivedSessionId, but expected $sessionId" + } + return + } + val confirmIntent = getParcelableExtra(intent, EXTRA_INTENT, Intent::class.java) + val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) + val status = intent.getIntExtra(EXTRA_STATUS, Int.Companion.MIN_VALUE) + val msg = intent.getStringExtra(EXTRA_STATUS_MESSAGE) + val warnings = intent.getStringArrayListExtra("android.content.pm.extra.WARNINGS") + log.info { + "Received broadcast for $packageName ($sessionId) $status: $msg" + } + if (warnings != null && warnings.isNotEmpty()) { + warnings.forEach { + log.warn { it } + } + } + listener(status, confirmIntent, msg) + } +} diff --git a/next/src/main/kotlin/org/fdroid/install/InstallState.kt b/next/src/main/kotlin/org/fdroid/install/InstallState.kt new file mode 100644 index 000000000..0ccb89ade --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/InstallState.kt @@ -0,0 +1,27 @@ +package org.fdroid.install + +import android.app.PendingIntent + +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 Downloading( + val sessionId: Int?, + val downloadedBytes: Long, + val totalBytes: Long, + ) : InstallState(true) + + data class Installing(val sessionId: Int?) : InstallState(true) + data class UserConfirmationNeeded( + val sessionId: Int, + val intent: PendingIntent, + val progress: Float, + ) : InstallState(true) + + data object PreApprovalFailed : InstallState(true) + + data object Installed : InstallState(false) + data object UserAborted : InstallState(false) + data class Error(val msg: String?) : InstallState(false) +} diff --git a/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt b/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt new file mode 100644 index 000000000..ab0013ce2 --- /dev/null +++ b/next/src/main/kotlin/org/fdroid/install/PreApprovalResult.kt @@ -0,0 +1,8 @@ +package org.fdroid.install + +sealed interface PreApprovalResult { + data object NotSupported : PreApprovalResult + data object UserAborted : PreApprovalResult + data class Success(val sessionId: Int) : PreApprovalResult + data class Error(val errorMsg: String?) : PreApprovalResult +} diff --git a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt index ff418468d..a3d97c9a8 100644 --- a/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt +++ b/next/src/main/kotlin/org/fdroid/install/SessionInstallManager.kt @@ -1,25 +1,329 @@ package org.fdroid.install +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_MUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.IntentSender +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.EXTRA_PACKAGE_NAME +import android.content.pm.PackageInstaller.EXTRA_SESSION_ID +import android.content.pm.PackageInstaller.SessionParams +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.icu.util.ULocale import android.os.Build.VERSION.SDK_INT +import androidx.annotation.RequiresApi +import androidx.annotation.WorkerThread +import androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED +import androidx.core.content.ContextCompat.registerReceiver +import androidx.core.os.LocaleListCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import mu.KotlinLogging +import org.fdroid.LocaleChooser.getBestLocale +import org.fdroid.database.AppMetadata +import org.fdroid.utils.IoDispatcher +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume -object SessionInstallManager { +@Singleton +class SessionInstallManager @Inject constructor( + @param:ApplicationContext private val context: Context, + @param:IoDispatcher private val coroutineScope: CoroutineScope, +) { + + private val log = KotlinLogging.logger { } + private val installer = context.packageManager.packageInstaller + + companion object { + private const val ACTION_INSTALL = "org.fdroid.install.SessionInstallManager.install" + + /** + * If this returns true, we can use + * [SessionParams.setRequireUserAction] with false, + * thus updating the app with the given targetSdk without user action. + */ + fun isAutoUpdateSupported(targetSdk: Int): Boolean { + if (SDK_INT < 31) return false // not supported below Android 12 + + if (SDK_INT == 31 && targetSdk >= 29) return true + if (SDK_INT == 32 && targetSdk >= 29) return true + if (SDK_INT == 33 && targetSdk >= 30) return true + if (SDK_INT == 34 && targetSdk >= 31) return true + // This needs to be adjusted as new Android versions are released + // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) + // https://cs.android.com/android/platform/superproject/+/android-16.0.0_r2:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=329;drc=73caa0299d9196ddeefe4f659f557fb880f6536d + // current code requires targetSdk 33 on SDK 35+ + return SDK_INT >= 35 && targetSdk >= 33 + } + } + + init { + // abandon old sessions, because there's a limit + // that will throw IllegalStateException when we try to open new sessions + coroutineScope.launch { + for (session in installer.mySessions) { + log.debug { "Abandon session ${session.sessionId} for ${session.appPackageName}" } + try { + installer.abandonSession(session.sessionId) + } catch (e: SecurityException) { + log.error(e) { "Error abandoning session: " } + } + } + } + } /** - * If this returns true, we can use - * [android.content.pm.PackageInstaller.SessionParams.setRequireUserAction] with false, - * thus updating the app with the given targetSdk without user action. + * Requests installation pre-approval (if available on this device). */ - fun isTargetSdkSupported(targetSdk: Int): Boolean { - if (SDK_INT < 31) return false // not supported below Android 12 + suspend fun requestPreapproval(app: AppMetadata, icon: Bitmap?): PreApprovalResult { + return if (SDK_INT >= 34) { + try { + preapproval(app, icon) + } catch (e: Exception) { + log.error(e) { "Error requesting pre-approval: " } + PreApprovalResult.Error("${e::class.java.simpleName} ${e.message}") + } + } else { + PreApprovalResult.NotSupported + } + } - if (SDK_INT == 31 && targetSdk >= 29) return true - if (SDK_INT == 32 && targetSdk >= 29) return true - if (SDK_INT == 33 && targetSdk >= 30) return true - if (SDK_INT == 34 && targetSdk >= 31) return true - // This needs to be adjusted as new Android versions are released - // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int) - // https://cs.android.com/android/platform/superproject/+/android-16.0.0_r2:frameworks/base/services/core/java/com/android/server/pm/PackageInstallerSession.java;l=329;drc=73caa0299d9196ddeefe4f659f557fb880f6536d - // current code requires targetSdk 33 on SDK 35+ - return SDK_INT >= 35 && targetSdk >= 33 + @RequiresApi(34) + private suspend fun preapproval( + app: AppMetadata, + icon: Bitmap?, + ): PreApprovalResult = suspendCancellableCoroutine { cont -> + val params = getSessionParams(app.packageName) + val sessionId = installer.createSession(params) + log.info { "Opened session $sessionId" } + val name = app.name.getBestLocale(LocaleListCompat.getDefault()) ?: "" + + val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg -> + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + cont.resume(PreApprovalResult.Success(sessionId)) + context.unregisterReceiver(this) + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + val pendingIntent = + PendingIntent.getActivity(context, sessionId, intent, flags) + // There should be no bugs on Android versions where this is supported + // and we should be in the foreground right now, + // so fire up intent here and now. + pendingIntent.send() + } + else -> { // some error, can't help it now, continue + if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { + cont.resume(PreApprovalResult.UserAborted) + } else { + cont.resume(PreApprovalResult.Error(msg)) + } + context.unregisterReceiver(this) + } + } + } + registerReceiver( + context, + receiver, + IntentFilter(ACTION_INSTALL), + RECEIVER_NOT_EXPORTED + ) + cont.invokeOnCancellation { + log.info { "Pre-approval cancelled." } + context.unregisterReceiver(receiver) + } + + installer.openSession(sessionId).use { session -> + log.info { "app name locales: ${app.name} using: ${ULocale.getDefault()}" } + val details = PackageInstaller.PreapprovalDetails.Builder() + .setPackageName(app.packageName) + .setLabel(name) + .setLocale(ULocale.getDefault()) // TODO get the real one used for label + .apply { if (icon != null) setIcon(icon) } + .build() + val sender = getInstallIntentSender(sessionId, app.packageName) + session.requestUserPreapproval(details, sender) + } + sessionId + } + + @WorkerThread + @SuppressLint("RequestInstallPackagesPolicy") + suspend fun install( + sessionId: Int?, + packageName: String, + apkFile: File, + ): InstallState = suspendCancellableCoroutine { cont -> + val size = apkFile.length() + log.info { "Installing ${apkFile.name} with size $size bytes" } + + val sessionId = try { + if (sessionId == null) { + val params = getSessionParams(packageName, size) + installer.createSession(params) + } else { + sessionId + } + } catch (e: Exception) { + log.error(e) { "Error when creating session: " } + cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}")) + return@suspendCancellableCoroutine + } + // set-up receiver for install result + val receiver = InstallBroadcastReceiver(sessionId) { status, intent, msg -> + context.unregisterReceiver(this) + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + cont.resume(InstallState.Installed) + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val flags = if (SDK_INT >= 31) { + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + } else { + FLAG_UPDATE_CURRENT + } + val pendingIntent = + PendingIntent.getActivity(context, sessionId, intent, flags) + val progress = installer.getSessionInfo(sessionId)?.progress + ?: error("No session info for $sessionId") + cont.resume( + InstallState.UserConfirmationNeeded(sessionId, pendingIntent, progress) + ) + } + else -> { + if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { + cont.resume(InstallState.UserAborted) + } else if (status == PackageInstaller.STATUS_FAILURE && + msg != null && + msg.contains("PreapprovalDetails") + ) { + cont.resume(InstallState.PreApprovalFailed) + } else { + cont.resume(InstallState.Error(msg)) + } + } + } + } + registerReceiver( + context, + receiver, + IntentFilter(ACTION_INSTALL), + RECEIVER_NOT_EXPORTED + ) + cont.invokeOnCancellation { + log.info { "App installation was cancelled, unregistering broadcast receiver..." } + context.unregisterReceiver(receiver) + try { + installer.abandonSession(sessionId) + } catch (e: SecurityException) { + // this can happen if the cancellation came too late and session already concluded + log.warn(e) { "Error while abandoning session: " } + } + } + // do the actual installation + try { + installer.openSession(sessionId).use { session -> + apkFile.inputStream().use { inputStream -> + session.openWrite(packageName, 0, size).use { outputStream -> + inputStream.copyTo(outputStream) + session.fsync(outputStream) + } + } + val sender = getInstallIntentSender(sessionId, packageName) + log.info { "Committing session..." } + session.commit(sender) + } + } catch (e: Exception) { + log.error(e) { "Error during install session: " } + cont.resume(InstallState.Error("${e::class.java.simpleName} ${e.message}")) + } + } + + suspend fun requestUserConfirmation( + installState: InstallState.UserConfirmationNeeded, + ): InstallState = suspendCancellableCoroutine { cont -> + val receiver = InstallBroadcastReceiver(installState.sessionId) { status, intent, msg -> + context.unregisterReceiver(this) + when (status) { + PackageInstaller.STATUS_SUCCESS -> { + cont.resume(InstallState.Installed) + } + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + error("Got STATUS_PENDING_USER_ACTION again") + } + else -> { + if (status == PackageInstaller.STATUS_FAILURE_ABORTED) { + cont.resume(InstallState.UserAborted) + } else { + cont.resume(InstallState.Error(msg)) + } + } + } + } + registerReceiver( + context, + receiver, + IntentFilter(ACTION_INSTALL), + RECEIVER_NOT_EXPORTED, + ) + cont.invokeOnCancellation { + context.unregisterReceiver(receiver) + } + installState.intent.send() + } + + private fun getSessionParams(packageName: String, size: Long? = null): SessionParams { + val params = SessionParams(SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(packageName) + size?.let { params.setSize(it) } + params.setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO) + if (SDK_INT >= 26) { + params.setInstallReason(PackageManager.INSTALL_REASON_USER) + } + if (SDK_INT >= 31) { + params.setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED) + } + if (SDK_INT >= 33) { + params.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (SDK_INT >= 34) { + // Once the update ownership enforcement is enabled, + // the other installers will need the user action to update the package + // even if the installers have been granted the INSTALL_PACKAGES permission. + // The update ownership enforcement can only be enabled on initial installation. + // Set this to true on package update is a no-op. + params.setRequestUpdateOwnership(true) + } + return params + } + + private fun getInstallIntentSender( + sessionId: Int, + packageName: String, + ): IntentSender { + // Don't use a different action for preapproval and installation, + // because Android sometimes sends installation broadcasts to preapproval intent. + val broadcastIntent = Intent(ACTION_INSTALL).apply { + setPackage(context.packageName) + putExtra(EXTRA_SESSION_ID, sessionId) + putExtra(EXTRA_PACKAGE_NAME, packageName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } + // intent flag needs to be mutable, otherwise the intent has no extras + val flags = if (SDK_INT >= 31) FLAG_UPDATE_CURRENT or FLAG_MUTABLE else FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getBroadcast(context, sessionId, broadcastIntent, flags) + return pendingIntent.intentSender } } diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt index a1e2947cd..3f57238f0 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetails.kt @@ -1,5 +1,6 @@ package org.fdroid.ui.details +import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column @@ -34,6 +35,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -41,6 +44,7 @@ import androidx.compose.material3.carousel.HorizontalUncontainedCarousel import androidx.compose.material3.carousel.rememberCarouselState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +55,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation @@ -64,6 +69,7 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import org.fdroid.LocaleChooser.getBestLocale import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.install.InstallState import org.fdroid.next.R import org.fdroid.ui.NavigationKey import org.fdroid.ui.categories.CategoryChip @@ -84,7 +90,9 @@ fun AppDetails( onBackNav: (() -> Unit)?, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val topAppBarState = rememberTopAppBarState() + val snackbarHostState = remember { SnackbarHostState() } val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) if (item == null) BigLoadingIndicator() else Scaffold( @@ -92,10 +100,23 @@ fun AppDetails( topBar = { AppDetailsTopAppBar(item, topAppBarState, scrollBehavior, onBackNav) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> + // react to install state changes + LaunchedEffect(item.installState) { + val state = item.installState + if (state is InstallState.UserConfirmationNeeded) { + Log.i("AppDetails", "Requesting user confirmation... $state") + item.actions.requestUserConfirmation(item.app.packageName, state) + } else if (state is InstallState.Error) { + val msg = context.getString(R.string.install_error_notify_title, state.msg ?: "") + snackbarHostState.showSnackbar(msg) + } + } + val scrollState = rememberScrollState() Column( modifier = modifier - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .fillMaxWidth() .padding(bottom = innerPadding.calculateBottomPadding()), ) { @@ -329,7 +350,7 @@ fun AppDetails( } // Versions if (!item.versions.isNullOrEmpty()) { - Versions(item) + Versions(item) { scrollState.scrollTo(0) } } // Developer contact if (item.showAuthorContact) ExpandableSection( diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt index 36d24b182..365223d36 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsHeader.kt @@ -1,6 +1,9 @@ package org.fdroid.ui.details import android.text.format.Formatter +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,19 +15,29 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode @@ -38,8 +51,12 @@ 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 androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import coil3.compose.AsyncImage import org.fdroid.fdroid.ui.theme.FDroidContent +import org.fdroid.install.InstallState import org.fdroid.next.R import org.fdroid.ui.utils.AsyncShimmerImage import org.fdroid.ui.utils.asRelativeTimeString @@ -147,12 +164,83 @@ fun AppDetailsHeader( onPreferredRepoChanged = {}, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + // check user confirmation ON_RESUME to work around Android bug + val lifecycleOwner = LocalLifecycleOwner.current + val currentInstallState by rememberUpdatedState(item.installState) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + val state = currentInstallState + if (state is InstallState.UserConfirmationNeeded) { + Log.i("AppDetailsHeader", "Resumed. Checking user confirmation... $state") + item.actions.checkUserConfirmation(item.app.packageName, state) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } // Main Buttons - if (item.showOpenButton || item.mainButtonState != MainButtonState.NONE) Row( + val buttonLineModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + if (item.mainButtonState == MainButtonState.PROGRESS) { + Row( + modifier = buttonLineModifier, + verticalAlignment = CenterVertically, + ) { + Column( + verticalArrangement = spacedBy(8.dp, CenterVertically), + modifier = Modifier.weight(1f) + ) { + val strRes = when (item.installState) { + 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 + is InstallState.UserConfirmationNeeded -> R.string.installing + else -> -1 + } + if (strRes >= 0) Text( + text = stringResource(strRes), + style = MaterialTheme.typography.bodyMedium, + ) + if (item.installState is InstallState.Downloading) { + val animatedProgress by animateFloatAsState( + targetValue = item.installState.downloadedBytes / + item.installState.totalBytes.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ) + LinearWavyProgressIndicator( + stopSize = 0.dp, + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + var cancelled by remember { mutableStateOf(false) } + IconButton(onClick = { + if (!cancelled) item.actions.cancelInstall(item.app.packageName) + cancelled = true + }) { + AnimatedVisibility(cancelled) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + AnimatedVisibility(!cancelled) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(R.string.cancel), + ) + } + } + } + } else if (item.showOpenButton || item.mainButtonState != MainButtonState.NONE) Row( horizontalArrangement = spacedBy(8.dp, CenterHorizontally), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = buttonLineModifier, ) { if (item.showOpenButton) { val context = LocalContext.current @@ -166,7 +254,16 @@ fun AppDetailsHeader( } } if (item.mainButtonState != MainButtonState.NONE) { - Button(onClick = {}, modifier = Modifier.weight(1f)) { + // button is for either installing or updating + Button( + onClick = { + require(item.suggestedVersion != null) { + "suggestedVersion was null" + } + item.actions.installAction(item.app, item.suggestedVersion) + }, + modifier = Modifier.weight(1f) + ) { if (item.mainButtonState == MainButtonState.INSTALL) { Text(stringResource(R.string.menu_install)) } else if (item.mainButtonState == MainButtonState.UPDATE) { @@ -186,3 +283,14 @@ fun AppDetailsHeaderPreview() { } } } + +@Preview +@Composable +private fun PreviewProgress() { + FDroidContent { + Column { + val app = testApp.copy(installState = InstallState.Starting) + AppDetailsHeader(app, PaddingValues()) + } + } +} diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt index 41af0eb94..54761b93b 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsItem.kt @@ -13,12 +13,14 @@ import org.fdroid.download.DownloadRequest import org.fdroid.download.getDownloadRequest import org.fdroid.index.RELEASE_CHANNEL_BETA import org.fdroid.index.v2.PackageVersion +import org.fdroid.install.InstallState import org.fdroid.install.SessionInstallManager import org.fdroid.ui.categories.CategoryItem data class AppDetailsItem( val app: AppMetadata, val actions: AppDetailsActions, + val installState: InstallState, /** * The ID of the repo that is currently set as preferred. * Note that the repository ID of this [app] may be different. @@ -45,7 +47,7 @@ data class AppDetailsItem( /** * The currently suggested version for installation. */ - val suggestedVersion: PackageVersion? = null, + val suggestedVersion: AppVersion? = null, /** * Similar to [suggestedVersion], but doesn't obey [appPrefs] for ignoring versions. * This is useful for (un-)ignoring this version. @@ -68,6 +70,7 @@ data class AppDetailsItem( repositories: List, dbApp: App, actions: AppDetailsActions, + installState: InstallState, versions: List?, installedVersion: AppVersion?, installedVersionCode: Long?, @@ -80,6 +83,7 @@ data class AppDetailsItem( ) : this( app = dbApp.metadata, actions = actions, + installState = installState, preferredRepoId = preferredRepoId, repositories = repositories, name = dbApp.name ?: "Unknown App", @@ -138,7 +142,9 @@ data class AppDetailsItem( */ val mainButtonState: MainButtonState get() { - return if (installedVersionCode == null) { // app is not installed + return if (installState.showProgress) { + MainButtonState.PROGRESS + } else if (installedVersionCode == null) { // app is not installed if (suggestedVersion == null) MainButtonState.NONE else MainButtonState.INSTALL } else { // app is installed @@ -163,7 +169,7 @@ data class AppDetailsItem( val targetSdk = suggestedVersion?.packageManifest?.targetSdkVersion // auto-updates are only available on SDK 31 and up return if (targetSdk != null && SDK_INT >= 31) { - !SessionInstallManager.isTargetSdkSupported(targetSdk) + !SessionInstallManager.isAutoUpdateSupported(targetSdk) } else { false } @@ -182,6 +188,16 @@ data class AppDetailsItem( } class AppDetailsActions( + val installAction: (AppMetadata, AppVersion) -> Unit, + val requestUserConfirmation: (String, InstallState.UserConfirmationNeeded) -> Unit, + /** + * A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog + * dismisses it without any feedback for us. + * So when our activity resumes while we are in state [InstallState.UserConfirmationNeeded] + * we need to call this method, so we can manually check if our session progressed or not. + */ + val checkUserConfirmation: (String, InstallState.UserConfirmationNeeded) -> Unit, + val cancelInstall: (String) -> Unit, val allowBetaVersions: () -> Unit, val ignoreAllUpdates: (() -> Unit)? = null, val ignoreThisUpdate: (() -> Unit)? = null, @@ -195,6 +211,7 @@ enum class MainButtonState { NONE, INSTALL, UPDATE, + PROGRESS, } data class AntiFeature( diff --git a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt index 1f5383842..d10499e0b 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/AppDetailsViewModel.kt @@ -7,18 +7,26 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import androidx.annotation.UiThread import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import app.cash.molecule.RecompositionMode.Immediate import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mu.KotlinLogging import org.fdroid.UpdateChecker +import org.fdroid.database.AppMetadata +import org.fdroid.database.AppVersion import org.fdroid.database.FDroidDatabase import org.fdroid.index.RELEASE_CHANNEL_BETA 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 @@ -26,12 +34,14 @@ import javax.inject.Inject @HiltViewModel class AppDetailsViewModel @Inject constructor( private val app: Application, - @IoDispatcher private val scope: CoroutineScope, + @param:IoDispatcher private val scope: CoroutineScope, private val db: FDroidDatabase, private val repoManager: RepoManager, private val updateChecker: UpdateChecker, private val updatesManager: UpdatesManager, + private val appInstallManager: AppInstallManager, ) : AndroidViewModel(app) { + private val log = KotlinLogging.logger { } private val packageInfoFlow = MutableStateFlow(null) val appDetails: StateFlow = scope.launchMolecule( @@ -41,6 +51,7 @@ class AppDetailsViewModel @Inject constructor( db = db, repoManager = repoManager, updateChecker = updateChecker, + appInstallManager = appInstallManager, viewModel = this, packageInfoFlow = packageInfoFlow, ) @@ -48,6 +59,10 @@ class AppDetailsViewModel @Inject constructor( fun setAppDetails(packageName: String) { packageInfoFlow.value = null + loadPackageInfoFlow(packageName) + } + + private fun loadPackageInfoFlow(packageName: String) { val packageManager = app.packageManager scope.launch { val packageInfo = try { @@ -64,6 +79,57 @@ 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) + if (result is InstallState.Installed) { + // to reload packageInfoFlow with fresh packageInfo + loadPackageInfoFlow(appMetadata.packageName) + } + } + } + + @UiThread + fun requestUserConfirmation( + packageName: String, + installState: InstallState.UserConfirmationNeeded, + ) { + scope.launch(Dispatchers.Main) { + val result = appInstallManager.requestUserConfirmation(packageName, installState) + if (result is InstallState.Installed) withContext(Dispatchers.Main) { + // to reload packageInfoFlow with fresh packageInfo + loadPackageInfoFlow(packageName) + } + } + } + + @UiThread + fun checkUserConfirmation( + packageName: String, + installState: InstallState.UserConfirmationNeeded, + ) { + scope.launch(Dispatchers.Main) { + delay(500) // wait a moment to increase chance that state got updated + appInstallManager.checkUserConfirmation(packageName, installState) + } + } + + @UiThread + fun cancelInstall(packageName: String) { + appInstallManager.cancel(packageName) + } + + override fun onCleared() { + val packageName = packageInfoFlow.value?.packageName + log.info { "App details screen left: $packageName" } + packageName?.let { + appInstallManager.cleanUp(it) + } + } + @UiThread fun allowBetaUpdates() { val appPrefs = appDetails.value?.appPrefs ?: return diff --git a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt index 711879244..21cf96033 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/DetailsPresenter.kt @@ -14,6 +14,7 @@ import org.fdroid.UpdateChecker import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository import org.fdroid.index.RepoManager +import org.fdroid.install.AppInstallManager import org.fdroid.utils.sha256 private const val TAG = "DetailsPresenter" @@ -25,6 +26,7 @@ fun DetailsPresenter( db: FDroidDatabase, repoManager: RepoManager, updateChecker: UpdateChecker, + appInstallManager: AppInstallManager, viewModel: AppDetailsViewModel, packageInfoFlow: StateFlow, ): AppDetailsItem? { @@ -41,6 +43,7 @@ fun DetailsPresenter( repoManager.getRepository(repoId) } } + val installState = appInstallManager.getAppFlow(packageName).collectAsState().value val versions = db.getVersionDao().getAppVersions(packageName).asFlow().collectAsState(null).value @@ -91,12 +94,17 @@ fun DetailsPresenter( Log.d(TAG, " app '${app.name}' ($packageName) in ${repo.address}") Log.d(TAG, " versions: ${versions?.size}") Log.d(TAG, " appPrefs: $appPrefs") + Log.d(TAG, " installState: $installState") return AppDetailsItem( repository = repo, preferredRepoId = preferredRepoId, repositories = repositories, // TODO maybe use emptyList() when only in F-Droid repo dbApp = app, actions = AppDetailsActions( + installAction = viewModel::install, + requestUserConfirmation = viewModel::requestUserConfirmation, + checkUserConfirmation = viewModel::checkUserConfirmation, + cancelInstall = viewModel::cancelInstall, allowBetaVersions = viewModel::allowBetaUpdates, ignoreAllUpdates = if (installedVersionCode == null) { null @@ -116,6 +124,7 @@ fun DetailsPresenter( launchIntent = packagePair.launchIntent, shareIntent = getShareIntent(repo, packageName, app.name ?: ""), ), + installState = installState, versions = versions, installedVersion = installedVersion, installedVersionCode = installedVersionCode, diff --git a/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt b/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt index 8378a8ad2..b7be2a384 100644 --- a/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt +++ b/next/src/main/kotlin/org/fdroid/ui/details/Versions.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -30,6 +31,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.fdroid.database.AppVersion import org.fdroid.fdroid.ui.theme.FDroidContent import org.fdroid.index.v2.PackageVersion import org.fdroid.next.R @@ -41,6 +44,7 @@ import org.fdroid.ui.utils.testApp @OptIn(ExperimentalMaterial3ExpressiveApi::class) fun Versions( item: AppDetailsItem, + scrollUp: suspend () -> Unit, ) { ExpandableSection( icon = rememberVectorPainter(Icons.Default.AccessTime), @@ -53,12 +57,20 @@ fun Versions( version = version, isInstalled = item.installedVersion == version, isSuggested = item.suggestedVersion == version, - isInstallable = if (item.installedVersion == null) { - true + isInstallable = if (item.installState.showProgress) { + false } else { - // TODO take compatibility and signer into account - item.installedVersion.versionCode < version.versionCode + if (item.installedVersion == null) { + true + } else { + // TODO take compatibility and signer into account + item.installedVersion.versionCode < version.versionCode + } }, + installAction = { version: AppVersion -> + item.actions.installAction(item.app, version) + }, + scrollUp = scrollUp, ) } } @@ -71,6 +83,8 @@ fun Version( isInstalled: Boolean, isSuggested: Boolean, isInstallable: Boolean, + installAction: (AppVersion) -> Unit, + scrollUp: suspend () -> Unit, ) { val isPreview = LocalInspectionMode.current var expanded by rememberSaveable { mutableStateOf(isPreview) } @@ -172,10 +186,18 @@ fun Version( ) } } - if (isInstallable) FDroidOutlineButton( - text = stringResource(R.string.menu_install), - onClick = {}, - ) + if (isInstallable) { + val coroutineScope = rememberCoroutineScope() + FDroidOutlineButton( + text = stringResource(R.string.menu_install), + onClick = { + installAction(version as AppVersion) + coroutineScope.launch { + scrollUp() + } + }, + ) + } } } } @@ -185,6 +207,6 @@ fun Version( @Composable fun VersionsPreview() { FDroidContent { - Versions(testApp) + Versions(testApp) {} } } diff --git a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt index 931f5ed8d..72ea9193c 100644 --- a/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt +++ b/next/src/main/kotlin/org/fdroid/ui/utils/PreviewUtils.kt @@ -8,6 +8,7 @@ import org.fdroid.database.AppPrefs 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.categories.CategoryItem import org.fdroid.ui.details.AntiFeature import org.fdroid.ui.details.AppDetailsActions @@ -111,7 +112,20 @@ val testApp = AppDetailsItem( categories = listOf("Internet", "Multimedia"), isCompatible = true, ), - actions = AppDetailsActions({}, {}, {}, {}, {}, Intent(), Intent()), + actions = AppDetailsActions( + installAction = { _, _ -> }, + requestUserConfirmation = { _, _ -> }, + checkUserConfirmation = { _, _ -> }, + cancelInstall = {}, + allowBetaVersions = {}, + ignoreAllUpdates = {}, + ignoreThisUpdate = {}, + shareApk = {}, + uninstallApp = {}, + launchIntent = Intent(), + shareIntent = Intent(), + ), + installState = InstallState.Unknown, appPrefs = AppPrefs("org.schabi.newpipe"), name = "New Pipe", summary = "Lightweight YouTube frontend", @@ -149,7 +163,7 @@ val testApp = AppDetailsItem( testVersion2, ), installedVersion = testVersion2, - suggestedVersion = testVersion1, + suggestedVersion = null, possibleUpdate = testVersion1, ) diff --git a/next/src/main/res/values/strings-next.xml b/next/src/main/res/values/strings-next.xml index 6afcc7cf8..2d9f95c26 100644 --- a/next/src/main/res/values/strings-next.xml +++ b/next/src/main/res/values/strings-next.xml @@ -50,6 +50,7 @@ Last updated: %1$s Last updated: %1$s (%2$s) + Preparing installation… What\'s new Donate This app has anti-features