Merge branch 'improvement/download-routine' into 'dev'
Fix issues around downloads & install states See merge request AuroraOSS/AuroraStore!573
This commit is contained in:
@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = flow {
|
fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo> = flow {
|
||||||
var bytesCopied: Long = 0
|
var bytesCopied: Long = 0
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
var bytes = read(buffer)
|
|
||||||
var lastTotalBytesRead: Long = 0
|
var lastTotalBytesRead: Long = 0
|
||||||
var speed: Long = 0
|
var speed: Long = 0
|
||||||
|
|
||||||
@@ -23,14 +22,20 @@ fun InputStream.copyTo(out: OutputStream, streamSize: Long): Flow<DownloadInfo>
|
|||||||
lastTotalBytesRead = totalBytesRead
|
lastTotalBytesRead = totalBytesRead
|
||||||
}
|
}
|
||||||
|
|
||||||
while (bytes >= 0) {
|
// Cancel the timer even when the collector aborts mid-stream (e.g. the download is
|
||||||
out.write(buffer, 0, bytes)
|
// stopped), otherwise the timer thread would leak.
|
||||||
out.flush()
|
try {
|
||||||
|
var bytes = read(buffer)
|
||||||
|
while (bytes >= 0) {
|
||||||
|
out.write(buffer, 0, bytes)
|
||||||
|
out.flush()
|
||||||
|
|
||||||
bytesCopied += bytes
|
bytesCopied += bytes
|
||||||
// Emit stream progress in percentage
|
// Emit stream progress in percentage
|
||||||
emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed))
|
emit(DownloadInfo((bytesCopied * 100 / streamSize).toInt(), bytes.toLong(), speed))
|
||||||
bytes = read(buffer)
|
bytes = read(buffer)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
timer.cancel()
|
||||||
}
|
}
|
||||||
timer.cancel()
|
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun DownloadListItem(modifier: Modifier = Modifier, download: Download, onClick:
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailing = when (download.status) {
|
trailing = when (download.status) {
|
||||||
DownloadStatus.COMPLETED -> {
|
DownloadStatus.COMPLETED, DownloadStatus.INSTALLED -> {
|
||||||
{
|
{
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_check),
|
painter = painterResource(R.drawable.ic_check),
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ package com.aurora.store.data.helper
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.OutOfQuotaPolicy
|
import androidx.work.OutOfQuotaPolicy
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkRequest
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
import com.aurora.store.AuroraApp
|
import com.aurora.store.AuroraApp
|
||||||
|
import com.aurora.store.data.event.InstallerEvent
|
||||||
|
import com.aurora.store.data.installer.AppInstaller
|
||||||
import com.aurora.store.data.model.DownloadStatus
|
import com.aurora.store.data.model.DownloadStatus
|
||||||
import com.aurora.store.data.room.download.Download
|
import com.aurora.store.data.room.download.Download
|
||||||
import com.aurora.store.data.room.download.DownloadDao
|
import com.aurora.store.data.room.download.DownloadDao
|
||||||
@@ -18,6 +24,7 @@ import com.aurora.store.data.room.update.Update
|
|||||||
import com.aurora.store.data.work.DownloadWorker
|
import com.aurora.store.data.work.DownloadWorker
|
||||||
import com.aurora.store.util.PathUtil
|
import com.aurora.store.util.PathUtil
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -32,7 +39,8 @@ import kotlinx.coroutines.launch
|
|||||||
*/
|
*/
|
||||||
class DownloadHelper @Inject constructor(
|
class DownloadHelper @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val downloadDao: DownloadDao
|
private val downloadDao: DownloadDao,
|
||||||
|
private val appInstaller: AppInstaller
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -68,13 +76,50 @@ class DownloadHelper @Inject constructor(
|
|||||||
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
|
cancelFailedDownloads(downloadDao.downloads().firstOrNull() ?: emptyList())
|
||||||
}.invokeOnCompletion {
|
}.invokeOnCompletion {
|
||||||
observeDownloads()
|
observeDownloads()
|
||||||
|
observeInstalls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances a download row through the installer phase so its history reflects whether the
|
||||||
|
* app actually installed, not just that the bytes finished downloading:
|
||||||
|
* - [InstallerEvent.Installing] moves a [DownloadStatus.COMPLETED] row to
|
||||||
|
* [DownloadStatus.INSTALLING];
|
||||||
|
* - [InstallerEvent.Installed] marks it [DownloadStatus.INSTALLED] (kept so the user can
|
||||||
|
* still export the APK);
|
||||||
|
* - [InstallerEvent.Failed] reverts an in-progress install back to
|
||||||
|
* [DownloadStatus.COMPLETED] so the downloaded files can be re-installed without
|
||||||
|
* re-downloading.
|
||||||
|
*/
|
||||||
|
private fun observeInstalls() {
|
||||||
|
AuroraApp.events.installerEvent.onEach { event ->
|
||||||
|
val existing = getDownload(event.packageName) ?: return@onEach
|
||||||
|
when (event) {
|
||||||
|
is InstallerEvent.Installing -> if (existing.status == DownloadStatus.COMPLETED) {
|
||||||
|
downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLING)
|
||||||
|
}
|
||||||
|
|
||||||
|
is InstallerEvent.Installed -> if (existing.status != DownloadStatus.INSTALLED) {
|
||||||
|
downloadDao.updateStatus(event.packageName, DownloadStatus.INSTALLED)
|
||||||
|
}
|
||||||
|
|
||||||
|
is InstallerEvent.Failed -> if (existing.status == DownloadStatus.INSTALLING) {
|
||||||
|
downloadDao.updateStatus(event.packageName, DownloadStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}.launchIn(AuroraApp.scope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeDownloads() {
|
private fun observeDownloads() {
|
||||||
downloadDao.downloads().onEach { list ->
|
downloadDao.downloads().onEach { list ->
|
||||||
try {
|
try {
|
||||||
if (list.none { it.status == DownloadStatus.DOWNLOADING }) {
|
// Serialize downloads: only start the next queued item once nothing else is
|
||||||
|
// actively purchasing/downloading/verifying. Previously this only checked for
|
||||||
|
// DOWNLOADING, so a worker in PURCHASING/VERIFYING didn't count and a second
|
||||||
|
// download could start concurrently and clobber the shared notification.
|
||||||
|
if (list.none { it.status in DownloadStatus.processing }) {
|
||||||
list.find { it.status == DownloadStatus.QUEUED }
|
list.find { it.status == DownloadStatus.QUEUED }
|
||||||
?.let { queuedDownload ->
|
?.let { queuedDownload ->
|
||||||
Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}")
|
Log.i(TAG, "Enqueued download worker for ${queuedDownload.packageName}")
|
||||||
@@ -92,7 +137,7 @@ class DownloadHelper @Inject constructor(
|
|||||||
* @param app [App] to download
|
* @param app [App] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueApp(app: App) {
|
suspend fun enqueueApp(app: App) {
|
||||||
downloadDao.insert(Download.fromApp(app))
|
enqueue(Download.fromApp(app))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,7 +145,7 @@ class DownloadHelper @Inject constructor(
|
|||||||
* @param update [Update] to download
|
* @param update [Update] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueUpdate(update: Update) {
|
suspend fun enqueueUpdate(update: Update) {
|
||||||
downloadDao.insert(Download.fromUpdate(update))
|
enqueue(Download.fromUpdate(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +153,40 @@ class DownloadHelper @Inject constructor(
|
|||||||
* @param externalApk [ExternalApk] to download
|
* @param externalApk [ExternalApk] to download
|
||||||
*/
|
*/
|
||||||
suspend fun enqueueStandalone(externalApk: ExternalApk) {
|
suspend fun enqueueStandalone(externalApk: ExternalApk) {
|
||||||
downloadDao.insert(Download.fromExternalApk(externalApk))
|
enqueue(Download.fromExternalApk(externalApk))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a new download row, but only when a (re)download is actually needed. For an
|
||||||
|
* existing record of the same version this:
|
||||||
|
* - **installs without re-downloading** if the files are already downloaded & verified
|
||||||
|
* (e.g. the user missed the system install prompt, or the periodic update check runs
|
||||||
|
* again before a pending install completed); or
|
||||||
|
* - **skips** entirely if the download is still active (queued/purchasing/downloading/
|
||||||
|
* verifying), so the periodic [UpdateWorker] and repeated user taps can't reset it back
|
||||||
|
* to [DownloadStatus.QUEUED] and re-download it.
|
||||||
|
*
|
||||||
|
* A genuinely newer version, or a previously failed/cancelled download whose files are
|
||||||
|
* gone, falls through and is (re)enqueued.
|
||||||
|
*/
|
||||||
|
private suspend fun enqueue(download: Download) {
|
||||||
|
val existing = getDownload(download.packageName)
|
||||||
|
if (existing != null && existing.versionCode == download.versionCode) {
|
||||||
|
if (existing.canInstall(context)) {
|
||||||
|
Log.i(TAG, "${download.packageName} already downloaded, installing directly")
|
||||||
|
runCatching { appInstaller.getPreferredInstaller().install(existing) }
|
||||||
|
.onFailure { Log.e(TAG, "Failed to install ${download.packageName}", it) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (existing.isActive) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Skipping enqueue for ${download.packageName}; already ${existing.status}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadDao.insert(download)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,6 +196,8 @@ class DownloadHelper @Inject constructor(
|
|||||||
suspend fun cancelDownload(packageName: String) {
|
suspend fun cancelDownload(packageName: String) {
|
||||||
Log.i(TAG, "Cancelling download for $packageName")
|
Log.i(TAG, "Cancelling download for $packageName")
|
||||||
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName")
|
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName")
|
||||||
|
// Abandon any session already staged for install so we don't leak it.
|
||||||
|
runCatching { appInstaller.getPreferredInstaller().cancelInstall(packageName) }
|
||||||
downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED)
|
downloadDao.updateStatus(packageName, DownloadStatus.CANCELLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +277,24 @@ class DownloadHelper @Inject constructor(
|
|||||||
.putString(PACKAGE_NAME, download.packageName)
|
.putString(PACKAGE_NAME, download.packageName)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// Require connectivity so the worker doesn't spin up (or keep running) without a
|
||||||
|
// network, and back off exponentially so transient failures resume cleanly once the
|
||||||
|
// connection returns instead of hammering the server.
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
|
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.addTag(DOWNLOAD_WORKER)
|
.addTag(DOWNLOAD_WORKER)
|
||||||
.addTag("$PACKAGE_NAME:${download.packageName}")
|
.addTag("$PACKAGE_NAME:${download.packageName}")
|
||||||
.addTag("$VERSION_CODE:${download.versionCode}")
|
.addTag("$VERSION_CODE:${download.versionCode}")
|
||||||
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
|
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(
|
||||||
|
BackoffPolicy.EXPONENTIAL,
|
||||||
|
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
)
|
||||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||||
.setInputData(inputData)
|
.setInputData(inputData)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ class SessionInstaller @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun cancelInstall(packageName: String) {
|
||||||
|
val sessionSet = enqueuedSessions
|
||||||
|
.find { set -> set.any { it.packageName == packageName } } ?: return
|
||||||
|
|
||||||
|
Log.i(TAG, "Abandoning staged session(s) for $packageName")
|
||||||
|
sessionSet.forEach { runCatching { packageInstaller.abandonSession(it.sessionId) } }
|
||||||
|
enqueuedSessions.remove(sessionSet)
|
||||||
|
removeFromInstallQueue(packageName)
|
||||||
|
}
|
||||||
|
|
||||||
private fun stageInstall(
|
private fun stageInstall(
|
||||||
packageName: String,
|
packageName: String,
|
||||||
versionCode: Long,
|
versionCode: Long,
|
||||||
@@ -189,7 +199,12 @@ class SessionInstaller @Inject constructor(
|
|||||||
): Int? {
|
): Int? {
|
||||||
val resolvedPackageName = sharedLibPkgName.ifBlank { packageName }
|
val resolvedPackageName = sharedLibPkgName.ifBlank { packageName }
|
||||||
|
|
||||||
val sessionParams = buildSessionParams(resolvedPackageName)
|
// Size hint lets the system reserve space (and evict its cache) for the staged copy.
|
||||||
|
val totalSize = runCatching {
|
||||||
|
getFiles(packageName, versionCode, sharedLibPkgName).sumOf { it.length() }
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
|
||||||
|
val sessionParams = buildSessionParams(resolvedPackageName, totalSize)
|
||||||
val sessionId = packageInstaller.createSession(sessionParams)
|
val sessionId = packageInstaller.createSession(sessionParams)
|
||||||
val session = packageInstaller.openSession(sessionId)
|
val session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
@@ -216,9 +231,10 @@ class SessionInstaller @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSessionParams(packageName: String): SessionParams =
|
private fun buildSessionParams(packageName: String, totalSize: Long = 0L): SessionParams =
|
||||||
SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
|
SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
|
||||||
setAppPackageName(packageName)
|
setAppPackageName(packageName)
|
||||||
|
if (totalSize > 0) setSize(totalSize)
|
||||||
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
|
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
|
||||||
if (isNAndAbove) {
|
if (isNAndAbove) {
|
||||||
setOriginatingUid(Process.myUid())
|
setOriginatingUid(Process.myUid())
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ interface IInstaller {
|
|||||||
fun clearQueue()
|
fun clearQueue()
|
||||||
fun isAlreadyQueued(packageName: String): Boolean
|
fun isAlreadyQueued(packageName: String): Boolean
|
||||||
fun removeFromInstallQueue(packageName: String)
|
fun removeFromInstallQueue(packageName: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abandons any staged-but-uncommitted install session for [packageName] so cancelling
|
||||||
|
* a download doesn't leak a [android.content.pm.PackageInstaller] session. Default no-op
|
||||||
|
* for installers that don't stage sessions.
|
||||||
|
*/
|
||||||
|
fun cancelInstall(packageName: String) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,20 @@ enum class DownloadStatus(@StringRes val localized: Int) {
|
|||||||
QUEUED(R.string.status_queued),
|
QUEUED(R.string.status_queued),
|
||||||
UNAVAILABLE(R.string.status_unavailable),
|
UNAVAILABLE(R.string.status_unavailable),
|
||||||
VERIFYING(R.string.status_verifying),
|
VERIFYING(R.string.status_verifying),
|
||||||
PURCHASING(R.string.preparing_to_install);
|
PURCHASING(R.string.preparing_to_install),
|
||||||
|
INSTALLING(R.string.status_installing),
|
||||||
|
INSTALLED(R.string.status_installed);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val finished = listOf(FAILED, CANCELLED, COMPLETED)
|
val finished = setOf(FAILED, CANCELLED, COMPLETED, INSTALLED)
|
||||||
val running = listOf(QUEUED, PURCHASING, DOWNLOADING)
|
val running = setOf(QUEUED, PURCHASING, DOWNLOADING)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* States in which a download worker is actively occupying the (single) download
|
||||||
|
* slot — purchasing, transferring bytes or verifying. Used to serialize downloads:
|
||||||
|
* the next [QUEUED] item is only started once none of these are in progress, so
|
||||||
|
* concurrent workers can't clobber the shared foreground/progress notification.
|
||||||
|
*/
|
||||||
|
val processing = setOf(PURCHASING, DOWNLOADING, VERIFYING)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ data class Download(
|
|||||||
val isRunning get() = status in DownloadStatus.running
|
val isRunning get() = status in DownloadStatus.running
|
||||||
private val isSuccessful get() = status == DownloadStatus.COMPLETED
|
private val isSuccessful get() = status == DownloadStatus.COMPLETED
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` while the download is queued, purchasing, downloading or verifying, i.e.
|
||||||
|
* the pipeline is actively working on it. Unlike [isRunning] this also covers
|
||||||
|
* [DownloadStatus.VERIFYING], which sits between downloading and completion.
|
||||||
|
*/
|
||||||
|
val isActive get() = isRunning || status == DownloadStatus.VERIFYING
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromApp(app: App): Download = Download(
|
fun fromApp(app: App): Download = Download(
|
||||||
app.packageName,
|
app.packageName,
|
||||||
@@ -108,7 +115,10 @@ data class Download(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun canInstall(context: Context): Boolean {
|
fun canInstall(context: Context): Boolean {
|
||||||
|
if (!isSuccessful) return false
|
||||||
val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode)
|
val dir = PathUtil.getAppDownloadDir(context, packageName, versionCode)
|
||||||
return isSuccessful && dir.listFiles() != null
|
// Require at least one actual APK on disk, not just that the directory exists —
|
||||||
|
// an empty/partially-cleaned directory must not look installable.
|
||||||
|
return dir.listFiles()?.any { it.name.endsWith(".apk") } == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.work.ExistingPeriodicWorkPolicy.KEEP
|
|||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.aurora.store.data.model.DownloadStatus
|
||||||
|
import com.aurora.store.data.room.download.DownloadDao
|
||||||
import com.aurora.store.util.PathUtil
|
import com.aurora.store.util.PathUtil
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
@@ -17,12 +19,14 @@ import java.util.concurrent.TimeUnit.HOURS
|
|||||||
import java.util.concurrent.TimeUnit.MINUTES
|
import java.util.concurrent.TimeUnit.MINUTES
|
||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.toDuration
|
import kotlin.time.toDuration
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A periodic worker to automatically clear the old downloads cache periodically.
|
* A periodic worker to automatically clear the old downloads cache periodically.
|
||||||
*/
|
*/
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
class CacheWorker @AssistedInject constructor(
|
class CacheWorker @AssistedInject constructor(
|
||||||
|
private val downloadDao: DownloadDao,
|
||||||
@Assisted private val context: Context,
|
@Assisted private val context: Context,
|
||||||
@Assisted workerParams: WorkerParameters
|
@Assisted workerParams: WorkerParameters
|
||||||
) : CoroutineWorker(context, workerParams) {
|
) : CoroutineWorker(context, workerParams) {
|
||||||
@@ -58,6 +62,17 @@ class CacheWorker @AssistedInject constructor(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Log.i(TAG, "Cleaning cache")
|
Log.i(TAG, "Cleaning cache")
|
||||||
|
|
||||||
|
// Files for downloads that are still in-flight or downloaded & awaiting install
|
||||||
|
// must be protected from the age-based purge, otherwise a download the user hasn't
|
||||||
|
// installed yet (e.g. they missed the system prompt) would lose its files and have
|
||||||
|
// to be re-downloaded. Keyed by packageName -> versionCode.
|
||||||
|
val protectedVersions = runCatching {
|
||||||
|
downloadDao.downloads().first()
|
||||||
|
.filter { it.isActive || it.status == DownloadStatus.COMPLETED }
|
||||||
|
.map { it.packageName to it.versionCode }
|
||||||
|
.toSet()
|
||||||
|
}.getOrDefault(emptySet())
|
||||||
|
|
||||||
PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir ->
|
PathUtil.getOldDownloadDirectories(context).filter { it.exists() }.forEach { dir ->
|
||||||
// Downloads
|
// Downloads
|
||||||
Log.i(TAG, "Deleting old unused download directory: $dir")
|
Log.i(TAG, "Deleting old unused download directory: $dir")
|
||||||
@@ -75,10 +90,14 @@ class CacheWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
download.listFiles()!!.forEach { versionCode ->
|
download.listFiles()!!.forEach { versionCode ->
|
||||||
// 20240325
|
// 20240325
|
||||||
|
val isProtected = (download.name to versionCode.name.toLongOrNull()) in
|
||||||
|
protectedVersions
|
||||||
if (versionCode.listFiles().isNullOrEmpty()) {
|
if (versionCode.listFiles().isNullOrEmpty()) {
|
||||||
// Purge empty non-accessible directory
|
// Purge empty non-accessible directory
|
||||||
Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}")
|
Log.i(TAG, "Removing empty directory for ${download.name}, ${versionCode.name}")
|
||||||
versionCode.deleteRecursively()
|
versionCode.deleteRecursively()
|
||||||
|
} else if (isProtected) {
|
||||||
|
Log.i(TAG, "Keeping ${download.name} (${versionCode.name}); install pending")
|
||||||
} else {
|
} else {
|
||||||
versionCode.deleteIfOld()
|
versionCode.deleteIfOld()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.content.Context
|
|||||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.storage.StorageManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
@@ -21,6 +22,7 @@ import androidx.work.WorkInfo.Companion.STOP_REASON_USER
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
import com.aurora.extensions.copyTo
|
import com.aurora.extensions.copyTo
|
||||||
|
import com.aurora.extensions.isOAndAbove
|
||||||
import com.aurora.extensions.isPAndAbove
|
import com.aurora.extensions.isPAndAbove
|
||||||
import com.aurora.extensions.isQAndAbove
|
import com.aurora.extensions.isQAndAbove
|
||||||
import com.aurora.extensions.isSAndAbove
|
import com.aurora.extensions.isSAndAbove
|
||||||
@@ -48,6 +50,10 @@ import dagger.assisted.Assisted
|
|||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
||||||
|
import java.net.HttpURLConnection.HTTP_GONE
|
||||||
|
import java.net.HttpURLConnection.HTTP_PARTIAL
|
||||||
import java.net.SocketException
|
import java.net.SocketException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
@@ -77,6 +83,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val NOTIFICATION_ID: Int = 200
|
private const val NOTIFICATION_ID: Int = 200
|
||||||
|
|
||||||
|
// Upper bound on automatic WorkManager retries for transient (network) failures
|
||||||
|
// before the download is marked as failed and left for the user to retry.
|
||||||
|
private const val MAX_DOWNLOAD_RETRIES = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var download: Download
|
private lateinit var download: Download
|
||||||
@@ -88,6 +98,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private var totalProgress = 0
|
private var totalProgress = 0
|
||||||
private var downloadedBytes = 0L
|
private var downloadedBytes = 0L
|
||||||
|
|
||||||
|
// Absolute paths of files already verified during the download pass, so the final
|
||||||
|
// verification gate doesn't hash large APKs a second time.
|
||||||
|
private val verifiedFiles = mutableSetOf<String>()
|
||||||
|
|
||||||
inner class NoNetworkException : Exception(context.getString(R.string.title_no_network))
|
inner class NoNetworkException : Exception(context.getString(R.string.title_no_network))
|
||||||
inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file))
|
inner class NothingToDownloadException : Exception(context.getString(R.string.purchase_no_file))
|
||||||
inner class DownloadFailedException : Exception(context.getString(R.string.download_failed))
|
inner class DownloadFailedException : Exception(context.getString(R.string.download_failed))
|
||||||
@@ -97,6 +111,11 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
inner class VerificationFailedException :
|
inner class VerificationFailedException :
|
||||||
Exception(context.getString(R.string.verification_failed))
|
Exception(context.getString(R.string.verification_failed))
|
||||||
|
|
||||||
|
inner class ExpiredUrlException : Exception(context.getString(R.string.download_failed))
|
||||||
|
|
||||||
|
inner class InsufficientStorageException :
|
||||||
|
Exception(context.getString(R.string.download_failed_storage))
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
super.doWork()
|
super.doWork()
|
||||||
|
|
||||||
@@ -173,6 +192,15 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
downloadDao.updateFiles(download.packageName, download.fileList)
|
downloadDao.updateFiles(download.packageName, download.fileList)
|
||||||
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs)
|
downloadDao.updateSharedLibs(download.packageName, download.sharedLibs)
|
||||||
|
|
||||||
|
// Fail fast (and let the system free its cache) if there isn't room for the download,
|
||||||
|
// instead of dying mid-write with a partial file. Only the not-yet-downloaded bytes
|
||||||
|
// need to fit.
|
||||||
|
try {
|
||||||
|
ensureStorageAvailable(totalBytes - downloadedBytesOnDisk(files))
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
return onFailure(exception)
|
||||||
|
}
|
||||||
|
|
||||||
// Download files
|
// Download files
|
||||||
try {
|
try {
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
@@ -184,24 +212,34 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
download.downloadedFiles++
|
download.downloadedFiles++
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
if (exception is DownloadCancelledException) {
|
// Only purge partial files on a genuine user/app cancellation. A stop caused
|
||||||
Log.i(TAG, "Download cancelled for ${download.packageName}")
|
// by lost connectivity, quota or device-state must keep the partials so the
|
||||||
// Try to delete all downloaded files
|
// retry resumes instead of re-downloading from scratch (this was the source of
|
||||||
|
// downloads appearing to "restart" on a flaky network).
|
||||||
|
if (exception is DownloadCancelledException && isCancelledByUser()) {
|
||||||
|
Log.i(TAG, "Download cancelled by user for ${download.packageName}")
|
||||||
runCatching { files.forEach { deleteFile(it) } }
|
runCatching { files.forEach { deleteFile(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
return onFailure(exception)
|
return onFailure(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report failure if download was stopped or failed
|
// A stop that isn't a user cancellation (e.g. connectivity constraint) should be
|
||||||
if (isStopped) return onFailure(DownloadFailedException())
|
// retried with the partials intact rather than treated as a hard failure.
|
||||||
|
if (isStopped) return onFailure(DownloadCancelledException())
|
||||||
|
|
||||||
// Verify downloaded files
|
// Verify downloaded files (skipping any already verified during the download pass)
|
||||||
try {
|
try {
|
||||||
notifyStatus(DownloadStatus.VERIFYING)
|
notifyStatus(DownloadStatus.VERIFYING)
|
||||||
files.forEach { file -> require(verifyFile(file)) }
|
files.forEach { file ->
|
||||||
|
val path = PathUtil.getLocalFile(context, file, download).absolutePath
|
||||||
|
if (path !in verifiedFiles) require(verifyFile(file))
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to verify ${download.packageName}", exception)
|
Log.e(TAG, "Failed to verify ${download.packageName}", exception)
|
||||||
|
// Drop the corrupt files so the next attempt re-downloads them clean instead
|
||||||
|
// of resuming from a poisoned offset.
|
||||||
|
runCatching { files.forEach { deleteFile(it) } }
|
||||||
return onFailure(VerificationFailedException())
|
return onFailure(VerificationFailedException())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,12 +261,60 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current stop/cancellation was initiated by the user (or the app on the
|
||||||
|
* user's behalf) rather than by the system (connectivity/quota/device-state). Uses the
|
||||||
|
* S+ [stopReason] when available and otherwise falls back to the persisted status, which
|
||||||
|
* [DownloadHelper.cancelDownload] sets to [DownloadStatus.CANCELLED] before cancelling
|
||||||
|
* the work — making this reliable below Android 12 too.
|
||||||
|
*/
|
||||||
|
private suspend fun isCancelledByUser(): Boolean {
|
||||||
|
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
|
||||||
|
if (isSAndAbove && stopReason in cancelReasons) return true
|
||||||
|
return runCatching {
|
||||||
|
downloadDao.getDownload(download.packageName).status == DownloadStatus.CANCELLED
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient errors worth retrying once connectivity returns. Walks the cause chain so a
|
||||||
|
* wrapped network error is still recognised.
|
||||||
|
*/
|
||||||
|
private fun isRetryable(throwable: Throwable?): Boolean = when (throwable) {
|
||||||
|
null -> false
|
||||||
|
is NoNetworkException,
|
||||||
|
is SocketException,
|
||||||
|
is SocketTimeoutException,
|
||||||
|
is UnknownHostException,
|
||||||
|
// Expired URLs were re-purchased by clearing the file list; retrying re-fetches them.
|
||||||
|
is ExpiredUrlException -> true
|
||||||
|
|
||||||
|
else -> isRetryable(throwable.cause)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun onFailure(exception: Exception): Result {
|
private suspend fun onFailure(exception: Exception): Result {
|
||||||
return withContext(NonCancellable) {
|
return withContext(NonCancellable) {
|
||||||
Log.i(TAG, "Job failed: ${download.packageName}", exception)
|
Log.i(TAG, "Job failed: ${download.packageName}", exception)
|
||||||
|
|
||||||
val cancelReasons = listOf(STOP_REASON_USER, STOP_REASON_CANCELLED_BY_APP)
|
val cancelledByUser = isCancelledByUser()
|
||||||
if (isSAndAbove && stopReason in cancelReasons) {
|
|
||||||
|
// Retry transient failures (lost connectivity, system-initiated stops) with
|
||||||
|
// backoff, keeping any partial download for resume. The network constraint on
|
||||||
|
// the work request means the retry only runs once connectivity is back.
|
||||||
|
val isSystemStop = exception is DownloadCancelledException && !cancelledByUser
|
||||||
|
if (!cancelledByUser &&
|
||||||
|
(isRetryable(exception) || isSystemStop) &&
|
||||||
|
runAttemptCount < MAX_DOWNLOAD_RETRIES
|
||||||
|
) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Transient failure for ${download.packageName}, " +
|
||||||
|
"retrying (attempt $runAttemptCount)"
|
||||||
|
)
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelledByUser) {
|
||||||
notifyStatus(DownloadStatus.CANCELLED)
|
notifyStatus(DownloadStatus.CANCELLED)
|
||||||
} else {
|
} else {
|
||||||
when (exception) {
|
when (exception) {
|
||||||
@@ -297,28 +383,63 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
if (file.exists() && verifyFile(gFile)) {
|
if (file.exists() && verifyFile(gFile)) {
|
||||||
Log.i(TAG, "$file is already downloaded!")
|
Log.i(TAG, "$file is already downloaded!")
|
||||||
downloadedBytes += file.length()
|
downloadedBytes += file.length()
|
||||||
|
verifiedFiles.add(file.absolutePath)
|
||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val tmpFileSuffix = ".tmp"
|
|
||||||
val tmpFile = File(file.absolutePath + tmpFileSuffix)
|
|
||||||
|
|
||||||
// Download as a temporary file to avoid installing corrupted files
|
// Download as a temporary file to avoid installing corrupted files
|
||||||
val isNewFile = tmpFile.createNewFile()
|
val tmpFile = File(file.absolutePath + ".tmp")
|
||||||
|
val existingBytes = if (tmpFile.exists()) tmpFile.length() else 0L
|
||||||
|
|
||||||
val okHttpClient = httpClient as HttpClient
|
val okHttpClient = httpClient as HttpClient
|
||||||
val headers = mutableMapOf<String, String>()
|
val headers = mutableMapOf<String, String>()
|
||||||
|
if (existingBytes > 0) {
|
||||||
if (!isNewFile) {
|
Log.i(TAG, "$tmpFile has an unfinished download, requesting resume!")
|
||||||
Log.i(TAG, "$tmpFile has an unfinished download, resuming!")
|
headers["Range"] = "bytes=$existingBytes-"
|
||||||
downloadedBytes += tmpFile.length()
|
|
||||||
headers["Range"] = "bytes=${tmpFile.length()}-"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
okHttpClient.call(gFile.url, headers).body.byteStream().use { input ->
|
val response = okHttpClient.call(gFile.url, headers)
|
||||||
FileOutputStream(tmpFile, !isNewFile).use {
|
if (!response.isSuccessful) {
|
||||||
input.copyTo(it, gFile.size).collect { info -> onProgress(info) }
|
val code = response.code
|
||||||
|
response.close()
|
||||||
|
// Play download URLs are short-lived; a 403/410 means ours expired while
|
||||||
|
// the download sat queued. Drop the stale file lists so the retry
|
||||||
|
// re-purchases fresh URLs instead of hammering the dead one.
|
||||||
|
if (code == HTTP_FORBIDDEN || code == HTTP_GONE) {
|
||||||
|
Log.w(TAG, "Download URL for ${download.packageName} expired (code=$code)")
|
||||||
|
downloadDao.updateFiles(download.packageName, emptyList())
|
||||||
|
downloadDao.updateSharedLibs(
|
||||||
|
download.packageName,
|
||||||
|
download.sharedLibs.map { it.copy(fileList = emptyList()) }
|
||||||
|
)
|
||||||
|
throw ExpiredUrlException()
|
||||||
|
}
|
||||||
|
throw DownloadFailedException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only resume when the server actually honored the Range request (206). If
|
||||||
|
// it replied 200 with the full body we must overwrite from the start,
|
||||||
|
// otherwise the full payload would be appended onto the existing partial and
|
||||||
|
// silently corrupt the file.
|
||||||
|
val resuming = existingBytes > 0 && response.code == HTTP_PARTIAL
|
||||||
|
if (resuming) {
|
||||||
|
downloadedBytes += existingBytes
|
||||||
|
} else if (existingBytes > 0) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Server ignored Range for $tmpFile (code=${response.code}), restarting"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.body.byteStream().use { input ->
|
||||||
|
FileOutputStream(tmpFile, resuming).use {
|
||||||
|
input.copyTo(it, gFile.size).collect { info ->
|
||||||
|
// Abort promptly mid-file when stopped, instead of only checking
|
||||||
|
// between files (a single split can be hundreds of MB).
|
||||||
|
if (isStopped) throw CancellationException("Download stopped")
|
||||||
|
onProgress(info)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +573,10 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
|
val algorithm = if (gFile.sha256.isBlank()) Algorithm.SHA1 else Algorithm.SHA256
|
||||||
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256
|
val expectedSha = if (algorithm == Algorithm.SHA1) gFile.sha1 else gFile.sha256
|
||||||
|
|
||||||
|
if (algorithm == Algorithm.SHA1) {
|
||||||
|
Log.w(TAG, "No SHA-256 for ${gFile.name}, falling back to SHA-1")
|
||||||
|
}
|
||||||
|
|
||||||
if (expectedSha.isBlank()) return false
|
if (expectedSha.isBlank()) return false
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
@@ -489,4 +614,44 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
Log.i(TAG, "Deleted Temp: $tmpFile")
|
Log.i(TAG, "Deleted Temp: $tmpFile")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes already present on disk (final or partial .tmp) for [files], so the storage check
|
||||||
|
* only requires room for what's still left to fetch.
|
||||||
|
*/
|
||||||
|
private fun downloadedBytesOnDisk(files: List<PlayFile>): Long = files.sumOf { gFile ->
|
||||||
|
val file = PathUtil.getLocalFile(context, gFile, download)
|
||||||
|
val tmpFile = File(file.absolutePath + ".tmp")
|
||||||
|
when {
|
||||||
|
file.exists() -> file.length()
|
||||||
|
tmpFile.exists() -> tmpFile.length()
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures there's room for [requiredBytes] before downloading, throwing
|
||||||
|
* [InsufficientStorageException] otherwise. On Android O+ this also asks the system to
|
||||||
|
* evict its own cache to make space, per the storage guidelines.
|
||||||
|
*/
|
||||||
|
private fun ensureStorageAvailable(requiredBytes: Long) {
|
||||||
|
if (requiredBytes <= 0) return
|
||||||
|
|
||||||
|
val dir = PathUtil.getDownloadDirectory(context).apply { mkdirs() }
|
||||||
|
if (isOAndAbove) {
|
||||||
|
val storageManager = context.getSystemService<StorageManager>()!!
|
||||||
|
try {
|
||||||
|
val uuid = storageManager.getUuidForPath(dir)
|
||||||
|
if (storageManager.getAllocatableBytes(uuid) < requiredBytes) {
|
||||||
|
throw InsufficientStorageException()
|
||||||
|
}
|
||||||
|
storageManager.allocateBytes(uuid, requiredBytes)
|
||||||
|
} catch (exception: IOException) {
|
||||||
|
Log.e(TAG, "Failed to allocate space for ${download.packageName}", exception)
|
||||||
|
throw InsufficientStorageException()
|
||||||
|
}
|
||||||
|
} else if (dir.usableSpace < requiredBytes) {
|
||||||
|
throw InsufficientStorageException()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,7 +314,8 @@ class AppDetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
DownloadStatus.VERIFYING -> AppState.Verifying
|
DownloadStatus.VERIFYING -> AppState.Verifying
|
||||||
|
|
||||||
DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F)
|
DownloadStatus.COMPLETED,
|
||||||
|
DownloadStatus.INSTALLING -> if (isInstalled) defaultAppState else AppState.Installing(0F)
|
||||||
|
|
||||||
else -> defaultAppState
|
else -> defaultAppState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,9 @@ class MicroGViewModel @Inject constructor(
|
|||||||
download == null -> InstallStatus.PENDING
|
download == null -> InstallStatus.PENDING
|
||||||
download.status == DownloadStatus.FAILED -> InstallStatus.FAILED
|
download.status == DownloadStatus.FAILED -> InstallStatus.FAILED
|
||||||
download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING
|
download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING
|
||||||
download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING
|
download.status == DownloadStatus.INSTALLED -> InstallStatus.INSTALLED
|
||||||
|
download.status == DownloadStatus.COMPLETED ||
|
||||||
|
download.status == DownloadStatus.INSTALLING -> InstallStatus.INSTALLING
|
||||||
else -> InstallStatus.DOWNLOADING
|
else -> InstallStatus.DOWNLOADING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
<string name="download_eta_min">%1$dm %2$ds left</string>
|
<string name="download_eta_min">%1$dm %2$ds left</string>
|
||||||
<string name="download_eta_sec">%1$ds left</string>
|
<string name="download_eta_sec">%1$ds left</string>
|
||||||
<string name="download_failed">"Download failed"</string>
|
<string name="download_failed">"Download failed"</string>
|
||||||
|
<string name="download_failed_storage">Not enough storage space to download this app</string>
|
||||||
<string name="download_force_clear_all">"Force clear all"</string>
|
<string name="download_force_clear_all">"Force clear all"</string>
|
||||||
<string name="download_metadata">"Getting metadata"</string>
|
<string name="download_metadata">"Getting metadata"</string>
|
||||||
<string name="download_none">"No downloads"</string>
|
<string name="download_none">"No downloads"</string>
|
||||||
@@ -520,6 +521,8 @@
|
|||||||
<string name="status_queued">Queued</string>
|
<string name="status_queued">Queued</string>
|
||||||
<string name="status_unavailable">Unavailable</string>
|
<string name="status_unavailable">Unavailable</string>
|
||||||
<string name="status_verifying">Verifying</string>
|
<string name="status_verifying">Verifying</string>
|
||||||
|
<string name="status_installing">Installing</string>
|
||||||
|
<string name="status_installed">Installed</string>
|
||||||
|
|
||||||
<!-- UnarchivePackageReceiver -->
|
<!-- UnarchivePackageReceiver -->
|
||||||
<string name="authentication_required_title">Authentication required</string>
|
<string name="authentication_required_title">Authentication required</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user