Improve app export: completeness, reliability and progress
Address several gaps in the app export feature (#1132): - Bundle shared-library APKs and OBB/patch files, preserving their relative layout (libraries/<pkg>/ and Android/obb/<pkg>/). - Fall back to the installed app's on-device APKs when the cached download was cleared (e.g. auto-deleted after install), guarded by an exact version match so a different installed version is never exported. - Guard against the download row being replaced by a manual download of another version between enqueue and execution. - Fail (and delete the partial/empty document) instead of reporting success when there is nothing to bundle or an error occurs. - Store entries uncompressed and use larger buffers, so multi-GB exports are no longer bottlenecked on pointless re-compression. - Report progress through the foreground notification (alert once, then silent) and dedupe concurrent exports for the same app via unique work.
This commit is contained in:
@@ -7,24 +7,30 @@ package com.aurora.store.data.work
|
|||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
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.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import com.aurora.extensions.isQAndAbove
|
||||||
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.util.NotificationUtil
|
import com.aurora.store.util.NotificationUtil
|
||||||
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
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.zip.Deflater
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
@@ -34,7 +40,8 @@ import java.util.zip.ZipOutputStream
|
|||||||
@HiltWorker
|
@HiltWorker
|
||||||
class ExportWorker @AssistedInject constructor(
|
class ExportWorker @AssistedInject constructor(
|
||||||
@Assisted private val context: Context,
|
@Assisted private val context: Context,
|
||||||
@Assisted workerParams: WorkerParameters
|
@Assisted workerParams: WorkerParameters,
|
||||||
|
private val downloadDao: DownloadDao
|
||||||
) : CoroutineWorker(context, workerParams) {
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -48,6 +55,9 @@ class ExportWorker @AssistedInject constructor(
|
|||||||
private const val NOTIFICATION_ID = 500
|
private const val NOTIFICATION_ID = 500
|
||||||
private const val NOTIFICATION_ID_FGS = 501
|
private const val NOTIFICATION_ID_FGS = 501
|
||||||
|
|
||||||
|
// Large copy buffer to keep throughput high when bundling multi-GB exports
|
||||||
|
private const val BUFFER_SIZE = 1024 * 1024
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports the given download to the URI
|
* Exports the given download to the URI
|
||||||
* @param download Download to export
|
* @param download Download to export
|
||||||
@@ -67,7 +77,12 @@ class ExportWorker @AssistedInject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
Log.i(TAG, "Exporting download for ${download.packageName}/${download.versionCode}")
|
Log.i(TAG, "Exporting download for ${download.packageName}/${download.versionCode}")
|
||||||
WorkManager.getInstance(context).enqueue(oneTimeWorkRequest)
|
// Keep any in-progress export for the same app so re-tapping does not queue duplicates
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
"$TAG/${download.packageName}",
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
oneTimeWorkRequest
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,26 +98,59 @@ class ExportWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
if (packageName.isNullOrEmpty() || versionCode == -1L) {
|
if (packageName.isNullOrEmpty() || versionCode == -1L) {
|
||||||
Log.e(TAG, "Input data is corrupt, bailing out!")
|
Log.e(TAG, "Input data is corrupt, bailing out!")
|
||||||
notifyStatus(displayName ?: String(), uri, false)
|
return fail(displayName ?: String(), uri)
|
||||||
return Result.failure()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
copyDownloadedApp(packageName, versionCode, uri)
|
val download = downloadDao.getDownload(packageName)
|
||||||
|
|
||||||
|
// The download row is keyed by package name only, so guard against it having
|
||||||
|
// been replaced by a manual download of a different version between enqueue and
|
||||||
|
// execution; the old version's file list is no longer available to export.
|
||||||
|
if (download.versionCode != versionCode) {
|
||||||
|
Log.e(TAG, "Download for $packageName is no longer version $versionCode, bailing!")
|
||||||
|
return fail(displayName ?: packageName, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
val entries = PathUtil.getExportableFiles(context, download)
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
// Nothing on disk to bundle, e.g. files were auto-deleted after install
|
||||||
|
Log.e(TAG, "No files to export for $packageName, was it auto-deleted?")
|
||||||
|
return fail(displayName ?: packageName, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleFiles(displayName ?: packageName, entries, uri)
|
||||||
notifyStatus(displayName ?: packageName, uri)
|
notifyStatus(displayName ?: packageName, uri)
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
Log.e(TAG, "Failed to export $packageName", exception)
|
Log.e(TAG, "Failed to export $packageName", exception)
|
||||||
notifyStatus(displayName ?: packageName, uri, false)
|
return fail(displayName ?: packageName, uri)
|
||||||
return Result.failure()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
|
override suspend fun getForegroundInfo(): ForegroundInfo =
|
||||||
NOTIFICATION_ID_FGS,
|
exportForegroundInfo(inputData.getString(DISPLAY_NAME))
|
||||||
NotificationUtil.getExportNotification(context)
|
|
||||||
)
|
private fun exportForegroundInfo(displayName: String?, progress: Int = -1): ForegroundInfo {
|
||||||
|
val notification = NotificationUtil.getExportNotification(context, displayName, progress)
|
||||||
|
return if (isQAndAbove) {
|
||||||
|
ForegroundInfo(NOTIFICATION_ID_FGS, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
ForegroundInfo(NOTIFICATION_ID_FGS, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the partially written (or empty) export document and notifies failure, so a
|
||||||
|
* failed export does not leave a corrupt zip behind at the user-chosen location.
|
||||||
|
*/
|
||||||
|
private fun fail(packageName: String, uri: Uri): Result {
|
||||||
|
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
|
||||||
|
.onFailure { Log.w(TAG, "Failed to delete incomplete export at $uri", it) }
|
||||||
|
notifyStatus(packageName, uri, false)
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
private fun notifyStatus(packageName: String, uri: Uri, success: Boolean = true) {
|
private fun notifyStatus(packageName: String, uri: Uri, success: Boolean = true) {
|
||||||
notificationManager.notify(
|
notificationManager.notify(
|
||||||
@@ -116,23 +164,49 @@ class ExportWorker @AssistedInject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs(
|
/**
|
||||||
PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(),
|
* Updates the ongoing foreground notification to reflect export [progress] (0-100).
|
||||||
uri
|
*/
|
||||||
)
|
private suspend fun notifyProgress(displayName: String, progress: Int) {
|
||||||
|
setForeground(exportForegroundInfo(displayName, progress))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundles all the given APKs to a zip file
|
* Bundles the given files into a zip written to the [uri], preserving each file's
|
||||||
* @param fileList List of APKs to add to the zip
|
* relative path within the archive so shared libraries and OBB/patch files retain
|
||||||
* @param uri [Uri] of the file to write the APKs
|
* their layout, while reporting progress through the foreground notification.
|
||||||
|
*
|
||||||
|
* Entries are stored without compression: APKs and OBBs are already compressed, so
|
||||||
|
* deflating them wastes CPU for no size gain and makes large bundles needlessly slow.
|
||||||
|
* @param displayName App name shown in the progress notification
|
||||||
|
* @param entries Map of zip entry name to the file to write
|
||||||
|
* @param uri [Uri] of the file to write the bundle to
|
||||||
*/
|
*/
|
||||||
private fun bundleAllAPKs(fileList: List<File>, uri: Uri) {
|
private suspend fun bundleFiles(displayName: String, entries: Map<String, File>, uri: Uri) {
|
||||||
ZipOutputStream(context.contentResolver.openOutputStream(uri)).use { zipOutput ->
|
val totalBytes = entries.values.sumOf { it.length() }.coerceAtLeast(1)
|
||||||
fileList.forEach { file ->
|
var writtenBytes = 0L
|
||||||
file.inputStream().use { input ->
|
var lastProgress = -1
|
||||||
val zipEntry = ZipEntry(file.name)
|
|
||||||
zipOutput.putNextEntry(zipEntry)
|
val output = context.contentResolver.openOutputStream(uri)!!.buffered(BUFFER_SIZE)
|
||||||
input.copyTo(zipOutput)
|
ZipOutputStream(output).use { zipOutput ->
|
||||||
|
zipOutput.setLevel(Deflater.NO_COMPRESSION)
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
entries.forEach { (entryName, file) ->
|
||||||
|
file.inputStream().buffered(BUFFER_SIZE).use { input ->
|
||||||
|
zipOutput.putNextEntry(ZipEntry(entryName))
|
||||||
|
var read = input.read(buffer)
|
||||||
|
while (read >= 0) {
|
||||||
|
zipOutput.write(buffer, 0, read)
|
||||||
|
writtenBytes += read
|
||||||
|
|
||||||
|
// Throttle to whole-percent changes to avoid being rate-limited
|
||||||
|
val progress = ((writtenBytes * 100) / totalBytes).toInt()
|
||||||
|
if (progress > lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
notifyProgress(displayName, progress)
|
||||||
|
}
|
||||||
|
read = input.read(buffer)
|
||||||
|
}
|
||||||
zipOutput.closeEntry()
|
zipOutput.closeEntry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,13 +273,20 @@ object NotificationUtil {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getExportNotification(context: Context): Notification =
|
fun getExportNotification(
|
||||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
|
context: Context,
|
||||||
.setSmallIcon(R.drawable.ic_file_copy)
|
displayName: String? = null,
|
||||||
.setContentTitle(context.getString(R.string.export_app_title))
|
progress: Int = -1
|
||||||
.setContentText(context.getString(R.string.export_app_summary))
|
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
|
||||||
.setOngoing(true)
|
.setSmallIcon(R.drawable.ic_file_copy)
|
||||||
.build()
|
.setContentTitle(displayName ?: context.getString(R.string.export_app_title))
|
||||||
|
.setContentText(context.getString(R.string.export_app_summary))
|
||||||
|
.setProgress(100, progress.coerceAtLeast(0), progress < 0)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||||
|
.setSilent(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
|
fun getUnarchiveAuthNotification(context: Context, packageName: String): Notification =
|
||||||
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT)
|
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package com.aurora.store.util
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import com.aurora.gplayapi.data.models.PlayFile
|
import com.aurora.gplayapi.data.models.PlayFile
|
||||||
import com.aurora.store.data.room.download.Download
|
import com.aurora.store.data.room.download.Download
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -84,6 +85,69 @@ object PathUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves every on-disk file belonging to a finished [Download] (base/split APKs,
|
||||||
|
* shared-library APKs and OBB/patch files), mapped to the relative path it should
|
||||||
|
* occupy inside an exported zip bundle.
|
||||||
|
*
|
||||||
|
* APKs are taken from the download cache, falling back to the APKs of the installed
|
||||||
|
* app when the cache was cleared (e.g. auto-deleted after install). They keep their
|
||||||
|
* layout relative to the app's download directory (shared libraries stay under
|
||||||
|
* `libraries/<packageName>/`). OBB/patch files live outside the cache, survive the
|
||||||
|
* cleanup, and are placed under `Android/obb/<packageName>/` so they can be restored
|
||||||
|
* to their on-device location.
|
||||||
|
*
|
||||||
|
* Files that are missing on disk are skipped, as OBB/patch files are optional.
|
||||||
|
*/
|
||||||
|
fun getExportableFiles(context: Context, download: Download): Map<String, File> {
|
||||||
|
val appDir = getAppDownloadDir(context, download.packageName, download.versionCode)
|
||||||
|
val playFiles = download.fileList + download.sharedLibs.flatMap { it.fileList }
|
||||||
|
val (obbPlayFiles, apkPlayFiles) = playFiles.partition {
|
||||||
|
it.type == PlayFile.Type.OBB || it.type == PlayFile.Type.PATCH
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFiles = apkPlayFiles.associate { playFile ->
|
||||||
|
val file = getLocalFile(context, playFile, download)
|
||||||
|
file.relativeTo(appDir).invariantSeparatorsPath to file
|
||||||
|
}.filterValues { it.exists() }
|
||||||
|
.ifEmpty { getInstalledApkFiles(context, download.packageName, download.versionCode) }
|
||||||
|
|
||||||
|
val obbFiles = obbPlayFiles.associate { playFile ->
|
||||||
|
val file = getLocalFile(context, playFile, download)
|
||||||
|
"Android/obb/${download.packageName}/${file.name}" to file
|
||||||
|
}.filterValues { it.exists() }
|
||||||
|
|
||||||
|
return apkFiles + obbFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the base and split APKs of the installed [packageName] from their on-device
|
||||||
|
* locations, used as a fallback when the downloaded files are no longer cached. Returns
|
||||||
|
* an empty map unless the installed version matches [versionCode], so a different
|
||||||
|
* installed version is never exported by mistake.
|
||||||
|
*/
|
||||||
|
private fun getInstalledApkFiles(
|
||||||
|
context: Context,
|
||||||
|
packageName: String,
|
||||||
|
versionCode: Long
|
||||||
|
): Map<String, File> {
|
||||||
|
val packageInfo = runCatching {
|
||||||
|
PackageUtil.getPackageInfo(context, packageName)
|
||||||
|
}.getOrNull() ?: return emptyMap()
|
||||||
|
|
||||||
|
if (PackageInfoCompat.getLongVersionCode(packageInfo) != versionCode) return emptyMap()
|
||||||
|
|
||||||
|
val appInfo = packageInfo.applicationInfo ?: return emptyMap()
|
||||||
|
val apkPaths = buildList {
|
||||||
|
appInfo.sourceDir?.let { add(it) }
|
||||||
|
appInfo.splitSourceDirs?.let { addAll(it.filterNotNull()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apkPaths.map { File(it) }
|
||||||
|
.filter { it.exists() }
|
||||||
|
.associateBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
fun getZipFile(context: Context, packageName: String, versionCode: Long): File = File(
|
fun getZipFile(context: Context, packageName: String, versionCode: Long): File = File(
|
||||||
getAppDownloadDir(
|
getAppDownloadDir(
|
||||||
context,
|
context,
|
||||||
|
|||||||
Reference in New Issue
Block a user