Implement installation of apps and updates

This commit is contained in:
Torsten Grote
2025-09-22 11:54:28 -03:00
parent 2b5a98c692
commit f24ff287d8
16 changed files with 938 additions and 45 deletions

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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