diff --git a/.gitignore b/.gitignore index 9904d9a..9a805dd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ captures/ # IntelliJ *.iml .idea +.kotlin # Keystore files # Uncomment the following line if you do not want to check your keystore files in. diff --git a/JAVA_COMPATIBILITY.md b/JAVA_COMPATIBILITY.md index 95ff40c..c1640db 100644 --- a/JAVA_COMPATIBILITY.md +++ b/JAVA_COMPATIBILITY.md @@ -19,6 +19,10 @@ You'll find that the most useful extension functions come from `DocumentFileExtK * `DocumentFile.copyFileTo()` and `File.copyFileTo()` * `DocumentFile.search()` and `File.search()`, etc. +Note that some long-running functions like copy, move, search, compress, and unzip are now only available in Kotlin. +You can still use these Java features in your project, but you will need [v1.5.6](https://github.com/anggrayudi/SimpleStorage/releases/tag/1.5.6) which is the latest version that +supports Java. + Suppose that you want to get storage ID of the file: #### In Kotlin diff --git a/README.md b/README.md index 2accca3..1bb30a0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ + [`MediaFile`](#mediafile) * [Request Storage Access, Pick Folder & Files, Request Create File, etc.](#request-storage-access-pick-folder--files-request-create-file-etc) * [Move & Copy: Files & Folders](#move--copy-files--folders) +* [Search: Files & Folders](#search-files--folders) +* [Compress & Unzip: Files & Folders](#compress--unzip-files--folders) + + [Compression](#compression) + + [Decompression](#decompression) * [FAQ](#faq) * [Other SimpleStorage Usage Examples](#other-simpleStorage-usage-examples) * [License](#license) @@ -25,7 +29,7 @@ The more higher API level, the more Google restricted file access on Android storage. Although Storage Access Framework (SAF) is designed to secure user's storage from malicious apps, -but this makes us even more difficult in accessing files. Let's take an example where +but this makes us even more difficult in accessing files as a developer. Let's take an example where [`java.io.File` has been deprecated in Android 10](https://commonsware.com/blog/2019/06/07/death-external-storage-end-saga.html). Simple Storage ease you in accessing and managing files across API levels. @@ -58,6 +62,11 @@ allprojects { Simple Storage is built in Kotlin. Follow this [documentation](JAVA_COMPATIBILITY.md) to use it in your Java project. +Note that some long-running functions like copy, move, search, compress, and unzip are now only available in Kotlin. +They are powered by Kotlin Coroutines & Flow, which are easy to use. +You can still use these Java features in your project, but you will need [v1.5.6](https://github.com/anggrayudi/SimpleStorage/releases/tag/1.5.6) which is the latest version that +supports Java. + ## Terminology ![Alt text](art/terminology.png?raw=true "Simple Storage Terms") @@ -73,7 +82,7 @@ To check whether you have access to particular paths, call `DocumentFileCompat.g ![Alt text](art/getAccessibleAbsolutePaths.png?raw=true "DocumentFileCompat.getAccessibleAbsolutePaths()") All paths in those locations are accessible via functions `DocumentFileCompat.from*()`, otherwise your action will be denied by the system if you want to -access paths other than those. Functions `DocumentFileCompat.from*()` (next section) will return null as well. On API 28-, you can obtain it by requesting +access paths other than those, then functions `DocumentFileCompat.from*()` (next section) will return null as well. On API 28-, you can obtain it by requesting the runtime permission. For API 29+, it is obtained automatically by calling `SimpleStorageHelper#requestStorageAccess()` or `SimpleStorageHelper#openFolderPicker()`. The granted paths are persisted by this library via `ContentResolver#takePersistableUriPermission()`, so you don't need to remember them in preferences: @@ -246,44 +255,46 @@ For example, you can move a folder with few lines of code: val folder: DocumentFile = ... val targetFolder: DocumentFile = ... -// Since moveFolderTo() is annotated with @WorkerThread, you must execute it in the background thread -folder.moveFolderTo(applicationContext, targetFolder, skipEmptyFiles = false, callback = object : FolderCallback() { - 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 - } - - override fun onParentConflict(destinationFolder: DocumentFile, action: FolderCallback.ParentFolderConflictAction, canMerge: Boolean) { - handleParentFolderConflict(destinationFolder, action, canMerge) +val job = ioScope.launch { + folder.moveFolderTo(applicationContext, targetFolder, skipEmptyFiles = false, updateInterval = 1000, onConflict = object : FolderConflictCallback(uiScope) { + override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { + handleParentFolderConflict(destinationFolder, action, canMerge) } override fun onContentConflict( - destinationFolder: DocumentFile, - conflictedFiles: MutableList, - action: FolderCallback.FolderContentConflictAction + destinationFolder: DocumentFile, + conflictedFiles: MutableList, + action: FolderContentConflictAction ) { - handleFolderContentConflict(action, conflictedFiles) + handleFolderContentConflict(action, conflictedFiles) } - - override fun onReport(report: Report) { - Timber.d("onReport() -> ${report.progress.toInt()}% | Copied ${report.fileCount} files") + }).onCompletion { + if (it is CancellationException) { + Timber.d("Folder move is aborted") } - - override fun onCompleted(result: Result) { - Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + }.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 -> uiScope.launch { + 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 -> uiScope.launch { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } } + } +} - override fun onFailed(errorCode: ErrorCode) { - Toast.makeText(baseContext, "An error has occurred: $errorCode", Toast.LENGTH_SHORT).show() - } -}) +// call this function somewhere, for example in a dialog with a cancel button: +job.cancel() // it will abort the process ``` The coolest thing of this library is you can ask users to choose Merge, Replace, Create New, or Skip Duplicate folders & files @@ -292,26 +303,106 @@ whenever a conflict is found via `onConflict()`. Here're screenshots of the samp ![Alt text](art/parent-folder-conflict.png?raw=true "Parent Folder Conflict") ![Alt text](art/folder-content-conflict.png?raw=true "Folder Content Conflict") -Read [`MainActivity`](https://github.com/anggrayudi/SimpleStorage/blob/master/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt) +Read [`MainActivity`](sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt) from the sample code if you want to mimic above dialogs. +## Search: Files & Folders + +You can search files and folders by using `DocumentFile.search()` extension function: + +```kotlin +ioScope.launch { + val nameToFind = "nicko" // search files with name containing "nicko" + folder.search(recursive = true, regex = Regex("^.*$nameToFind.*\$"), updateInterval = 1000).collect { + // update results every 1 second + Timber.d("Found ${it.size} files, last: ${it.lastOrNull()?.fullName}") + } +} +``` + +## Compress & Unzip: Files & Folders + +### Compression + +To compress files and folders, use `List.compressToZip()` extension function: + +```kotlin +ioScope.launch { + // make sure you have an URI access to /storage/emulated/0/Documents, otherwise it will return null + val targetZipFile = DocumentFileCompat.createFile(baseContext, basePath = "Documents/compress test.zip", mimeType = "application/zip") + if (targetZipFile != null) { + listOf(folder).compressToZip(baseContext, targetZipFile, deleteSourceWhenComplete = false, updateInterval = 500).collect { + when (it) { + is ZipCompressionResult.CountingFiles -> Timber.d("Calculating...") + is ZipCompressionResult.Compressing -> Timber.d("Compressing... ${it.progress.toInt()}%") + is ZipCompressionResult.Completed -> Timber.d("Completed: ${it.zipFile.fullName}") + is ZipCompressionResult.Error -> Timber.e(it.errorCode.name) + is ZipCompressionResult.DeletingEntryFiles -> Timber.d("Deleting ...") // will be emitted if `deleteSourceWhenComplete` is true + } + } + } +} +``` + +If you don't have any URI access, then you can request the user to create a ZIP file in the desired location: + +```kotlin +storageHelper.onFileCreated = { requestCode, file -> + ioScope.launch { + listOf(folder).compressToZip(baseContext, file).collect { + // do stuff + } + } +} +storageHelper.createFile(mimeType = "application/zip", fileName = "compress test", initialPath = FileFullPath(baseContext, StorageId.PRIMARY, "Documents")) +``` + +### Decompression + +FYI, decompressing ZIP files is also easy: + +```kotlin +ioScope.launch { + file.decompressZip(baseContext, targetFolder) + .onCompletion { + if (it is CancellationException) { + Timber.d("Decompression is aborted") + } + }.collect { + when (it) { + is ZipDecompressionResult.Validating -> Timber.d("Validating...") + is ZipDecompressionResult.Decompressing -> Timber.d("Decompressing... ${it.bytesDecompressed}") + is ZipDecompressionResult.Completed -> uiScope.launch { + Toast.makeText(baseContext, "Decompressed successfully", Toast.LENGTH_SHORT).show() + } + + is ZipDecompressionResult.Error -> uiScope.launch { + Toast.makeText(baseContext, "An error has occurred: ${it.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } +} +``` + ## FAQ -Having trouble? Read the [Frequently Asked Questions](FAQ.md). +Having trouble? Read the [Frequently Asked Questions](FAQ.md) or join the [Discussions](https://github.com/anggrayudi/SimpleStorage/discussions). ## Other SimpleStorage Usage Examples SimpleStorage is used in these open source projects. Check how these repositories use it: -* [Snapdrop](https://github.com/anggrayudi/snapdrop-android) + +* [Snapdrop](https://github.com/fm-sys/snapdrop-android) * [MaterialPreference](https://github.com/anggrayudi/MaterialPreference) * [Super Productivity](https://github.com/johannesjo/super-productivity-android) * [Shared Storage for Flutter](https://pub.dev/packages/shared_storage) * [Nextcloud Cookbook](https://codeberg.org/MicMun/nextcloud-cookbook) +* [Audiobookshelf](https://github.com/advplyr/audiobookshelf-app) ## License - Copyright © 2020-2023 Anggrayudi Hardiannico A. + Copyright © 2020-2024 Anggrayudi Hardiannico A. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/build.gradle b/build.gradle index 73850d9..b0e0fde 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' @@ -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/gradle.properties b/gradle.properties index 416b4a3..84e697f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,8 +22,8 @@ kotlin.code.style=official # For publishing: GROUP=com.anggrayudi POM_ARTIFACT_ID=storage -VERSION_NAME=1.5.6-SNAPSHOT -RELEASE_SIGNING_ENABLED=true +VERSION_NAME=2.0.0-SNAPSHOT +RELEASE_SIGNING_ENABLED=false SONATYPE_AUTOMATIC_RELEASE=true SONATYPE_HOST=DEFAULT POM_NAME=storage 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/build.gradle b/sample/build.gradle index edf2f34..a9e7c86 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -78,7 +78,8 @@ dependencies { implementation deps.timber implementation deps.material_progressbar - implementation 'androidx.preference:preference-ktx:1.2.0' + 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..ee8dbbc 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/App.kt +++ b/sample/src/main/java/com/anggrayudi/storage/sample/App.kt @@ -1,6 +1,7 @@ package com.anggrayudi.storage.sample import androidx.multidex.MultiDexApplication +import timber.log.Timber /** * @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id) @@ -10,5 +11,6 @@ class App : MultiDexApplication() { override fun onCreate() { super.onCreate() + Timber.plant(Timber.DebugTree()) } } \ 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..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 } @@ -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/FileCompressionActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt index 4524134..2c5f5ec 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 -> uiScope.launch { + 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 -> uiScope.launch { + 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..068247c 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,14 +6,15 @@ 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 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 @@ -67,10 +68,10 @@ class FileDecompressionActivity : BaseActivity() { return } ioScope.launch { - zipFile.decompressZip(applicationContext, targetFolder, object : ZipDecompressionCallback(uiScope) { - var actionForAllConflicts: FileCallback.ConflictResolution? = null + zipFile.decompressZip(applicationContext, targetFolder, onConflict = object : SingleFileConflictCallback(uiScope) { + var actionForAllConflicts: ConflictResolution? = null - override fun onFileConflict(destinationFile: DocumentFile, action: FileCallback.FileConflictAction) { + override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { actionForAllConflicts?.let { action.confirmResolution(it) return @@ -83,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 = FileCallback.ConflictResolution.values()[index] + val resolution = ConflictResolution.entries[index] if (doForAll) { actionForAllConflicts = resolution } @@ -91,23 +92,23 @@ class FileDecompressionActivity : BaseActivity() { } .show() } + }).collect { + when (it) { + is ZipDecompressionResult.Validating -> Timber.d("Validating") + is ZipDecompressionResult.Decompressing -> Timber.d("Decompressing") + is ZipDecompressionResult.Completed -> uiScope.launch { + 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 -> uiScope.launch { + 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 f79a707..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,23 +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.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; @@ -26,9 +10,20 @@ 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 timber.log.Timber; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +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 @@ -102,34 +97,6 @@ private void setupSimpleStorage(Bundle savedState) { }); } - private void moveFile(DocumentFile source, DocumentFile destinationFolder) { - DocumentFileUtils.moveFileTo(source, getApplicationContext(), destinationFolder, null, new FileCallback() { - @Override - public void onConflict(@NotNull DocumentFile destinationFile, @NotNull FileCallback.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 0b84baf..5811c75 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.MultipleFilesConflictCallback +import com.anggrayudi.storage.callback.SingleFileConflictCallback +import com.anggrayudi.storage.callback.SingleFolderConflictCallback 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,18 @@ 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.MultipleFilesResult +import com.anggrayudi.storage.result.SingleFileResult +import com.anggrayudi.storage.result.SingleFolderResult 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.flow.onCompletion import kotlinx.coroutines.launch import timber.log.Timber import java.io.IOException @@ -108,10 +112,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) { @@ -198,7 +199,9 @@ class MainActivity : AppCompatActivity() { folder ) - else -> Toast.makeText(baseContext, folder.getAbsolutePath(this), Toast.LENGTH_SHORT).show() + else -> { + Toast.makeText(baseContext, folder.getAbsolutePath(this), Toast.LENGTH_SHORT).show() + } } } storageHelper.onFileCreated = { requestCode, file -> @@ -280,7 +283,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, onConflict = createMultipleFileCallback()) + .onCompletion { + if (it is CancellationException) { + Timber.d("Multiple copies is aborted") + } + }.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 -> uiScope.launch { + 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 -> uiScope.launch { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -311,18 +337,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, onConflict = createMultipleFileCallback()) + .onCompletion { + if (it is CancellationException) { + Timber.d("Multiple file moves is aborted") + } + }.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 -> uiScope.launch { + 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 -> uiScope.launch { + 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 : MultipleFilesConflictCallback(uiScope) { override fun onParentConflict( destinationParentFolder: DocumentFile, conflictedFolders: MutableList, @@ -334,23 +377,11 @@ class MainActivity : AppCompatActivity() { override fun onContentConflict( destinationParentFolder: DocumentFile, - conflictedFiles: MutableList, - action: FolderCallback.FolderContentConflictAction + conflictedFiles: MutableList, + action: SingleFolderConflictCallback.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 +404,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, onConflict = createFolderCallback()) + .onCompletion { + if (it is CancellationException) { + Timber.d("Folder copy is aborted") + } + }.collect { result -> + when (result) { + is SingleFolderResult.Validating -> Timber.d("Validating...") + is SingleFolderResult.Preparing -> Timber.d("Preparing...") + is SingleFolderResult.CountingFiles -> Timber.d("Counting files...") + is SingleFolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is SingleFolderResult.Starting -> Timber.d("Starting...") + is SingleFolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is SingleFolderResult.Completed -> uiScope.launch { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is SingleFolderResult.Error -> uiScope.launch { + Timber.e(result.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -398,26 +452,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, onConflict = createFolderCallback()) + .onCompletion { + if (it is CancellationException) { + Timber.d("Folder move is aborted") + } + }.collect { result -> + when (result) { + is SingleFolderResult.Validating -> Timber.d("Validating...") + is SingleFolderResult.Preparing -> Timber.d("Preparing...") + is SingleFolderResult.CountingFiles -> Timber.d("Counting files...") + is SingleFolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") + is SingleFolderResult.Starting -> Timber.d("Starting...") + is SingleFolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") + is SingleFolderResult.Completed -> uiScope.launch { + Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") + Toast.makeText(baseContext, "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + } + + is SingleFolderResult.Error -> uiScope.launch { + 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 : SingleFolderConflictCallback(uiScope) { override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { handleParentFolderConflict(destinationFolder, action, canMerge) } @@ -429,18 +492,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() { @@ -463,7 +514,30 @@ 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) { + Timber.d("File copy is aborted") + } + }.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 -> uiScope.launch { + Timber.d("Completed") + Toast.makeText(baseContext, "Copied successfully", Toast.LENGTH_SHORT).show() + } + + is SingleFileResult.Error -> uiScope.launch { + Timber.e(it.errorCode.name) + Toast.makeText(baseContext, "An error has occurred: ${it.errorCode.name}", Toast.LENGTH_SHORT).show() + } + } + } } } } @@ -487,68 +561,78 @@ class MainActivity : AppCompatActivity() { val file = binding.layoutMoveSrcFile.tvFilePath.tag as DocumentFile val targetFolder = binding.layoutMoveFileTargetFolder.tvFilePath.tag as DocumentFile Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() - ioScope.launch { - file.moveFileTo(applicationContext, targetFolder, callback = createFileCallback()) - } - } - } - - 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%" + var job: Job? = null + job = ioScope.launch { + var dialog: MaterialDialog? = null + var tvStatus: TextView? = null + var progressBar: ProgressBar? = null + + file.moveFileTo(applicationContext, targetFolder, onConflict = createFileCallback()) + .onCompletion { + if (it is CancellationException) { + Timber.d("File move is aborted") } - - 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) { job?.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 -> uiScope.launch { + Timber.d("Completed") + Toast.makeText(baseContext, "Moved successfully", Toast.LENGTH_SHORT).show() + } + + is SingleFileResult.Error -> uiScope.launch { + 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 +640,19 @@ class MainActivity : AppCompatActivity() { } private fun handleParentFolderConflict( - conflictedFolders: MutableList, - conflictedFiles: MutableList, - action: MultipleFileCallback.ParentFolderConflictAction + conflictedFolders: MutableList, + conflictedFiles: MutableList, + action: MultipleFilesConflictCallback.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: MultipleFilesConflictCallback.ParentFolderConflictAction, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFolders.removeFirstOrNull() if (currentSolution == null) { @@ -583,7 +667,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 = SingleFolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFolders.forEach { it.solution = currentSolution.solution } @@ -597,10 +681,10 @@ class MainActivity : AppCompatActivity() { } private fun askFileSolution( - action: MultipleFileCallback.ParentFolderConflictAction, - conflictedFolders: MutableList, - conflictedFiles: MutableList, - newSolution: MutableList + action: MultipleFilesConflictCallback.ParentFolderConflictAction, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFiles.removeFirstOrNull() if (currentSolution == null) { @@ -614,7 +698,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 = SingleFolderConflictCallback.ConflictResolution.entries[if (index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -627,30 +711,37 @@ class MainActivity : AppCompatActivity() { .show() } - private fun handleParentFolderConflict(destinationFolder: DocumentFile, action: FolderCallback.ParentFolderConflictAction, canMerge: Boolean) { + private fun handleParentFolderConflict( + destinationFolder: DocumentFile, + action: SingleFolderConflictCallback.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 = SingleFolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] action.confirmResolution(resolution) - if (resolution == FolderCallback.ConflictResolution.SKIP) { + if (resolution == SingleFolderConflictCallback.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: SingleFolderConflictCallback.FolderContentConflictAction, + conflictedFiles: MutableList + ) { + val newSolution = ArrayList(conflictedFiles.size) askSolution(action, conflictedFiles, newSolution) } private fun askSolution( - action: FolderCallback.FolderContentConflictAction, - conflictedFiles: MutableList, - newSolution: MutableList + action: SingleFolderConflictCallback.FolderContentConflictAction, + conflictedFiles: MutableList, + newSolution: MutableList ) { val currentSolution = conflictedFiles.removeFirstOrNull() if (currentSolution == null) { @@ -664,7 +755,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 } @@ -677,7 +768,7 @@ class MainActivity : AppCompatActivity() { .show() } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) storageHelper.storage.checkIfFileReceived(intent) } @@ -759,7 +850,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/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/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..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,31 +1,18 @@ package com.anggrayudi.storage.sample.fragment; -import android.content.Context; +import com.anggrayudi.storage.SimpleStorageHelper; +import com.anggrayudi.storage.file.DocumentFileUtils; +import com.anggrayudi.storage.file.PublicDirectory; +import com.anggrayudi.storage.sample.R; + 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 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; - /** * Created on 08/08/21 * @@ -66,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 FileCallback createCallback() { - return new FileCallback() { - @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(); - } - }; - } } 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" />