mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-05-25 00:46:45 -04:00
Implement installation of apps and updates
This commit is contained in:
@@ -6,16 +6,26 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:ignore="RequestInstallPackagesPolicy" />
|
||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||
|
||||
<application
|
||||
android:name="org.fdroid.App"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Fdroidclient">
|
||||
android:theme="@style/Theme.Fdroidclient"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name="org.fdroid.MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -50,12 +50,8 @@ class App : Application(), Configuration.Provider, SingletonImageLoader.Factory
|
||||
.crossfade(true)
|
||||
.components {
|
||||
val downloadRequestKeyer = object : Keyer<DownloadRequest> {
|
||||
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)
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
230
next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt
Normal file
230
next/src/main/kotlin/org/fdroid/install/AppInstallManager.kt
Normal file
@@ -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<AppVersion>()
|
||||
private val apps = ConcurrentHashMap<String, MutableStateFlow<InstallState>>()
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun getAppFlow(packageName: String): StateFlow<InstallState> {
|
||||
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<InstallState>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
27
next/src/main/kotlin/org/fdroid/install/InstallState.kt
Normal file
27
next/src/main/kotlin/org/fdroid/install/InstallState.kt
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Repository>,
|
||||
dbApp: App,
|
||||
actions: AppDetailsActions,
|
||||
installState: InstallState,
|
||||
versions: List<AppVersion>?,
|
||||
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(
|
||||
|
||||
@@ -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<AppInfo?>(null)
|
||||
|
||||
val appDetails: StateFlow<AppDetailsItem?> = 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
|
||||
|
||||
@@ -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<AppInfo?>,
|
||||
): 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,
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<string name="last_updated">Last updated: %1$s</string>
|
||||
<!-- The placeholder in round brackets will be replaced with the size of the app -->
|
||||
<string name="last_updated_with_size">Last updated: %1$s (%2$s)</string>
|
||||
<string name="status_install_preparing">Preparing installation…</string>
|
||||
<string name="whats_new_title">What\'s new</string>
|
||||
<string name="donate_title">Donate</string>
|
||||
<string name="anti_features_title">This app has anti-features</string>
|
||||
|
||||
Reference in New Issue
Block a user