diff --git a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt index 21740d6ac..a08e1a151 100644 --- a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt @@ -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, 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, 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() } } diff --git a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt index 99a6b9634..36b6cea7a 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -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) diff --git a/app/src/main/java/com/aurora/store/util/PathUtil.kt b/app/src/main/java/com/aurora/store/util/PathUtil.kt index fe7108cc7..113a22bea 100644 --- a/app/src/main/java/com/aurora/store/util/PathUtil.kt +++ b/app/src/main/java/com/aurora/store/util/PathUtil.kt @@ -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//`). OBB/patch files live outside the cache, survive the + * cleanup, and are placed under `Android/obb//` 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 { + 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 { + 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,