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:
Rahul Patel
2026-05-30 05:39:18 +05:30
parent fa4f7d829f
commit 25d0944e37
3 changed files with 177 additions and 32 deletions

View File

@@ -7,24 +7,30 @@ package com.aurora.store.data.work
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aurora.extensions.isQAndAbove
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.PathUtil
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.io.File
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -34,7 +40,8 @@ import java.util.zip.ZipOutputStream
@HiltWorker
class ExportWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters
@Assisted workerParams: WorkerParameters,
private val downloadDao: DownloadDao
) : CoroutineWorker(context, workerParams) {
companion object {
@@ -48,6 +55,9 @@ class ExportWorker @AssistedInject constructor(
private const val NOTIFICATION_ID = 500
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
* @param download Download to export
@@ -67,7 +77,12 @@ class ExportWorker @AssistedInject constructor(
.build()
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) {
Log.e(TAG, "Input data is corrupt, bailing out!")
notifyStatus(displayName ?: String(), uri, false)
return Result.failure()
return fail(displayName ?: String(), uri)
}
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)
} catch (exception: Exception) {
Log.e(TAG, "Failed to export $packageName", exception)
notifyStatus(displayName ?: packageName, uri, false)
return Result.failure()
return fail(displayName ?: packageName, uri)
}
return Result.success()
}
override suspend fun getForegroundInfo(): ForegroundInfo = ForegroundInfo(
NOTIFICATION_ID_FGS,
NotificationUtil.getExportNotification(context)
)
override suspend fun getForegroundInfo(): ForegroundInfo =
exportForegroundInfo(inputData.getString(DISPLAY_NAME))
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) {
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(),
uri
)
/**
* Updates the ongoing foreground notification to reflect export [progress] (0-100).
*/
private suspend fun notifyProgress(displayName: String, progress: Int) {
setForeground(exportForegroundInfo(displayName, progress))
}
/**
* Bundles all the given APKs to a zip file
* @param fileList List of APKs to add to the zip
* @param uri [Uri] of the file to write the APKs
* Bundles the given files into a zip written to the [uri], preserving each file's
* relative path within the archive so shared libraries and OBB/patch files retain
* 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) {
ZipOutputStream(context.contentResolver.openOutputStream(uri)).use { zipOutput ->
fileList.forEach { file ->
file.inputStream().use { input ->
val zipEntry = ZipEntry(file.name)
zipOutput.putNextEntry(zipEntry)
input.copyTo(zipOutput)
private suspend fun bundleFiles(displayName: String, entries: Map<String, File>, uri: Uri) {
val totalBytes = entries.values.sumOf { it.length() }.coerceAtLeast(1)
var writtenBytes = 0L
var lastProgress = -1
val output = context.contentResolver.openOutputStream(uri)!!.buffered(BUFFER_SIZE)
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()
}
}

View File

@@ -273,13 +273,20 @@ object NotificationUtil {
.build()
}
fun getExportNotification(context: Context): Notification =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
.setSmallIcon(R.drawable.ic_file_copy)
.setContentTitle(context.getString(R.string.export_app_title))
.setContentText(context.getString(R.string.export_app_summary))
.setOngoing(true)
.build()
fun getExportNotification(
context: Context,
displayName: String? = null,
progress: Int = -1
): Notification = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_EXPORT)
.setSmallIcon(R.drawable.ic_file_copy)
.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 =
NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_ACCOUNT)

View File

@@ -21,6 +21,7 @@ package com.aurora.store.util
import android.content.Context
import android.os.Environment
import androidx.core.content.pm.PackageInfoCompat
import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.data.room.download.Download
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(
getAppDownloadDir(
context,