mirror of
https://github.com/whyorean/AuroraStore.git
synced 2026-06-11 09:16:06 -04:00
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.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user