From 324c30c71b7c77f68149c748a10fc0127688b6b1 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 10 Jun 2024 00:52:47 +0700 Subject: [PATCH 01/39] Upgraded to Kotlin 2.0 and added full support coroutines on file manipulations --- build.gradle | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../activity/FileCompressionActivity.kt | 47 +- .../activity/FileDecompressionActivity.kt | 9 +- .../storage/sample/activity/JavaActivity.java | 33 +- .../storage/sample/activity/MainActivity.kt | 339 +++-- .../sample/fragment/SettingsFragment.java | 25 +- .../storage/callback/BaseFileCallback.kt | 60 - .../storage/callback/FileConflictCallback.kt | 29 - ...rCallback.kt => FolderConflictCallback.kt} | 64 +- .../storage/callback/MultipleFileCallback.kt | 123 -- .../callback/MultipleFileConflictCallback.kt | 81 ++ ...lback.kt => SingleFileConflictCallback.kt} | 47 +- .../callback/ZipCompressionCallback.kt | 83 -- .../callback/ZipDecompressionCallback.kt | 92 -- .../storage/file/DocumentFileExt.kt | 1124 +++++++++-------- .../com/anggrayudi/storage/file/FileExt.kt | 19 +- .../anggrayudi/storage/file/FileProperties.kt | 53 - .../com/anggrayudi/storage/media/MediaFile.kt | 185 ++- .../anggrayudi/storage/media/MediaFileExt.kt | 108 +- .../storage/result/FilePropertiesResult.kt | 30 + .../anggrayudi/storage/result/FolderResult.kt | 49 + .../storage/result/MultipleFilesResult.kt | 41 + .../storage/result/SingleFileResult.kt | 35 + .../storage/result/ZipCompressionResult.kt | 28 + .../storage/result/ZipDecompressionResult.kt | 37 + versions.gradle | 2 +- 27 files changed, 1356 insertions(+), 1393 deletions(-) delete mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt delete mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt rename storage/src/main/java/com/anggrayudi/storage/callback/{FolderCallback.kt => FolderConflictCallback.kt} (54%) delete mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileConflictCallback.kt rename storage/src/main/java/com/anggrayudi/storage/callback/{FileCallback.kt => SingleFileConflictCallback.kt} (58%) delete mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt delete mode 100644 storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt delete mode 100644 storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/FolderResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt diff --git a/build.gradle b/build.gradle index 73850d9..434b8f2 100644 --- a/build.gradle +++ b/build.gradle @@ -4,10 +4,10 @@ buildscript { addRepos(repositories) - ext.kotlin_version = '1.8.22' + ext.kotlin_version = '2.0.0' dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' + classpath 'com.android.tools.build:gradle:8.4.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.vanniktech:gradle-maven-publish-plugin:0.22.0' classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.7.20' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f0d76de..befbbe6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt index 4524134..553c707 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt @@ -4,11 +4,11 @@ import android.os.Bundle import android.widget.TextView import android.widget.Toast import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.ZipCompressionCallback import com.anggrayudi.storage.file.MimeType import com.anggrayudi.storage.file.compressToZip import com.anggrayudi.storage.file.fullName import com.anggrayudi.storage.file.getAbsolutePath +import com.anggrayudi.storage.result.ZipCompressionResult import com.anggrayudi.storage.sample.databinding.ActivityFileCompressionBinding import kotlinx.coroutines.launch import timber.log.Timber @@ -90,27 +90,32 @@ class FileCompressionActivity : BaseActivity() { (binding.layoutCompressFilesSrcFolder2.tvFilePath.tag as? DocumentFile)?.let { files.add(it) } ioScope.launch { - files.compressToZip(applicationContext, targetZip, callback = object : ZipCompressionCallback(uiScope) { - override fun onCountingFiles() { - // show a notification or dialog with indeterminate progress bar + files.compressToZip(applicationContext, targetZip) + .collect { result -> + when (result) { + is ZipCompressionResult.CountingFiles -> { + // show a notification or dialog with indeterminate progress bar + } + + is ZipCompressionResult.Compressing -> { + Timber.d("onReport() -> ${result.progress.toInt()}% | Compressed ${result.fileCount} files") + } + + is ZipCompressionResult.Completed -> { + Timber.d("onCompleted() -> Compressed ${result.totalFilesCompressed} with compression rate %.2f", result.compressionRate) + Toast.makeText(applicationContext, "Successfully compressed ${result.totalFilesCompressed} files", Toast.LENGTH_SHORT).show() + } + + is ZipCompressionResult.DeletingEntryFiles -> { + // show a notification or dialog with indeterminate progress bar + } + + is ZipCompressionResult.Error -> { + Timber.d("onFailed() -> ${result.errorCode}: ${result.message}") + Toast.makeText(applicationContext, "Error compressing files: ${result.errorCode}", Toast.LENGTH_SHORT).show() + } + } } - - override fun onStart(files: List, workerThread: Thread): Long = 500 - - override fun onReport(report: Report) { - Timber.d("onReport() -> ${report.progress.toInt()}% | Compressed ${report.fileCount} files") - } - - override fun onCompleted(zipFile: DocumentFile, bytesCompressed: Long, totalFilesCompressed: Int, compressionRate: Float) { - Timber.d("onCompleted() -> Compressed $totalFilesCompressed with compression rate %.2f", compressionRate) - Toast.makeText(applicationContext, "Successfully compressed $totalFilesCompressed files", Toast.LENGTH_SHORT).show() - } - - override fun onFailed(errorCode: ErrorCode, message: String?) { - Timber.d("onFailed() -> $errorCode: $message") - Toast.makeText(applicationContext, "Error compressing files: $errorCode", Toast.LENGTH_SHORT).show() - } - }) } } diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt index eae3d63..2517340 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt @@ -6,8 +6,7 @@ import androidx.documentfile.provider.DocumentFile import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.checkbox.checkBoxPrompt import com.afollestad.materialdialogs.list.listItems -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.callback.ZipDecompressionCallback +import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.file.MimeType import com.anggrayudi.storage.file.decompressZip import com.anggrayudi.storage.file.fullName @@ -68,9 +67,9 @@ class FileDecompressionActivity : BaseActivity() { } ioScope.launch { zipFile.decompressZip(applicationContext, targetFolder, object : ZipDecompressionCallback(uiScope) { - var actionForAllConflicts: FileCallback.ConflictResolution? = null + var actionForAllConflicts: SingleFileConflictCallback.ConflictResolution? = null - override fun onFileConflict(destinationFile: DocumentFile, action: FileCallback.FileConflictAction) { + override fun onFileConflict(destinationFile: DocumentFile, action: SingleFileConflictCallback.FileConflictAction) { actionForAllConflicts?.let { action.confirmResolution(it) return @@ -83,7 +82,7 @@ class FileDecompressionActivity : BaseActivity() { .message(text = "File \"${destinationFile.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = mutableListOf("Replace", "Create New", "Skip Duplicate")) { _, index, _ -> - val resolution = FileCallback.ConflictResolution.values()[index] + val resolution = SingleFileConflictCallback.ConflictResolution.values()[index] if (doForAll) { actionForAllConflicts = resolution } diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java index f79a707..354e296 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java @@ -1,21 +1,7 @@ package com.anggrayudi.storage.sample.activity; -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_CREATE_FILE; -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FILE; -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FOLDER; - -import android.Manifest; -import android.os.Build; -import android.os.Bundle; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.documentfile.provider.DocumentFile; - import com.anggrayudi.storage.SimpleStorageHelper; -import com.anggrayudi.storage.callback.FileCallback; +import com.anggrayudi.storage.callback.SingleFileConflictCallback; import com.anggrayudi.storage.file.DocumentFileUtils; import com.anggrayudi.storage.media.MediaFile; import com.anggrayudi.storage.permission.ActivityPermissionRequest; @@ -26,10 +12,23 @@ import org.jetbrains.annotations.NotNull; +import android.Manifest; +import android.os.Build; +import android.os.Bundle; +import android.widget.Toast; + import java.util.List; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.documentfile.provider.DocumentFile; import timber.log.Timber; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_CREATE_FILE; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FILE; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FOLDER; + /** * Created on 17/07/21 * @@ -103,9 +102,9 @@ private void setupSimpleStorage(Bundle savedState) { } private void moveFile(DocumentFile source, DocumentFile destinationFolder) { - DocumentFileUtils.moveFileTo(source, getApplicationContext(), destinationFolder, null, new FileCallback() { + DocumentFileUtils.moveFileTo(source, getApplicationContext(), destinationFolder, null, new SingleFileConflictCallback() { @Override - public void onConflict(@NotNull DocumentFile destinationFile, @NotNull FileCallback.FileConflictAction action) { + public void onFileConflict(@NotNull DocumentFile destinationFile, @NotNull SingleFileConflictCallback.FileConflictAction action) { // do stuff } diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt index 0b84baf..5a9c1d9 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -23,11 +23,10 @@ import com.afollestad.materialdialogs.customview.getCustomView import com.afollestad.materialdialogs.input.input import com.afollestad.materialdialogs.list.listItems import com.anggrayudi.storage.SimpleStorageHelper -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.callback.FolderCallback -import com.anggrayudi.storage.callback.MultipleFileCallback +import com.anggrayudi.storage.callback.FolderConflictCallback +import com.anggrayudi.storage.callback.MultipleFileConflictCallback +import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.extension.launchOnUiThread -import com.anggrayudi.storage.file.FileSize import com.anggrayudi.storage.file.baseName import com.anggrayudi.storage.file.changeName import com.anggrayudi.storage.file.copyFileTo @@ -43,13 +42,19 @@ import com.anggrayudi.storage.permission.ActivityPermissionRequest import com.anggrayudi.storage.permission.PermissionCallback import com.anggrayudi.storage.permission.PermissionReport import com.anggrayudi.storage.permission.PermissionResult +import com.anggrayudi.storage.result.FolderResult +import com.anggrayudi.storage.result.MultipleFilesResult +import com.anggrayudi.storage.result.SingleFileResult import com.anggrayudi.storage.sample.R import com.anggrayudi.storage.sample.StorageInfoAdapter import com.anggrayudi.storage.sample.databinding.ActivityMainBinding +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.Runnable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import timber.log.Timber import java.io.IOException @@ -280,7 +285,30 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.copyTo(applicationContext, targetFolder, callback = createMultipleFileCallback(false)) + sources.copyTo(applicationContext, targetFolder, callback = createMultipleFileCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled + } + }.collect { result -> + when (result) { + is MultipleFilesResult.Validating -> Timber.d("Validating...") + is MultipleFilesResult.Preparing -> Timber.d("Preparing...") + is MultipleFilesResult.CountingFiles -> Timber.d("Counting files...") + is MultipleFilesResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is MultipleFilesResult.Starting -> Timber.d("Starting...") + is MultipleFilesResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is MultipleFilesResult.Completed -> { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is MultipleFilesResult.Error -> { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -295,7 +323,7 @@ class MainActivity : AppCompatActivity() { binding.layoutMoveMultipleFilesTargetFolder.btnBrowse.setOnClickListener { storageHelper.openFolderPicker(REQUEST_CODE_PICK_TARGET_FOLDER_FOR_MULTIPLE_FILE_MOVE) } - binding.btnStartMoveMultipleFiles.setOnClickListener { + binding.btnStartCopyMultipleFiles.setOnClickListener { val targetFolder = binding.layoutMoveMultipleFilesTargetFolder.tvFilePath.tag as? DocumentFile if (targetFolder == null) { Toast.makeText(this, "Please select target folder", Toast.LENGTH_SHORT).show() @@ -311,18 +339,35 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.moveTo(applicationContext, targetFolder, callback = createMultipleFileCallback(true)) + sources.moveTo(applicationContext, targetFolder, callback = createMultipleFileCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled + } + }.collect { result -> + when (result) { + is MultipleFilesResult.Validating -> Timber.d("Validating...") + is MultipleFilesResult.Preparing -> Timber.d("Preparing...") + is MultipleFilesResult.CountingFiles -> Timber.d("Counting files...") + is MultipleFilesResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is MultipleFilesResult.Starting -> Timber.d("Starting...") + is MultipleFilesResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is MultipleFilesResult.Completed -> { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is MultipleFilesResult.Error -> { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } - private fun createMultipleFileCallback(isMoveFileMode: Boolean) = object : MultipleFileCallback(uiScope) { - val mode = if (isMoveFileMode) "Moved" else "Copied" - - override fun onStart(files: List, totalFilesToCopy: Int, workerThread: Thread): Long { - return 1000 // update progress every 1 second - } - + private fun createMultipleFileCallback() = object : MultipleFileConflictCallback(uiScope) { override fun onParentConflict( destinationParentFolder: DocumentFile, conflictedFolders: MutableList, @@ -334,23 +379,11 @@ class MainActivity : AppCompatActivity() { override fun onContentConflict( destinationParentFolder: DocumentFile, - conflictedFiles: MutableList, - action: FolderCallback.FolderContentConflictAction + conflictedFiles: MutableList, + action: FolderConflictCallback.FolderContentConflictAction ) { handleFolderContentConflict(action, conflictedFiles) } - - override fun onReport(report: Report) { - Timber.d("onReport() -> ${report.progress.toInt()}% | $mode ${report.fileCount} files") - } - - override fun onCompleted(result: Result) { - Toast.makeText(baseContext, "$mode ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() - } - - override fun onFailed(errorCode: ErrorCode) { - Toast.makeText(baseContext, "An error has occurred: $errorCode", Toast.LENGTH_SHORT).show() - } } private fun setupFolderCopy() { @@ -373,7 +406,30 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.copyFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback(false)) + folder.copyFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled + } + }.collect { result -> + when (result) { + is FolderResult.Validating -> Timber.d("Validating...") + is FolderResult.Preparing -> Timber.d("Preparing...") + is FolderResult.CountingFiles -> Timber.d("Counting files...") + is FolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is FolderResult.Starting -> Timber.d("Starting...") + is FolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is FolderResult.Completed -> { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is FolderResult.Error -> { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -398,26 +454,35 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.moveFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback(true)) + folder.moveFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled + } + }.collect { result -> + when (result) { + is FolderResult.Validating -> Timber.d("Validating...") + is FolderResult.Preparing -> Timber.d("Preparing...") + is FolderResult.CountingFiles -> Timber.d("Counting files...") + is FolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is FolderResult.Starting -> Timber.d("Starting...") + is FolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is FolderResult.Completed -> { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is FolderResult.Error -> { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } - private fun createFolderCallback(isMoveFileMode: Boolean) = object : FolderCallback(uiScope) { - val mode = if (isMoveFileMode) "Moved" else "Copied" - - override fun onPrepare() { - // Show notification or progress bar dialog with indeterminate state - } - - override fun onCountingFiles() { - // Inform user that the app is counting & calculating files - } - - override fun onStart(folder: DocumentFile, totalFilesToCopy: Int, workerThread: Thread): Long { - return 1000 // update progress every 1 second - } - + private fun createFolderCallback() = object : FolderConflictCallback(uiScope) { override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { handleParentFolderConflict(destinationFolder, action, canMerge) } @@ -429,18 +494,6 @@ class MainActivity : AppCompatActivity() { ) { handleFolderContentConflict(action, conflictedFiles) } - - override fun onReport(report: Report) { - Timber.d("onReport() -> ${report.progress.toInt()}% | $mode ${report.fileCount} files") - } - - override fun onCompleted(result: Result) { - Toast.makeText(baseContext, "$mode ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() - } - - override fun onFailed(errorCode: ErrorCode) { - Toast.makeText(baseContext, "An error has occurred: $errorCode", Toast.LENGTH_SHORT).show() - } } private fun setupFileCopy() { @@ -464,6 +517,29 @@ class MainActivity : AppCompatActivity() { Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { file.copyFileTo(applicationContext, targetFolder, callback = createFileCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled + } + }.collect { + when (it) { + is SingleFileResult.Validating -> Timber.d("Validating...") + is SingleFileResult.Preparing -> Timber.d("Preparing...") + is SingleFileResult.CountingFiles -> Timber.d("Counting files...") + is SingleFileResult.DeletingConflictedFile -> Timber.d("Deleting conflicted file...") + is SingleFileResult.Starting -> Timber.d("Starting...") + is SingleFileResult.InProgress -> Timber.d("Progress: ${it.progress.toInt()}%") + is SingleFileResult.Completed -> { + Timber.d("Completed") + Toast.makeText(baseContext, "Copied successfully", Toast.LENGTH_SHORT).show() + } + + is SingleFileResult.Error -> { + Timber.e(it.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${it.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -488,67 +564,76 @@ class MainActivity : AppCompatActivity() { val targetFolder = binding.layoutMoveFileTargetFolder.tvFilePath.tag as DocumentFile Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - file.moveFileTo(applicationContext, targetFolder, callback = createFileCallback()) - } - } - } + var dialog: MaterialDialog? = null + var tvStatus: TextView? = null + var progressBar: ProgressBar? = null - private fun createFileCallback() = object : FileCallback(uiScope) { - - var dialog: MaterialDialog? = null - var tvStatus: TextView? = null - var progressBar: ProgressBar? = null - - override fun onConflict(destinationFile: DocumentFile, action: FileConflictAction) { - handleFileConflict(action) - } - - override fun onStart(file: Any, workerThread: Thread): Long { - // only show dialog if file size greater than 10Mb - if ((file as DocumentFile).length() > 10 * FileSize.MB) { - dialog = MaterialDialog(this@MainActivity) - .cancelable(false) - .positiveButton(android.R.string.cancel) { workerThread.interrupt() } - .customView(R.layout.dialog_copy_progress).apply { - tvStatus = getCustomView().findViewById(R.id.tvProgressStatus).apply { - text = "Copying file: 0%" + file.moveFileTo(applicationContext, targetFolder, callback = createFileCallback()) + .onCompletion { + if (it is CancellationException) { + // maybe you want to show to the user that the operation was cancelled } - - progressBar = getCustomView().findViewById(R.id.progressCopy).apply { - isIndeterminate = true + dialog?.dismiss() + dialog = null + }.collect { result -> + when (result) { + is SingleFileResult.Validating -> Timber.d("Validating...") + is SingleFileResult.Preparing -> Timber.d("Preparing...") + is SingleFileResult.CountingFiles -> Timber.d("Counting files...") + is SingleFileResult.DeletingConflictedFile -> Timber.d("Deleting conflicted file...") + is SingleFileResult.Starting -> Timber.d("Starting...") + is SingleFileResult.InProgress -> uiScope.launch { + Timber.d("Progress: ${result.progress.toInt()}%") + if (dialog == null) { + dialog = MaterialDialog(this@MainActivity) + .cancelable(false) + .positiveButton(android.R.string.cancel) { cancel() } + .customView(R.layout.dialog_copy_progress).apply { + tvStatus = getCustomView().findViewById(R.id.tvProgressStatus).apply { + text = "Copying file: 0%" + } + + progressBar = getCustomView().findViewById(R.id.progressCopy).apply { + isIndeterminate = true + } + show() + } + } + tvStatus?.text = "Copying file: ${result.progress.toInt()}%" + progressBar?.isIndeterminate = false + progressBar?.progress = result.progress.toInt() + } + + is SingleFileResult.Completed -> { + Timber.d("Completed") + Toast.makeText(baseContext, "Moved successfully", Toast.LENGTH_SHORT).show() + } + + is SingleFileResult.Error -> { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } } - show() } } - return 500 // 0.5 second - } - - override fun onReport(report: Report) { - tvStatus?.text = "Copying file: ${report.progress.toInt()}%" - progressBar?.isIndeterminate = false - progressBar?.progress = report.progress.toInt() - } - - override fun onFailed(errorCode: ErrorCode) { - dialog?.dismiss() - Toast.makeText(baseContext, "Failed copying file: $errorCode", Toast.LENGTH_SHORT).show() } + } - override fun onCompleted(result: Any) { - dialog?.dismiss() - Toast.makeText(baseContext, "File copied successfully", Toast.LENGTH_SHORT).show() + private fun createFileCallback() = object : SingleFileConflictCallback(uiScope) { + override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { + handleFileConflict(action) } } - private fun handleFileConflict(action: FileCallback.FileConflictAction) { + private fun handleFileConflict(action: SingleFileConflictCallback.FileConflictAction) { MaterialDialog(this) .cancelable(false) .title(text = "Conflict Found") .message(text = "What do you want to do with the file already exists in destination?") .listItems(items = listOf("Replace", "Create New", "Skip Duplicate")) { _, index, _ -> - val resolution = FileCallback.ConflictResolution.values()[index] + val resolution = SingleFileConflictCallback.ConflictResolution.entries[index] action.confirmResolution(resolution) - if (resolution == FileCallback.ConflictResolution.SKIP) { + if (resolution == SingleFileConflictCallback.ConflictResolution.SKIP) { Toast.makeText(this, "Skipped duplicate file", Toast.LENGTH_SHORT).show() } } @@ -556,19 +641,19 @@ class MainActivity : AppCompatActivity() { } private fun handleParentFolderConflict( - conflictedFolders: MutableList, - conflictedFiles: MutableList, - action: MultipleFileCallback.ParentFolderConflictAction + conflictedFolders: MutableList, + conflictedFiles: MutableList, + action: MultipleFileConflictCallback.ParentFolderConflictAction ) { - val newSolution = ArrayList(conflictedFiles.size) + val newSolution = ArrayList(conflictedFiles.size) askFolderSolution(action, conflictedFolders, conflictedFiles, newSolution) } private fun askFolderSolution( - action: MultipleFileCallback.ParentFolderConflictAction, - conflictedFolders: MutableList, - conflictedFiles: MutableList, - newSolution: MutableList + action: MultipleFileConflictCallback.ParentFolderConflictAction, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFolders.removeFirstOrNull() if (currentSolution == null) { @@ -583,7 +668,7 @@ class MainActivity : AppCompatActivity() { .message(text = "Folder \"${currentSolution.target.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = mutableListOf("Replace", "Merge", "Create New", "Skip Duplicate").apply { if (!canMerge) remove("Merge") }) { _, index, _ -> - currentSolution.solution = FolderCallback.ConflictResolution.values()[if (!canMerge && index > 0) index + 1 else index] + currentSolution.solution = FolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFolders.forEach { it.solution = currentSolution.solution } @@ -597,10 +682,10 @@ class MainActivity : AppCompatActivity() { } private fun askFileSolution( - action: MultipleFileCallback.ParentFolderConflictAction, - conflictedFolders: MutableList, - conflictedFiles: MutableList, - newSolution: MutableList + action: MultipleFileConflictCallback.ParentFolderConflictAction, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFiles.removeFirstOrNull() if (currentSolution == null) { @@ -614,7 +699,7 @@ class MainActivity : AppCompatActivity() { .message(text = "File \"${currentSolution.target.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = mutableListOf("Replace", "Create New", "Skip Duplicate")) { _, index, _ -> - currentSolution.solution = FolderCallback.ConflictResolution.values()[if (index > 0) index + 1 else index] + currentSolution.solution = FolderConflictCallback.ConflictResolution.entries[if (index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -627,30 +712,33 @@ class MainActivity : AppCompatActivity() { .show() } - private fun handleParentFolderConflict(destinationFolder: DocumentFile, action: FolderCallback.ParentFolderConflictAction, canMerge: Boolean) { + private fun handleParentFolderConflict(destinationFolder: DocumentFile, action: FolderConflictCallback.ParentFolderConflictAction, canMerge: Boolean) { MaterialDialog(this) .cancelable(false) .title(text = "Conflict Found") .message(text = "Folder \"${destinationFolder.name}\" already exists in destination. What's your action?") .listItems(items = mutableListOf("Replace", "Merge", "Create New", "Skip Duplicate").apply { if (!canMerge) remove("Merge") }) { _, index, _ -> - val resolution = FolderCallback.ConflictResolution.values()[if (!canMerge && index > 0) index + 1 else index] + val resolution = FolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] action.confirmResolution(resolution) - if (resolution == FolderCallback.ConflictResolution.SKIP) { + if (resolution == FolderConflictCallback.ConflictResolution.SKIP) { Toast.makeText(this, "Skipped duplicate folders & files", Toast.LENGTH_SHORT).show() } } .show() } - private fun handleFolderContentConflict(action: FolderCallback.FolderContentConflictAction, conflictedFiles: MutableList) { - val newSolution = ArrayList(conflictedFiles.size) + private fun handleFolderContentConflict( + action: FolderConflictCallback.FolderContentConflictAction, + conflictedFiles: MutableList + ) { + val newSolution = ArrayList(conflictedFiles.size) askSolution(action, conflictedFiles, newSolution) } private fun askSolution( - action: FolderCallback.FolderContentConflictAction, - conflictedFiles: MutableList, - newSolution: MutableList + action: FolderConflictCallback.FolderContentConflictAction, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFiles.removeFirstOrNull() if (currentSolution == null) { @@ -664,7 +752,7 @@ class MainActivity : AppCompatActivity() { .message(text = "File \"${currentSolution.target.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = listOf("Replace", "Create New", "Skip")) { _, index, _ -> - currentSolution.solution = FileCallback.ConflictResolution.values()[index] + currentSolution.solution = SingleFileConflictCallback.ConflictResolution.entries[index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -759,7 +847,6 @@ class MainActivity : AppCompatActivity() { thread { file.openOutputStream(context)?.use { try { - @Suppress("BlockingMethodInNonBlockingContext") it.write("Welcome to SimpleStorage!\nRequest code: $requestCode\nTime: ${System.currentTimeMillis()}".toByteArray()) launchOnUiThread { Toast.makeText(context, "Successfully created file \"${file.name}\"", Toast.LENGTH_SHORT).show() } } catch (e: IOException) { diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java index b2bfbbd..6b67230 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java @@ -1,5 +1,15 @@ package com.anggrayudi.storage.sample.fragment; +import com.anggrayudi.storage.SimpleStorageHelper; +import com.anggrayudi.storage.callback.SingleFileConflictCallback; +import com.anggrayudi.storage.file.DocumentFileCompat; +import com.anggrayudi.storage.file.DocumentFileType; +import com.anggrayudi.storage.file.DocumentFileUtils; +import com.anggrayudi.storage.file.PublicDirectory; +import com.anggrayudi.storage.media.FileDescription; +import com.anggrayudi.storage.media.MediaFile; +import com.anggrayudi.storage.sample.R; + import android.content.Context; import android.content.SharedPreferences; import android.net.Uri; @@ -13,17 +23,6 @@ import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; - -import com.anggrayudi.storage.SimpleStorageHelper; -import com.anggrayudi.storage.callback.FileCallback; -import com.anggrayudi.storage.file.DocumentFileCompat; -import com.anggrayudi.storage.file.DocumentFileType; -import com.anggrayudi.storage.file.DocumentFileUtils; -import com.anggrayudi.storage.file.PublicDirectory; -import com.anggrayudi.storage.media.FileDescription; -import com.anggrayudi.storage.media.MediaFile; -import com.anggrayudi.storage.sample.R; - import timber.log.Timber; /** @@ -82,8 +81,8 @@ private void moveFileToSaveLocation(@NonNull DocumentFile sourceFile) { } } - private FileCallback createCallback() { - return new FileCallback() { + private SingleFileConflictCallback createCallback() { + return new SingleFileConflictCallback() { @Override public void onReport(Report report) { Timber.d("Progress: %s", report.getProgress()); diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt deleted file mode 100644 index 4cf9888..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.anggrayudi.storage.callback - -import androidx.annotation.RestrictTo -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread -import com.anggrayudi.storage.file.FileSize -import kotlinx.coroutines.CoroutineScope - -/** - * Created on 02/06/21 - * @author Anggrayudi H - */ -abstract class BaseFileCallback -@RestrictTo(RestrictTo.Scope.LIBRARY) constructor(var uiScope: CoroutineScope) { - - @UiThread - open fun onValidate() { - // default implementation - } - - @UiThread - open fun onPrepare() { - // default implementation - } - - /** - * Called after the user chooses [FolderCallback.ConflictResolution.REPLACE] or [FileCallback.ConflictResolution.REPLACE] - */ - @UiThread - open fun onDeleteConflictedFiles() { - // default implementation - } - - /** - * Given `freeSpace` and `fileSize`, then you decide whether the process will be continued or not. - * You can give space tolerant here, e.g. 100MB - * - * @param freeSpace of target path - * @return `true` to continue process - */ - @WorkerThread - open fun onCheckFreeSpace(freeSpace: Long, fileSize: Long): Boolean { - return fileSize + 100 * FileSize.MB < freeSpace // Give tolerant 100MB - } - - @UiThread - open fun onReport(report: Report) { - // default implementation - } - - @UiThread - open fun onCompleted(result: Result) { - // default implementation - } - - @UiThread - open fun onFailed(errorCode: ErrorCode) { - // default implementation - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt deleted file mode 100644 index d9002e9..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.anggrayudi.storage.callback - -import androidx.annotation.UiThread -import com.anggrayudi.storage.callback.FileCallback.ConflictResolution -import com.anggrayudi.storage.callback.FileCallback.FileConflictAction -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope - -/** - * @author Anggrayudi Hardiannico A. - */ -abstract class FileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - var uiScope: CoroutineScope = GlobalScope -) { - - /** - * Do not call `super` when you override this function. - * - * The thread that does copy/move/decompress will be suspended until the user gives an answer via [FileConflictAction.confirmResolution]. - * You have to give an answer, or the thread will be alive until the app is killed and end up as a zombie thread. - * If you want to cancel, just pass [ConflictResolution.SKIP] into [FileConflictAction.confirmResolution]. - * If the worker thread is suspended for too long, it may be interrupted by the system. - */ - @UiThread - open fun onFileConflict(destinationFile: T, action: FileConflictAction) { - action.confirmResolution(ConflictResolution.CREATE_NEW) - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/FolderConflictCallback.kt similarity index 54% rename from storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt rename to storage/src/main/java/com/anggrayudi/storage/callback/FolderConflictCallback.kt index e630ccc..12aa5a7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/FolderConflictCallback.kt @@ -3,7 +3,7 @@ package com.anggrayudi.storage.callback import androidx.annotation.RestrictTo import androidx.annotation.UiThread import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback.FileConflictAction +import com.anggrayudi.storage.callback.SingleFileConflictCallback.FileConflictAction import com.anggrayudi.storage.file.CreateMode import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineScope @@ -14,22 +14,9 @@ import kotlinx.coroutines.GlobalScope * Created on 3/1/21 * @author Anggrayudi H */ -abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - uiScope: CoroutineScope = GlobalScope -) : BaseFileCallback(uiScope) { - - @UiThread - open fun onCountingFiles() { - // default implementation - } - - /** - * @param folder directory to be copied/moved - * @return Time interval to watch folder copy/move progress in milliseconds, otherwise `0` if you don't want to watch at all. - * Setting negative value will cancel the operation. - */ - @UiThread - open fun onStart(folder: DocumentFile, totalFilesToCopy: Int, workerThread: Thread): Long = 0 +abstract class FolderConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( + var uiScope: CoroutineScope = GlobalScope +) { /** * Do not call `super` when you override this function. @@ -56,14 +43,14 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads class ParentFolderConflictAction(private val continuation: CancellableContinuation) { fun confirmResolution(resolution: ConflictResolution) { - continuation.resumeWith(kotlin.Result.success(resolution)) + continuation.resumeWith(Result.success(resolution)) } } class FolderContentConflictAction(private val continuation: CancellableContinuation>) { fun confirmResolution(resolutions: List) { - continuation.resumeWith(kotlin.Result.success(resolutions)) + continuation.resumeWith(Result.success(resolutions)) } } @@ -98,46 +85,15 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads @RestrictTo(RestrictTo.Scope.LIBRARY) fun toFileConflictResolution() = when (this) { - REPLACE -> FileCallback.ConflictResolution.REPLACE - CREATE_NEW -> FileCallback.ConflictResolution.CREATE_NEW - else -> FileCallback.ConflictResolution.SKIP + REPLACE -> SingleFileConflictCallback.ConflictResolution.REPLACE + CREATE_NEW -> SingleFileConflictCallback.ConflictResolution.CREATE_NEW + else -> SingleFileConflictCallback.ConflictResolution.SKIP } } class FileConflict( val source: DocumentFile, val target: DocumentFile, - var solution: FileCallback.ConflictResolution = FileCallback.ConflictResolution.CREATE_NEW + var solution: SingleFileConflictCallback.ConflictResolution = SingleFileConflictCallback.ConflictResolution.CREATE_NEW ) - - enum class ErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - SOURCE_FOLDER_NOT_FOUND, - SOURCE_FILE_NOT_FOUND, - INVALID_TARGET_FOLDER, - UNKNOWN_IO_ERROR, - CANCELED, - TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, - NO_SPACE_LEFT_ON_TARGET_PATH - } - - /** - * Only called if the returned [onStart] greater than `0` - * - * @param progress in percent - * @param writeSpeed in bytes - * @param fileCount total files/folders that are successfully copied/moved - */ - class Report(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) - - /** - * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] - * [BaseFileCallback.onFailed] can be called before [BaseFileCallback.onCompleted] when an error has occurred. - * @param folder newly moved/copied file - * @param success `true` if the process is not canceled and no error during copy/move - * @param totalFilesToCopy total files, not folders - * @param totalCopiedFiles total files, not folders - */ - class Result(val folder: DocumentFile, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt deleted file mode 100644 index 33ac178..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.anggrayudi.storage.callback - -import androidx.annotation.UiThread -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback.FileConflictAction -import com.anggrayudi.storage.callback.FolderCallback.ConflictResolution -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope - -/** - * Created on 31/05/21 - * @author Anggrayudi H - */ -abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - uiScope: CoroutineScope = GlobalScope -) : BaseFileCallback(uiScope) { - - /** - * The reason can be one of: - * * [FolderCallback.ErrorCode.SOURCE_FILE_NOT_FOUND] - * * [FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED] - * * [FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER] - */ - @UiThread - open fun onInvalidSourceFilesFound(invalidSourceFiles: Map, action: InvalidSourceFilesAction) { - action.confirmResolution(false) - } - - @UiThread - open fun onCountingFiles() { - // default implementation - } - - /** - * @param files directories/files to be copied/moved - * @return Time interval to watch folder copy/move progress in milliseconds, otherwise `0` if you don't want to watch at all. - * Setting negative value will cancel the operation. - */ - @UiThread - open fun onStart(files: List, totalFilesToCopy: Int, workerThread: Thread): Long = 0 - - /** - * Do not call `super` when you override this function. - * - * The thread that does copy/move will be suspended until the user gives an answer via [FileConflictAction.confirmResolution]. - * You have to give an answer, or the thread will be alive until the app is killed and end up as a zombie thread. - * If you want to cancel, just pass [ConflictResolution.SKIP] into [FileConflictAction.confirmResolution]. - * If the worker thread is suspended for too long, it may be interrupted by the system. - */ - @UiThread - open fun onParentConflict( - destinationParentFolder: DocumentFile, - conflictedFolders: MutableList, - conflictedFiles: MutableList, - action: ParentFolderConflictAction - ) { - action.confirmResolution(conflictedFiles) - } - - @UiThread - open fun onContentConflict( - destinationParentFolder: DocumentFile, - conflictedFiles: MutableList, - action: FolderCallback.FolderContentConflictAction - ) { - action.confirmResolution(conflictedFiles) - } - - class InvalidSourceFilesAction(private val continuation: CancellableContinuation) { - - /** - * @param abort stop the process - */ - fun confirmResolution(abort: Boolean) { - continuation.resumeWith(kotlin.Result.success(abort)) - } - } - - class ParentFolderConflictAction(private val continuation: CancellableContinuation>) { - - fun confirmResolution(resolution: List) { - continuation.resumeWith(kotlin.Result.success(resolution)) - } - } - - class ParentConflict( - val source: DocumentFile, - val target: DocumentFile, - val canMerge: Boolean, - var solution: ConflictResolution = ConflictResolution.CREATE_NEW - ) - - enum class ErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - SOURCE_FILE_NOT_FOUND, - INVALID_TARGET_FOLDER, - UNKNOWN_IO_ERROR, - CANCELED, - NO_SPACE_LEFT_ON_TARGET_PATH - } - - /** - * Only called if the returned [onStart] greater than `0` - * - * @param progress in percent - * @param writeSpeed in bytes - * @param fileCount total files/folders that are successfully copied/moved - */ - class Report(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) - - /** - * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] - * [BaseFileCallback.onFailed] can be called before [BaseFileCallback.onCompleted] when an error has occurred. - * @param files newly moved/copied parent files/folders - * @param success `true` if the process is not canceled and no error during copy/move - * @param totalFilesToCopy total files, not folders - * @param totalCopiedFiles total files, not folders - */ - class Result(val files: List, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileConflictCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileConflictCallback.kt new file mode 100644 index 0000000..aab4d9f --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileConflictCallback.kt @@ -0,0 +1,81 @@ +package com.anggrayudi.storage.callback + +import androidx.annotation.UiThread +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.callback.FolderConflictCallback.ConflictResolution +import com.anggrayudi.storage.callback.SingleFileConflictCallback.FileConflictAction +import com.anggrayudi.storage.result.FolderErrorCode +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope + +/** + * Created on 31/05/21 + * @author Anggrayudi H + */ +abstract class MultipleFileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( + var uiScope: CoroutineScope = GlobalScope +) { + /** + * The reason can be one of: + * * [FolderErrorCode.SOURCE_FILE_NOT_FOUND] + * * [FolderErrorCode.STORAGE_PERMISSION_DENIED] + * * [FolderErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER] + */ + @UiThread + open fun onInvalidSourceFilesFound(invalidSourceFiles: Map, action: InvalidSourceFilesAction) { + action.confirmResolution(false) + } + + /** + * Do not call `super` when you override this function. + * + * The thread that does copy/move will be suspended until the user gives an answer via [FileConflictAction.confirmResolution]. + * You have to give an answer, or the thread will be alive until the app is killed and end up as a zombie thread. + * If you want to cancel, just pass [ConflictResolution.SKIP] into [FileConflictAction.confirmResolution]. + * If the worker thread is suspended for too long, it may be interrupted by the system. + */ + @UiThread + open fun onParentConflict( + destinationParentFolder: DocumentFile, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + action: ParentFolderConflictAction + ) { + action.confirmResolution(conflictedFiles) + } + + @UiThread + open fun onContentConflict( + destinationParentFolder: DocumentFile, + conflictedFiles: MutableList, + action: FolderConflictCallback.FolderContentConflictAction + ) { + action.confirmResolution(conflictedFiles) + } + + class InvalidSourceFilesAction(private val continuation: CancellableContinuation) { + + /** + * @param abort stop the process + */ + fun confirmResolution(abort: Boolean) { + continuation.resumeWith(Result.success(abort)) + } + } + + class ParentFolderConflictAction(private val continuation: CancellableContinuation>) { + + fun confirmResolution(resolution: List) { + continuation.resumeWith(Result.success(resolution)) + } + } + + class ParentConflict( + val source: DocumentFile, + val target: DocumentFile, + val canMerge: Boolean, + var solution: ConflictResolution = ConflictResolution.CREATE_NEW + ) +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt similarity index 58% rename from storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt rename to storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt index b336d78..500e586 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt @@ -4,7 +4,6 @@ import androidx.annotation.RestrictTo import androidx.annotation.UiThread import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.CreateMode -import com.anggrayudi.storage.media.MediaFile import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -14,17 +13,9 @@ import kotlinx.coroutines.GlobalScope * Created on 17/08/20 * @author Anggrayudi H */ -abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - uiScope: CoroutineScope = GlobalScope -) : BaseFileCallback(uiScope) { - - /** - * @param file can be [DocumentFile] or [MediaFile] - * @return Time interval to watch file copy/move progress in milliseconds, otherwise `0` if you don't want to watch at all. - * Setting negative value will cancel the operation. - */ - @UiThread - open fun onStart(file: Any, workerThread: Thread): Long = 0 +abstract class SingleFileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( + var uiScope: CoroutineScope = GlobalScope +) { /** * Do not call `super` when you override this function. @@ -33,20 +24,14 @@ abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads c * You have to give an answer, or the thread will be alive until the app is killed and end up as a zombie thread. * If you want to cancel, just pass [ConflictResolution.SKIP] into [FileConflictAction.confirmResolution]. * If the worker thread is suspended for too long, it may be interrupted by the system. + * + * @param destinationFile can be [DocumentFile] or [java.io.File] */ @UiThread - open fun onConflict(destinationFile: DocumentFile, action: FileConflictAction) { + open fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { action.confirmResolution(ConflictResolution.CREATE_NEW) } - /** - * @param result can be [DocumentFile] or [MediaFile] - */ - @UiThread - override fun onCompleted(result: Any) { - // default implementation - } - class FileConflictAction(private val continuation: CancellableContinuation) { fun confirmResolution(resolution: ConflictResolution) { @@ -77,24 +62,4 @@ abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads c SKIP -> if (allowReuseFile) CreateMode.REUSE else CreateMode.CREATE_NEW } } - - enum class ErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - SOURCE_FILE_NOT_FOUND, - TARGET_FILE_NOT_FOUND, - TARGET_FOLDER_NOT_FOUND, - UNKNOWN_IO_ERROR, - CANCELED, - TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, - NO_SPACE_LEFT_ON_TARGET_PATH - } - - /** - * Only called if the returned [onStart] greater than `0` - * - * @param progress in percent - * @param writeSpeed in bytes - */ - class Report(val progress: Float, val bytesMoved: Long, val writeSpeed: Int) } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt deleted file mode 100644 index f4f7d21..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.anggrayudi.storage.callback - -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.file.FileSize -import com.anggrayudi.storage.media.MediaFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope - -/** - * Created on 02/01/22 - * @author Anggrayudi H - */ -abstract class ZipCompressionCallback @OptIn(DelicateCoroutinesApi::class) -@JvmOverloads constructor(var uiScope: CoroutineScope = GlobalScope) { - - @UiThread - open fun onCountingFiles() { - // default implementation - } - - /** - * @param files files to be compressed. Can be `List` or `List` - * @param workerThread Use [Thread.interrupt] to cancel the operation - * @return Time interval to watch the progress in milliseconds, otherwise `0` if you don't want to watch at all. - * Setting negative value will cancel the operation. - */ - @UiThread - open fun onStart(files: List, workerThread: Thread): Long = 0 - - /** - * Given `freeSpace` and `fileSize`, then you decide whether the process will be continued or not. - * You can give space tolerant here, e.g. 100MB. - * This function will not be triggered when compressing [MediaFile]. - * - * @param freeSpace of target path - * @return `true` to continue process - */ - @WorkerThread - open fun onCheckFreeSpace(freeSpace: Long, fileSize: Long): Boolean { - return fileSize + 100 * FileSize.MB < freeSpace // Give tolerant 100MB - } - - @UiThread - open fun onReport(report: Report) { - // default implementation - } - - @UiThread - open fun onDeleteEntryFiles() { - // default implementation - } - - /** - * @param compressionRate size reduction in percent, e.g. 23.5 - */ - @UiThread - open fun onCompleted(zipFile: DocumentFile, bytesCompressed: Long, totalFilesCompressed: Int, compressionRate: Float) { - // default implementation - } - - @UiThread - open fun onFailed(errorCode: ErrorCode, message: String? = null) { - // default implementation - } - - /** - * @param progress always `0` when compressing [MediaFile] - */ - class Report(val progress: Float, val bytesCompressed: Long, val writeSpeed: Int, val fileCount: Int) - - enum class ErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - MISSING_ENTRY_FILE, - DUPLICATE_ENTRY_FILE, - UNKNOWN_IO_ERROR, - CANCELED, - NO_SPACE_LEFT_ON_TARGET_PATH - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt deleted file mode 100644 index 96cd853..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.anggrayudi.storage.callback - -import androidx.annotation.UiThread -import androidx.annotation.WorkerThread -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.file.FileSize -import com.anggrayudi.storage.media.MediaFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope - -/** - * Created on 02/01/22 - * @author Anggrayudi H - */ -abstract class ZipDecompressionCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - uiScope: CoroutineScope = GlobalScope -) : FileConflictCallback(uiScope) { - - @UiThread - open fun onValidate() { - // default implementation - } - - /** - * @param zipFile files to be decompressed. Can be [DocumentFile] or [MediaFile] - * @param workerThread Use [Thread.interrupt] to cancel the operation - * @return Time interval to watch the progress in milliseconds, otherwise `0` if you don't want to watch at all. - * Setting negative value will cancel the operation. - */ - @UiThread - open fun onStart(zipFile: T, workerThread: Thread): Long = 0 - - /** - * Given `freeSpace` and `fileSize`, then you decide whether the process will be continued or not. - * You can give space tolerant here, e.g. 100MB. - * This function will not be triggered when decompressing [MediaFile]. - * - * @param freeSpace of target path - * @return `true` to continue process - */ - @WorkerThread - open fun onCheckFreeSpace(freeSpace: Long, zipFileSize: Long): Boolean { - // Give tolerant 100MB - // Estimate the final size of decompressed files is increased by 20% - return zipFileSize * 1.2 + 100 * FileSize.MB < freeSpace - } - - @UiThread - open fun onReport(report: Report) { - // default implementation - } - - /** - * @param zipFile can be [DocumentFile] or [MediaFile] - * But for decompressing [MediaFile], it is always `0` because we can't get the actual zip file size from SAF database. - */ - @UiThread - open fun onCompleted(zipFile: T, targetFolder: DocumentFile, decompressionInfo: DecompressionInfo) { - // default implementation - } - - @UiThread - open fun onFailed(errorCode: ErrorCode) { - // default implementation - } - - /** - * Can't calculate write speed, progress and decompressed file size for the given period [onStart], - * because we can't get the final size of the decompressed files unless we unzip it first, - * so only `bytesDecompressed` and `fileCount` that can be provided. - * @param fileCount decompressed files in total - */ - class Report(val bytesDecompressed: Long, val writeSpeed: Int, val fileCount: Int) - - /** - * @param decompressionRate size expansion in percent, e.g. 23.5. - * @param skippedDecompressedBytes total skipped bytes because the file already exists and the user has selected [FileCallback.ConflictResolution.SKIP] - * @param bytesDecompressed total decompressed bytes, excluded skipped files - */ - class DecompressionInfo(val bytesDecompressed: Long, val skippedDecompressedBytes: Long, val totalFilesDecompressed: Int, val decompressionRate: Float) - - enum class ErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - MISSING_ZIP_FILE, - NOT_A_ZIP_FILE, - UNKNOWN_IO_ERROR, - CANCELED, - NO_SPACE_LEFT_ON_TARGET_PATH - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index 36c3cd5..bb1420e 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -18,14 +18,9 @@ import androidx.core.content.FileProvider import androidx.core.content.MimeTypeFilter import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.callback.BaseFileCallback -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.callback.FileConflictCallback -import com.anggrayudi.storage.callback.FolderCallback -import com.anggrayudi.storage.callback.MultipleFileCallback -import com.anggrayudi.storage.callback.ZipCompressionCallback -import com.anggrayudi.storage.callback.ZipDecompressionCallback -import com.anggrayudi.storage.extension.awaitUiResult +import com.anggrayudi.storage.callback.FolderConflictCallback +import com.anggrayudi.storage.callback.MultipleFileConflictCallback +import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.extension.awaitUiResultWithPending import com.anggrayudi.storage.extension.childOf import com.anggrayudi.storage.extension.closeEntryQuietly @@ -43,7 +38,6 @@ import com.anggrayudi.storage.extension.isTreeDocumentFile import com.anggrayudi.storage.extension.openInputStream import com.anggrayudi.storage.extension.openOutputStream import com.anggrayudi.storage.extension.parent -import com.anggrayudi.storage.extension.postToUi import com.anggrayudi.storage.extension.startCoroutineTimer import com.anggrayudi.storage.extension.toDocumentFile import com.anggrayudi.storage.extension.trimFileSeparator @@ -53,7 +47,23 @@ import com.anggrayudi.storage.file.StorageId.PRIMARY import com.anggrayudi.storage.media.FileDescription import com.anggrayudi.storage.media.MediaFile import com.anggrayudi.storage.media.MediaStoreCompat +import com.anggrayudi.storage.result.FileProperties +import com.anggrayudi.storage.result.FilePropertiesResult +import com.anggrayudi.storage.result.FolderErrorCode +import com.anggrayudi.storage.result.FolderResult +import com.anggrayudi.storage.result.MultipleFilesErrorCode +import com.anggrayudi.storage.result.MultipleFilesResult +import com.anggrayudi.storage.result.SingleFileErrorCode +import com.anggrayudi.storage.result.SingleFileResult +import com.anggrayudi.storage.result.ZipCompressionErrorCode +import com.anggrayudi.storage.result.ZipCompressionResult +import com.anggrayudi.storage.result.ZipDecompressionErrorCode +import com.anggrayudi.storage.result.ZipDecompressionResult +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -65,6 +75,10 @@ import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +typealias CheckFileSize = (freeSpace: Long, fileSize: Long) -> Boolean + +internal val defaultFileSizeChecker: CheckFileSize = { freeSpace, fileSize -> fileSize + 100 * FileSize.MB < freeSpace /* 100MB tolerance */ } + /** * Created on 16/08/20 * @author Anggrayudi H @@ -140,12 +154,34 @@ fun DocumentFile.isEmpty(context: Context): Boolean { /** * Similar to Get Info on MacOS or File Properties in Windows. - * Use [Thread.interrupt] to cancel the proccess and it will trigger [FileProperties.CalculationCallback.onCanceled] + * Example: + * ``` + * val job = ioScope.launch { + * getProperties(context) + * .onCompletion { + * if (it is CancellationException) { + * // update UI + * } + * } + * .collect { result -> + * when (result) { + * is FilePropertiesResult.OnUpdate -> // do something + * is FilePropertiesResult.OnComplete -> // do something + * is FilePropertiesResult.OnError -> // do something + * } + * } + * } + * // call this if you want to stop in the middle of process + * job.cancel() + * ``` */ @WorkerThread -fun DocumentFile.getProperties(context: Context, callback: FileProperties.CalculationCallback) { +fun DocumentFile.getProperties( + context: Context, + updateInterval: Long = 500 +): Flow = callbackFlow { when { - !canRead() -> callback.uiScope.postToUi { callback.onError() } + !canRead() -> send(FilePropertiesResult.Error) isDirectory -> { val properties = FileProperties( @@ -156,23 +192,14 @@ fun DocumentFile.getProperties(context: Context, callback: FileProperties.Calcul lastModified = lastModified().let { if (it > 0) Date(it) else null } ) if (isEmpty(context)) { - callback.uiScope.postToUi { callback.onComplete(properties) } + send(FilePropertiesResult.Completed(properties)) } else { - val timer = if (callback.updateInterval < 1) null else startCoroutineTimer(repeatMillis = callback.updateInterval) { - callback.uiScope.postToUi { callback.onUpdate(properties) } + val timer = if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { + trySend(FilePropertiesResult.Updating(properties)) } - val thread = Thread.currentThread() - walkFileTreeForInfo(properties, thread) + walkFileTreeForInfo(properties, this) timer?.cancel() - // need to store isInterrupted in a variable, because calling it from UI thread always returns false - val interrupted = thread.isInterrupted - callback.uiScope.postToUi { - if (interrupted) { - callback.onCanceled(properties) - } else { - callback.onComplete(properties) - } - } + send(FilePropertiesResult.Completed(properties)) } } @@ -184,19 +211,20 @@ fun DocumentFile.getProperties(context: Context, callback: FileProperties.Calcul isVirtual = isVirtual, lastModified = lastModified().let { if (it > 0) Date(it) else null } ) - callback.uiScope.postToUi { callback.onComplete(properties) } + send(FilePropertiesResult.Completed(properties)) } } } -private fun DocumentFile.walkFileTreeForInfo(properties: FileProperties, thread: Thread) { +@OptIn(DelicateCoroutinesApi::class) +private fun DocumentFile.walkFileTreeForInfo(properties: FileProperties, scope: ProducerScope) { val list = listFiles() if (list.isEmpty()) { properties.emptyFolders++ return } list.forEach { - if (thread.isInterrupted) { + if (scope.isClosedForSend) { return } if (it.isFile) { @@ -206,7 +234,7 @@ private fun DocumentFile.walkFileTreeForInfo(properties: FileProperties, thread: if (size == 0L) properties.emptyFiles++ } else { properties.folders++ - it.walkFileTreeForInfo(properties, thread) + it.walkFileTreeForInfo(properties, scope) } } } @@ -436,7 +464,6 @@ fun DocumentFile.checkRequirements(context: Context, requiresWriteAccess: Boolea * * It is not a raw file and the authority is neither [DocumentFileCompat.EXTERNAL_STORAGE_AUTHORITY] nor [DocumentFileCompat.DOWNLOADS_FOLDER_AUTHORITY] * * The authority is [DocumentFileCompat.DOWNLOADS_FOLDER_AUTHORITY], but [isTreeDocumentFile] returns `false` */ -@Suppress("DEPRECATION") fun DocumentFile.getBasePath(context: Context): String { val path = uri.path.orEmpty() val storageID = getStorageId(context) @@ -548,7 +575,6 @@ fun DocumentFile.getRelativePath(context: Context) = getBasePath(context).substr * @see File.getAbsolutePath * @see getSimplePath */ -@Suppress("DEPRECATION") fun DocumentFile.getAbsolutePath(context: Context): String { val path = uri.path.orEmpty() val storageID = getStorageId(context) @@ -725,7 +751,7 @@ fun DocumentFile.makeFile( name: String, mimeType: String? = MimeType.UNKNOWN, mode: CreateMode = CreateMode.CREATE_NEW, - onConflict: FileConflictCallback? = null + onConflict: SingleFileConflictCallback? = null ): DocumentFile? { if (!isDirectory || !isWritable(context)) { return null @@ -753,7 +779,7 @@ fun DocumentFile.makeFile( parent.child(context, fullFileName)?.let { targetFile -> existingFile = targetFile createMode = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onFileConflict(targetFile, FileCallback.FileConflictAction(it)) + onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) }.toCreateMode(true) } } @@ -1134,9 +1160,10 @@ fun List.compressToZip( context: Context, targetZipFile: DocumentFile, deleteSourceWhenComplete: Boolean = false, - callback: ZipCompressionCallback -) { - callback.uiScope.postToUi { callback.onCountingFiles() } + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, +): Flow = callbackFlow { + send(ZipCompressionResult.CountingFiles) val treeFiles = ArrayList(size) val mediaFiles = ArrayList(size) var foldersBasePath = mutableListOf() @@ -1144,10 +1171,8 @@ fun List.compressToZip( for (srcFile in distinctBy { it.uri }) { if (srcFile.exists()) { if (!srcFile.canRead()) { - callback.uiScope.postToUi { - callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, "Can't read file: ${srcFile.uri}") - } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Can't read file: ${srcFile.uri}")) + return@callbackFlow } else if (srcFile.isFile) { if (srcFile.isTreeDocumentFile || srcFile.isRawFile) { treeFiles.add(srcFile) @@ -1158,8 +1183,8 @@ fun List.compressToZip( directories.add(srcFile) } } else { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, "File not found: ${srcFile.uri}") } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "File not found: ${srcFile.uri}")) + return@callbackFlow } } @@ -1192,17 +1217,17 @@ fun List.compressToZip( val totalFiles = treeFiles.size + mediaFiles.size if (totalFiles == 0) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, "No entry files found") } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "No entry files found")) + return@callbackFlow } var actualFilesSize = 0L treeFiles.forEach { actualFilesSize += it.length() } mediaFiles.forEach { actualFilesSize += it.length() } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetZipFile.getStorageId(context)), actualFilesSize)) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetZipFile.getStorageId(context)), actualFilesSize)) { + send(ZipCompressionResult.Error(ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } val entryFiles = ArrayList(totalFiles) @@ -1225,10 +1250,13 @@ fun List.compressToZip( mediaFiles.forEach { entryFiles.add(EntryFile(it, it.fullName)) } val duplicateFiles = entryFiles.groupingBy { it }.eachCount().filterValues { it > 1 } if (duplicateFiles.isNotEmpty()) { - callback.uiScope.postToUi { - callback.onFailed(ZipCompressionCallback.ErrorCode.DUPLICATE_ENTRY_FILE, "Found duplicate entry files: ${duplicateFiles.keys.map { it.file.uri }}") - } - return + send( + ZipCompressionResult.Error( + ZipCompressionErrorCode.DUPLICATE_ENTRY_FILE, + "Found duplicate entry files: ${duplicateFiles.keys.map { it.file.uri }}" + ) + ) + return@callbackFlow } var zipFile: DocumentFile? = targetZipFile @@ -1236,18 +1264,14 @@ fun List.compressToZip( zipFile = targetZipFile.findParent(context)?.makeFile(context, targetZipFile.fullName, MimeType.ZIP) } if (zipFile == null) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return@callbackFlow } if (!zipFile.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable") } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable")) + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(entryFiles.map { it.file }, thread) } - if (reportInterval < 0) return - var success = false var bytesCompressed = 0L var timer: Job? = null @@ -1257,10 +1281,9 @@ fun List.compressToZip( var writeSpeed = 0 var fileCompressedCount = 0 // using timer on small file is useless. We set minimum 10MB. - if (reportInterval > 0 && actualFilesSize > 10 * FileSize.MB) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipCompressionCallback.Report(bytesCompressed * 100f / actualFilesSize, bytesCompressed, writeSpeed, fileCompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0 && actualFilesSize > 10 * FileSize.MB) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(ZipCompressionResult.Compressing(bytesCompressed * 100f / actualFilesSize, bytesCompressed, writeSpeed, fileCompressedCount)) writeSpeed = 0 } } @@ -1280,13 +1303,13 @@ fun List.compressToZip( } success = true } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.CANCELED)) } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, e.message) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, e.message)) } catch (e: IOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR, e.message) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, e.message) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) } finally { timer?.cancel() zos.closeEntryQuietly() @@ -1294,11 +1317,11 @@ fun List.compressToZip( } if (success) { if (deleteSourceWhenComplete) { - callback.uiScope.postToUi { callback.onDeleteEntryFiles() } - forEach { it.forceDelete(context) } + send(ZipCompressionResult.DeletingEntryFiles) + forEach { it.deleteRecursively(context) } } val sizeReduction = (actualFilesSize - zipFile.length()).toFloat() / actualFilesSize * 100 - callback.uiScope.postToUi { callback.onCompleted(zipFile, actualFilesSize, totalFiles, sizeReduction) } + send(ZipCompressionResult.Completed(zipFile, actualFilesSize, totalFiles, sizeReduction)) } else { zipFile.delete() } @@ -1312,25 +1335,27 @@ fun List.compressToZip( fun DocumentFile.decompressZip( context: Context, targetFolder: DocumentFile, - callback: ZipDecompressionCallback -) { - callback.uiScope.postToUi { callback.onValidate() } + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + onConflict: SingleFileConflictCallback? = null +): Flow = callbackFlow { + send(ZipDecompressionResult.Validating) if (exists()) { if (!canRead()) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Can't read file: $uri")) + return@callbackFlow } else if (isFile) { if (type != MimeType.ZIP && name?.endsWith(".zip", ignoreCase = true) != false) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NOT_A_ZIP_FILE, "Not a ZIP file: $uri")) + return@callbackFlow } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, "ZIP file not found: $uri")) + return@callbackFlow } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, "ZIP file not found: $uri")) + return@callbackFlow } var destFolder: DocumentFile? = targetFolder @@ -1338,20 +1363,16 @@ fun DocumentFile.decompressZip( destFolder = targetFolder.findParent(context)?.makeFolder(context, targetFolder.fullName) } if (destFolder == null || !destFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination folder is not writable")) + return@callbackFlow } val zipSize = length() - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), zipSize)) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), zipSize)) { + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - if (reportInterval < 0) return - var success = false var bytesDecompressed = 0L var skippedDecompressedBytes = 0L @@ -1363,10 +1384,9 @@ fun DocumentFile.decompressZip( zis = ZipInputStream(openInputStream(context)) var writeSpeed = 0 // using timer on small file is useless. We set minimum 10MB. - if (reportInterval > 0 && zipSize > 10 * FileSize.MB) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipDecompressionCallback.Report(bytesDecompressed, writeSpeed, fileDecompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0 && zipSize > 10 * FileSize.MB) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(ZipDecompressionResult.Decompressing(bytesDecompressed, writeSpeed, fileDecompressedCount)) writeSpeed = 0 } } @@ -1381,9 +1401,9 @@ fun DocumentFile.decompressZip( if (it.isEmpty()) destFolder else destFolder.makeFolder(context, it, CreateMode.REUSE) } ?: throw IOException() val fileName = entry.name.substringAfterLast('/') - targetFile = folder.makeFile(context, fileName, onConflict = callback) + targetFile = folder.makeFile(context, fileName, onConflict = onConflict) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) canSuccess = false break } @@ -1408,17 +1428,17 @@ fun DocumentFile.decompressZip( } success = canSuccess } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANCELED)) } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, e.message)) } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) } finally { timer?.cancel() zis.closeEntryQuietly() @@ -1427,8 +1447,7 @@ fun DocumentFile.decompressZip( if (success) { // Sometimes, the decompressed size is smaller than the compressed size, and you may get negative values. You should worry about this. val sizeExpansion = (bytesDecompressed - zipSize).toFloat() / zipSize * 100 - val info = ZipDecompressionCallback.DecompressionInfo(bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, sizeExpansion) - callback.uiScope.postToUi { callback.onCompleted(this, destFolder, info) } + send(ZipDecompressionResult.Completed(this, destFolder, bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, sizeExpansion)) } else { targetFile?.delete() } @@ -1439,9 +1458,11 @@ fun List.moveTo( context: Context, targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, - callback: MultipleFileCallback -) { - copyTo(context, targetParentFolder, skipEmptyFiles, true, callback) + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: MultipleFileConflictCallback +): Flow { + return copyTo(context, targetParentFolder, skipEmptyFiles, true, updateInterval, isFileSizeAllowed, callback) } @WorkerThread @@ -1449,58 +1470,52 @@ fun List.copyTo( context: Context, targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, - callback: MultipleFileCallback -) { - copyTo(context, targetParentFolder, skipEmptyFiles, false, callback) + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: MultipleFileConflictCallback +): Flow { + return copyTo(context, targetParentFolder, skipEmptyFiles, false, updateInterval, isFileSizeAllowed, callback) } +@OptIn(DelicateCoroutinesApi::class) private fun List.copyTo( context: Context, targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, deleteSourceWhenComplete: Boolean, - callback: MultipleFileCallback -) { - val pair = doesMeetCopyRequirements(context, targetParentFolder, callback) ?: return - - callback.uiScope.postToUi { callback.onPrepare() } + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: MultipleFileConflictCallback +): Flow = callbackFlow { + send(MultipleFilesResult.Validating) + val pair = doesMeetCopyRequirements(context, targetParentFolder, this, callback) ?: return@callbackFlow + send(MultipleFilesResult.Preparing) val validSources = pair.second val writableTargetParentFolder = pair.first - val conflictResolutions = validSources.handleParentFolderConflict(context, writableTargetParentFolder, callback) ?: return - validSources.removeAll(conflictResolutions.filter { it.solution == FolderCallback.ConflictResolution.SKIP }.map { it.source }) + val conflictResolutions = validSources.handleParentFolderConflict(context, writableTargetParentFolder, this, callback) ?: return@callbackFlow + validSources.removeAll(conflictResolutions.filter { it.solution == FolderConflictCallback.ConflictResolution.SKIP }.map { it.source }) if (validSources.isEmpty()) { - return + return@callbackFlow } - callback.uiScope.postToUi { callback.onCountingFiles() } + send(MultipleFilesResult.CountingFiles) - class SourceInfo(val children: List?, val size: Long, val totalFiles: Int, val conflictResolution: FolderCallback.ConflictResolution) + class SourceInfo(val children: List, val size: Long, val totalFiles: Int, val conflictResolution: FolderConflictCallback.ConflictResolution) val sourceInfos = validSources.associateWith { src -> - val resolution = conflictResolutions.find { it.source == src }?.solution ?: FolderCallback.ConflictResolution.CREATE_NEW - if (src.isFile) { - SourceInfo(null, src.length(), 1, resolution) - } else { - val children = if (skipEmptyFiles) src.walkFileTreeAndSkipEmptyFiles() else src.walkFileTree(context) - var totalFilesToCopy = 0 - var totalSizeToCopy = 0L - children.forEach { - if (it.isFile) { - totalFilesToCopy++ - totalSizeToCopy += it.length() - } + val children = if (skipEmptyFiles) src.walkFileTreeAndSkipEmptyFiles() else src.walkFileTree(context) + var totalFilesToCopy = 0 + var totalSizeToCopy = 0L + children.forEach { + if (it.isFile) { + totalFilesToCopy++ + totalSizeToCopy += it.length() } - SourceInfo(children, totalSizeToCopy, totalFilesToCopy, resolution) } - // allow empty folders, but empty files need check - }.filterValues { it.children != null || (skipEmptyFiles && it.size > 0 || !skipEmptyFiles) }.toMutableMap() - - if (sourceInfos.isEmpty()) { - val result = MultipleFileCallback.Result(emptyList(), 0, 0, true) - callback.uiScope.postToUi { callback.onCompleted(result) } - return - } + val resolution = conflictResolutions.find { it.source == src }?.solution ?: FolderConflictCallback.ConflictResolution.CREATE_NEW + SourceInfo(children, totalSizeToCopy, totalFilesToCopy, resolution) + }.toMutableMap() // key=src, value=result val results = mutableMapOf() @@ -1519,14 +1534,14 @@ private fun List.copyTo( results[src] = result } - is FolderCallback.ErrorCode -> { + is FolderErrorCode -> { val errorCode = when (result) { - FolderCallback.ErrorCode.INVALID_TARGET_FOLDER -> MultipleFileCallback.ErrorCode.INVALID_TARGET_FOLDER - FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED -> MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED - else -> return + FolderErrorCode.INVALID_TARGET_FOLDER -> MultipleFilesErrorCode.INVALID_TARGET_FOLDER + FolderErrorCode.STORAGE_PERMISSION_DENIED -> MultipleFilesErrorCode.STORAGE_PERMISSION_DENIED + else -> return@callbackFlow } - callback.uiScope.postToUi { callback.onFailed(errorCode) } - return + send(MultipleFilesResult.Error(errorCode)) + return@callbackFlow } } } @@ -1539,33 +1554,28 @@ private fun List.copyTo( } if (sourceInfos.isEmpty()) { - val result = MultipleFileCallback.Result(results.map { it.value }, copiedFiles, copiedFiles, true) - callback.uiScope.postToUi { callback.onCompleted(result) } - return + send(MultipleFilesResult.Completed(results.map { it.value }, copiedFiles, copiedFiles, true)) + return@callbackFlow } } val totalSizeToCopy = sourceInfos.values.sumOf { it.size } - - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { + send(MultipleFilesResult.Error(MultipleFilesErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } - val thread = Thread.currentThread() - val totalFilesToCopy = sourceInfos.values.sumOf { it.totalFiles } - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(sourceInfos.map { it.key }, totalFilesToCopy, thread) } - if (reportInterval < 0) return + val totalFilesToCopy = validSources.count { it.isFile } + sourceInfos.values.sumOf { it.totalFiles } + send(MultipleFilesResult.Starting(sourceInfos.map { it.key }, totalFilesToCopy)) var totalCopiedFiles = 0 var timer: Job? = null var bytesMoved = 0L var writeSpeed = 0 val startTimer: (Boolean) -> Unit = { start -> - if (start && reportInterval > 0) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = MultipleFileCallback.Report(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles) - callback.uiScope.postToUi { callback.onReport(report) } + if (start && updateInterval > 0) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(MultipleFilesResult.InProgress(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles)) writeSpeed = 0 } } @@ -1574,60 +1584,69 @@ private fun List.copyTo( var targetFile: DocumentFile? = null var canceled = false // is required to prevent the callback from called again on next FOR iteration after the thread was interrupted - val notifyCanceled: (MultipleFileCallback.ErrorCode) -> Unit = { errorCode -> + val notifyCanceled: (MultipleFilesErrorCode) -> Unit = { errorCode -> if (!canceled) { canceled = true timer?.cancel() targetFile?.delete() - val result = MultipleFileCallback.Result(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, false) - callback.uiScope.postToUi { - callback.onFailed(errorCode) - callback.onCompleted(result) - } + trySend( + MultipleFilesResult.Error( + errorCode, + completedData = MultipleFilesResult.Completed(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, false) + ) + ) } } val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var success = true - val copy: (DocumentFile, DocumentFile) -> Unit = { sourceFile, destFile -> - createFileStreams(context, sourceFile, destFile, callback) { inputStream, outputStream -> - try { - var read = inputStream.read(buffer) - while (read != -1) { - outputStream.write(buffer, 0, read) - bytesMoved += read - writeSpeed += read - read = inputStream.read(buffer) - } - } finally { - inputStream.closeStreamQuietly() - outputStream.closeStreamQuietly() + @Suppress("BlockingMethodInNonBlockingContext") + fun copy(sourceFile: DocumentFile, destFile: DocumentFile) { + val outputStream = destFile.openOutputStream(context) + if (outputStream == null) { + trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return + } + val inputStream = sourceFile.openInputStream(context) + if (inputStream == null) { + trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.SOURCE_FILE_NOT_FOUND)) + return + } + try { + var read = inputStream.read(buffer) + while (read != -1) { + outputStream.write(buffer, 0, read) + bytesMoved += read + writeSpeed += read + read = inputStream.read(buffer) } + } finally { + inputStream.closeStreamQuietly() + outputStream.closeStreamQuietly() } totalCopiedFiles++ if (deleteSourceWhenComplete) sourceFile.delete() - } val handleError: (Exception) -> Boolean = { val errorCode = it.toMultipleFileCallbackErrorCode() - if (errorCode == MultipleFileCallback.ErrorCode.CANCELED || errorCode == MultipleFileCallback.ErrorCode.UNKNOWN_IO_ERROR) { + if (errorCode == MultipleFilesErrorCode.CANCELED || errorCode == MultipleFilesErrorCode.UNKNOWN_IO_ERROR) { notifyCanceled(errorCode) true } else { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(errorCode) } + trySend(MultipleFilesResult.Error(errorCode)) false } } - val conflictedFiles = mutableListOf() + val conflictedFiles = mutableListOf() for ((src, info) in sourceInfos) { - if (thread.isInterrupted) { - notifyCanceled(MultipleFileCallback.ErrorCode.CANCELED) - return + if (isClosedForSend) { + notifyCanceled(MultipleFilesErrorCode.CANCELED) + return@callbackFlow } val mode = info.conflictResolution.toCreateMode() val targetRootFile = writableTargetParentFolder.let { @@ -1635,12 +1654,12 @@ private fun List.copyTo( } if (targetRootFile == null) { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(MultipleFilesResult.Error(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return@callbackFlow } try { - if (targetRootFile.isFile || info.children == null) { + if (targetRootFile.isFile) { copy(src, targetRootFile) results[src] = targetRootFile continue @@ -1649,9 +1668,9 @@ private fun List.copyTo( val srcParentAbsolutePath = src.getAbsolutePath(context) for (sourceFile in info.children) { - if (thread.isInterrupted) { - notifyCanceled(MultipleFileCallback.ErrorCode.CANCELED) - return + if (isClosedForSend) { + notifyCanceled(MultipleFilesErrorCode.CANCELED) + return@callbackFlow } if (!sourceFile.exists()) { continue @@ -1671,20 +1690,20 @@ private fun List.copyTo( targetFile = targetRootFile.makeFile(context, filename, sourceFile.type, CreateMode.REUSE) if (targetFile != null && targetFile.length() > 0) { - conflictedFiles.add(FolderCallback.FileConflict(sourceFile, targetFile)) + conflictedFiles.add(FolderConflictCallback.FileConflict(sourceFile, targetFile)) continue } if (targetFile == null) { - notifyCanceled(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) - return + notifyCanceled(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET) + return@callbackFlow } copy(sourceFile, targetFile) } results[src] = targetRootFile } catch (e: Exception) { - if (handleError(e)) return + if (handleError(e)) return@callbackFlow success = false break } @@ -1694,52 +1713,51 @@ private fun List.copyTo( timer?.cancel() if (!success || conflictedFiles.isEmpty()) { if (deleteSourceWhenComplete && success) { - sourceInfos.forEach { (src, _) -> src.forceDelete(context) } + sourceInfos.forEach { (src, _) -> src.deleteRecursively(context) } } - val result = MultipleFileCallback.Result(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, success) - callback.uiScope.postToUi { callback.onCompleted(result) } + trySend(MultipleFilesResult.Completed(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, success)) true } else false } - if (finalize()) return + if (finalize()) return@callbackFlow - val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(writableTargetParentFolder, conflictedFiles, FolderCallback.FolderContentConflictAction(it)) + val solutions = awaitUiResultWithPending>(callback.uiScope) { + callback.onContentConflict(writableTargetParentFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) }.filter { // free up space first, by deleting some files - if (it.solution == FileCallback.ConflictResolution.SKIP) { + if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { if (deleteSourceWhenComplete) it.source.delete() totalCopiedFiles++ } - it.solution != FileCallback.ConflictResolution.SKIP + it.solution != SingleFileConflictCallback.ConflictResolution.SKIP } val leftoverSize = totalSizeToCopy - bytesMoved startTimer(solutions.isNotEmpty() && leftoverSize > 10 * FileSize.MB) for (conflict in solutions) { - if (thread.isInterrupted) { - notifyCanceled(MultipleFileCallback.ErrorCode.CANCELED) - return + if (isClosedForSend) { + notifyCanceled(MultipleFilesErrorCode.CANCELED) + return@callbackFlow } if (!conflict.source.isFile) { continue } val filename = conflict.target.fullName - if (conflict.solution == FileCallback.ConflictResolution.REPLACE && conflict.target.let { !it.delete() || it.exists() }) { + if (conflict.solution == SingleFileConflictCallback.ConflictResolution.REPLACE && conflict.target.let { !it.delete() || it.exists() }) { continue } targetFile = conflict.target.findParent(context)?.makeFile(context, filename) if (targetFile == null) { - notifyCanceled(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) - return + notifyCanceled(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET) + return@callbackFlow } try { copy(conflict.source, targetFile) } catch (e: Exception) { - if (handleError(e)) return + if (handleError(e)) return@callbackFlow success = false break } @@ -1751,16 +1769,15 @@ private fun List.copyTo( private fun List.doesMeetCopyRequirements( context: Context, targetParentFolder: DocumentFile, - callback: MultipleFileCallback + scope: ProducerScope, + callback: MultipleFileConflictCallback ): Pair>? { - callback.uiScope.postToUi { callback.onValidate() } - if (!targetParentFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.INVALID_TARGET_FOLDER) } + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.INVALID_TARGET_FOLDER)) return null } if (!targetParentFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.STORAGE_PERMISSION_DENIED)) return null } @@ -1768,32 +1785,32 @@ private fun List.doesMeetCopyRequirements( val sourceFiles = distinctBy { it.name } val invalidSourceFiles = sourceFiles.mapNotNull { when { - !it.exists() -> Pair(it, FolderCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) - !it.canRead() -> Pair(it, FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) + !it.exists() -> Pair(it, FolderErrorCode.SOURCE_FILE_NOT_FOUND) + !it.canRead() -> Pair(it, FolderErrorCode.STORAGE_PERMISSION_DENIED) targetParentFolderPath == it.parentFile?.getAbsolutePath(context) -> - Pair(it, FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) + Pair(it, FolderErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) else -> null } }.toMap() if (invalidSourceFiles.isNotEmpty()) { - val abort = awaitUiResultWithPending(callback.uiScope) { - callback.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFileCallback.InvalidSourceFilesAction(it)) + val abort = awaitUiResultWithPending(callback.uiScope) { + callback.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFileConflictCallback.InvalidSourceFilesAction(it)) } if (abort) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANCELED) } + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANCELED)) return null } if (invalidSourceFiles.size == size) { - callback.uiScope.postToUi { callback.onCompleted(MultipleFileCallback.Result(emptyList(), 0, 0, true)) } + scope.trySend(MultipleFilesResult.Completed(emptyList(), 0, 0, true)) return null } } val writableFolder = targetParentFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } if (writableFolder == null) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.STORAGE_PERMISSION_DENIED)) return null } @@ -1806,7 +1823,7 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( targetFolderParentName: String, skipEmptyFiles: Boolean, newFolderNameInTargetPath: String?, - conflictResolution: FolderCallback.ConflictResolution + conflictResolution: FolderConflictCallback.ConflictResolution ): Any? { if (inSameMountPointWith(context, writableTargetParentFolder)) { if (inInternalStorage(context)) { @@ -1822,7 +1839,7 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( } if (isExternalStorageManager(context)) { - val sourceFile = toRawFile(context) ?: return FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED + val sourceFile = toRawFile(context) ?: return FolderErrorCode.STORAGE_PERMISSION_DENIED writableTargetParentFolder.toRawFile(context)?.let { destinationFolder -> sourceFile.moveTo(context, destinationFolder, targetFolderParentName, conflictResolution.toFileConflictResolution())?.let { if (skipEmptyFiles) it.deleteEmptyFolders(context) @@ -1841,12 +1858,12 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( if (skipEmptyFiles) newFile.deleteEmptyFolders(context) newFile } else { - FolderCallback.ErrorCode.INVALID_TARGET_FOLDER + FolderErrorCode.INVALID_TARGET_FOLDER } } } } catch (e: Throwable) { - return FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED + return FolderErrorCode.STORAGE_PERMISSION_DENIED } } return null @@ -1858,9 +1875,11 @@ fun DocumentFile.moveFolderTo( targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, newFolderNameInTargetPath: String? = null, - callback: FolderCallback -) { - copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, callback) + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: FolderConflictCallback +): Flow { + return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, updateInterval, isFileSizeAllowed, callback) } @WorkerThread @@ -1869,44 +1888,49 @@ fun DocumentFile.copyFolderTo( targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, newFolderNameInTargetPath: String? = null, - callback: FolderCallback -) { - copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, callback) + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: FolderConflictCallback +): Flow { + return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, updateInterval, isFileSizeAllowed, callback) } /** * @param skipEmptyFiles skip copying empty files & folders */ +@OptIn(DelicateCoroutinesApi::class) private fun DocumentFile.copyFolderTo( context: Context, targetParentFolder: DocumentFile, skipEmptyFiles: Boolean = true, newFolderNameInTargetPath: String? = null, deleteSourceWhenComplete: Boolean, - callback: FolderCallback -) { - val writableTargetParentFolder = doesMeetCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, callback) ?: return + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: FolderConflictCallback +): Flow = callbackFlow { + val writableTargetParentFolder = doesMeetCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) ?: return@callbackFlow - callback.uiScope.postToUi { callback.onPrepare() } + send(FolderResult.Preparing) val targetFolderParentName = (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, callback) - if (conflictResolution == FolderCallback.ConflictResolution.SKIP) { - return + val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, this, callback) + if (conflictResolution == FolderConflictCallback.ConflictResolution.SKIP) { + return@callbackFlow } - callback.uiScope.postToUi { callback.onCountingFiles() } + send(FolderResult.CountingFiles) val filesToCopy = if (skipEmptyFiles) walkFileTreeAndSkipEmptyFiles() else walkFileTree(context) if (filesToCopy.isEmpty()) { val targetFolder = writableTargetParentFolder.makeFolder(context, targetFolderParentName, conflictResolution.toCreateMode()) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { if (deleteSourceWhenComplete) delete() - callback.uiScope.postToUi { callback.onCompleted(FolderCallback.Result(targetFolder, 0, 0, true)) } + send(FolderResult.Completed(targetFolder, 0, 0, true)) } - return + return@callbackFlow } var totalFilesToCopy = 0 @@ -1918,10 +1942,9 @@ private fun DocumentFile.copyFolderTo( } } - val thread = Thread.currentThread() - if (thread.isInterrupted) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANCELED) } - return + if (isClosedForSend) { + send(FolderResult.Error(FolderErrorCode.CANCELED)) + return@callbackFlow } if (deleteSourceWhenComplete) { @@ -1934,29 +1957,26 @@ private fun DocumentFile.copyFolderTo( conflictResolution )) { is DocumentFile -> { - callback.uiScope.postToUi { callback.onCompleted(FolderCallback.Result(result, totalFilesToCopy, totalFilesToCopy, true)) } - return + send(FolderResult.Completed(result, totalFilesToCopy, totalFilesToCopy, true)) + return@callbackFlow } - is FolderCallback.ErrorCode -> { - callback.uiScope.postToUi { callback.onFailed(result) } - return + is FolderErrorCode -> { + send(FolderResult.Error(result)) + return@callbackFlow } } } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { + send(FolderResult.Error(FolderErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, totalFilesToCopy, thread) } - if (reportInterval < 0) return - val targetFolder = writableTargetParentFolder.makeFolder(context, targetFolderParentName, conflictResolution.toCreateMode()) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return@callbackFlow } var totalCopiedFiles = 0 @@ -1964,10 +1984,9 @@ private fun DocumentFile.copyFolderTo( var bytesMoved = 0L var writeSpeed = 0 val startTimer: (Boolean) -> Unit = { start -> - if (start && reportInterval > 0) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = FolderCallback.Report(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles) - callback.uiScope.postToUi { callback.onReport(report) } + if (start && updateInterval > 0) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(FolderResult.InProgress(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles)) writeSpeed = 0 } } @@ -1976,36 +1995,43 @@ private fun DocumentFile.copyFolderTo( var targetFile: DocumentFile? = null var canceled = false // is required to prevent the callback from called again on next FOR iteration after the thread was interrupted - val notifyCanceled: (FolderCallback.ErrorCode) -> Unit = { errorCode -> + val notifyCanceled: (FolderErrorCode) -> Unit = { errorCode -> if (!canceled) { canceled = true timer?.cancel() targetFile?.delete() - callback.uiScope.postToUi { - callback.onFailed(errorCode) - callback.onCompleted(FolderCallback.Result(targetFolder, totalFilesToCopy, totalCopiedFiles, false)) - } + trySend(FolderResult.Error(errorCode, completedData = FolderResult.Completed(targetFolder, totalFilesToCopy, totalCopiedFiles, false))) } } - val conflictedFiles = ArrayList(totalFilesToCopy) + val conflictedFiles = ArrayList(totalFilesToCopy) val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var success = true - val copy: (DocumentFile, DocumentFile) -> Unit = { sourceFile, destFile -> - createFileStreams(context, sourceFile, destFile, callback) { inputStream, outputStream -> - try { - var read = inputStream.read(buffer) - while (read != -1) { - outputStream.write(buffer, 0, read) - bytesMoved += read - writeSpeed += read - read = inputStream.read(buffer) - } - } finally { - inputStream.closeStreamQuietly() - outputStream.closeStreamQuietly() + @Suppress("BlockingMethodInNonBlockingContext") + fun copy(sourceFile: DocumentFile, destFile: DocumentFile) { + val outputStream = destFile.openOutputStream(context) + if (outputStream == null) { + trySend(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return + } + val inputStream = sourceFile.openInputStream(context) + if (inputStream == null) { + outputStream.closeStreamQuietly() + trySend(FolderResult.Error(FolderErrorCode.SOURCE_FILE_NOT_FOUND)) + return + } + try { + var read = inputStream.read(buffer) + while (read != -1) { + outputStream.write(buffer, 0, read) + bytesMoved += read + writeSpeed += read + read = inputStream.read(buffer) } + } finally { + inputStream.closeStreamQuietly() + outputStream.closeStreamQuietly() } totalCopiedFiles++ if (deleteSourceWhenComplete) sourceFile.delete() @@ -2013,12 +2039,12 @@ private fun DocumentFile.copyFolderTo( val handleError: (Exception) -> Boolean = { val errorCode = it.toFolderCallbackErrorCode() - if (errorCode == FolderCallback.ErrorCode.CANCELED || errorCode == FolderCallback.ErrorCode.UNKNOWN_IO_ERROR) { + if (errorCode == FolderErrorCode.CANCELED || errorCode == FolderErrorCode.UNKNOWN_IO_ERROR) { notifyCanceled(errorCode) true } else { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(errorCode) } + trySend(FolderResult.Error(errorCode)) false } } @@ -2026,9 +2052,9 @@ private fun DocumentFile.copyFolderTo( val srcAbsolutePath = getAbsolutePath(context) for (sourceFile in filesToCopy) { try { - if (Thread.currentThread().isInterrupted) { - notifyCanceled(FolderCallback.ErrorCode.CANCELED) - return + if (isClosedForSend) { + notifyCanceled(FolderErrorCode.CANCELED) + return@callbackFlow } if (!sourceFile.exists()) { continue @@ -2048,18 +2074,18 @@ private fun DocumentFile.copyFolderTo( targetFile = targetFolder.makeFile(context, filename, sourceFile.type, CreateMode.REUSE) if (targetFile != null && targetFile.length() > 0) { - conflictedFiles.add(FolderCallback.FileConflict(sourceFile, targetFile)) + conflictedFiles.add(FolderConflictCallback.FileConflict(sourceFile, targetFile)) continue } if (targetFile == null) { - notifyCanceled(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) - return + notifyCanceled(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET) + return@callbackFlow } copy(sourceFile, targetFile) } catch (e: Exception) { - if (handleError(e)) return + if (handleError(e)) return@callbackFlow success = false break } @@ -2069,30 +2095,30 @@ private fun DocumentFile.copyFolderTo( timer?.cancel() if (!success || conflictedFiles.isEmpty()) { if (deleteSourceWhenComplete && success) forceDelete(context) - callback.uiScope.postToUi { callback.onCompleted(FolderCallback.Result(targetFolder, totalFilesToCopy, totalCopiedFiles, success)) } + trySend(FolderResult.Completed(targetFolder, totalFilesToCopy, totalCopiedFiles, success)) true } else false } - if (finalize()) return + if (finalize()) return@callbackFlow - val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(targetFolder, conflictedFiles, FolderCallback.FolderContentConflictAction(it)) + val solutions = awaitUiResultWithPending>(callback.uiScope) { + callback.onContentConflict(targetFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) }.filter { // free up space first, by deleting some files - if (it.solution == FileCallback.ConflictResolution.SKIP) { + if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { if (deleteSourceWhenComplete) it.source.delete() totalCopiedFiles++ } - it.solution != FileCallback.ConflictResolution.SKIP + it.solution != SingleFileConflictCallback.ConflictResolution.SKIP } val leftoverSize = totalSizeToCopy - bytesMoved startTimer(solutions.isNotEmpty() && leftoverSize > 10 * FileSize.MB) for (conflict in solutions) { - if (Thread.currentThread().isInterrupted) { - notifyCanceled(FolderCallback.ErrorCode.CANCELED) - return + if (isClosedForSend) { + notifyCanceled(FolderErrorCode.CANCELED) + return@callbackFlow } if (!conflict.source.isFile) { continue @@ -2100,14 +2126,14 @@ private fun DocumentFile.copyFolderTo( val filename = conflict.target.name.orEmpty() targetFile = conflict.target.findParent(context)?.makeFile(context, filename, mode = conflict.solution.toCreateMode()) if (targetFile == null) { - notifyCanceled(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) - return + notifyCanceled(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET) + return@callbackFlow } try { copy(conflict.source, targetFile) } catch (e: Exception) { - if (handleError(e)) return + if (handleError(e)) return@callbackFlow success = false break } @@ -2116,19 +2142,19 @@ private fun DocumentFile.copyFolderTo( finalize() } -private fun Exception.toFolderCallbackErrorCode(): FolderCallback.ErrorCode { +private fun Exception.toFolderCallbackErrorCode(): FolderErrorCode { return when (this) { - is SecurityException -> FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED - is InterruptedIOException, is InterruptedException -> FolderCallback.ErrorCode.CANCELED - else -> FolderCallback.ErrorCode.UNKNOWN_IO_ERROR + is SecurityException -> FolderErrorCode.STORAGE_PERMISSION_DENIED + is InterruptedIOException, is InterruptedException -> FolderErrorCode.CANCELED + else -> FolderErrorCode.UNKNOWN_IO_ERROR } } -private fun Exception.toMultipleFileCallbackErrorCode(): MultipleFileCallback.ErrorCode { +private fun Exception.toMultipleFileCallbackErrorCode(): MultipleFilesErrorCode { return when (this) { - is SecurityException -> MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED - is InterruptedIOException, is InterruptedException -> MultipleFileCallback.ErrorCode.CANCELED - else -> MultipleFileCallback.ErrorCode.UNKNOWN_IO_ERROR + is SecurityException -> MultipleFilesErrorCode.STORAGE_PERMISSION_DENIED + is InterruptedIOException, is InterruptedException -> MultipleFilesErrorCode.CANCELED + else -> MultipleFilesErrorCode.UNKNOWN_IO_ERROR } } @@ -2136,33 +2162,33 @@ private fun DocumentFile.doesMeetCopyRequirements( context: Context, targetParentFolder: DocumentFile, newFolderNameInTargetPath: String?, - callback: FolderCallback + scope: ProducerScope, ): DocumentFile? { - callback.uiScope.postToUi { callback.onValidate() } + scope.trySend(FolderResult.Validating) if (!isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.SOURCE_FOLDER_NOT_FOUND) } + scope.trySend(FolderResult.Error(FolderErrorCode.SOURCE_FOLDER_NOT_FOUND)) return null } if (!targetParentFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.INVALID_TARGET_FOLDER) } + scope.trySend(FolderResult.Error(FolderErrorCode.INVALID_TARGET_FOLDER)) return null } if (!canRead() || !targetParentFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(FolderResult.Error(FolderErrorCode.STORAGE_PERMISSION_DENIED)) return null } if (targetParentFolder.getAbsolutePath(context) == parentFile?.getAbsolutePath(context) && (newFolderNameInTargetPath.isNullOrEmpty() || name == newFolderNameInTargetPath)) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) } + scope.trySend(FolderResult.Error(FolderErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER)) return null } val writableFolder = targetParentFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } if (writableFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(FolderResult.Error(FolderErrorCode.STORAGE_PERMISSION_DENIED)) } return writableFolder } @@ -2175,9 +2201,11 @@ fun DocumentFile.copyFileTo( context: Context, targetFolder: File, fileDescription: FileDescription? = null, - callback: FileCallback -) { - copyFileTo(context, targetFolder.absolutePath, fileDescription, callback) + reportInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow { + return copyFileTo(context, targetFolder.absolutePath, fileDescription, reportInterval, isFileSizeAllowed, callback) } /** @@ -2189,13 +2217,16 @@ fun DocumentFile.copyFileTo( context: Context, targetFolderAbsolutePath: String, fileDescription: FileDescription? = null, - callback: FileCallback -) { + reportInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetFolder, fileDescription, callback) + // todo how to continue/return current flow with this flow function? + copyFileTo(context, targetFolder, fileDescription, reportInterval, isFileSizeAllowed, callback) } } @@ -2207,16 +2238,18 @@ fun DocumentFile.copyFileTo( context: Context, targetFolder: DocumentFile, fileDescription: FileDescription? = null, - callback: FileCallback -) { + reportInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { - copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, callback) + copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, this, isFileSizeAllowed, callback) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, callback) + copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, reportInterval, this, isFileSizeAllowed, callback) } } } @@ -2226,39 +2259,46 @@ private fun DocumentFile.copyFileTo( targetFolder: DocumentFile, newFilenameInTargetPath: String?, newMimeTypeInTargetPath: String?, - callback: FileCallback + updateInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, + callback: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return + val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return - callback.uiScope.postToUi { callback.onPrepare() } + scope.trySend(SingleFileResult.Preparing) - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, writableTargetFolder.getStorageId(context)), length())) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetFolder.getStorageId(context)), length())) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return } val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, callback) - if (fileConflictResolution == FileCallback.ConflictResolution.SKIP) { + val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, callback) + if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - if (reportInterval < 0) return - val watchProgress = reportInterval > 0 - try { val targetFile = createTargetFile( context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), callback + fileConflictResolution.toCreateMode(), scope ) ?: return - createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, false, callback) + val outputStream = targetFile.openOutputStream(context) + if (outputStream == null) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) + return + } + val inputStream = openInputStream(context) + if (inputStream == null) { + outputStream.closeStreamQuietly() + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) + return } + copyFileStream(inputStream, outputStream, targetFile, updateInterval, false, scope) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + scope.trySend(SingleFileResult.Error(e.toFileCallbackErrorCode())) } } @@ -2269,105 +2309,48 @@ private fun DocumentFile.doesMeetCopyRequirements( context: Context, targetFolder: DocumentFile, newFilenameInTargetPath: String?, - callback: FileCallback + scope: ProducerScope ): DocumentFile? { - callback.uiScope.postToUi { callback.onValidate() } + scope.trySend(SingleFileResult.Validating) if (!isFile) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) return null } if (!targetFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FOLDER_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FOLDER_NOT_FOUND)) return null } if (!canRead() || !targetFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) return null } if (parentFile?.getAbsolutePath(context) == targetFolder.getAbsolutePath(context) && (newFilenameInTargetPath.isNullOrEmpty() || name == newFilenameInTargetPath)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER)) return null } val writableFolder = targetFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } if (writableFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) } return writableFolder } -@Suppress("UNCHECKED_CAST") -private fun createFileStreams( - context: Context, - sourceFile: DocumentFile, - targetFile: DocumentFile, - callback: BaseFileCallback, - onStreamsReady: (InputStream, OutputStream) -> Unit -) { - val outputStream = targetFile.openOutputStream(context) - if (outputStream == null) { - val errorCode = when (callback) { - is MultipleFileCallback -> MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET - is FolderCallback -> FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET - else -> FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND - } - callback.uiScope.postToUi { callback.onFailed(errorCode as Enum) } - return - } - - val inputStream = sourceFile.openInputStream(context) - if (inputStream == null) { - outputStream.closeStreamQuietly() - val errorCode = when (callback) { - is MultipleFileCallback -> MultipleFileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND - is FolderCallback -> FolderCallback.ErrorCode.SOURCE_FILE_NOT_FOUND - else -> FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND - } - callback.uiScope.postToUi { callback.onFailed(errorCode as Enum) } - return - } - - onStreamsReady(inputStream, outputStream) -} - -private inline fun createFileStreams( - context: Context, - sourceFile: DocumentFile, - targetFile: MediaFile, - callback: FileCallback, - onStreamsReady: (InputStream, OutputStream) -> Unit -) { - val outputStream = targetFile.openOutputStream() - if (outputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } - return - } - - val inputStream = sourceFile.openInputStream(context) - if (inputStream == null) { - outputStream.closeStreamQuietly() - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } - return - } - - onStreamsReady(inputStream, outputStream) -} - private fun createTargetFile( context: Context, targetFolder: DocumentFile, newFilenameInTargetPath: String, mimeType: String?, mode: CreateMode, - callback: FileCallback + scope: ProducerScope, ): DocumentFile? { val targetFile = targetFolder.makeFile(context, newFilenameInTargetPath, mimeType, mode) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } return targetFile } @@ -2379,10 +2362,9 @@ private fun DocumentFile.copyFileStream( inputStream: InputStream, outputStream: OutputStream, targetFile: Any, - watchProgress: Boolean, - reportInterval: Long, + updateInterval: Long, deleteSourceFileWhenComplete: Boolean, - callback: FileCallback + scope: ProducerScope, ) { var timer: Job? = null try { @@ -2390,10 +2372,9 @@ private fun DocumentFile.copyFileStream( var writeSpeed = 0 val srcSize = length() // using timer on small file is useless. We set minimum 10MB. - if (watchProgress && srcSize > 10 * FileSize.MB) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = FileCallback.Report(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0 && srcSize > 10 * FileSize.MB) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + scope.trySend(SingleFileResult.InProgress(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed)) writeSpeed = 0 } } @@ -2412,7 +2393,7 @@ private fun DocumentFile.copyFileStream( if (targetFile is MediaFile) { targetFile.length = srcSize } - callback.uiScope.postToUi { callback.onCompleted(targetFile) } + scope.trySend(SingleFileResult.Completed(targetFile)) } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -2428,9 +2409,11 @@ fun DocumentFile.moveFileTo( context: Context, targetFolder: File, fileDescription: FileDescription? = null, - callback: FileCallback -) { - moveFileTo(context, targetFolder.absolutePath, fileDescription, callback) + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow { + return moveFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, callback) } /** @@ -2442,13 +2425,16 @@ fun DocumentFile.moveFileTo( context: Context, targetFolderAbsolutePath: String, fileDescription: FileDescription? = null, - callback: FileCallback -) { + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetFolder, fileDescription, callback) + // TODO: Return another flow + moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) } } @@ -2460,16 +2446,18 @@ fun DocumentFile.moveFileTo( context: Context, targetFolder: DocumentFile, fileDescription: FileDescription? = null, - callback: FileCallback -) { + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { - moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, callback) + moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, callback) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, callback) + moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, callback) } } } @@ -2479,22 +2467,25 @@ private fun DocumentFile.moveFileTo( targetFolder: DocumentFile, newFilenameInTargetPath: String?, newMimeTypeInTargetPath: String?, - callback: FileCallback + updateInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, + callback: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return + val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return - callback.uiScope.postToUi { callback.onPrepare() } + scope.trySend(SingleFileResult.Preparing) val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, callback) - if (fileConflictResolution == FileCallback.ConflictResolution.SKIP) { + val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, callback) + if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } if (inInternalStorage(context)) { toRawFile(context)?.moveTo(context, writableTargetFolder.getAbsolutePath(context), cleanFileName, fileConflictResolution)?.let { - callback.uiScope.postToUi { callback.onCompleted(DocumentFile.fromFile(it)) } + scope.trySend(SingleFileResult.Completed(DocumentFile.fromFile(it))) return } } @@ -2503,12 +2494,12 @@ private fun DocumentFile.moveFileTo( if (isExternalStorageManager(context) && getStorageId(context) == targetStorageId) { val sourceFile = toRawFile(context) if (sourceFile == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) return } writableTargetFolder.toRawFile(context)?.let { destinationFolder -> sourceFile.moveTo(context, destinationFolder, cleanFileName, fileConflictResolution)?.let { - callback.uiScope.postToUi { callback.onCompleted(DocumentFile.fromFile(it)) } + scope.trySend(SingleFileResult.Completed(DocumentFile.fromFile(it))) return } } @@ -2521,51 +2512,55 @@ private fun DocumentFile.moveFileTo( val newFile = context.fromTreeUri(movedFileUri) if (newFile != null && newFile.isFile) { if (newFilenameInTargetPath != null) newFile.renameTo(cleanFileName) - callback.uiScope.postToUi { callback.onCompleted(newFile) } + scope.trySend(SingleFileResult.Completed(newFile)) } else { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) } return } } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetStorageId), length())) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetStorageId), length())) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return } } catch (e: Throwable) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) return } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - if (reportInterval < 0) return - val watchProgress = reportInterval > 0 - try { val targetFile = createTargetFile( context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), callback + fileConflictResolution.toCreateMode(), scope ) ?: return - createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, true, callback) + val outputStream = targetFile.openOutputStream(context) + if (outputStream == null) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) + return + } + val inputStream = openInputStream(context) + if (inputStream == null) { + outputStream.closeStreamQuietly() + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) + return } + copyFileStream(inputStream, outputStream, targetFile, updateInterval, true, scope) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + scope.trySend(SingleFileResult.Error(e.toFileCallbackErrorCode())) } } /** * @return `true` if error */ -private fun DocumentFile.simpleCheckSourceFile(callback: FileCallback): Boolean { +private fun DocumentFile.simpleCheckSourceFile(scope: ProducerScope): Boolean { if (!isFile) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) return true } if (!canRead()) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) return true } return false @@ -2574,23 +2569,26 @@ private fun DocumentFile.simpleCheckSourceFile(callback: FileCallback): Boolean private fun DocumentFile.copyFileToMedia( context: Context, fileDescription: FileDescription, - callback: FileCallback, publicDirectory: PublicDirectory, deleteSourceFileWhenComplete: Boolean, - mode: CreateMode + mode: CreateMode, + updateInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, + callback: SingleFileConflictCallback, ) { - if (simpleCheckSourceFile(callback)) return + if (simpleCheckSourceFile(scope)) return val publicFolder = DocumentFileCompat.fromPublicFolder(context, publicDirectory, fileDescription.subFolder, true) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || deleteSourceFileWhenComplete && !isRawFile && publicFolder?.isTreeDocumentFile == true) { if (publicFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) return } publicFolder.child(context, fileDescription.fullName)?.let { if (mode == CreateMode.REPLACE) { if (!it.forceDelete(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return } } else { @@ -2599,9 +2597,9 @@ private fun DocumentFile.copyFileToMedia( } fileDescription.subFolder = "" if (deleteSourceFileWhenComplete) { - moveFileTo(context, publicFolder, fileDescription, callback) + moveFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) } else { - copyFileTo(context, publicFolder, fileDescription, callback) + copyFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) } } else { val validMode = if (mode == CreateMode.REUSE) CreateMode.CREATE_NEW else mode @@ -2611,85 +2609,129 @@ private fun DocumentFile.copyFileToMedia( MediaStoreCompat.createImage(context, fileDescription, mode = validMode) } if (mediaFile == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, mediaFile, deleteSourceFileWhenComplete, callback) + copyFileTo(context, mediaFile, deleteSourceFileWhenComplete, updateInterval, scope, isFileSizeAllowed) } } } @WorkerThread @JvmOverloads -fun DocumentFile.copyFileToDownloadMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { - copyFileToMedia(context, fileDescription, callback, PublicDirectory.DOWNLOADS, false, mode) +fun DocumentFile.copyFileToDownloadMedia( + context: Context, + fileDescription: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback, +): Flow = callbackFlow { + copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, false, mode, updateInterval, this, isFileSizeAllowed, callback) } @WorkerThread @JvmOverloads -fun DocumentFile.copyFileToPictureMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { - copyFileToMedia(context, fileDescription, callback, PublicDirectory.PICTURES, false, mode) +fun DocumentFile.copyFileToPictureMedia( + context: Context, + fileDescription: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback, +): Flow = callbackFlow { + copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, false, mode, updateInterval, this, isFileSizeAllowed, callback) } @WorkerThread @JvmOverloads -fun DocumentFile.moveFileToDownloadMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { - copyFileToMedia(context, fileDescription, callback, PublicDirectory.DOWNLOADS, true, mode) +fun DocumentFile.moveFileToDownloadMedia( + context: Context, + fileDescription: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { + copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, true, mode, updateInterval, this, isFileSizeAllowed, callback) } @WorkerThread @JvmOverloads -fun DocumentFile.moveFileToPictureMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { - copyFileToMedia(context, fileDescription, callback, PublicDirectory.PICTURES, true, mode) +fun DocumentFile.moveFileToPictureMedia( + context: Context, + fileDescription: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback +): Flow = callbackFlow { + copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, true, mode, updateInterval, this, isFileSizeAllowed, callback) } /** * @param targetFile create it with [MediaStoreCompat], e.g. [MediaStoreCompat.createDownload] */ @WorkerThread -fun DocumentFile.moveFileTo(context: Context, targetFile: MediaFile, callback: FileCallback) { - copyFileTo(context, targetFile, true, callback) +fun DocumentFile.moveFileTo( + context: Context, + targetFile: MediaFile, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, +): Flow = callbackFlow { + copyFileTo(context, targetFile, true, updateInterval, this, isFileSizeAllowed) } /** * @param targetFile create it with [MediaStoreCompat], e.g. [MediaStoreCompat.createDownload] */ @WorkerThread -fun DocumentFile.copyFileTo(context: Context, targetFile: MediaFile, callback: FileCallback) { - copyFileTo(context, targetFile, false, callback) +fun DocumentFile.copyFileTo( + context: Context, + targetFile: MediaFile, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, +): Flow = callbackFlow { + copyFileTo(context, targetFile, false, updateInterval, this, isFileSizeAllowed) } private fun DocumentFile.copyFileTo( context: Context, targetFile: MediaFile, deleteSourceFileWhenComplete: Boolean, - callback: FileCallback + updateInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, ) { - if (simpleCheckSourceFile(callback)) return + if (simpleCheckSourceFile(scope)) return - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, PRIMARY), length())) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, PRIMARY), length())) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - if (reportInterval < 0) return - val watchProgress = reportInterval > 0 - try { - createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, deleteSourceFileWhenComplete, callback) + val outputStream = targetFile.openOutputStream() + if (outputStream == null) { + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) + return } + val inputStream = openInputStream(context) + if (inputStream == null) { + outputStream.closeStreamQuietly() + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) + return + } + copyFileStream(inputStream, outputStream, targetFile, updateInterval, deleteSourceFileWhenComplete, scope) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + scope.trySend(SingleFileResult.Error(e.toFileCallbackErrorCode())) } } -internal fun Exception.toFileCallbackErrorCode(): FileCallback.ErrorCode { +internal fun Exception.toFileCallbackErrorCode(): SingleFileErrorCode { return when (this) { - is SecurityException -> FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED - is InterruptedIOException, is InterruptedException -> FileCallback.ErrorCode.CANCELED - else -> FileCallback.ErrorCode.UNKNOWN_IO_ERROR + is SecurityException -> SingleFileErrorCode.STORAGE_PERMISSION_DENIED + is InterruptedIOException, is InterruptedException -> SingleFileErrorCode.CANCELED + else -> SingleFileErrorCode.UNKNOWN_IO_ERROR } } @@ -2697,69 +2739,71 @@ private fun handleFileConflict( context: Context, targetFolder: DocumentFile, targetFileName: String, - callback: FileCallback -): FileCallback.ConflictResolution { + scope: ProducerScope, + callback: SingleFileConflictCallback +): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, targetFileName)?.let { targetFile -> val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onConflict(targetFile, FileCallback.FileConflictAction(it)) + callback.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) } - if (resolution == FileCallback.ConflictResolution.REPLACE) { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + if (resolution == SingleFileConflictCallback.ConflictResolution.REPLACE) { + scope.trySend(SingleFileResult.DeletingConflictedFile) if (!targetFile.forceDelete(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FileCallback.ConflictResolution.SKIP + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return SingleFileConflictCallback.ConflictResolution.SKIP } } return resolution } - return FileCallback.ConflictResolution.CREATE_NEW + return SingleFileConflictCallback.ConflictResolution.CREATE_NEW } private fun handleParentFolderConflict( context: Context, targetParentFolder: DocumentFile, targetFolderParentName: String, - callback: FolderCallback -): FolderCallback.ConflictResolution { + scope: ProducerScope, + callback: FolderConflictCallback +): FolderConflictCallback.ConflictResolution { targetParentFolder.child(context, targetFolderParentName)?.let { targetFolder -> val canMerge = targetFolder.isDirectory if (canMerge && targetFolder.isEmpty(context)) { - return FolderCallback.ConflictResolution.MERGE + return FolderConflictCallback.ConflictResolution.MERGE } - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onParentConflict(targetFolder, FolderCallback.ParentFolderConflictAction(it), canMerge) + val resolution = awaitUiResultWithPending(callback.uiScope) { + callback.onParentConflict(targetFolder, FolderConflictCallback.ParentFolderConflictAction(it), canMerge) } when (resolution) { - FolderCallback.ConflictResolution.REPLACE -> { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + FolderConflictCallback.ConflictResolution.REPLACE -> { + scope.trySend(FolderResult.DeletingConflictedFiles) val isFolder = targetFolder.isDirectory if (targetFolder.forceDelete(context, true)) { if (!isFolder) { val newFolder = targetFolder.parentFile?.createDirectory(targetFolderParentName) if (newFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FolderCallback.ConflictResolution.SKIP + scope.trySend(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return FolderConflictCallback.ConflictResolution.SKIP } } } else { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FolderCallback.ConflictResolution.SKIP + scope.trySend(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return FolderConflictCallback.ConflictResolution.SKIP } } - FolderCallback.ConflictResolution.MERGE -> { + FolderConflictCallback.ConflictResolution.MERGE -> { if (targetFolder.isFile) { if (targetFolder.delete()) { val newFolder = targetFolder.parentFile?.createDirectory(targetFolderParentName) if (newFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FolderCallback.ConflictResolution.SKIP + scope.trySend(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return FolderConflictCallback.ConflictResolution.SKIP } } else { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FolderCallback.ConflictResolution.SKIP + scope.trySend(FolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return FolderConflictCallback.ConflictResolution.SKIP } } } @@ -2770,44 +2814,46 @@ private fun handleParentFolderConflict( } return resolution } - return FolderCallback.ConflictResolution.CREATE_NEW + return FolderConflictCallback.ConflictResolution.CREATE_NEW } private fun List.handleParentFolderConflict( context: Context, targetParentFolder: DocumentFile, - callback: MultipleFileCallback -): List? { + scope: ProducerScope, + callback: MultipleFileConflictCallback +): List? { val sourceFileNames = map { it.name } val conflictedFiles = targetParentFolder.listFiles().filter { it.name in sourceFileNames } val conflicts = conflictedFiles.map { val sourceFile = first { src -> src.name == it.name } val canMerge = sourceFile.isDirectory && it.isDirectory - val solution = if (canMerge && it.isEmpty(context)) FolderCallback.ConflictResolution.MERGE else FolderCallback.ConflictResolution.CREATE_NEW - MultipleFileCallback.ParentConflict(sourceFile, it, canMerge, solution) + val solution = + if (canMerge && it.isEmpty(context)) FolderConflictCallback.ConflictResolution.MERGE else FolderConflictCallback.ConflictResolution.CREATE_NEW + MultipleFileConflictCallback.ParentConflict(sourceFile, it, canMerge, solution) } - val unresolvedConflicts = conflicts.filter { it.solution != FolderCallback.ConflictResolution.MERGE }.toMutableList() + val unresolvedConflicts = conflicts.filter { it.solution != FolderConflictCallback.ConflictResolution.MERGE }.toMutableList() if (unresolvedConflicts.isNotEmpty()) { val unresolvedFiles = unresolvedConflicts.filter { it.source.isFile }.toMutableList() val unresolvedFolders = unresolvedConflicts.filter { it.source.isDirectory }.toMutableList() - val resolution = awaitUiResultWithPending>(callback.uiScope) { - callback.onParentConflict(targetParentFolder, unresolvedFolders, unresolvedFiles, MultipleFileCallback.ParentFolderConflictAction(it)) + val resolution = awaitUiResultWithPending(callback.uiScope) { + callback.onParentConflict(targetParentFolder, unresolvedFolders, unresolvedFiles, MultipleFileConflictCallback.ParentFolderConflictAction(it)) } - if (resolution.any { it.solution == FolderCallback.ConflictResolution.REPLACE }) { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + if (resolution.any { it.solution == FolderConflictCallback.ConflictResolution.REPLACE }) { + scope.trySend(MultipleFilesResult.DeletingConflictedFiles) } resolution.forEach { conflict -> when (conflict.solution) { - FolderCallback.ConflictResolution.REPLACE -> { - if (!conflict.target.let { it.forceDelete(context, true) || !it.exists() }) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + FolderConflictCallback.ConflictResolution.REPLACE -> { + if (!conflict.target.let { it.deleteRecursively(context, true) || !it.exists() }) { + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return null } } - FolderCallback.ConflictResolution.MERGE -> { + FolderConflictCallback.ConflictResolution.MERGE -> { if (conflict.target.isFile && !conflict.target.delete()) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return null } } @@ -2817,7 +2863,7 @@ private fun List.handleParentFolderConflict( } } } - return resolution.toMutableList().apply { addAll(conflicts.filter { it.solution == FolderCallback.ConflictResolution.MERGE }) } + return resolution.toMutableList().apply { addAll(conflicts.filter { it.solution == FolderConflictCallback.ConflictResolution.MERGE }) } } return emptyList() } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt index 82ea05a..8847b3b 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt @@ -10,8 +10,7 @@ import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.callback.FileConflictCallback +import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.extension.awaitUiResultWithPending import com.anggrayudi.storage.extension.isKitkatSdCardStorageId import com.anggrayudi.storage.extension.trimFileSeparator @@ -180,7 +179,7 @@ fun File.makeFile( name: String, mimeType: String? = MimeType.UNKNOWN, mode: CreateMode = CreateMode.CREATE_NEW, - onConflict: FileConflictCallback? = null + onConflict: SingleFileConflictCallback? = null ): File? { if (!isDirectory || !isWritable(context)) { return null @@ -206,7 +205,7 @@ fun File.makeFile( val targetFile = File(parent, fullFileName) if (onConflict != null && targetFile.exists()) { createMode = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onFileConflict(targetFile, FileCallback.FileConflictAction(it)) + onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) }.toCreateMode(true) } @@ -329,20 +328,20 @@ fun File.moveTo( context: Context, targetFolder: String, newFileNameInTarget: String? = null, - conflictResolution: FileCallback.ConflictResolution = FileCallback.ConflictResolution.CREATE_NEW + conflictResolution: SingleFileConflictCallback.ConflictResolution = SingleFileConflictCallback.ConflictResolution.CREATE_NEW ): File? { return moveTo(context, File(targetFolder), newFileNameInTarget, conflictResolution) } /** - * @param conflictResolution using [FileCallback.ConflictResolution.SKIP] will return `null` + * @param conflictResolution using [SingleFileConflictCallback.ConflictResolution.SKIP] will return `null` */ @JvmOverloads fun File.moveTo( context: Context, targetFolder: File, newFileNameInTarget: String? = null, - conflictResolution: FileCallback.ConflictResolution = FileCallback.ConflictResolution.CREATE_NEW + conflictResolution: SingleFileConflictCallback.ConflictResolution = SingleFileConflictCallback.ConflictResolution.CREATE_NEW ): File? { if (!exists() || !isWritable(context)) { return null @@ -361,9 +360,9 @@ fun File.moveTo( } if (dest.exists()) { when (conflictResolution) { - FileCallback.ConflictResolution.SKIP -> return null - FileCallback.ConflictResolution.REPLACE -> if (!dest.forceDelete()) return null - FileCallback.ConflictResolution.CREATE_NEW -> { + SingleFileConflictCallback.ConflictResolution.SKIP -> return null + SingleFileConflictCallback.ConflictResolution.REPLACE -> if (!dest.forceDelete()) return null + SingleFileConflictCallback.ConflictResolution.CREATE_NEW -> { dest = targetFolder.child(targetFolder.autoIncrementFileName(filename)) } } diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt deleted file mode 100644 index 41363a4..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.anggrayudi.storage.file - -import android.content.Context -import android.text.format.Formatter -import androidx.annotation.UiThread -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import java.util.* - -/** - * Created on 03/06/21 - * @author Anggrayudi H - */ -data class FileProperties( - var name: String = "", - var location: String = "", - var size: Long = 0, - var isFolder: Boolean = false, - var folders: Int = 0, - var files: Int = 0, - var emptyFiles: Int = 0, - var emptyFolders: Int = 0, - var isVirtual: Boolean = false, - var lastModified: Date? = null -) { - fun formattedSize(context: Context): String = Formatter.formatFileSize(context, size) - - abstract class CalculationCallback( - val updateInterval: Long = 500, // 500ms - @OptIn(DelicateCoroutinesApi::class) - var uiScope: CoroutineScope = GlobalScope - ) { - - @UiThread - open fun onUpdate(properties: FileProperties) { - // default implementation - } - - @UiThread - abstract fun onComplete(properties: FileProperties) - - @UiThread - open fun onCanceled(properties: FileProperties) { - // default implementation - } - - @UiThread - open fun onError() { - // default implementation - } - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt index 0f75c23..ae51cdd 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt @@ -17,12 +17,50 @@ import androidx.annotation.WorkerThread import androidx.core.content.FileProvider import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.extension.* -import com.anggrayudi.storage.file.* +import com.anggrayudi.storage.callback.SingleFileConflictCallback +import com.anggrayudi.storage.extension.awaitUiResultWithPending +import com.anggrayudi.storage.extension.closeStreamQuietly +import com.anggrayudi.storage.extension.getString +import com.anggrayudi.storage.extension.isRawFile +import com.anggrayudi.storage.extension.openInputStream +import com.anggrayudi.storage.extension.replaceCompletely +import com.anggrayudi.storage.extension.startCoroutineTimer +import com.anggrayudi.storage.extension.toDocumentFile +import com.anggrayudi.storage.extension.toInt +import com.anggrayudi.storage.extension.trimFileSeparator +import com.anggrayudi.storage.file.CheckFileSize +import com.anggrayudi.storage.file.CreateMode +import com.anggrayudi.storage.file.DocumentFileCompat import com.anggrayudi.storage.file.DocumentFileCompat.removeForbiddenCharsFromFilename +import com.anggrayudi.storage.file.FileSize +import com.anggrayudi.storage.file.MimeType +import com.anggrayudi.storage.file.child +import com.anggrayudi.storage.file.copyFileTo +import com.anggrayudi.storage.file.defaultFileSizeChecker +import com.anggrayudi.storage.file.forceDelete +import com.anggrayudi.storage.file.fullName +import com.anggrayudi.storage.file.getBasePath +import com.anggrayudi.storage.file.getStorageId +import com.anggrayudi.storage.file.isEmpty +import com.anggrayudi.storage.file.makeFile +import com.anggrayudi.storage.file.makeFolder +import com.anggrayudi.storage.file.mimeType +import com.anggrayudi.storage.file.moveFileTo +import com.anggrayudi.storage.file.openOutputStream +import com.anggrayudi.storage.file.toDocumentFile +import com.anggrayudi.storage.file.toFileCallbackErrorCode +import com.anggrayudi.storage.result.SingleFileErrorCode +import com.anggrayudi.storage.result.SingleFileResult import kotlinx.coroutines.Job -import java.io.* +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream /** * Created on 06/09/20 @@ -167,6 +205,7 @@ class MediaFile(context: Context, val uri: Uri) { "" } } + else -> { val projection = arrayOf(MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.DISPLAY_NAME) context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> @@ -194,6 +233,7 @@ class MediaFile(context: Context, val uri: Uri) { file != null -> { file.path.substringBeforeLast('/').replaceFirst(SimpleStorage.externalStoragePath, "").trimFileSeparator() + "/" } + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> { try { context.contentResolver.query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)?.use { cursor -> @@ -207,6 +247,7 @@ class MediaFile(context: Context, val uri: Uri) { "" } } + else -> { val projection = arrayOf(MediaStore.MediaColumns.RELATIVE_PATH) context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> @@ -262,11 +303,11 @@ class MediaFile(context: Context, val uri: Uri) { } } - private fun handleSecurityException(e: SecurityException, callback: FileCallback? = null) { + private fun handleSecurityException(e: SecurityException, scope: ProducerScope? = null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && e is RecoverableSecurityException) { accessCallback?.onWriteAccessDenied(this, e.userAction.actionIntent.intentSender) } else { - callback?.uiScope?.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope?.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) } } @@ -320,16 +361,22 @@ class MediaFile(context: Context, val uri: Uri) { } @WorkerThread - fun moveTo(targetFolder: DocumentFile, fileDescription: FileDescription? = null, callback: FileCallback) { + fun moveTo( + targetFolder: DocumentFile, + fileDescription: FileDescription? = null, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback + ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { - sourceFile.moveFileTo(context, targetFolder, fileDescription, callback) - return + sourceFile.moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + return@callbackFlow } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { + send(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } val targetDirectory = if (fileDescription?.subFolder.isNullOrEmpty()) { @@ -337,8 +384,8 @@ class MediaFile(context: Context, val uri: Uri) { } else { val directory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (directory == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return@callbackFlow } else { directory } @@ -346,41 +393,43 @@ class MediaFile(context: Context, val uri: Uri) { val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) .removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, callback) - if (conflictResolution == FileCallback.ConflictResolution.SKIP) { - return + val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, callback) + if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - val watchProgress = reportInterval > 0 - try { val targetFile = createTargetFile( targetDirectory, cleanFileName, fileDescription?.mimeType ?: type, - conflictResolution.toCreateMode(), callback - ) ?: return - createFileStreams(targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, true, callback) + conflictResolution.toCreateMode(), this + ) ?: return@callbackFlow + createFileStreams(targetFile, this) { inputStream, outputStream -> + copyFileStream(inputStream, outputStream, targetFile, updateInterval, true, this) } } catch (e: SecurityException) { - handleSecurityException(e, callback) + handleSecurityException(e, this) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + send(SingleFileResult.Error(e.toFileCallbackErrorCode())) } } @WorkerThread - fun copyTo(targetFolder: DocumentFile, fileDescription: FileDescription? = null, callback: FileCallback) { + fun copyTo( + targetFolder: DocumentFile, + fileDescription: FileDescription? = null, + updateInterval: Long = 500, + isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, + callback: SingleFileConflictCallback + ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { - sourceFile.copyFileTo(context, targetFolder, fileDescription, callback) - return + sourceFile.copyFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + return@callbackFlow } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } - return + if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { + send(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) + return@callbackFlow } val targetDirectory = if (fileDescription?.subFolder.isNullOrEmpty()) { @@ -388,8 +437,8 @@ class MediaFile(context: Context, val uri: Uri) { } else { val directory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (directory == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return@callbackFlow } else { directory } @@ -397,26 +446,23 @@ class MediaFile(context: Context, val uri: Uri) { val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) .removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, callback) - if (conflictResolution == FileCallback.ConflictResolution.SKIP) { - return + val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, callback) + if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - val watchProgress = reportInterval > 0 try { val targetFile = createTargetFile( targetDirectory, cleanFileName, fileDescription?.mimeType ?: type, - conflictResolution.toCreateMode(), callback - ) ?: return - createFileStreams(targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, false, callback) + conflictResolution.toCreateMode(), this + ) ?: return@callbackFlow + createFileStreams(targetFile, this) { inputStream, outputStream -> + copyFileStream(inputStream, outputStream, targetFile, updateInterval, false, this) } } catch (e: SecurityException) { - handleSecurityException(e, callback) + handleSecurityException(e, this) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + send(SingleFileResult.Error(e.toFileCallbackErrorCode())) } } @@ -425,44 +471,44 @@ class MediaFile(context: Context, val uri: Uri) { fileName: String, mimeType: String?, mode: CreateMode, - callback: FileCallback + scope: ProducerScope, ): DocumentFile? { try { val absolutePath = DocumentFileCompat.buildAbsolutePath(context, targetDirectory.getStorageId(context), targetDirectory.getBasePath(context)) val targetFolder = DocumentFileCompat.mkdirs(context, absolutePath) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) return null } val targetFile = targetFolder.makeFile(context, fileName, mimeType, mode) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { return targetFile } } catch (e: SecurityException) { - handleSecurityException(e, callback) + handleSecurityException(e, scope) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + scope.trySend(SingleFileResult.Error(e.toFileCallbackErrorCode())) } return null } private inline fun createFileStreams( targetFile: DocumentFile, - callback: FileCallback, + scope: ProducerScope, onStreamsReady: (InputStream, OutputStream) -> Unit ) { val outputStream = targetFile.openOutputStream(context) if (outputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) return } val inputStream = openInputStream() if (inputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) outputStream.closeStreamQuietly() return } @@ -474,10 +520,9 @@ class MediaFile(context: Context, val uri: Uri) { inputStream: InputStream, outputStream: OutputStream, targetFile: DocumentFile, - watchProgress: Boolean, - reportInterval: Long, + updateInterval: Long, deleteSourceFileWhenComplete: Boolean, - callback: FileCallback + scope: ProducerScope, ) { var timer: Job? = null try { @@ -485,10 +530,9 @@ class MediaFile(context: Context, val uri: Uri) { var writeSpeed = 0 val srcSize = length // using timer on small file is useless. We set minimum 10MB. - if (watchProgress && srcSize > 10 * FileSize.MB) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = FileCallback.Report(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0 && srcSize > 10 * FileSize.MB) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + scope.trySend(SingleFileResult.InProgress(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed)) writeSpeed = 0 } } @@ -504,7 +548,7 @@ class MediaFile(context: Context, val uri: Uri) { if (deleteSourceFileWhenComplete) { delete() } - callback.uiScope.postToUi { callback.onCompleted(targetFile) } + scope.trySend(SingleFileResult.Completed(targetFile)) } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -515,21 +559,22 @@ class MediaFile(context: Context, val uri: Uri) { private fun handleFileConflict( targetFolder: DocumentFile, fileName: String, - callback: FileCallback - ): FileCallback.ConflictResolution { + scope: ProducerScope, + callback: SingleFileConflictCallback + ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, fileName)?.let { targetFile -> val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onConflict(targetFile, FileCallback.FileConflictAction(it)) + callback.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) } - if (resolution == FileCallback.ConflictResolution.REPLACE) { + if (resolution == SingleFileConflictCallback.ConflictResolution.REPLACE) { if (!targetFile.forceDelete(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return FileCallback.ConflictResolution.SKIP + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + return SingleFileConflictCallback.ConflictResolution.SKIP } } return resolution } - return FileCallback.ConflictResolution.CREATE_NEW + return SingleFileConflictCallback.ConflictResolution.CREATE_NEW } private fun getColumnInfoString(column: String): String? { diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt index 5ab23a3..788814c 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt @@ -5,11 +5,24 @@ package com.anggrayudi.storage.media import android.content.Context import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.ZipCompressionCallback -import com.anggrayudi.storage.callback.ZipDecompressionCallback -import com.anggrayudi.storage.extension.* -import com.anggrayudi.storage.file.* +import com.anggrayudi.storage.extension.closeEntryQuietly +import com.anggrayudi.storage.extension.closeStreamQuietly +import com.anggrayudi.storage.extension.startCoroutineTimer +import com.anggrayudi.storage.file.CreateMode +import com.anggrayudi.storage.file.MimeType +import com.anggrayudi.storage.file.findParent +import com.anggrayudi.storage.file.fullName +import com.anggrayudi.storage.file.isWritable +import com.anggrayudi.storage.file.makeFile +import com.anggrayudi.storage.file.makeFolder +import com.anggrayudi.storage.file.openOutputStream +import com.anggrayudi.storage.result.ZipCompressionErrorCode +import com.anggrayudi.storage.result.ZipCompressionResult +import com.anggrayudi.storage.result.ZipDecompressionErrorCode +import com.anggrayudi.storage.result.ZipDecompressionResult import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import java.io.FileNotFoundException import java.io.IOException import java.io.InterruptedIOException @@ -27,13 +40,13 @@ fun List.compressToZip( context: Context, targetZipFile: DocumentFile, deleteSourceWhenComplete: Boolean = false, - callback: ZipCompressionCallback -) { - callback.uiScope.postToUi { callback.onCountingFiles() } + updateInterval: Long = 500, +): Flow = callbackFlow { + send(ZipCompressionResult.CountingFiles) val entryFiles = distinctBy { it.uri }.filter { !it.isEmpty } if (entryFiles.isEmpty()) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, "No entry files found") } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "No entry files found")) + return@callbackFlow } var zipFile: DocumentFile? = targetZipFile @@ -41,18 +54,14 @@ fun List.compressToZip( zipFile = targetZipFile.findParent(context)?.makeFile(context, targetZipFile.fullName, MimeType.ZIP) } if (zipFile == null) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, "Cannot create ZIP file in target")) + return@callbackFlow } if (!zipFile.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable") } - return + send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable")) + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(entryFiles, thread) } - if (reportInterval < 0) return - var success = false var bytesCompressed = 0L var timer: Job? = null @@ -61,10 +70,9 @@ fun List.compressToZip( zos = ZipOutputStream(zipFile.openOutputStream(context)) var writeSpeed = 0 var fileCompressedCount = 0 - if (reportInterval > 0) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipCompressionCallback.Report(0f, bytesCompressed, writeSpeed, fileCompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(ZipCompressionResult.Compressing(0f, bytesCompressed, writeSpeed, fileCompressedCount)) writeSpeed = 0 } } @@ -84,17 +92,17 @@ fun List.compressToZip( } success = true } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.CANCELED, "Compression canceled")) } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, e.message) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, e.message)) } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, e.message)) } else { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, e.message) } + send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) } finally { timer?.cancel() zos.closeEntryQuietly() @@ -102,11 +110,11 @@ fun List.compressToZip( } if (success) { if (deleteSourceWhenComplete) { - callback.uiScope.postToUi { callback.onDeleteEntryFiles() } + send(ZipCompressionResult.DeletingEntryFiles) forEach { it.delete() } } val sizeReduction = (bytesCompressed - zipFile.length()).toFloat() / bytesCompressed * 100 - callback.uiScope.postToUi { callback.onCompleted(zipFile, bytesCompressed, entryFiles.size, sizeReduction) } + send(ZipCompressionResult.Completed(zipFile, bytesCompressed, entryFiles.size, sizeReduction)) } else { zipFile.delete() } @@ -116,16 +124,16 @@ fun List.compressToZip( fun MediaFile.decompressZip( context: Context, targetFolder: DocumentFile, - callback: ZipDecompressionCallback -) { - callback.uiScope.postToUi { callback.onValidate() } + updateInterval: Long = 500, +): Flow = callbackFlow { + send(ZipDecompressionResult.Validating) if (isEmpty) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, "No zip file found")) + return@callbackFlow } if (mimeType != MimeType.ZIP) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NOT_A_ZIP_FILE, "Not a ZIP file")) + return@callbackFlow } var destFolder: DocumentFile? = targetFolder @@ -133,14 +141,10 @@ fun MediaFile.decompressZip( destFolder = targetFolder.findParent(context)?.makeFolder(context, targetFolder.fullName) } if (destFolder == null || !destFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } - return + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination folder is not writable")) + return@callbackFlow } - val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, thread) } - if (reportInterval < 0) return - var success = false var bytesDecompressed = 0L var skippedDecompressedBytes = 0L @@ -151,10 +155,9 @@ fun MediaFile.decompressZip( try { zis = ZipInputStream(openInputStream()) var writeSpeed = 0 - if (reportInterval > 0) { - timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipDecompressionCallback.Report(bytesDecompressed, writeSpeed, fileDecompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + if (updateInterval > 0) { + timer = startCoroutineTimer(repeatMillis = updateInterval) { + trySend(ZipDecompressionResult.Decompressing(bytesDecompressed, writeSpeed, fileDecompressedCount)) writeSpeed = 0 } } @@ -171,7 +174,7 @@ fun MediaFile.decompressZip( val fileName = entry.name.substringAfterLast('/') targetFile = folder.makeFile(context, fileName) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, "Cannot create file in target")) canSuccess = false break } @@ -196,25 +199,24 @@ fun MediaFile.decompressZip( } success = canSuccess } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANCELED, "Decompression canceled")) } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, e.message)) } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, e.message)) } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) } finally { timer?.cancel() zis.closeEntryQuietly() zis.closeStreamQuietly() } if (success) { - val info = ZipDecompressionCallback.DecompressionInfo(bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, 0f) - callback.uiScope.postToUi { callback.onCompleted(this, destFolder, info) } + send(ZipDecompressionResult.Completed(this, destFolder, bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, 0f)) } else { targetFile?.delete() } diff --git a/storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt new file mode 100644 index 0000000..9f0e57e --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt @@ -0,0 +1,30 @@ +package com.anggrayudi.storage.result + +import android.content.Context +import android.text.format.Formatter +import java.util.Date + +/** + * Created on 03/06/21 + * @author Anggrayudi H + */ +data class FileProperties( + var name: String = "", + var location: String = "", + var size: Long = 0, + var isFolder: Boolean = false, + var folders: Int = 0, + var files: Int = 0, + var emptyFiles: Int = 0, + var emptyFolders: Int = 0, + var isVirtual: Boolean = false, + var lastModified: Date? = null +) { + fun formattedSize(context: Context): String = Formatter.formatFileSize(context, size) +} + +sealed class FilePropertiesResult { + data class Updating(val properties: FileProperties) : FilePropertiesResult() + data class Completed(val properties: FileProperties) : FilePropertiesResult() + data object Error : FilePropertiesResult() +} diff --git a/storage/src/main/java/com/anggrayudi/storage/result/FolderResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/FolderResult.kt new file mode 100644 index 0000000..7d5d6b8 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/FolderResult.kt @@ -0,0 +1,49 @@ +package com.anggrayudi.storage.result + +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.callback.FolderConflictCallback +import com.anggrayudi.storage.callback.FolderConflictCallback.ConflictResolution +import com.anggrayudi.storage.callback.SingleFileConflictCallback + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed class FolderResult { + data object Validating : FolderResult() + data object Preparing : FolderResult() + data object CountingFiles : FolderResult() + + /** + * Called after the user chooses [FolderConflictCallback.ConflictResolution.REPLACE] or [SingleFileConflictCallback.ConflictResolution.REPLACE] + */ + data object DeletingConflictedFiles : FolderResult() + data class Starting(val files: List, val totalFilesToCopy: Int) : FolderResult() + + /** + * @param fileCount total files/folders that are successfully copied/moved + */ + data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) : FolderResult() + + /** + * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] + * @param folder newly moved/copied file + * @param success `true` if the process is not canceled and no error during copy/move + * @param totalFilesToCopy total files, not folders + * @param totalCopiedFiles total files, not folders + */ + data class Completed(val folder: DocumentFile, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) : FolderResult() + data class Error(val errorCode: FolderErrorCode, val message: String? = null, val completedData: Completed? = null) : FolderResult() +} + +enum class FolderErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + SOURCE_FOLDER_NOT_FOUND, + SOURCE_FILE_NOT_FOUND, + INVALID_TARGET_FOLDER, + UNKNOWN_IO_ERROR, + CANCELED, + TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt new file mode 100644 index 0000000..68441f8 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt @@ -0,0 +1,41 @@ +package com.anggrayudi.storage.result + +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.callback.FolderConflictCallback.ConflictResolution + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed class MultipleFilesResult { + data object Validating : MultipleFilesResult() + data object Preparing : MultipleFilesResult() + data object CountingFiles : MultipleFilesResult() + data object DeletingConflictedFiles : MultipleFilesResult() + data class Starting(val files: List, val totalFilesToCopy: Int) : MultipleFilesResult() + + /** + * @param fileCount total files/folders that are successfully copied/moved + */ + data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) : MultipleFilesResult() + + /** + * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] + * @param files newly moved/copied parent files/folders + * @param success `true` if the process is not canceled and no error during copy/move + * @param totalFilesToCopy total files, not folders + * @param totalCopiedFiles total files, not folders + */ + data class Completed(val files: List, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) : MultipleFilesResult() + data class Error(val errorCode: MultipleFilesErrorCode, val message: String? = null, val completedData: Completed? = null) : MultipleFilesResult() +} + +enum class MultipleFilesErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + SOURCE_FILE_NOT_FOUND, + INVALID_TARGET_FOLDER, + UNKNOWN_IO_ERROR, + CANCELED, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt new file mode 100644 index 0000000..d31b8bb --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt @@ -0,0 +1,35 @@ +package com.anggrayudi.storage.result + +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.media.MediaFile + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed class SingleFileResult { + data object Validating : SingleFileResult() + data object Preparing : SingleFileResult() + data object CountingFiles : SingleFileResult() + data object DeletingConflictedFile : SingleFileResult() + data class Starting(val files: List, val totalFilesToCopy: Int) : SingleFileResult() + data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int) : SingleFileResult() + + /** + * @param result can be [DocumentFile] or [MediaFile] + */ + data class Completed(val result: Any) : SingleFileResult() + data class Error(val errorCode: SingleFileErrorCode, val message: String? = null) : SingleFileResult() +} + +enum class SingleFileErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + SOURCE_FILE_NOT_FOUND, + TARGET_FILE_NOT_FOUND, + TARGET_FOLDER_NOT_FOUND, + UNKNOWN_IO_ERROR, + CANCELED, + TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt new file mode 100644 index 0000000..c4427e2 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt @@ -0,0 +1,28 @@ +package com.anggrayudi.storage.result + +import androidx.documentfile.provider.DocumentFile + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed class ZipCompressionResult { + data object CountingFiles : ZipCompressionResult() + data class Compressing(val progress: Float, val bytesCompressed: Long, val writeSpeed: Int, val fileCount: Int) : ZipCompressionResult() + data class Completed(val zipFile: DocumentFile, val bytesCompressed: Long, val totalFilesCompressed: Int, val compressionRate: Float) : + ZipCompressionResult() + + data object DeletingEntryFiles : ZipCompressionResult() + + data class Error(val errorCode: ZipCompressionErrorCode, val message: String? = null) : ZipCompressionResult() +} + +enum class ZipCompressionErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + MISSING_ENTRY_FILE, + DUPLICATE_ENTRY_FILE, + UNKNOWN_IO_ERROR, + CANCELED, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt new file mode 100644 index 0000000..d635033 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt @@ -0,0 +1,37 @@ +package com.anggrayudi.storage.result + +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.media.MediaFile + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed class ZipDecompressionResult { + data object Validating : ZipDecompressionResult() + data class Decompressing(val bytesDecompressed: Long, val writeSpeed: Int, val fileCount: Int) : ZipDecompressionResult() + + /** + * @param zipFile can be [DocumentFile] or [MediaFile] + */ + data class Completed( + val zipFile: Any, + val targetFolder: DocumentFile, + val bytesDecompressed: Long, + val skippedDecompressedBytes: Long, + val totalFilesDecompressed: Int, + val decompressionRate: Float + ) : ZipDecompressionResult() + + data class Error(val errorCode: ZipDecompressionErrorCode, val message: String? = null) : ZipDecompressionResult() +} + +enum class ZipDecompressionErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + MISSING_ZIP_FILE, + NOT_A_ZIP_FILE, + UNKNOWN_IO_ERROR, + CANCELED, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/versions.gradle b/versions.gradle index 9caa4ec..9465995 100644 --- a/versions.gradle +++ b/versions.gradle @@ -7,7 +7,7 @@ def versions = [:] versions.activity = "1.6.0" versions.appcompat = "1.5.1" versions.core_ktx = "1.9.0" -versions.coroutines = "1.6.4" +versions.coroutines = "1.8.1" versions.documentfile = "1.0.1" versions.fragment = "1.5.3" versions.junit = "5.9.1" From 0eaee2778f1a42e68a7a7246511515197fcda1c8 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 10 Jun 2024 01:00:13 +0700 Subject: [PATCH 02/39] Added generic SingleFileConflictCallback --- .../callback/SingleFileConflictCallback.kt | 4 +- .../storage/file/DocumentFileExt.kt | 42 +++++++++---------- .../com/anggrayudi/storage/media/MediaFile.kt | 6 +-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt b/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt index 500e586..18f9c89 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt +++ b/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.GlobalScope * Created on 17/08/20 * @author Anggrayudi H */ -abstract class SingleFileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( +abstract class SingleFileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( var uiScope: CoroutineScope = GlobalScope ) { @@ -28,7 +28,7 @@ abstract class SingleFileConflictCallback @OptIn(DelicateCoroutinesApi::class) @ * @param destinationFile can be [DocumentFile] or [java.io.File] */ @UiThread - open fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { + open fun onFileConflict(destinationFile: T, action: FileConflictAction) { action.confirmResolution(ConflictResolution.CREATE_NEW) } diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index bb1420e..69306eb 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -751,7 +751,7 @@ fun DocumentFile.makeFile( name: String, mimeType: String? = MimeType.UNKNOWN, mode: CreateMode = CreateMode.CREATE_NEW, - onConflict: SingleFileConflictCallback? = null + onConflict: SingleFileConflictCallback? = null ): DocumentFile? { if (!isDirectory || !isWritable(context)) { return null @@ -1337,7 +1337,7 @@ fun DocumentFile.decompressZip( targetFolder: DocumentFile, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - onConflict: SingleFileConflictCallback? = null + onConflict: SingleFileConflictCallback? = null ): Flow = callbackFlow { send(ZipDecompressionResult.Validating) if (exists()) { @@ -1909,7 +1909,7 @@ private fun DocumentFile.copyFolderTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, callback: FolderConflictCallback ): Flow = callbackFlow { - val writableTargetParentFolder = doesMeetCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) ?: return@callbackFlow + val writableTargetParentFolder = doesMeetFolderCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) ?: return@callbackFlow send(FolderResult.Preparing) @@ -2158,7 +2158,7 @@ private fun Exception.toMultipleFileCallbackErrorCode(): MultipleFilesErrorCode } } -private fun DocumentFile.doesMeetCopyRequirements( +private fun DocumentFile.doesMeetFolderCopyRequirements( context: Context, targetParentFolder: DocumentFile, newFolderNameInTargetPath: String?, @@ -2203,7 +2203,7 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow { return copyFileTo(context, targetFolder.absolutePath, fileDescription, reportInterval, isFileSizeAllowed, callback) } @@ -2219,7 +2219,7 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { @@ -2240,7 +2240,7 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, this, isFileSizeAllowed, callback) @@ -2262,9 +2262,9 @@ private fun DocumentFile.copyFileTo( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return + val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return scope.trySend(SingleFileResult.Preparing) @@ -2305,7 +2305,7 @@ private fun DocumentFile.copyFileTo( /** * @return writable [DocumentFile] for `targetFolder` */ -private fun DocumentFile.doesMeetCopyRequirements( +private fun DocumentFile.doesMeetFileCopyRequirements( context: Context, targetFolder: DocumentFile, newFilenameInTargetPath: String?, @@ -2411,7 +2411,7 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow { return moveFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, callback) } @@ -2427,7 +2427,7 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { @@ -2448,7 +2448,7 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, callback) @@ -2470,9 +2470,9 @@ private fun DocumentFile.moveFileTo( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return + val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return scope.trySend(SingleFileResult.Preparing) @@ -2575,7 +2575,7 @@ private fun DocumentFile.copyFileToMedia( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback, + callback: SingleFileConflictCallback, ) { if (simpleCheckSourceFile(scope)) return @@ -2624,7 +2624,7 @@ fun DocumentFile.copyFileToDownloadMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback, + callback: SingleFileConflictCallback, ): Flow = callbackFlow { copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, false, mode, updateInterval, this, isFileSizeAllowed, callback) } @@ -2637,7 +2637,7 @@ fun DocumentFile.copyFileToPictureMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback, + callback: SingleFileConflictCallback, ): Flow = callbackFlow { copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, false, mode, updateInterval, this, isFileSizeAllowed, callback) } @@ -2650,7 +2650,7 @@ fun DocumentFile.moveFileToDownloadMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, true, mode, updateInterval, this, isFileSizeAllowed, callback) } @@ -2663,7 +2663,7 @@ fun DocumentFile.moveFileToPictureMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, true, mode, updateInterval, this, isFileSizeAllowed, callback) } @@ -2740,7 +2740,7 @@ private fun handleFileConflict( targetFolder: DocumentFile, targetFileName: String, scope: ProducerScope, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, targetFileName)?.let { targetFile -> val resolution = awaitUiResultWithPending(callback.uiScope) { diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt index ae51cdd..d1d0a71 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt @@ -366,7 +366,7 @@ class MediaFile(context: Context, val uri: Uri) { fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { @@ -419,7 +419,7 @@ class MediaFile(context: Context, val uri: Uri) { fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { @@ -560,7 +560,7 @@ class MediaFile(context: Context, val uri: Uri) { targetFolder: DocumentFile, fileName: String, scope: ProducerScope, - callback: SingleFileConflictCallback + callback: SingleFileConflictCallback ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, fileName)?.let { targetFile -> val resolution = awaitUiResultWithPending(callback.uiScope) { From 00239561d52fd4c713c53e94d0f5f2a1e9c3f495 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 19:09:33 +0700 Subject: [PATCH 03/39] Resolved todos --- .../storage/file/DocumentFileExt.kt | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index 69306eb..b6a24ef 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -2225,8 +2225,7 @@ fun DocumentFile.copyFileTo( if (targetFolder == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - // todo how to continue/return current flow with this flow function? - copyFileTo(context, targetFolder, fileDescription, reportInterval, isFileSizeAllowed, callback) + copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, callback) } } @@ -2242,14 +2241,26 @@ fun DocumentFile.copyFileTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, callback: SingleFileConflictCallback ): Flow = callbackFlow { + copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, callback) +} + +private fun DocumentFile.copyFileTo( + context: Context, + targetFolder: DocumentFile, + fileDescription: FileDescription?, + reportInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, + callback: SingleFileConflictCallback +) { if (fileDescription?.subFolder.isNullOrEmpty()) { - copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, this, isFileSizeAllowed, callback) + copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, callback) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { - send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, reportInterval, this, isFileSizeAllowed, callback) + copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, callback) } } } @@ -2433,8 +2444,7 @@ fun DocumentFile.moveFileTo( if (targetFolder == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - // TODO: Return another flow - moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, callback) } } @@ -2450,14 +2460,26 @@ fun DocumentFile.moveFileTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, callback: SingleFileConflictCallback ): Flow = callbackFlow { + moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, callback) +} + +private fun DocumentFile.moveFileTo( + context: Context, + targetFolder: DocumentFile, + fileDescription: FileDescription?, + updateInterval: Long, + scope: ProducerScope, + isFileSizeAllowed: CheckFileSize, + callback: SingleFileConflictCallback +) { if (fileDescription?.subFolder.isNullOrEmpty()) { - moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, callback) + moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, callback) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { - send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) + scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, callback) + moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, callback) } } } From 521ce0e07ddbc93fb3cf2a85a3a3d8b196eb63d5 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 19:21:33 +0700 Subject: [PATCH 04/39] fixed compile error & remove java support --- .../activity/FileDecompressionActivity.kt | 38 ++++++------ .../storage/sample/activity/JavaActivity.java | 32 ---------- .../storage/sample/activity/MainActivity.kt | 2 +- .../sample/fragment/SettingsFragment.java | 61 ------------------- 4 files changed, 20 insertions(+), 113 deletions(-) diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt index 2517340..1c8d0a5 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt @@ -11,8 +11,10 @@ import com.anggrayudi.storage.file.MimeType import com.anggrayudi.storage.file.decompressZip import com.anggrayudi.storage.file.fullName import com.anggrayudi.storage.file.getAbsolutePath +import com.anggrayudi.storage.result.ZipDecompressionResult import com.anggrayudi.storage.sample.databinding.ActivityFileDecompressionBinding import kotlinx.coroutines.launch +import timber.log.Timber /** * Created on 04/01/22 @@ -66,10 +68,10 @@ class FileDecompressionActivity : BaseActivity() { return } ioScope.launch { - zipFile.decompressZip(applicationContext, targetFolder, object : ZipDecompressionCallback(uiScope) { - var actionForAllConflicts: SingleFileConflictCallback.ConflictResolution? = null + zipFile.decompressZip(applicationContext, targetFolder, onConflict = object : SingleFileConflictCallback(uiScope) { + var actionForAllConflicts: ConflictResolution? = null - override fun onFileConflict(destinationFile: DocumentFile, action: SingleFileConflictCallback.FileConflictAction) { + override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { actionForAllConflicts?.let { action.confirmResolution(it) return @@ -82,7 +84,7 @@ class FileDecompressionActivity : BaseActivity() { .message(text = "File \"${destinationFile.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = mutableListOf("Replace", "Create New", "Skip Duplicate")) { _, index, _ -> - val resolution = SingleFileConflictCallback.ConflictResolution.values()[index] + val resolution = ConflictResolution.entries[index] if (doForAll) { actionForAllConflicts = resolution } @@ -90,23 +92,21 @@ class FileDecompressionActivity : BaseActivity() { } .show() } + }).collect { + when (it) { + is ZipDecompressionResult.Validating -> Timber.d("Validating") + is ZipDecompressionResult.Decompressing -> Timber.d("Decompressing") + is ZipDecompressionResult.Completed -> { + Toast.makeText( + applicationContext, + "Decompressed ${it.totalFilesDecompressed} files from ${zipFile.name}", + Toast.LENGTH_SHORT + ).show() + } - override fun onCompleted( - zipFile: DocumentFile, - targetFolder: DocumentFile, - decompressionInfo: DecompressionInfo - ) { - Toast.makeText( - applicationContext, - "Decompressed ${decompressionInfo.totalFilesDecompressed} files from ${zipFile.name}", - Toast.LENGTH_SHORT - ).show() - } - - override fun onFailed(errorCode: ErrorCode) { - Toast.makeText(applicationContext, "$errorCode", Toast.LENGTH_SHORT).show() + is ZipDecompressionResult.Error -> Toast.makeText(applicationContext, "${it.errorCode}", Toast.LENGTH_SHORT).show() } - }) + } } } } \ No newline at end of file diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java index 354e296..a281dc4 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java @@ -1,9 +1,7 @@ package com.anggrayudi.storage.sample.activity; import com.anggrayudi.storage.SimpleStorageHelper; -import com.anggrayudi.storage.callback.SingleFileConflictCallback; import com.anggrayudi.storage.file.DocumentFileUtils; -import com.anggrayudi.storage.media.MediaFile; import com.anggrayudi.storage.permission.ActivityPermissionRequest; import com.anggrayudi.storage.permission.PermissionCallback; import com.anggrayudi.storage.permission.PermissionReport; @@ -22,8 +20,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; -import androidx.documentfile.provider.DocumentFile; -import timber.log.Timber; import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_CREATE_FILE; import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FILE; @@ -101,34 +97,6 @@ private void setupSimpleStorage(Bundle savedState) { }); } - private void moveFile(DocumentFile source, DocumentFile destinationFolder) { - DocumentFileUtils.moveFileTo(source, getApplicationContext(), destinationFolder, null, new SingleFileConflictCallback() { - @Override - public void onFileConflict(@NotNull DocumentFile destinationFile, @NotNull SingleFileConflictCallback.FileConflictAction action) { - // do stuff - } - - @Override - public void onCompleted(@NotNull Object result) { - if (result instanceof DocumentFile) { - // do stuff - } else if (result instanceof MediaFile) { - // do stuff - } - } - - @Override - public void onReport(Report report) { - Timber.d("%s", report.getProgress()); - } - - @Override - public void onFailed(ErrorCode errorCode) { - Timber.d("Error: %s", errorCode.toString()); - } - }); - } - @Override protected void onSaveInstanceState(@NonNull Bundle outState) { storageHelper.onSaveInstanceState(outState); diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt index 5a9c1d9..8d54b0e 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -619,7 +619,7 @@ class MainActivity : AppCompatActivity() { } } - private fun createFileCallback() = object : SingleFileConflictCallback(uiScope) { + private fun createFileCallback() = object : SingleFileConflictCallback(uiScope) { override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { handleFileConflict(action) } diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java index 6b67230..cf77da6 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java @@ -1,29 +1,17 @@ package com.anggrayudi.storage.sample.fragment; import com.anggrayudi.storage.SimpleStorageHelper; -import com.anggrayudi.storage.callback.SingleFileConflictCallback; -import com.anggrayudi.storage.file.DocumentFileCompat; -import com.anggrayudi.storage.file.DocumentFileType; import com.anggrayudi.storage.file.DocumentFileUtils; import com.anggrayudi.storage.file.PublicDirectory; -import com.anggrayudi.storage.media.FileDescription; -import com.anggrayudi.storage.media.MediaFile; import com.anggrayudi.storage.sample.R; -import android.content.Context; import android.content.SharedPreferences; -import android.net.Uri; import android.os.Bundle; -import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.WorkerThread; -import androidx.core.content.FileProvider; -import androidx.documentfile.provider.DocumentFile; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; -import timber.log.Timber; /** * Created on 08/08/21 @@ -65,53 +53,4 @@ public void onSaveInstanceState(final @NonNull Bundle outState) { storageHelper.onSaveInstanceState(outState); super.onSaveInstanceState(outState); } - - @WorkerThread - private void moveFileToSaveLocation(@NonNull DocumentFile sourceFile) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); - String downloadsFolder = PublicDirectory.DOWNLOADS.getAbsolutePath(); - String saveLocationPath = preferences.getString(PREF_SAVE_LOCATION, downloadsFolder); - DocumentFile saveLocationFolder = DocumentFileCompat.fromFullPath(requireContext(), saveLocationPath, DocumentFileType.FOLDER, true); - if (saveLocationFolder != null) { - // write any files into folder 'saveLocationFolder' - DocumentFileUtils.moveFileTo(sourceFile, requireContext(), saveLocationFolder, null, createCallback()); - } else { - FileDescription fileDescription = new FileDescription(sourceFile.getName(), "", sourceFile.getType()); - DocumentFileUtils.moveFileToDownloadMedia(sourceFile, requireContext(), fileDescription, createCallback()); - } - } - - private SingleFileConflictCallback createCallback() { - return new SingleFileConflictCallback() { - @Override - public void onReport(Report report) { - Timber.d("Progress: %s", report.getProgress()); - } - - @Override - public void onFailed(ErrorCode errorCode) { - Toast.makeText(requireContext(), errorCode.toString(), Toast.LENGTH_SHORT).show(); - } - - @Override - public void onCompleted(@NonNull Object file) { - final Uri uri; - final Context context = requireContext(); - - if (file instanceof MediaFile) { - final MediaFile mediaFile = (MediaFile) file; - uri = mediaFile.getUri(); - } else if (file instanceof DocumentFile) { - final DocumentFile documentFile = (DocumentFile) file; - uri = DocumentFileUtils.isRawFile(documentFile) - ? FileProvider.getUriForFile(context, context.getPackageName() + ".provider", DocumentFileUtils.toRawFile(documentFile, context)) - : documentFile.getUri(); - } else { - return; - } - - Toast.makeText(context, "Completed. File URI: " + uri.toString(), Toast.LENGTH_SHORT).show(); - } - }; - } } From 80ce150a70e723c5cf9495ee8f36987303eaeb33 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 19:44:58 +0700 Subject: [PATCH 05/39] updated dependencies --- build.gradle | 12 ++++++------ sample/build.gradle | 2 +- .../storage/sample/activity/MainActivity.kt | 2 +- versions.gradle | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.gradle b/build.gradle index 434b8f2..b0e0fde 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ allprojects { addRepos(repositories) //Support @JvmDefault - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all', '-opt-in=kotlin.RequiresOptIn'] jvmTarget = '1.8' @@ -41,11 +41,11 @@ subprojects { afterEvaluate { android { - compileSdkVersion 33 + compileSdkVersion 34 defaultConfig { - minSdkVersion 19 - targetSdkVersion 33 + minSdkVersion 21 + targetSdkVersion 34 versionCode 1 versionName "$VERSION_NAME" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -61,7 +61,7 @@ subprojects { buildConfig true } } - configurations.all { + configurations.configureEach { resolutionStrategy { // Force Kotlin to use current version force "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" @@ -79,6 +79,6 @@ subprojects { } } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index edf2f34..4fb79e7 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -78,7 +78,7 @@ dependencies { implementation deps.timber implementation deps.material_progressbar - implementation 'androidx.preference:preference-ktx:1.2.0' + implementation 'androidx.preference:preference-ktx:1.2.1' //test testImplementation deps.junit diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt index 8d54b0e..2e90594 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -765,7 +765,7 @@ class MainActivity : AppCompatActivity() { .show() } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) storageHelper.storage.checkIfFileReceived(intent) } diff --git a/versions.gradle b/versions.gradle index 9465995..bfd6581 100644 --- a/versions.gradle +++ b/versions.gradle @@ -4,12 +4,12 @@ **/ ext.deps = [:] def versions = [:] -versions.activity = "1.6.0" -versions.appcompat = "1.5.1" -versions.core_ktx = "1.9.0" +versions.activity = "1.9.0" +versions.appcompat = "1.7.0" +versions.core_ktx = "1.13.1" versions.coroutines = "1.8.1" versions.documentfile = "1.0.1" -versions.fragment = "1.5.3" +versions.fragment = "1.8.0" versions.junit = "5.9.1" versions.material_dialogs = "3.3.0" versions.material_progressbar = "1.6.1" From 3b249e35b1fc70a9e3fce66b62f327d93ac9cb48 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 20:27:08 +0700 Subject: [PATCH 06/39] removed deprecated code --- sample/build.gradle | 1 + .../java/com/anggrayudi/storage/sample/App.kt | 7 +- .../storage/sample/StorageInfoAdapter.kt | 1 - .../storage/sample/activity/MainActivity.kt | 5 +- .../storage/sample/fragment/SampleFragment.kt | 11 +- storage/build.gradle | 3 +- .../com/anggrayudi/storage/SimpleStorage.kt | 67 ++++------ .../anggrayudi/storage/SimpleStorageHelper.kt | 15 ++- .../anggrayudi/storage/extension/TextExt.kt | 8 -- .../storage/file/DocumentFileCompat.kt | 122 ++++++------------ .../storage/file/DocumentFileExt.kt | 13 +- .../com/anggrayudi/storage/file/FileExt.kt | 18 +-- .../anggrayudi/storage/file/FileFullPath.kt | 8 -- .../storage/file/PublicDirectory.kt | 1 - .../com/anggrayudi/storage/file/StorageId.kt | 5 - .../com/anggrayudi/storage/media/MediaFile.kt | 2 +- .../storage/media/MediaStoreCompat.kt | 28 ++-- .../com/anggrayudi/storage/media/MediaType.kt | 7 +- .../storage/file/DocumentFileCompatTest.kt | 1 - 19 files changed, 113 insertions(+), 210 deletions(-) diff --git a/sample/build.gradle b/sample/build.gradle index 4fb79e7..a9e7c86 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -79,6 +79,7 @@ dependencies { implementation deps.timber implementation deps.material_progressbar implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'com.afollestad.material-dialogs:files:3.3.0' //test testImplementation deps.junit diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/App.kt b/sample/src/main/java/com/anggrayudi/storage/sample/App.kt index 88760a3..0d691b5 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/App.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/App.kt @@ -6,9 +6,4 @@ import androidx.multidex.MultiDexApplication * @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id) * @version App, v 0.0.1 10/08/20 00.39 by Anggrayudi Hardiannico A. */ -class App : MultiDexApplication() { - - override fun onCreate() { - super.onCreate() - } -} \ No newline at end of file +class App : MultiDexApplication() \ No newline at end of file diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt b/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt index 9b45777..25847a8 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt @@ -61,7 +61,6 @@ class StorageInfoAdapter( * A storageId may contains more than one granted URIs */ @SuppressLint("NewApi") - @Suppress("DEPRECATION") private fun showGrantedUris(context: Context, filterStorageId: String) { val grantedPaths = DocumentFileCompat.getAccessibleAbsolutePaths(context)[filterStorageId] if (grantedPaths == null) { diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt index 2e90594..c789e8f 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -113,10 +113,7 @@ class MainActivity : AppCompatActivity() { isEnabled = Build.VERSION.SDK_INT in 23..28 } - binding.layoutBaseOperation.btnRequestStorageAccess.run { - isEnabled = Build.VERSION.SDK_INT >= 21 - setOnClickListener { storageHelper.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS) } - } + binding.layoutBaseOperation.btnRequestStorageAccess.setOnClickListener { storageHelper.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS) } binding.layoutBaseOperation.btnRequestFullStorageAccess.run { isEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt index bdcb32f..769b5ee 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt @@ -11,7 +11,11 @@ import com.afollestad.materialdialogs.MaterialDialog import com.anggrayudi.storage.SimpleStorageHelper import com.anggrayudi.storage.file.fullName import com.anggrayudi.storage.file.getAbsolutePath -import com.anggrayudi.storage.permission.* +import com.anggrayudi.storage.permission.FragmentPermissionRequest +import com.anggrayudi.storage.permission.PermissionCallback +import com.anggrayudi.storage.permission.PermissionReport +import com.anggrayudi.storage.permission.PermissionRequest +import com.anggrayudi.storage.permission.PermissionResult import com.anggrayudi.storage.sample.R import com.anggrayudi.storage.sample.activity.MainActivity import com.anggrayudi.storage.sample.databinding.InclBaseOperationBinding @@ -63,10 +67,7 @@ class SampleFragment : Fragment(R.layout.incl_base_operation) { isEnabled = Build.VERSION.SDK_INT in 23..28 } - binding.btnRequestStorageAccess.run { - isEnabled = Build.VERSION.SDK_INT >= 21 - setOnClickListener { storageHelper.requestStorageAccess(MainActivity.REQUEST_CODE_STORAGE_ACCESS) } - } + binding.btnRequestStorageAccess.setOnClickListener { storageHelper.requestStorageAccess(MainActivity.REQUEST_CODE_STORAGE_ACCESS) } binding.btnRequestFullStorageAccess.run { isEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/storage/build.gradle b/storage/build.gradle index 1e17cfa..8ba4bf7 100644 --- a/storage/build.gradle +++ b/storage/build.gradle @@ -33,8 +33,7 @@ dependencies { api deps.documentfile api deps.coroutines.core api deps.coroutines.android - api 'com.afollestad.material-dialogs:files:3.3.0' - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2" testImplementation deps.junit testImplementation deps.mockk diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt index d20242d..5b9ba2e 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt @@ -20,13 +20,26 @@ import androidx.annotation.RequiresPermission import androidx.core.content.ContextCompat.checkSelfPermission import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.callbacks.onCancel -import com.afollestad.materialdialogs.files.folderChooser -import com.anggrayudi.storage.callback.* -import com.anggrayudi.storage.extension.* -import com.anggrayudi.storage.file.* +import com.anggrayudi.storage.callback.CreateFileCallback +import com.anggrayudi.storage.callback.FilePickerCallback +import com.anggrayudi.storage.callback.FileReceiverCallback +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback +import com.anggrayudi.storage.extension.fromSingleUri +import com.anggrayudi.storage.extension.fromTreeUri +import com.anggrayudi.storage.extension.getStorageId +import com.anggrayudi.storage.extension.isDocumentsDocument +import com.anggrayudi.storage.extension.isDownloadsDocument +import com.anggrayudi.storage.extension.isExternalStorageDocument +import com.anggrayudi.storage.file.DocumentFileCompat +import com.anggrayudi.storage.file.FileFullPath +import com.anggrayudi.storage.file.MimeType +import com.anggrayudi.storage.file.PublicDirectory import com.anggrayudi.storage.file.StorageId.PRIMARY +import com.anggrayudi.storage.file.StorageType +import com.anggrayudi.storage.file.canModify +import com.anggrayudi.storage.file.getAbsolutePath +import com.anggrayudi.storage.file.getBasePath import java.io.File import kotlin.concurrent.thread @@ -106,7 +119,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { * volume/path of SdCard, and of course, SdCard != External Storage. */ private val sdCardRootAccessIntent: Intent - @Suppress("DEPRECATION") @RequiresApi(api = Build.VERSION_CODES.N) get() { val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager @@ -145,7 +157,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { * trigger [StorageAccessCallback.onRootPathNotSelected]. Set to [StorageType.UNKNOWN] to accept any storage type. * @param expectedBasePath applicable for API 30+ only, because Android 11 does not allow selecting the root path. */ - @RequiresApi(21) @JvmOverloads fun requestStorageAccess( requestCode: Int = requestCodeStorageAccess, @@ -229,21 +240,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { initialPath?.checkIfStorageIdIsAccessibleInSafSelector() requestCodeFolderPicker = requestCode - if (Build.VERSION.SDK_INT < 21) { - MaterialDialog(context).folderChooser( - context, - initialDirectory = initialPath?.let { File(it.absolutePath) } ?: lastVisitedFolder, - allowFolderCreation = true, - selection = { _, file -> - lastVisitedFolder = file - folderPickerCallback?.onFolderSelected(requestCode, DocumentFile.fromFile(file)) - } - ).negativeButton(android.R.string.cancel, click = { it.cancel() }) - .onCancel { folderPickerCallback?.onCanceledByUser(requestCode) } - .show() - return - } - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || hasStoragePermission(context)) { val intent = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) @@ -258,7 +254,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } } - @Suppress("DEPRECATION") private var lastVisitedFolder: File = Environment.getExternalStorageDirectory() /** @@ -274,12 +269,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { initialPath?.checkIfStorageIdIsAccessibleInSafSelector() requestCodeFilePicker = requestCode - val intent = if (Build.VERSION.SDK_INT < 21) { - Intent(Intent.ACTION_GET_CONTENT) - } else { - Intent(Intent.ACTION_OPEN_DOCUMENT) - } - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) if (filterMimeTypes.size > 1) { intent.setType(MimeType.UNKNOWN) .putExtra(Intent.EXTRA_MIME_TYPES, filterMimeTypes) @@ -374,7 +365,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager - @Suppress("DEPRECATION") sm.storageVolumes.firstOrNull { !it.isPrimary }?.createAccessIntent(null)?.let { if (!wrapper.startActivityForResult(it, requestCode)) { storageAccessCallback?.onActivityHandlerNotFound(requestCode, it) @@ -427,13 +417,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { if (uri.isDownloadsDocument && Build.VERSION.SDK_INT < 28 && uri.path?.startsWith("/document/raw:") == true) { val fullPath = uri.path.orEmpty().substringAfterLast("/document/raw:") DocumentFile.fromFile(File(fullPath)) - } else context.fromSingleUri(uri)?.let { file -> - // content://com.android.externalstorage.documents/document/15FA-160C%3Aabc.txt - if (Build.VERSION.SDK_INT < 21 && file.getStorageId(context).matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX)) { - DocumentFile.fromFile(DocumentFileCompat.getKitkatSdCardRootFile(file.getBasePath(context))) - } else { - file - } + } else { + context.fromSingleUri(uri) } }.filter { it.isFile } } @@ -522,7 +507,7 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { fun onRestoreInstanceState(savedInstanceState: Bundle) { savedInstanceState.getString(KEY_LAST_VISITED_FOLDER)?.let { lastVisitedFolder = File(it) } expectedBasePathForAccessRequest = savedInstanceState.getString(KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST) - expectedStorageTypeForAccessRequest = StorageType.values()[savedInstanceState.getInt(KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST)] + expectedStorageTypeForAccessRequest = StorageType.entries.toTypedArray()[savedInstanceState.getInt(KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST)] requestCodeStorageAccess = savedInstanceState.getInt(KEY_REQUEST_CODE_STORAGE_ACCESS, DEFAULT_REQUEST_CODE_STORAGE_ACCESS) requestCodeFolderPicker = savedInstanceState.getInt(KEY_REQUEST_CODE_FOLDER_PICKER, DEFAULT_REQUEST_CODE_FOLDER_PICKER) requestCodeFilePicker = savedInstanceState.getInt(KEY_REQUEST_CODE_FILE_PICKER, DEFAULT_REQUEST_CODE_FILE_PICKER) @@ -576,11 +561,7 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { private const val DEFAULT_REQUEST_CODE_FILE_PICKER: Int = 3 private const val DEFAULT_REQUEST_CODE_CREATE_FILE: Int = 4 - const val KITKAT_SD_CARD_ID = "sdcard" - const val KITKAT_SD_CARD_PATH = "/storage/$KITKAT_SD_CARD_ID" - @JvmStatic - @Suppress("DEPRECATION") val externalStoragePath: String get() = Environment.getExternalStorageDirectory().absolutePath diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt index 5200e1c..9c5d214 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt @@ -10,16 +10,24 @@ import android.os.Bundle import android.provider.Settings import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment -import com.anggrayudi.storage.callback.* +import com.anggrayudi.storage.callback.CreateFileCallback +import com.anggrayudi.storage.callback.FilePickerCallback +import com.anggrayudi.storage.callback.FileReceiverCallback +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback import com.anggrayudi.storage.extension.getStorageId import com.anggrayudi.storage.file.FileFullPath import com.anggrayudi.storage.file.StorageType import com.anggrayudi.storage.file.getAbsolutePath -import com.anggrayudi.storage.permission.* +import com.anggrayudi.storage.permission.ActivityPermissionRequest +import com.anggrayudi.storage.permission.FragmentPermissionRequest +import com.anggrayudi.storage.permission.PermissionCallback +import com.anggrayudi.storage.permission.PermissionReport +import com.anggrayudi.storage.permission.PermissionRequest +import com.anggrayudi.storage.permission.PermissionResult /** * Helper class to ease you using file & folder picker. @@ -344,7 +352,6 @@ class SimpleStorageHelper { } } - @RequiresApi(21) @JvmOverloads fun requestStorageAccess( requestCode: Int = storage.requestCodeStorageAccess, diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt b/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt index c74c06c..e121e5c 100644 --- a/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt @@ -2,12 +2,9 @@ package com.anggrayudi.storage.extension -import android.os.Build import androidx.annotation.RestrictTo import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.file.DocumentFileCompat import com.anggrayudi.storage.file.DocumentFileCompat.removeForbiddenCharsFromFilename -import com.anggrayudi.storage.file.StorageId /** * Created on 19/08/20 @@ -46,10 +43,6 @@ fun String.replaceCompletely(match: String, replaceWith: String) = let { path } -@RestrictTo(RestrictTo.Scope.LIBRARY) -fun String.isKitkatSdCardStorageId() = - Build.VERSION.SDK_INT < 21 && (this == StorageId.KITKAT_SDCARD || this.matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX)) - @RestrictTo(RestrictTo.Scope.LIBRARY) fun String.hasParent(parentPath: String): Boolean { val parentTree = parentPath.getFolderTree() @@ -73,7 +66,6 @@ fun String.parent(): String { val parentPath = folderTree.take(folderTree.size - 1).joinToString("/", "/") return if (parentPath.startsWith(SimpleStorage.externalStoragePath) || parentPath.matches(Regex("/storage/[A-Z0-9]{4}-[A-Z0-9]{4}(.*?)")) - || Build.VERSION.SDK_INT < 21 && parentPath.startsWith(SimpleStorage.KITKAT_SD_CARD_PATH) ) { parentPath } else { diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt index 6abf04f..9669ae9 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt @@ -14,12 +14,19 @@ import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.FileWrapper import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.SimpleStorage.Companion.KITKAT_SD_CARD_ID -import com.anggrayudi.storage.SimpleStorage.Companion.KITKAT_SD_CARD_PATH -import com.anggrayudi.storage.extension.* +import com.anggrayudi.storage.extension.fromSingleUri +import com.anggrayudi.storage.extension.fromTreeUri +import com.anggrayudi.storage.extension.getStorageId +import com.anggrayudi.storage.extension.hasParent +import com.anggrayudi.storage.extension.isDocumentsDocument +import com.anggrayudi.storage.extension.isDownloadsDocument +import com.anggrayudi.storage.extension.isExternalStorageDocument +import com.anggrayudi.storage.extension.isRawFile +import com.anggrayudi.storage.extension.isTreeDocumentFile +import com.anggrayudi.storage.extension.replaceCompletely +import com.anggrayudi.storage.extension.trimFileSeparator import com.anggrayudi.storage.file.StorageId.DATA import com.anggrayudi.storage.file.StorageId.HOME -import com.anggrayudi.storage.file.StorageId.KITKAT_SDCARD import com.anggrayudi.storage.file.StorageId.PRIMARY import com.anggrayudi.storage.media.FileDescription import com.anggrayudi.storage.media.MediaStoreCompat @@ -67,9 +74,6 @@ object DocumentFileCompat { @RestrictTo(RestrictTo.Scope.LIBRARY) val SD_CARD_STORAGE_PATH_REGEX = Regex("/storage/$SD_CARD_STORAGE_ID_REGEX(.*?)") - @RestrictTo(RestrictTo.Scope.LIBRARY) - fun getKitkatSdCardRootFile(basePath: String = "") = File(KITKAT_SD_CARD_PATH + "/$basePath".trimEnd('/')) - @JvmStatic fun isRootUri(uri: Uri): Boolean { val path = uri.path ?: return false @@ -87,7 +91,6 @@ object DocumentFileCompat { when { fullPath.startsWith(SimpleStorage.externalStoragePath) -> PRIMARY fullPath.startsWith(context.dataDirectory.path) -> DATA - fullPath.startsWith(KITKAT_SD_CARD_PATH) -> KITKAT_SD_CARD_ID else -> if (fullPath.matches(SD_CARD_STORAGE_PATH_REGEX)) { fullPath.substringAfter("/storage/", "").substringBefore('/') } else "" @@ -111,7 +114,6 @@ object DocumentFileCompat { when { fullPath.startsWith(externalStoragePath) -> fullPath.substringAfter(externalStoragePath) fullPath.startsWith(dataDir) -> fullPath.substringAfter(dataDir) - fullPath.startsWith(KITKAT_SD_CARD_PATH) -> fullPath.substringAfter(KITKAT_SD_CARD_PATH) else -> if (fullPath.matches(SD_CARD_STORAGE_PATH_REGEX)) { fullPath.substringAfter("/storage/", "").substringAfter('/', "") } else "" @@ -127,13 +129,7 @@ object DocumentFileCompat { return when { uri.isRawFile -> File(uri.path ?: return null).run { if (canRead()) DocumentFile.fromFile(this) else null } uri.isTreeDocumentFile -> context.fromTreeUri(uri)?.run { if (isDownloadsDocument) toWritableDownloadsDocumentFile(context) else this } - else -> context.fromSingleUri(uri)?.let { - if (Build.VERSION.SDK_INT < 21 && it.getStorageId(context).matches(SD_CARD_STORAGE_ID_REGEX)) { - DocumentFile.fromFile(getKitkatSdCardRootFile(it.getBasePath(context))) - } else { - it - } - } + else -> context.fromSingleUri(uri) } } @@ -216,7 +212,7 @@ object DocumentFileCompat { requiresWriteAccess: Boolean = false, considerRawFile: Boolean = true ): DocumentFile? { - return if (file.checkRequirements(context, requiresWriteAccess, considerRawFile || Build.VERSION.SDK_INT < 21)) { + return if (file.checkRequirements(context, requiresWriteAccess, considerRawFile)) { if (documentType == DocumentFileType.FILE && !file.isFile || documentType == DocumentFileType.FOLDER && !file.isDirectory) { null } else { @@ -236,7 +232,6 @@ object DocumentFileCompat { */ @JvmOverloads @JvmStatic - @Suppress("DEPRECATION") fun fromPublicFolder( context: Context, type: PublicDirectory, @@ -303,9 +298,6 @@ object DocumentFileCompat { if (storageId == DATA) { return DocumentFile.fromFile(context.dataDirectory) } - if (storageId.isKitkatSdCardStorageId()) { - return DocumentFile.fromFile(getKitkatSdCardRootFile()).takeIf { it.canWrite() } - } val file = if (storageId == HOME) { if (Build.VERSION.SDK_INT == 29) { context.fromTreeUri(createDocumentUri(PRIMARY)) @@ -330,7 +322,6 @@ object DocumentFileCompat { * @param fullPath construct it using [buildAbsolutePath] or [buildSimplePath] * @return `null` if accessible root path is not found in [ContentResolver.getPersistedUriPermissions], or the folder does not exist. */ - @Suppress("DEPRECATION") @JvmOverloads @JvmStatic fun getAccessibleRootDocumentFile( @@ -388,12 +379,10 @@ object DocumentFileCompat { */ @JvmOverloads @JvmStatic - @Suppress("DEPRECATION") fun getRootRawFile(context: Context, storageId: String, requiresWriteAccess: Boolean = false): File? { - val rootFile = when { - storageId == PRIMARY || storageId == HOME -> Environment.getExternalStorageDirectory() - storageId == DATA -> context.dataDirectory - storageId.isKitkatSdCardStorageId() -> getKitkatSdCardRootFile() + val rootFile = when (storageId) { + PRIMARY, HOME -> Environment.getExternalStorageDirectory() + DATA -> context.dataDirectory else -> File("/storage/$storageId") } return rootFile.takeIf { rootFile.canRead() && (requiresWriteAccess && rootFile.isWritable(context) || !requiresWriteAccess) } @@ -536,9 +525,6 @@ object DocumentFileCompat { if (Build.VERSION.SDK_INT < 29 && SimpleStorage.hasStoragePermission(context)) { storages[PRIMARY]?.add(SimpleStorage.externalStoragePath) } - if (Build.VERSION.SDK_INT < 21 && File(KITKAT_SD_CARD_PATH).canWrite()) { - storages[KITKAT_SDCARD] = mutableSetOf(KITKAT_SD_CARD_PATH) - } if (storages[PRIMARY].isNullOrEmpty()) { storages.remove(PRIMARY) } @@ -622,18 +608,17 @@ object DocumentFileCompat { val dataDir = context.dataDirectory.path val results = arrayOfNulls(fullPaths.size) val cleanedFullPaths = fullPaths.map { buildAbsolutePath(context, it) } - val shouldUseRawFile = considerRawFile || Build.VERSION.SDK_INT < 21 for (path in findUniqueDeepestSubFolders(context, cleanedFullPaths)) { // use java.io.File for faster performance val folder = File(path).apply { mkdirs() } - if (shouldUseRawFile && folder.isDirectory && folder.canRead() || path.startsWith(dataDir)) { + if (considerRawFile && folder.isDirectory && folder.canRead() || path.startsWith(dataDir)) { cleanedFullPaths.forEachIndexed { index, s -> if (path.hasParent(s)) { results[index] = DocumentFile.fromFile(File(getDirectorySequence(s).joinToString(prefix = "/", separator = "/"))) } } } else { - var currentDirectory = getAccessibleRootDocumentFile(context, path, requiresWriteAccess, shouldUseRawFile) ?: continue + var currentDirectory = getAccessibleRootDocumentFile(context, path, requiresWriteAccess, considerRawFile) ?: continue val isRawFile = currentDirectory.isRawFile val resolver = context.contentResolver getDirectorySequence(getBasePath(context, path)).forEach { @@ -701,12 +686,8 @@ object DocumentFileCompat { mimeType: String = MimeType.UNKNOWN, considerRawFile: Boolean = true ): DocumentFile? { - return if (storageId == DATA || storageId.isKitkatSdCardStorageId() || considerRawFile && storageId == PRIMARY && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - val file = if (storageId.isKitkatSdCardStorageId()) { - getKitkatSdCardRootFile(basePath) - } else { - File(buildAbsolutePath(context, storageId, basePath)) - } + return if (storageId == DATA || considerRawFile && storageId == PRIMARY && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + val file = File(buildAbsolutePath(context, storageId, basePath)) file.parentFile?.mkdirs() if (create(file)) DocumentFile.fromFile(file) else null } else try { @@ -740,11 +721,7 @@ object DocumentFileCompat { mimeType: String = MimeType.UNKNOWN, considerRawFile: Boolean = true ): DocumentFile? { - val file = if (storageId.isKitkatSdCardStorageId()) { - getKitkatSdCardRootFile(basePath) - } else { - File(buildAbsolutePath(context, storageId, basePath)) - } + val file = File(buildAbsolutePath(context, storageId, basePath)) file.delete() file.parentFile?.mkdirs() if ((considerRawFile || storageId == DATA) && create(file)) { @@ -781,12 +758,6 @@ object DocumentFileCompat { requiresWriteAccess: Boolean, considerRawFile: Boolean ): DocumentFile? { - if (storageId.isKitkatSdCardStorageId()) { - return DocumentFile.fromFile(getKitkatSdCardRootFile(basePath)).takeIf { - it.canWrite() && (documentType == DocumentFileType.ANY || documentType == DocumentFileType.FILE && it.isFile - || documentType == DocumentFileType.FOLDER && it.isDirectory) - } - } val rawFile = File(buildAbsolutePath(context, storageId, basePath)) if ((considerRawFile || storageId == DATA) && rawFile.canRead() && rawFile.shouldWritable(context, requiresWriteAccess)) { return if (documentType == DocumentFileType.ANY || documentType == DocumentFileType.FILE && rawFile.isFile @@ -918,15 +889,13 @@ object DocumentFileCompat { fun getFreeSpace(context: Context, storageId: String): Long { return try { val file = getDocumentFileForStorageInfo(context, storageId) ?: return 0 - when { - file.isRawFile -> StatFs(file.uri.path!!).availableBytes - Build.VERSION.SDK_INT >= 21 -> { - context.contentResolver.openFileDescriptor(file.uri, "r")?.use { - val stats = Os.fstatvfs(it.fileDescriptor) - stats.f_bavail * stats.f_frsize - } ?: 0 - } - else -> 0 + if (file.isRawFile) { + StatFs(file.uri.path!!).availableBytes + } else { + context.contentResolver.openFileDescriptor(file.uri, "r")?.use { + val stats = Os.fstatvfs(it.fileDescriptor) + stats.f_bavail * stats.f_frsize + } ?: 0 } } catch (e: Throwable) { 0 @@ -937,15 +906,13 @@ object DocumentFileCompat { fun getUsedSpace(context: Context, storageId: String): Long { return try { val file = getDocumentFileForStorageInfo(context, storageId) ?: return 0 - when { - file.isRawFile -> StatFs(file.uri.path!!).run { totalBytes - availableBytes } - Build.VERSION.SDK_INT >= 21 -> { - context.contentResolver.openFileDescriptor(file.uri, "r")?.use { - val stats = Os.fstatvfs(it.fileDescriptor) - stats.f_blocks * stats.f_frsize - stats.f_bavail * stats.f_frsize - } ?: 0 - } - else -> 0 + if (file.isRawFile) { + StatFs(file.uri.path!!).run { totalBytes - availableBytes } + } else { + context.contentResolver.openFileDescriptor(file.uri, "r")?.use { + val stats = Os.fstatvfs(it.fileDescriptor) + stats.f_blocks * stats.f_frsize - stats.f_bavail * stats.f_frsize + } ?: 0 } } catch (e: Throwable) { 0 @@ -956,15 +923,13 @@ object DocumentFileCompat { fun getStorageCapacity(context: Context, storageId: String): Long { return try { val file = getDocumentFileForStorageInfo(context, storageId) ?: return 0 - when { - file.isRawFile -> StatFs(file.uri.path!!).totalBytes - Build.VERSION.SDK_INT >= 21 -> { - context.contentResolver.openFileDescriptor(file.uri, "r")?.use { - val stats = Os.fstatvfs(it.fileDescriptor) - stats.f_blocks * stats.f_frsize - } ?: 0 - } - else -> 0 + if (file.isFile) { + StatFs(file.uri.path!!).totalBytes + } else { + context.contentResolver.openFileDescriptor(file.uri, "r")?.use { + val stats = Os.fstatvfs(it.fileDescriptor) + stats.f_blocks * stats.f_frsize + } ?: 0 } } catch (e: Throwable) { 0 @@ -982,9 +947,6 @@ object DocumentFileCompat { DATA -> DocumentFile.fromFile(context.dataDirectory) else -> { - if (storageId.isKitkatSdCardStorageId()) { - return DocumentFile.fromFile(getKitkatSdCardRootFile()) - } // /storage/131D-261A/Android/data/com.anggrayudi.storage.sample/files val folder = File("/storage/$storageId/Android/data/${context.packageName}/files") folder.mkdirs() diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index b6a24ef..03150b3 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -31,7 +31,6 @@ import com.anggrayudi.storage.extension.hasParent import com.anggrayudi.storage.extension.isDocumentsDocument import com.anggrayudi.storage.extension.isDownloadsDocument import com.anggrayudi.storage.extension.isExternalStorageDocument -import com.anggrayudi.storage.extension.isKitkatSdCardStorageId import com.anggrayudi.storage.extension.isMediaDocument import com.anggrayudi.storage.extension.isRawFile import com.anggrayudi.storage.extension.isTreeDocumentFile @@ -116,11 +115,6 @@ val DocumentFile.rootId: String fun DocumentFile.isExternalStorageManager(context: Context) = isRawFile && File(uri.path!!).isExternalStorageManager(context) -@RestrictTo(RestrictTo.Scope.LIBRARY) -fun DocumentFile.inKitkatSdCard() = Build.VERSION.SDK_INT < 21 && uri.path?.let { - it.startsWith(StorageId.KITKAT_SDCARD) || it.matches(DocumentFileCompat.SD_CARD_STORAGE_PATH_REGEX) -} == true - /** * Some media files do not return file extension from [DocumentFile.getName]. This function helps you to fix this kind of issue. */ @@ -266,8 +260,7 @@ fun DocumentFile.inPrimaryStorage(context: Context) = isTreeDocumentFile && getS /** * `true` if this file located in SD Card */ -fun DocumentFile.inSdCardStorage(context: Context) = - getStorageId(context).let { it.matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX) || Build.VERSION.SDK_INT < 21 && it == StorageId.KITKAT_SDCARD } +fun DocumentFile.inSdCardStorage(context: Context) = getStorageId(context).matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX) fun DocumentFile.inDataStorage(context: Context) = isRawFile && File(uri.path!!).inDataStorage(context) @@ -316,9 +309,7 @@ fun DocumentFile.toRawFile(context: Context): File? { isRawFile -> File(uri.path ?: return null) inPrimaryStorage(context) -> File("${SimpleStorage.externalStoragePath}/${getBasePath(context)}") else -> getStorageId(context).let { storageId -> - if (storageId.isKitkatSdCardStorageId()) { - DocumentFileCompat.getKitkatSdCardRootFile(getBasePath(context)) - } else if (storageId.isNotEmpty()) { + if (storageId.isNotEmpty()) { File("/storage/$storageId/${getBasePath(context)}") } else { null diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt index 8847b3b..bd708c8 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt @@ -12,12 +12,10 @@ import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.extension.awaitUiResultWithPending -import com.anggrayudi.storage.extension.isKitkatSdCardStorageId import com.anggrayudi.storage.extension.trimFileSeparator import com.anggrayudi.storage.file.DocumentFileCompat.removeForbiddenCharsFromFilename import com.anggrayudi.storage.file.StorageId.DATA import com.anggrayudi.storage.file.StorageId.HOME -import com.anggrayudi.storage.file.StorageId.KITKAT_SDCARD import com.anggrayudi.storage.file.StorageId.PRIMARY import java.io.File import java.io.IOException @@ -34,7 +32,6 @@ import java.io.IOException fun File.getStorageId(context: Context) = when { path.startsWith(SimpleStorage.externalStoragePath) -> PRIMARY path.startsWith(context.dataDirectory.path) -> DATA - path.startsWith(SimpleStorage.KITKAT_SD_CARD_PATH) -> KITKAT_SDCARD else -> if (path.matches(DocumentFileCompat.SD_CARD_STORAGE_PATH_REGEX)) { path.substringAfter("/storage/", "").substringBefore('/') } else "" @@ -90,7 +87,6 @@ fun File.getRootPath(context: Context): String { return when { storageId == PRIMARY || storageId == HOME -> SimpleStorage.externalStoragePath storageId == DATA -> context.dataDirectory.path - storageId.isKitkatSdCardStorageId() -> SimpleStorage.KITKAT_SD_CARD_PATH storageId.isNotEmpty() -> "/storage/$storageId" else -> "" } @@ -142,20 +138,14 @@ fun File.createNewFileIfPossible(): Boolean = try { */ fun File.isWritable(context: Context) = canWrite() && (isFile || isExternalStorageManager(context)) -@RestrictTo(RestrictTo.Scope.LIBRARY) -fun File.inKitkatSdCard() = - Build.VERSION.SDK_INT < 21 && (path.startsWith(StorageId.KITKAT_SDCARD) || path.matches(DocumentFileCompat.SD_CARD_STORAGE_PATH_REGEX)) - /** * @return `true` if you have full disk access * @see Environment.isExternalStorageManager */ -@Suppress("DEPRECATION") -fun File.isExternalStorageManager(context: Context) = Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && Environment.isExternalStorageManager(this) - || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && - (path.startsWith(SimpleStorage.externalStoragePath) || Build.VERSION.SDK_INT < 21 && path.startsWith(StorageId.KITKAT_SDCARD)) - && SimpleStorage.hasStoragePermission(context) - || context.writableDirs.any { path.startsWith(it.path) } +fun File.isExternalStorageManager(context: Context) = + Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && Environment.isExternalStorageManager(this) + || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && path.startsWith(SimpleStorage.externalStoragePath) + && SimpleStorage.hasStoragePermission(context) || context.writableDirs.any { path.startsWith(it.path) } /** * These directories do not require storage permissions. They are always writable with full disk access. diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt index 9d58460..b0ae8be 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt @@ -6,10 +6,8 @@ import android.os.storage.StorageManager import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo import com.anggrayudi.storage.SimpleStorage -import com.anggrayudi.storage.SimpleStorage.Companion.KITKAT_SD_CARD_PATH import com.anggrayudi.storage.extension.fromTreeUri import com.anggrayudi.storage.extension.trimFileSeparator -import com.anggrayudi.storage.file.StorageId.KITKAT_SDCARD import java.io.File /** @@ -62,12 +60,6 @@ class FileFullPath { simplePath = "$storageId:$basePath" absolutePath = "$rootPath/$basePath".trimEnd('/') } - fullPath.startsWith(KITKAT_SD_CARD_PATH) -> { - storageId = KITKAT_SDCARD - basePath = fullPath.substringAfter(KITKAT_SD_CARD_PATH, "").trimFileSeparator() - simplePath = "$storageId:$basePath" - absolutePath = "$KITKAT_SD_CARD_PATH/$basePath".trimEnd('/') - } else -> if (fullPath.matches(DocumentFileCompat.SD_CARD_STORAGE_PATH_REGEX)) { storageId = fullPath.substringAfter("/storage/", "").substringBefore('/') basePath = fullPath.substringAfter("/storage/$storageId", "").trimFileSeparator() diff --git a/storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt b/storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt index 524089f..38268d7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt @@ -68,7 +68,6 @@ enum class PublicDirectory(val folderName: String) { */ DOCUMENTS(Environment.DIRECTORY_DOCUMENTS); - @Suppress("DEPRECATION") val file: File get() = Environment.getExternalStoragePublicDirectory(folderName) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt b/storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt index 2fb429f..b886c34 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt @@ -21,11 +21,6 @@ object StorageId { */ const val DATA = "data" - /** - * To access SD card in Kitkat, use `sdcard` as the storage ID, instead of the actual ID like `15FA-160C` - */ - const val KITKAT_SDCARD = "sdcard" - /** * For `/storage/emulated/0/Documents` * It is only exists on API 29- diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt index d1d0a71..10b1e35 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt @@ -66,7 +66,6 @@ import java.io.OutputStream * Created on 06/09/20 * @author Anggrayudi H */ -@Suppress("DEPRECATION") class MediaFile(context: Context, val uri: Uri) { constructor(context: Context, rawFile: File) : this(context, Uri.fromFile(rawFile)) @@ -601,6 +600,7 @@ class MediaFile(context: Context, val uri: Uri) { return 0 } + @Suppress("SameParameterValue") private fun getColumnInfoInt(column: String): Int { context.contentResolver.query(uri, arrayOf(column), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt index 03b9944..224955e 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt @@ -13,8 +13,18 @@ import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.extension.getString import com.anggrayudi.storage.extension.trimFileName import com.anggrayudi.storage.extension.trimFileSeparator -import com.anggrayudi.storage.file.* +import com.anggrayudi.storage.file.CreateMode +import com.anggrayudi.storage.file.DocumentFileCompat import com.anggrayudi.storage.file.DocumentFileCompat.removeForbiddenCharsFromFilename +import com.anggrayudi.storage.file.DocumentFileType +import com.anggrayudi.storage.file.MimeType +import com.anggrayudi.storage.file.PublicDirectory +import com.anggrayudi.storage.file.autoIncrementFileName +import com.anggrayudi.storage.file.canModify +import com.anggrayudi.storage.file.child +import com.anggrayudi.storage.file.createNewFileIfPossible +import com.anggrayudi.storage.file.recreateFile +import com.anggrayudi.storage.file.search import java.io.File /** @@ -77,9 +87,9 @@ object MediaStoreCompat { val mediaFolder = basePath.substringBefore('/') val mediaType = when (mediaFolder) { Environment.DIRECTORY_DOWNLOADS -> MediaType.DOWNLOADS - in ImageMediaDirectory.values().map { it.folderName } -> MediaType.IMAGE - in AudioMediaDirectory.values().map { it.folderName } -> MediaType.AUDIO - in VideoMediaDirectory.values().map { it.folderName } -> MediaType.VIDEO + in ImageMediaDirectory.entries.map { it.folderName } -> MediaType.IMAGE + in AudioMediaDirectory.entries.map { it.folderName } -> MediaType.AUDIO + in VideoMediaDirectory.entries.map { it.folderName } -> MediaType.VIDEO else -> return null } val subFolder = basePath.substringAfter('/', "") @@ -140,10 +150,10 @@ object MediaStoreCompat { tryInsertMediaFile(context, mediaType, contentValues) } + else -> tryInsertMediaFile(context, mediaType, contentValues) } } else { - @Suppress("DEPRECATION") val publicDirectory = Environment.getExternalStoragePublicDirectory(folderName) if (publicDirectory.canModify(context)) { val filename = file.fullName @@ -203,7 +213,6 @@ object MediaStoreCompat { @JvmStatic fun fromFileName(context: Context, mediaType: MediaType, name: String): MediaFile? { return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - @Suppress("DEPRECATION") File(PublicDirectory.DOWNLOADS.file, name).let { if (it.isFile && it.canRead()) MediaFile(context, it) else null } @@ -223,7 +232,6 @@ object MediaStoreCompat { fun fromBasePath(context: Context, mediaType: MediaType, basePath: String): MediaFile? { val cleanBasePath = basePath.removeForbiddenCharsFromFilename().trimFileSeparator() return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - @Suppress("DEPRECATION") File(Environment.getExternalStorageDirectory(), cleanBasePath).let { if (it.isFile && it.canRead()) MediaFile(context, it) else null } } else { val relativePath = cleanBasePath.substringBeforeLast('/', "") @@ -243,6 +251,7 @@ object MediaStoreCompat { Environment.DIRECTORY_MOVIES, Environment.DIRECTORY_DCIM -> MediaType.VIDEO Environment.DIRECTORY_MUSIC, Environment.DIRECTORY_PODCASTS, Environment.DIRECTORY_RINGTONES, Environment.DIRECTORY_ALARMS, Environment.DIRECTORY_NOTIFICATIONS -> MediaType.AUDIO + Environment.DIRECTORY_DOWNLOADS -> MediaType.DOWNLOADS else -> null } @@ -260,7 +269,6 @@ object MediaStoreCompat { fun fromRelativePath(context: Context, relativePath: String): List { val cleanRelativePath = relativePath.trimFileSeparator() return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - @Suppress("DEPRECATION") DocumentFile.fromFile(File(Environment.getExternalStorageDirectory(), cleanRelativePath)) .search(true, DocumentFileType.FILE) .map { MediaFile(context, File(it.uri.path!!)) } @@ -282,7 +290,6 @@ object MediaStoreCompat { fun fromRelativePath(context: Context, relativePath: String, name: String): MediaFile? { val cleanRelativePath = relativePath.trimFileSeparator() return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - @Suppress("DEPRECATION") DocumentFile.fromFile(File(Environment.getExternalStorageDirectory(), cleanRelativePath)) .search(true, DocumentFileType.FILE, name = name) .map { MediaFile(context, File(it.uri.path!!)) } @@ -302,7 +309,6 @@ object MediaStoreCompat { fun fromFileNameContains(context: Context, mediaType: MediaType, containsName: String): List { return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { mediaType.directories.map { directory -> - @Suppress("DEPRECATION") DocumentFile.fromFile(directory) .search(true, regex = Regex("^.*$containsName.*\$"), mimeTypes = arrayOf(mediaType.mimeType)) .map { MediaFile(context, File(it.uri.path!!)) } @@ -319,7 +325,6 @@ object MediaStoreCompat { fun fromMimeType(context: Context, mediaType: MediaType, mimeType: String): List { return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { mediaType.directories.map { directory -> - @Suppress("DEPRECATION") DocumentFile.fromFile(directory) .search(true, DocumentFileType.FILE, arrayOf(mimeType)) .map { MediaFile(context, File(it.uri.path!!)) } @@ -336,7 +341,6 @@ object MediaStoreCompat { fun fromMediaType(context: Context, mediaType: MediaType): List { return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { mediaType.directories.map { directory -> - @Suppress("DEPRECATION") DocumentFile.fromFile(directory) .search(true, mimeTypes = arrayOf(mediaType.mimeType)) .map { MediaFile(context, File(it.uri.path!!)) } diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaType.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaType.kt index c402a64..8d70a47 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaType.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaType.kt @@ -24,12 +24,11 @@ enum class MediaType(val readUri: Uri?, val writeUri: Uri?) { /** * Get all directories associated with this media type. */ - @Suppress("DEPRECATION") val directories: List get() = when (this) { - IMAGE -> ImageMediaDirectory.values().map { Environment.getExternalStoragePublicDirectory(it.folderName) } - AUDIO -> AudioMediaDirectory.values().map { Environment.getExternalStoragePublicDirectory(it.folderName) } - VIDEO -> VideoMediaDirectory.values().map { Environment.getExternalStoragePublicDirectory(it.folderName) } + IMAGE -> ImageMediaDirectory.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } + AUDIO -> AudioMediaDirectory.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } + VIDEO -> VideoMediaDirectory.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } DOWNLOADS -> listOf(PublicDirectory.DOWNLOADS.file) } diff --git a/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt b/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt index 58b6041..796e44b 100644 --- a/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt +++ b/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt @@ -17,7 +17,6 @@ import java.io.File * * @author Anggrayudi H */ -@Suppress("DEPRECATION") class DocumentFileCompatTest { private val context = mockk { From a9258a3a9e9540ca83600cacd9269d242ea204db Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 20:47:42 +0700 Subject: [PATCH 07/39] renamed callback parameter --- .../storage/sample/activity/MainActivity.kt | 12 +- .../storage/file/DocumentFileExt.kt | 124 +++++++++--------- .../com/anggrayudi/storage/media/MediaFile.kt | 29 ++-- 3 files changed, 88 insertions(+), 77 deletions(-) diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt index c789e8f..bf28522 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -282,7 +282,7 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.copyTo(applicationContext, targetFolder, callback = createMultipleFileCallback()) + sources.copyTo(applicationContext, targetFolder, onConflict = createMultipleFileCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled @@ -336,7 +336,7 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.moveTo(applicationContext, targetFolder, callback = createMultipleFileCallback()) + sources.moveTo(applicationContext, targetFolder, onConflict = createMultipleFileCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled @@ -403,7 +403,7 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.copyFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback()) + folder.copyFolderTo(applicationContext, targetFolder, false, onConflict = createFolderCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled @@ -451,7 +451,7 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.moveFolderTo(applicationContext, targetFolder, false, callback = createFolderCallback()) + folder.moveFolderTo(applicationContext, targetFolder, false, onConflict = createFolderCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled @@ -513,7 +513,7 @@ class MainActivity : AppCompatActivity() { val targetFolder = binding.layoutCopyFileTargetFolder.tvFilePath.tag as DocumentFile Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - file.copyFileTo(applicationContext, targetFolder, callback = createFileCallback()) + file.copyFileTo(applicationContext, targetFolder, onConflict = createFileCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled @@ -565,7 +565,7 @@ class MainActivity : AppCompatActivity() { var tvStatus: TextView? = null var progressBar: ProgressBar? = null - file.moveFileTo(applicationContext, targetFolder, callback = createFileCallback()) + file.moveFileTo(applicationContext, targetFolder, onConflict = createFileCallback()) .onCompletion { if (it is CancellationException) { // maybe you want to show to the user that the operation was cancelled diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index 03150b3..db5bda4 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -1451,9 +1451,9 @@ fun List.moveTo( skipEmptyFiles: Boolean = true, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: MultipleFileConflictCallback + onConflict: MultipleFileConflictCallback ): Flow { - return copyTo(context, targetParentFolder, skipEmptyFiles, true, updateInterval, isFileSizeAllowed, callback) + return copyTo(context, targetParentFolder, skipEmptyFiles, true, updateInterval, isFileSizeAllowed, onConflict) } @WorkerThread @@ -1463,9 +1463,9 @@ fun List.copyTo( skipEmptyFiles: Boolean = true, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: MultipleFileConflictCallback + onConflict: MultipleFileConflictCallback ): Flow { - return copyTo(context, targetParentFolder, skipEmptyFiles, false, updateInterval, isFileSizeAllowed, callback) + return copyTo(context, targetParentFolder, skipEmptyFiles, false, updateInterval, isFileSizeAllowed, onConflict) } @OptIn(DelicateCoroutinesApi::class) @@ -1476,15 +1476,15 @@ private fun List.copyTo( deleteSourceWhenComplete: Boolean, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: MultipleFileConflictCallback + onConflict: MultipleFileConflictCallback ): Flow = callbackFlow { send(MultipleFilesResult.Validating) - val pair = doesMeetCopyRequirements(context, targetParentFolder, this, callback) ?: return@callbackFlow + val pair = doesMeetCopyRequirements(context, targetParentFolder, this, onConflict) ?: return@callbackFlow send(MultipleFilesResult.Preparing) val validSources = pair.second val writableTargetParentFolder = pair.first - val conflictResolutions = validSources.handleParentFolderConflict(context, writableTargetParentFolder, this, callback) ?: return@callbackFlow + val conflictResolutions = validSources.handleParentFolderConflict(context, writableTargetParentFolder, this, onConflict) ?: return@callbackFlow validSources.removeAll(conflictResolutions.filter { it.solution == FolderConflictCallback.ConflictResolution.SKIP }.map { it.source }) if (validSources.isEmpty()) { return@callbackFlow @@ -1712,8 +1712,8 @@ private fun List.copyTo( } if (finalize()) return@callbackFlow - val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(writableTargetParentFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) + val solutions = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onContentConflict(writableTargetParentFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) }.filter { // free up space first, by deleting some files if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { @@ -1761,7 +1761,7 @@ private fun List.doesMeetCopyRequirements( context: Context, targetParentFolder: DocumentFile, scope: ProducerScope, - callback: MultipleFileConflictCallback + onConflict: MultipleFileConflictCallback ): Pair>? { if (!targetParentFolder.isDirectory) { scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.INVALID_TARGET_FOLDER)) @@ -1786,8 +1786,8 @@ private fun List.doesMeetCopyRequirements( }.toMap() if (invalidSourceFiles.isNotEmpty()) { - val abort = awaitUiResultWithPending(callback.uiScope) { - callback.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFileConflictCallback.InvalidSourceFilesAction(it)) + val abort = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFileConflictCallback.InvalidSourceFilesAction(it)) } if (abort) { scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANCELED)) @@ -1868,9 +1868,9 @@ fun DocumentFile.moveFolderTo( newFolderNameInTargetPath: String? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: FolderConflictCallback + onConflict: FolderConflictCallback ): Flow { - return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, updateInterval, isFileSizeAllowed, callback) + return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, updateInterval, isFileSizeAllowed, onConflict) } @WorkerThread @@ -1881,9 +1881,9 @@ fun DocumentFile.copyFolderTo( newFolderNameInTargetPath: String? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: FolderConflictCallback + onConflict: FolderConflictCallback ): Flow { - return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, updateInterval, isFileSizeAllowed, callback) + return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, updateInterval, isFileSizeAllowed, onConflict) } /** @@ -1898,14 +1898,14 @@ private fun DocumentFile.copyFolderTo( deleteSourceWhenComplete: Boolean, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: FolderConflictCallback + onConflict: FolderConflictCallback ): Flow = callbackFlow { val writableTargetParentFolder = doesMeetFolderCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) ?: return@callbackFlow send(FolderResult.Preparing) val targetFolderParentName = (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, this, callback) + val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, this, onConflict) if (conflictResolution == FolderConflictCallback.ConflictResolution.SKIP) { return@callbackFlow } @@ -2092,8 +2092,8 @@ private fun DocumentFile.copyFolderTo( } if (finalize()) return@callbackFlow - val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(targetFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) + val solutions = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onContentConflict(targetFolder, conflictedFiles, FolderConflictCallback.FolderContentConflictAction(it)) }.filter { // free up space first, by deleting some files if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { @@ -2194,9 +2194,9 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow { - return copyFileTo(context, targetFolder.absolutePath, fileDescription, reportInterval, isFileSizeAllowed, callback) + return copyFileTo(context, targetFolder.absolutePath, fileDescription, reportInterval, isFileSizeAllowed, onConflict) } /** @@ -2210,13 +2210,13 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, callback) + copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, onConflict) } } @@ -2230,9 +2230,9 @@ fun DocumentFile.copyFileTo( fileDescription: FileDescription? = null, reportInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, callback) + copyFileTo(context, targetFolder, fileDescription, reportInterval, this, isFileSizeAllowed, onConflict) } private fun DocumentFile.copyFileTo( @@ -2242,16 +2242,16 @@ private fun DocumentFile.copyFileTo( reportInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ) { if (fileDescription?.subFolder.isNullOrEmpty()) { - copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, callback) + copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, onConflict) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, callback) + copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, reportInterval, scope, isFileSizeAllowed, onConflict) } } } @@ -2264,7 +2264,7 @@ private fun DocumentFile.copyFileTo( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ) { val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return @@ -2277,7 +2277,7 @@ private fun DocumentFile.copyFileTo( val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, callback) + val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, onConflict) if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } @@ -2413,9 +2413,9 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow { - return moveFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, callback) + return moveFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, onConflict) } /** @@ -2429,13 +2429,13 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, callback) + moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, onConflict) } } @@ -2449,9 +2449,9 @@ fun DocumentFile.moveFileTo( fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, callback) + moveFileTo(context, targetFolder, fileDescription, updateInterval, this, isFileSizeAllowed, onConflict) } private fun DocumentFile.moveFileTo( @@ -2461,16 +2461,16 @@ private fun DocumentFile.moveFileTo( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ) { if (fileDescription?.subFolder.isNullOrEmpty()) { - moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, callback) + moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, onConflict) } else { val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, callback) + moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, scope, isFileSizeAllowed, onConflict) } } } @@ -2483,7 +2483,7 @@ private fun DocumentFile.moveFileTo( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ) { val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return @@ -2491,7 +2491,7 @@ private fun DocumentFile.moveFileTo( val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, callback) + val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, onConflict) if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } @@ -2588,7 +2588,7 @@ private fun DocumentFile.copyFileToMedia( updateInterval: Long, scope: ProducerScope, isFileSizeAllowed: CheckFileSize, - callback: SingleFileConflictCallback, + onConflict: SingleFileConflictCallback, ) { if (simpleCheckSourceFile(scope)) return @@ -2610,9 +2610,9 @@ private fun DocumentFile.copyFileToMedia( } fileDescription.subFolder = "" if (deleteSourceFileWhenComplete) { - moveFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + moveFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) } else { - copyFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + copyFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) } } else { val validMode = if (mode == CreateMode.REUSE) CreateMode.CREATE_NEW else mode @@ -2637,9 +2637,9 @@ fun DocumentFile.copyFileToDownloadMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback, + onConflict: SingleFileConflictCallback, ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, false, mode, updateInterval, this, isFileSizeAllowed, callback) + copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, false, mode, updateInterval, this, isFileSizeAllowed, onConflict) } @WorkerThread @@ -2650,9 +2650,9 @@ fun DocumentFile.copyFileToPictureMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback, + onConflict: SingleFileConflictCallback, ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, false, mode, updateInterval, this, isFileSizeAllowed, callback) + copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, false, mode, updateInterval, this, isFileSizeAllowed, onConflict) } @WorkerThread @@ -2663,9 +2663,9 @@ fun DocumentFile.moveFileToDownloadMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, true, mode, updateInterval, this, isFileSizeAllowed, callback) + copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, true, mode, updateInterval, this, isFileSizeAllowed, onConflict) } @WorkerThread @@ -2676,9 +2676,9 @@ fun DocumentFile.moveFileToPictureMedia( mode: CreateMode = CreateMode.CREATE_NEW, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, true, mode, updateInterval, this, isFileSizeAllowed, callback) + copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, true, mode, updateInterval, this, isFileSizeAllowed, onConflict) } /** @@ -2753,11 +2753,11 @@ private fun handleFileConflict( targetFolder: DocumentFile, targetFileName: String, scope: ProducerScope, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, targetFileName)?.let { targetFile -> - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) + val resolution = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) } if (resolution == SingleFileConflictCallback.ConflictResolution.REPLACE) { scope.trySend(SingleFileResult.DeletingConflictedFile) @@ -2776,7 +2776,7 @@ private fun handleParentFolderConflict( targetParentFolder: DocumentFile, targetFolderParentName: String, scope: ProducerScope, - callback: FolderConflictCallback + onConflict: FolderConflictCallback ): FolderConflictCallback.ConflictResolution { targetParentFolder.child(context, targetFolderParentName)?.let { targetFolder -> val canMerge = targetFolder.isDirectory @@ -2784,8 +2784,8 @@ private fun handleParentFolderConflict( return FolderConflictCallback.ConflictResolution.MERGE } - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onParentConflict(targetFolder, FolderConflictCallback.ParentFolderConflictAction(it), canMerge) + val resolution = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onParentConflict(targetFolder, FolderConflictCallback.ParentFolderConflictAction(it), canMerge) } when (resolution) { @@ -2834,7 +2834,7 @@ private fun List.handleParentFolderConflict( context: Context, targetParentFolder: DocumentFile, scope: ProducerScope, - callback: MultipleFileConflictCallback + onConflict: MultipleFileConflictCallback ): List? { val sourceFileNames = map { it.name } val conflictedFiles = targetParentFolder.listFiles().filter { it.name in sourceFileNames } @@ -2849,8 +2849,8 @@ private fun List.handleParentFolderConflict( if (unresolvedConflicts.isNotEmpty()) { val unresolvedFiles = unresolvedConflicts.filter { it.source.isFile }.toMutableList() val unresolvedFolders = unresolvedConflicts.filter { it.source.isDirectory }.toMutableList() - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onParentConflict(targetParentFolder, unresolvedFolders, unresolvedFiles, MultipleFileConflictCallback.ParentFolderConflictAction(it)) + val resolution = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onParentConflict(targetParentFolder, unresolvedFolders, unresolvedFiles, MultipleFileConflictCallback.ParentFolderConflictAction(it)) } if (resolution.any { it.solution == FolderConflictCallback.ConflictResolution.REPLACE }) { scope.trySend(MultipleFilesResult.DeletingConflictedFiles) diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt index 10b1e35..b966732 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt @@ -91,6 +91,7 @@ class MediaFile(context: Context, val uri: Uri) { /** * Some media files do not return file extension. This function helps you to fix this kind of issue. */ + @Suppress("DEPRECATION") val fullName: String get() = if (isRawFile) { toRawFile()?.name.orEmpty() @@ -103,6 +104,7 @@ class MediaFile(context: Context, val uri: Uri) { /** * @see [fullName] */ + @Suppress("DEPRECATION") val name: String? get() = toRawFile()?.name ?: getColumnInfoString(MediaStore.MediaColumns.DISPLAY_NAME) @@ -115,6 +117,7 @@ class MediaFile(context: Context, val uri: Uri) { /** * @see [mimeType] */ + @Suppress("DEPRECATION") val type: String? get() = toRawFile()?.name?.let { MimeType.getMimeTypeFromExtension(MimeType.getExtensionFromFileName(it)) } ?: getColumnInfoString(MediaStore.MediaColumns.MIME_TYPE) @@ -126,6 +129,7 @@ class MediaFile(context: Context, val uri: Uri) { get() = getColumnInfoString(MediaStore.MediaColumns.MIME_TYPE) ?: MimeType.getMimeTypeFromExtension(extension) + @Suppress("DEPRECATION") var length: Long get() = toRawFile()?.length() ?: getColumnInfoLong(MediaStore.MediaColumns.SIZE) set(value) { @@ -164,6 +168,7 @@ class MediaFile(context: Context, val uri: Uri) { val isRawFile: Boolean get() = uri.isRawFile + @Suppress("DEPRECATION") val lastModified: Long get() = toRawFile()?.lastModified() ?: getColumnInfoLong(MediaStore.MediaColumns.DATE_MODIFIED) @@ -187,6 +192,7 @@ class MediaFile(context: Context, val uri: Uri) { fun toDocumentFile() = absolutePath.let { if (it.isEmpty()) null else DocumentFileCompat.fromFullPath(context, it) } + @Suppress("DEPRECATION") val absolutePath: String @SuppressLint("InlinedApi") get() { @@ -224,6 +230,7 @@ class MediaFile(context: Context, val uri: Uri) { /** * @see MediaStore.MediaColumns.RELATIVE_PATH */ + @Suppress("DEPRECATION") val relativePath: String @SuppressLint("InlinedApi") get() { @@ -258,6 +265,7 @@ class MediaFile(context: Context, val uri: Uri) { } } + @Suppress("DEPRECATION") fun delete(): Boolean { val file = toRawFile() return if (file != null) { @@ -274,6 +282,7 @@ class MediaFile(context: Context, val uri: Uri) { * Please note that this function does not move file if you input `newName` as `Download/filename.mp4`. * If you want to move media files, please use [moveFileTo] instead. */ + @Suppress("DEPRECATION") fun renameTo(newName: String): Boolean { val file = toRawFile() val contentValues = ContentValues(1).apply { put(MediaStore.MediaColumns.DISPLAY_NAME, newName) } @@ -319,6 +328,7 @@ class MediaFile(context: Context, val uri: Uri) { /** * @param append if `false` and the file already exists, it will recreate the file. */ + @Suppress("DEPRECATION") @WorkerThread @JvmOverloads fun openOutputStream(append: Boolean = true): OutputStream? { @@ -334,6 +344,7 @@ class MediaFile(context: Context, val uri: Uri) { } } + @Suppress("DEPRECATION") @WorkerThread fun openInputStream(): InputStream? { return try { @@ -365,11 +376,11 @@ class MediaFile(context: Context, val uri: Uri) { fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { - sourceFile.moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + sourceFile.moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) return@callbackFlow } @@ -392,7 +403,7 @@ class MediaFile(context: Context, val uri: Uri) { val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) .removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, callback) + val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, onConflict) if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return@callbackFlow } @@ -418,11 +429,11 @@ class MediaFile(context: Context, val uri: Uri) { fileDescription: FileDescription? = null, updateInterval: Long = 500, isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { - sourceFile.copyFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, callback) + sourceFile.copyFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) return@callbackFlow } @@ -445,7 +456,7 @@ class MediaFile(context: Context, val uri: Uri) { val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) .removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, callback) + val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, this, onConflict) if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return@callbackFlow } @@ -559,11 +570,11 @@ class MediaFile(context: Context, val uri: Uri) { targetFolder: DocumentFile, fileName: String, scope: ProducerScope, - callback: SingleFileConflictCallback + onConflict: SingleFileConflictCallback ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, fileName)?.let { targetFile -> - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) + val resolution = awaitUiResultWithPending(onConflict.uiScope) { + onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) } if (resolution == SingleFileConflictCallback.ConflictResolution.REPLACE) { if (!targetFile.forceDelete(context)) { From a1d75c96066907263f63feb659705fb8cc2a8cac Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Mon, 17 Jun 2024 23:39:24 +0700 Subject: [PATCH 08/39] fixed wrong error codes --- .../storage/sample/StorageInfoAdapter.kt | 2 +- .../main/res/layout/incl_base_operation.xml | 2 +- .../com/anggrayudi/storage/SimpleStorage.kt | 2 ++ .../storage/file/DocumentFileExt.kt | 23 +++++++++---------- .../com/anggrayudi/storage/file/FileExt.kt | 7 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt b/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt index 25847a8..8f4b297 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt @@ -48,7 +48,7 @@ class StorageInfoAdapter( tvStorageUsedSpace.text = "Used Space: $storageUsedSpace" tvStorageFreeSpace.text = "Free Space: $storageFreeSpace" btnShowGrantedUri.setOnClickListener { showGrantedUris(it.context, storageId) } - if (storageId == PRIMARY && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || Build.VERSION.SDK_INT < 21) { + if (storageId == PRIMARY && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // No URI permission required for external storage btnShowGrantedUri.visibility = View.GONE } diff --git a/sample/src/main/res/layout/incl_base_operation.xml b/sample/src/main/res/layout/incl_base_operation.xml index b174be4..08f1ba9 100644 --- a/sample/src/main/res/layout/incl_base_operation.xml +++ b/sample/src/main/res/layout/incl_base_operation.xml @@ -16,7 +16,7 @@ android:id="@+id/btnRequestStorageAccess" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Request Storage Access (API 21+)" /> + android:text="Request Storage Access" />