/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.lemenzo.files.services import android.app.Notification import android.content.Context import android.net.Uri import android.os.FileUtils.copy import android.os.OperationCanceledException import android.os.ParcelFileDescriptor import android.os.SystemClock import android.os.Trace import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.MIME_TYPE_DIR import android.text.BidiFormatter import android.text.TextUtils import android.util.Log import com.lemenzo.files.DocumentsApplication import com.lemenzo.files.R import com.lemenzo.files.archives.ReadableArchive import com.lemenzo.files.base.DocumentInfo import com.lemenzo.files.base.DocumentStack import com.lemenzo.files.base.Features import com.lemenzo.files.base.SharedMinimal.DEBUG import com.lemenzo.files.base.SharedMinimal.VERBOSE import com.lemenzo.files.base.SharedMinimal.redact import com.lemenzo.files.clipping.UrisSupplier import com.lemenzo.files.util.FormatUtils import com.lemenzo.files.util.Material3Config.Companion.getRes import com.google.common.io.Files import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.text.NumberFormat import java.util.LinkedList import org.apache.commons.compress.archivers.ArchiveEntry /** * UnpackJob extracts all the files from a supported archive. It only works on one archive at a * time. Given an archive to unpack, it: * - Opens the archive * - Lists all the entries of the archive * - Estimates the number of directories, files and bytes to extract * - Checks the free space in the destination directory * - Creates a unique extraction directory in the destination directory * - Creates all the intermediate directories in the extraction directory * - Extracts all the files from the archive * - Tracks and reports its own progress * - Records file extraction errors * - Deletes any partially extracted file in case of error * - Handles cancellation gracefully */ class UnpackJob( service: Context, listener: Listener, id: String, destination: DocumentStack, srcs: UrisSupplier, features: Features ) : ResolvedResourcesJob( service, listener, id, FileOperationService.OPERATION_UNPACK, destination, srcs, features ) { private val dirPathToUri: MutableMap = mutableMapOf() private val tracker = ProgressTracker() private val dstInfo: DocumentInfo = destination.peek() private var archive: ReadableArchive? = null override fun createProgressBuilder(): Notification.Builder { return super.createProgressBuilder( service.getString(getRes(R.string.extract_notification_title)), getRes(R.drawable.ic_menu_extract), service.getString(android.R.string.cancel), getRes(R.drawable.ic_cab_cancel) ) } override fun getSetupNotification(): Notification { return getSetupNotification(service.getString(getRes(R.string.extract_preparing))) } /** This method is called on a different thread than the thread running the extraction. */ override fun getProgressNotification(): Notification { val absoluteProgress: Double val absoluteTarget: Double val remainingTime: Long synchronized(tracker) { absoluteProgress = tracker.absoluteProgress absoluteTarget = tracker.absoluteTarget remainingTime = tracker.getRemainingTimeEstimate(absoluteProgress, absoluteTarget) if (DEBUG) Log.d(TAG, "$tracker, ${remainingTime / 1000} seconds left") } if (absoluteTarget > 0) { val relativeProgress = absoluteProgress / absoluteTarget mProgressBuilder.setProgress(100, (relativeProgress * 100).toInt(), false) mProgressBuilder.setSubText(NumberFormat.getPercentInstance().format(relativeProgress)) } else { mProgressBuilder.setProgress(100, 0, true) mProgressBuilder.setSubText(null) } mProgressBuilder.setContentText( if (remainingTime > 0) { service.getString( getRes(R.string.copy_remaining), FormatUtils.formatDuration(remainingTime) ) } else { null } ) return mProgressBuilder.build() } override fun finish() { try { Trace.beginSection("UnpackJob#finish") if (DEBUG) Log.d(TAG, "Finished with $tracker") archive?.close() super.finish() } finally { Trace.endSection() } } override fun getFailureNotification(): Notification { return getFailureNotification( getFailureContentTitle(getRes(R.string.extract_error_notification_title)), getRes(R.drawable.ic_menu_extract) ) } /** This method is called on a different thread than the thread running the extraction. */ public override fun getJobProgress(): JobProgress { val args: MutableMap = mutableMapOf( "directory" to BidiFormatter.getInstance().unicodeWrap(dstInfo.displayName) ) val message = getProgressMessage(R.string.extract_in_progress, args) val bytesCopied: Long val bytesRequired: Long val timeEstimate: Long synchronized(tracker) { bytesCopied = tracker.bytesCopied bytesRequired = tracker.bytesRequired timeEstimate = tracker.remainingTimeEstimate } return JobProgress( id, operationType, state, message, hasFailures(), stack, bytesCopied, bytesRequired, timeEstimate ) } private val resolver = appContext.getContentResolver() private val archiveInfo: DocumentInfo get() { return mResolvedDocs.first() } override fun setUp(): Boolean { try { Trace.beginSection("UnpackJob#setUp") if (!super.setUp()) return false if (DEBUG) Log.d(TAG, "Unpacking ${archiveInfo.derivedUri}") try { openArchive() checkFreeSpace() createExtractionDirectory() return true } catch (_: OperationCanceledException) { if (DEBUG) Log.d(TAG, "Canceled unpacking of ${archiveInfo.derivedUri}") } catch (t: Throwable) { Log.e(TAG, "Cannot unpack ${redact(archiveInfo.derivedUri)}", t) onFileFailed(archiveInfo) } return false } finally { Trace.endSection() } } private fun createExtractionDirectory() { try { Trace.beginSection("UnpackJob#createExtractionDirectory") mSignal.throwIfCanceled() // Create the extraction directory with the same base name as the archive. // This lets the destination document provider deal with name collisions, if necessary. val dirName = Files.getNameWithoutExtension(archiveInfo.displayName) val dirUri = DocumentsContract.createDocument( resolver, dstInfo.derivedUri, MIME_TYPE_DIR, dirName ) if (dirUri == null) { throw IOException( "Cannot create extraction dir ${redact(dirName)}" + " in ${redact(dstInfo.derivedUri)}" ) } dirPathToUri.put("/", dirUri) val dirInfo = DocumentInfo.fromUri(resolver, dirUri, dstInfo.userId) if (VERBOSE) Log.v(TAG, "Created extraction dir ${redact(dirInfo)}") // Create a new DocumentStack object instead of modifying the existing DocumentStack // object in place. Since the stack is asynchronously read by the progress-reporting // thread, this ensures that the referenced DocumentStack object is immutable and // prevents any data race condition. stack = DocumentStack(stack, dirInfo) } finally { Trace.endSection() } } private fun openArchive() { mSignal.throwIfCanceled() // Open the archive. archive = ReadableArchive.createForParcelFileDescriptor( appContext, resolver.openFileDescriptor(archiveInfo.derivedUri, "r", null), archiveInfo.derivedUri, archiveInfo.mimeType, ParcelFileDescriptor.MODE_READ_ONLY, null ) mSignal.throwIfCanceled() // Count all the directories to create, all the files to extract, and all the bytes // to copy. val dirs = mutableSetOf("/") for (entry in archive!!.entries) { var path = File(ReadableArchive.getEntryPath(entry)) if (!entry.isDirectory()) { // The entry represents a file. Get the path of its containing directory. path = path.getParentFile()!! synchronized(tracker) { tracker.bytesRequired += entry.getSize() tracker.filesRequired++ } } while (dirs.add(path.toString())) { path = path.getParentFile()!! synchronized(tracker) { tracker.dirsRequired++ } } } } override fun start() { try { Trace.beginSection("UnpackJob#start") synchronized(tracker) { tracker.addPoint() } try { // Create all the directories first, to ensure that no directory will be renamed // because of a collision with a file name. createAllDirectories() extractAllFiles() } catch (_: OperationCanceledException) { if (DEBUG) Log.d(TAG, "Canceled unpacking of ${redact(archiveInfo.derivedUri)}") } catch (t: Throwable) { Log.e(TAG, "Cannot unpack ${redact(archiveInfo.derivedUri)}", t) onFileFailed(archiveInfo) } } finally { Trace.endSection() } } private fun extractAllFiles() { try { Trace.beginSection("UnpackJob#extractAllFiles") for (entry in archive!!.entries) { mSignal.throwIfCanceled() if (!entry.isDirectory()) processFileEntry(entry) } } finally { Trace.endSection() } } private fun createAllDirectories() { try { Trace.beginSection("UnpackJob#createAllDirectories") for (entry in archive!!.entries) { mSignal.throwIfCanceled() var path = File(ReadableArchive.getEntryPath(entry)) if (!entry.isDirectory()) path = path.getParentFile()!! ensureDirectoryExists(path) } } finally { Trace.endSection() } } private fun processFileEntry(entry: ArchiveEntry) { val path = File(ReadableArchive.getEntryPath(entry)) val dirUri = ensureDirectoryExists(path.getParentFile()!!) try { extractFile(entry, dirUri, path.getName()) } catch (e: OperationCanceledException) { // Propagate cancellation request. throw e } catch (t: Throwable) { Log.e(TAG, "Cannot extract ${redact(path)} from ${redact(archiveInfo.derivedUri)}", t) synchronized(tracker) { tracker.filesRequired-- } onPathFailed(path.toString()) } // Adjust progress expectations after extracting a file. synchronized(tracker) { tracker.bytesRequired -= entry.getSize() - tracker.bytesCopiedInCurrentFile tracker.bytesCopiedInPreviousFiles += tracker.bytesCopiedInCurrentFile tracker.bytesCopiedInCurrentFile = 0 tracker.addPoint() } } private fun extractFile(entry: ArchiveEntry, dirUri: Uri, fileName: String) { try { Trace.beginSection("UnpackJob#extractFile") // Open input stream serving the archive entry's contents. archive!!.getInputStream(entry).use { inputStream -> mSignal.throwIfCanceled() // Create output file. val mimeType = ReadableArchive.getMimeTypeForEntry(entry) val fileUri = createFile(dirUri, fileName, mimeType) try { copyFile(inputStream, fileUri) } catch (t: Throwable) { // Error or cancellation while copying a file. deletePartialFile(fileUri) throw t } } } finally { Trace.endSection() } } private fun createFile(parent: Uri, name: String, mimeType: String): Uri { try { Trace.beginSection("UnpackJob#createFile") val uri = DocumentsContract.createDocument(resolver, parent, mimeType, name)!! if (VERBOSE) Log.v(TAG, "Created file $uri") return uri } finally { Trace.endSection() } } private fun trackBytesInCurrentFile(bytes: Long) { synchronized(tracker) { tracker.bytesCopiedInCurrentFile = bytes tracker.addPoint() } } private fun copyFile(inputStream: InputStream, outputFile: Uri) { val client = getClient(outputFile) try { // Open output file for writing. client.openFile(outputFile, "w", mSignal).use { fd -> ParcelFileDescriptor.AutoCloseOutputStream(fd).use { outputStream -> copyBytes(inputStream, outputStream) } } } finally { releaseClient(outputFile) } synchronized(tracker) { tracker.filesCopied++ tracker.addPoint() } } private fun copyBytes(from: InputStream, to: OutputStream) { try { Trace.beginSection("UnpackJob#copyBytes") val bytes = copy(from, to, mSignal, Runnable::run, ::trackBytesInCurrentFile) trackBytesInCurrentFile(bytes) } finally { Trace.endSection() } } private fun deletePartialFile(uri: Uri) { try { if (DocumentsContract.deleteDocument(resolver, uri)) { if (DEBUG) Log.d(TAG, "Deleted partial file ${redact(uri)}") } else { Log.e(TAG, "Cannot delete partial file ${redact(uri)}") } } catch (t: Throwable) { Log.e(TAG, "Cannot delete partial file ${redact(uri)}", t) } } private fun ensureDirectoryExists(path: File): Uri { val uri = dirPathToUri[path.toString()] if (uri != null) return uri val parentUri = ensureDirectoryExists(path.getParentFile()!!) val name = path.getName() if (TextUtils.isEmpty(name)) return parentUri return createDirectory(parentUri, name, path) } private fun createDirectory(parent: Uri, name: String, path: File): Uri { try { Trace.beginSection("UnpackJob#createDirectory") mSignal.throwIfCanceled() val uri = DocumentsContract.createDocument(resolver, parent, MIME_TYPE_DIR, name) if (uri == null) { throw IOException("Cannot create dir ${redact(name)} in ${redact(parent)}") } if (VERBOSE) Log.v(TAG, "Created dir ${redact(uri)}") dirPathToUri.put(path.toString(), uri) synchronized(tracker) { tracker.dirsCreated++ tracker.addPoint() } return uri } finally { Trace.endSection() } } /** Checks whether the destination directory has enough free space. */ private fun checkFreeSpace() { try { Trace.beginSection("UnpackJob#checkFreeSpace") mSignal.throwIfCanceled() val bytesRequired = tracker.bytesRequired if (DEBUG) Log.d(TAG, "Need at least $bytesRequired bytes of free space") var root = stack.root if (root == null) { Log.w(TAG, "No root info for destination dir ${redact(dstInfo.derivedUri)}") return } // Query root info again instead of using stack.root because the numbers may be stale. root = DocumentsApplication.getProvidersCache(appContext).getRootOneshot( root.userId, root.authority, root.rootId, true ) if (root == null || root.availableBytes < 0) { Log.w(TAG, "Root $root does not provide its free space amount") return } if (DEBUG) Log.d(TAG, "Root $root has ${root.availableBytes} bytes of free space") if (bytesRequired > root.availableBytes) { throw IOException( "Not enough free space in ${redact(dstInfo.derivedUri)}: " + "Need $bytesRequired bytes, " + "but only got ${root.availableBytes} bytes of free space" ) } } finally { Trace.endSection() } } override fun toString(): String { return "UnpackJob {id=$id, uris=$mResourceUris, docs=$mResolvedDocs, dest=$dstInfo}" } private class ProgressTracker : Job.ProgressTracker { var bytesRequired: Long = 0 var bytesCopiedInPreviousFiles: Long = 0 var bytesCopiedInCurrentFile: Long = 0 var filesRequired = 0 var filesCopied = 0 var dirsRequired = 0 var dirsCreated = 0 /** A progress sample. */ private data class Point(val time: Long, val progress: Double) /** Circular queue of the most recent progress samples. */ val points = LinkedList() /** Records a progress sample if the latest one was recorded more than a second ago. */ fun addPoint() { val now = SystemClock.uptimeMillis() val last = points.peekLast() if (last != null && now - last.time < 1000) return points.addLast(Point(now, absoluteProgress)) // Only keep a handful of the most recent samples. while (points.size > 20) points.removeFirst() } val bytesCopied: Long get() = bytesCopiedInPreviousFiles + bytesCopiedInCurrentFile val absoluteProgress: Double get() = getLinear(dirsCreated, filesCopied, bytesCopied) val absoluteTarget: Double get() = getLinear(dirsRequired, filesRequired, bytesRequired) override fun getProgress(): Double { return absoluteProgress / absoluteTarget } override fun getRemainingTimeEstimate(): Long { return getRemainingTimeEstimate(absoluteProgress, absoluteTarget) } fun getRemainingTimeEstimate(progress: Double, target: Double): Long { val first = points.peekFirst() if (first == null) return -1 val now = SystemClock.uptimeMillis() val elapsedTime = now - first.time val t: Double = elapsedTime * (target - progress) / (progress - first.progress) return if (t > 0) t.toLong() else -1 } override fun toString(): String { return "Progress %.0f%%, %,d/%,d dirs, %,d/%,d files, %,d/%,d bytes".format( progress * 100, dirsCreated, dirsRequired, filesCopied, filesRequired, bytesCopied, bytesRequired ) } companion object { /** * Gets a linear approximation of the time taken to create the given number of * directories, create the given number of files and write the given number of bytes. * The unit of measure of the returned value is the time taken to write one byte. * * The constants used in this formula have been empirically determined to * approximate a smooth progress tracking with a test device. The time taken to * create an empty file and open it for writing matches the time taken to transfer * 2.9e6 bytes. The time taken to create a directory matches the time taken to * transfer 2.0e7 bytes. */ private fun getLinear(dirs: Int, files: Int, bytes: Long): Double { return bytes.toDouble() + 2.9e6 * files + 2.0e7 * dirs } } } companion object { private const val TAG = "UnpackJob" } }