diff --git a/.gitignore b/.gitignore index 9904d9a..b1ce123 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ fastlane/test_output fastlane/readme.md # Java dump memory -*.hprof \ No newline at end of file +*.hprof +/.kotlin/ diff --git a/.run/publishToCentral.run.xml b/.run/publishToCentral.run.xml index da54406..2a08658 100644 --- a/.run/publishToCentral.run.xml +++ b/.run/publishToCentral.run.xml @@ -1,17 +1,18 @@ - - + + \ No newline at end of file diff --git a/.run/publishToLocal.run.xml b/.run/publishToLocal.run.xml index 916ce8d..34df8c5 100644 --- a/.run/publishToLocal.run.xml +++ b/.run/publishToLocal.run.xml @@ -1,17 +1,17 @@ - - + + \ No newline at end of file diff --git a/FAQ.md b/FAQ.md index 80587cd..7b30aa2 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,65 +1,99 @@ # Frequently Asked Questions ### The app is not responding when copy, move, and other IO tasks + Read the quick documentation, Javadoc or go to the source code. All functions annotated by `@WorkerThread` must be called in the background thread, otherwise `@UiThread` must be called in the main thread. -If you ignore the annotation, your apps will lead to [ANR](https://developer.android.com/topic/performance/vitals/anr). +If you ignore the annotation, your apps will lead +to [ANR](https://developer.android.com/topic/performance/vitals/anr). ### How to open quick documentation? + Use keyboard shortcut Control + Q on Windows or Control + J on MacOS. -More shortcuts can be found on [Android Studio keyboard shortcuts](https://developer.android.com/studio/intro/keyboard-shortcuts). +More shortcuts can be found +on [Android Studio keyboard shortcuts](https://developer.android.com/studio/intro/keyboard-shortcuts). ### Why permission dialog is not shown on API 29+? + No runtime permission is required to be prompted on scoped storage. ### How to upload the `DocumentFile` and `MediaFile` to server? + Read the input stream with extension function `openInputStream()` and upload it as Base64 text. ### File path returns empty string -Getting file path (`getAbsolutePath()`, `getBasePath()`, etc.) may returns empty string if the `DocumentFile` is an instance of `androidx.documentfile.provider.SingleDocumentFile`. The following URIs are the example of `SingleDocumentFile`: + +Getting file path (`getAbsolutePath()`, `getBasePath()`, etc.) may returns empty string if +the `DocumentFile` is an instance of `androidx.documentfile.provider.SingleDocumentFile`. The +following URIs are the example of `SingleDocumentFile`: + ``` content://com.android.providers.downloads.documents/document/9 content://com.android.providers.media.documents/document/document%3A34 ``` + Here're some notes: + * Empty file path is not this library's limitation, but Android OS itself. -* To check if the file has guaranteed direct file path, extension function `DocumentFile.isTreeDocumentFile` will return `true`. -* You can convert `SingleDocumentFile` to `MediaFile` and use `MediaFile.absolutePath`. If this still does not work, then there's no other way. -* We don't recommend you to use direct file path for file management, such as reading, uploading it to the server, or importing it into your app. -Because Android OS wants us to use URI, thus direct file path is useless. So you need to use extension function `Uri.openInputStream()` for `DocumentFile` and `MediaFile`. +* To check if the file has guaranteed direct file path, extension + function `DocumentFile.isTreeDocumentFile` will return `true`. +* You can convert `SingleDocumentFile` to `MediaFile` and use `MediaFile.absolutePath`. If this + still does not work, then there's no other way. +* We don't recommend you to use direct file path for file management, such as reading, uploading it + to the server, or importing it into your app. + Because Android OS wants us to use URI, thus direct file path is useless. So you need to use + extension function `Uri.openInputStream()` for `DocumentFile` and `MediaFile`. ### How to check if a folder/file is writable? + Use `isWritable()` extension function, because `DocumentFile.canWrite()` sometimes buggy on API 30. ### Which paths are writable with `java.io.File` on scoped storage? -Accessing files in scoped storage requires URI, but the following paths are exception and no storage permission needed: + +Accessing files in scoped storage requires URI, but the following paths are exception and no storage +permission needed: + * `/storage/emulated/0/Android/data/` * `/storage//Android/data/` * `/data/user/0/` (API 24+) * `/data/data/` (API 23-) ### What is the target branch for pull requests? + Use branch `release/*` if exists, or use `master` instead. ### I have Java projects, but this library is built in Kotlin. How can I use it? + Kotlin is compatible with Java. You can read Kotlin functions as Java methods. Read: [Java Compatibility](https://github.com/anggrayudi/SimpleStorage/blob/master/JAVA_COMPATIBILITY.md) ### Why does SimpleStorage use Kotlin? + The main reasons why this library really needs Kotlin: -* SimpleStorage requires thread suspension feature, but this feature is only provided by [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines). -* SimpleStorage contains many `String` & `Collection` manipulations, and Kotlin can overcome them in simple and easy ways. + +* SimpleStorage requires thread suspension feature, but this feature is only provided + by [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines). +* SimpleStorage contains many `String` & `Collection` manipulations, and Kotlin can overcome them in + simple and easy ways. Other reasons are: + * Kotlin can shorten and simplify your code. * Writing code in Kotlin is faster, thus it saves your time and improves your productivity. -* [Google is Kotlin first](https://techcrunch.com/2019/05/07/kotlin-is-now-googles-preferred-language-for-android-app-development/) now. +* [Google is Kotlin first](https://techcrunch.com/2019/05/07/kotlin-is-now-googles-preferred-language-for-android-app-development/) + now. ### What are SimpleStorage alternatives? -You can't run from the fact that Google is Kotlin First now. Even Google has created [ModernStorage](https://github.com/google/modernstorage) (alternative for SimpleStorage) in Kotlin. + +You can't run from the fact that Google is Kotlin First now. Even Google has +created [ModernStorage](https://github.com/google/modernstorage) (alternative for SimpleStorage) in +Kotlin. Learn Kotlin, or Google will leave you far behind. -**We have no intention to create Java version of SimpleStorage.** It will double our works and requires a lot of effort. -Keep in mind that we don't want to archive this library, even though Google has released the stable version of ModernStorage. -This library has rich features that Google may not covers, e.g. moving, copying, compressing and scanning folders. +**We have no intention to create Java version of SimpleStorage.** It will double our works and +requires a lot of effort. +Keep in mind that we don't want to archive this library, even though Google has released the stable +version of ModernStorage. +This library has rich features that Google may not covers, e.g. moving, copying, compressing and +scanning folders. diff --git a/JAVA_COMPATIBILITY.md b/JAVA_COMPATIBILITY.md index 95ff40c..d8be6af 100644 --- a/JAVA_COMPATIBILITY.md +++ b/JAVA_COMPATIBILITY.md @@ -4,18 +4,25 @@ Kotlin is compatible with Java, meaning that Kotlin code is readable in Java. ## How to use? -Simple Storage contains utility functions stored in `object` class, e.g. `DocumentFileCompat` and `MediaStoreCompat`. +Simple Storage contains utility functions stored in `object` class, e.g. `DocumentFileCompat` +and `MediaStoreCompat`. These classes contain only static functions. Additionally, this library also has extension functions, e.g. `DocumentFileExtKt` and `FileExtKt`. -You can learn it [here](https://www.raywenderlich.com/10986797-extension-functions-and-properties-in-kotlin). +You can learn +it [here](https://www.raywenderlich.com/10986797-extension-functions-and-properties-in-kotlin). ### Extension Functions -Common extension functions are stored in package `com.anggrayudi.storage.extension`. The others are in `com.anggrayudi.storage.file`. -You'll find that the most useful extension functions come from `DocumentFileExtKt` and `FileExtKt`. They are: -* `DocumentFile.getStorageId()` and `File.getStorageId()` → Get storage ID. Returns `primary` for external storage and something like `AAAA-BBBB` for SD card. -* `DocumentFile.getAbsolutePath()` → Get file's absolute path. Returns something like `/storage/AAAA-BBBB/Music/My Love.mp3`. +Common extension functions are stored in package `com.anggrayudi.storage.extension`. The others are +in `com.anggrayudi.storage.file`. +You'll find that the most useful extension functions come from `DocumentFileExtKt` and `FileExtKt`. +They are: + +* `DocumentFile.getStorageId()` and `File.getStorageId()` → Get storage ID. Returns `primary` for + external storage and something like `AAAA-BBBB` for SD card. +* `DocumentFile.getAbsolutePath()` → Get file's absolute path. Returns something + like `/storage/AAAA-BBBB/Music/My Love.mp3`. * `DocumentFile.copyFileTo()` and `File.copyFileTo()` * `DocumentFile.search()` and `File.search()`, etc. @@ -43,7 +50,8 @@ their class names are renamed from using suffix `ExtKt` to `Utils`. I will refer to utility functions stored in Kotlin `object` class so you can understand it easily. You can find the most useful utility functions in `DocumentFileCompat` and `MediaStoreCompat`. -Suppose that I want to get file from SD card with the following simple path: `AAAA-BBBB:Music/My Love.mp3`. +Suppose that I want to get file from SD card with the following simple +path: `AAAA-BBBB:Music/My Love.mp3`. BTW, `AAAA-BBBB` is the SD card's storage ID for this example. #### In Kotlin @@ -65,5 +73,5 @@ Just go to the source code to check whether it has the annotation. ## Sample Code * More sample code in Java can be found in -[`JavaActivity`](https://github.com/anggrayudi/SimpleStorage/blob/master/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java) + [`JavaActivity`](https://github.com/anggrayudi/SimpleStorage/blob/master/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java) * Learn Kotlin on [Udacity](https://classroom.udacity.com/courses/ud9011). It's easy and free! \ No newline at end of file diff --git a/README.md b/README.md index 2accca3..531a2dd 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # SimpleStorage + ![Maven Central](https://img.shields.io/maven-central/v/com.anggrayudi/storage.svg) [![Build Status](https://github.com/anggrayudi/SimpleStorage/workflows/Android%20CI/badge.svg)](https://github.com/anggrayudi/SimpleStorage/actions?query=workflow%3A%22Android+CI%22) ### Table of Contents + * [Overview](#overview) - + [Java Compatibility](#java-compatibility) + + [Java Compatibility](#java-compatibility) * [Terminology](#terminology) * [Check Accessible Paths](#check-accessible-paths) * [Read Files](#read-files) - + [`DocumentFileCompat`](#documentfilecompat) - - [Example](#example) - + [`MediaStoreCompat`](#mediastorecompat) - - [Example](#example-1) + + [`DocumentFileCompat`](#documentfilecompat) + - [Example](#example) + + [`MediaStoreCompat`](#mediastorecompat) + - [Example](#example-1) * [Manage Files](#manage-files) - + [`DocumentFile`](#documentfile) - + [`MediaFile`](#mediafile) + + [`DocumentFile`](#documentfile) + + [`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) * [FAQ](#faq) @@ -38,9 +40,11 @@ Adding Simple Storage into your project is pretty simple: implementation "com.anggrayudi:storage:X.Y.Z" ``` -Where `X.Y.Z` is the library version: ![Maven Central](https://img.shields.io/maven-central/v/com.anggrayudi/storage.svg) +Where `X.Y.Z` is the library +version: ![Maven Central](https://img.shields.io/maven-central/v/com.anggrayudi/storage.svg) -All versions can be found [here](https://oss.sonatype.org/#nexus-search;gav~com.anggrayudi~storage~~~~kw,versionexpand). +All versions can be +found [here](https://oss.sonatype.org/#nexus-search;gav~com.anggrayudi~storage~~~~kw,versionexpand). To use `SNAPSHOT` version, you need to add this URL to the root Gradle: ```groovy @@ -56,27 +60,38 @@ allprojects { ### Java Compatibility -Simple Storage is built in Kotlin. Follow this [documentation](JAVA_COMPATIBILITY.md) to use it in your Java project. +Simple Storage is built in Kotlin. Follow this [documentation](JAVA_COMPATIBILITY.md) to use it in +your Java project. ## Terminology ![Alt text](art/terminology.png?raw=true "Simple Storage Terms") ### Other Terminology -* Storage Permission – related to [runtime permissions](https://developer.android.com/training/permissions/requesting) -* Storage Access – related to [URI permissions](https://developer.android.com/reference/android/content/ContentResolver#takePersistableUriPermission(android.net.Uri,%20int)) + +* Storage Permission – related + to [runtime permissions](https://developer.android.com/training/permissions/requesting) +* Storage Access – related + to [URI permissions](https://developer.android.com/reference/android/content/ContentResolver#takePersistableUriPermission(android.net.Uri,%20int)) ## Check Accessible Paths -To check whether you have access to particular paths, call `DocumentFileCompat.getAccessibleAbsolutePaths()`. The results will look like this in breakpoint: +To check whether you have access to particular paths, +call `DocumentFileCompat.getAccessibleAbsolutePaths()`. The results will look like this in +breakpoint: ![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 -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()`, +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 +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: + ```kotlin buttonSelectFolder.setOnClickListener { storageHelper.openFolderPicker() @@ -87,7 +102,9 @@ storageHelper.onFolderSelected = { requestCode, folder -> } ``` -In the future, if you want to write files into the granted path, use `DocumentFileCompat.fromFullPath()`: +In the future, if you want to write files into the granted path, +use `DocumentFileCompat.fromFullPath()`: + ```kotlin val grantedPaths = DocumentFileCompat.getAccessibleAbsolutePaths(this) val path = grantedPaths.values.firstOrNull()?.firstOrNull() ?: return @@ -97,8 +114,10 @@ val file = folder?.makeFile(this, "notes", "text/plain") ## Read Files -In Simple Storage, `DocumentFile` is used to access files when your app has been granted full storage access, -included URI permissions for read and write. Whereas `MediaFile` is used to access media files from `MediaStore` +In Simple Storage, `DocumentFile` is used to access files when your app has been granted full +storage access, +included URI permissions for read and write. Whereas `MediaFile` is used to access media files +from `MediaStore` without URI permissions to the storage. You can read file with helper functions in `DocumentFileCompat` and `MediaStoreCompat`: @@ -111,6 +130,7 @@ You can read file with helper functions in `DocumentFileCompat` and `MediaStoreC * `DocumentFileCompat.fromPublicFolder()` #### Example + ```kotlin val fileFromExternalStorage = DocumentFileCompat.fromSimplePath(context, basePath = "Download/MyMovie.mp4") @@ -127,6 +147,7 @@ val fileFromSdCard = DocumentFileCompat.fromSimplePath(context, storageId = "901 * `MediaStoreCompat.fromMediaType()` #### Example + ```kotlin val myVideo = MediaStoreCompat.fromFileName(context, MediaType.DOWNLOADS, "MyMovie.mp4") @@ -137,9 +158,11 @@ val imageList = MediaStoreCompat.fromMediaType(context, MediaType.IMAGE) ### `DocumentFile` -Since `java.io.File` has been deprecated in Android 10, thus you have to use `DocumentFile` for file management. +Since `java.io.File` has been deprecated in Android 10, thus you have to use `DocumentFile` for file +management. Simple Storage adds Kotlin extension functions to `DocumentFile`, so you can manage files like this: + * `DocumentFile.getStorageId()` * `DocumentFile.getStorageType()` * `DocumentFile.getBasePath()` @@ -153,6 +176,7 @@ Simple Storage adds Kotlin extension functions to `DocumentFile`, so you can man ### `MediaFile` For media files, you can have similar capabilities to `DocumentFile`, i.e.: + * `MediaFile.absolutePath` * `MediaFile.isPending` * `MediaFile.delete()` @@ -164,11 +188,14 @@ For media files, you can have similar capabilities to `DocumentFile`, i.e.: ## Request Storage Access, Pick Folder & Files, Request Create File, etc. -Although user has granted read and write permissions during runtime, your app may still does not have full access to the storage, -thus you cannot search, move and copy files. You can check whether you have the storage access via `SimpleStorage.hasStorageAccess()` or +Although user has granted read and write permissions during runtime, your app may still does not +have full access to the storage, +thus you cannot search, move and copy files. You can check whether you have the storage access +via `SimpleStorage.hasStorageAccess()` or `DocumentFileCompat.getAccessibleAbsolutePaths()`. -To enable full storage access, you need to open SAF and let user grant URI permissions for read and write access. +To enable full storage access, you need to open SAF and let user grant URI permissions for read and +write access. This library provides you an helper class named `SimpleStorageHelper` to ease the request process: ```kotlin @@ -235,6 +262,7 @@ If you want to use custom dialogs for `SimpleStorageHelper`, just copy the logic ## Move & Copy: Files & Folders Simple Storage helps you in copying/moving files & folders via: + * `DocumentFile.copyFileTo()` * `DocumentFile.moveFileTo()` * `DocumentFile.copyFolderTo()` @@ -286,8 +314,10 @@ folder.moveFolderTo(applicationContext, targetFolder, skipEmptyFiles = false, ca }) ``` -The coolest thing of this library is you can ask users to choose Merge, Replace, Create New, or Skip Duplicate folders & files -whenever a conflict is found via `onConflict()`. Here're screenshots of the sample code when dealing with conflicts: +The coolest thing of this library is you can ask users to choose Merge, Replace, Create New, or Skip +Duplicate folders & files +whenever a conflict is found via `onConflict()`. Here're screenshots of the sample code when dealing +with conflicts: ![Alt text](art/parent-folder-conflict.png?raw=true "Parent Folder Conflict") ![Alt text](art/folder-content-conflict.png?raw=true "Folder Content Conflict") @@ -303,6 +333,7 @@ Having trouble? Read the [Frequently Asked Questions](FAQ.md). SimpleStorage is used in these open source projects. Check how these repositories use it: + * [Snapdrop](https://github.com/anggrayudi/snapdrop-android) * [MaterialPreference](https://github.com/anggrayudi/MaterialPreference) * [Super Productivity](https://github.com/johannesjo/super-productivity-android) diff --git a/build.gradle b/build.gradle index 73850d9..1fc8712 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,18 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { apply from: 'versions.gradle' 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.3.2' 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' + classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' + classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.9.20' } } @@ -18,7 +20,7 @@ allprojects { addRepos(repositories) //Support @JvmDefault - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + tasks.withType(KotlinCompile).configureEach { kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all', '-opt-in=kotlin.RequiresOptIn'] jvmTarget = '1.8' @@ -41,11 +43,10 @@ subprojects { afterEvaluate { android { - compileSdkVersion 33 + compileSdkVersion 34 defaultConfig { minSdkVersion 19 - targetSdkVersion 33 versionCode 1 versionName "$VERSION_NAME" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -61,7 +62,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 +80,6 @@ subprojects { } } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f6b961f..d64cd49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f0d76de..a441313 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Dec 01 18:57:30 WIB 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip diff --git a/gradlew b/gradlew index cccdd3d..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -1,78 +1,127 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index e95643d..7101f8e 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,4 +1,20 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -9,25 +25,29 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -35,48 +55,36 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/sample/build.gradle b/sample/build.gradle index edf2f34..07b8a3e 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -78,9 +78,7 @@ dependencies { implementation deps.timber implementation deps.material_progressbar + implementation deps.material_dialogs_files implementation 'androidx.preference:preference-ktx:1.2.0' - //test - testImplementation deps.junit - testImplementation deps.mockk } \ 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..030e4e7 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 @@ -17,7 +17,6 @@ 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; @@ -109,15 +108,6 @@ public void onConflict(@NotNull DocumentFile destinationFile, @NotNull FileCallb // 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()); 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..b17fa62 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 @@ -21,9 +21,10 @@ 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 java.util.Objects; + import timber.log.Timber; /** @@ -77,7 +78,7 @@ private void moveFileToSaveLocation(@NonNull DocumentFile sourceFile) { // write any files into folder 'saveLocationFolder' DocumentFileUtils.moveFileTo(sourceFile, requireContext(), saveLocationFolder, null, createCallback()); } else { - FileDescription fileDescription = new FileDescription(sourceFile.getName(), "", sourceFile.getType()); + FileDescription fileDescription = new FileDescription(Objects.requireNonNull(sourceFile.getName()), "", sourceFile.getType()); DocumentFileUtils.moveFileToDownloadMedia(sourceFile, requireContext(), fileDescription, createCallback()); } } @@ -95,17 +96,16 @@ public void onFailed(ErrorCode errorCode) { } @Override - public void onCompleted(@NonNull Object file) { + public void onCompleted(FileCallback.Result result) { 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; + if (result instanceof Result.MediaFile) { + uri = ((Result.MediaFile) result).getValue().getUri(); + } else if (result instanceof FileCallback.Result.DocumentFile) { + final DocumentFile documentFile = ((Result.DocumentFile) result).getValue(); uri = DocumentFileUtils.isRawFile(documentFile) - ? FileProvider.getUriForFile(context, context.getPackageName() + ".provider", DocumentFileUtils.toRawFile(documentFile, context)) + ? FileProvider.getUriForFile(context, context.getPackageName() + ".provider", Objects.requireNonNull(DocumentFileUtils.toRawFile(documentFile, context))) : documentFile.getUri(); } else { return; diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/App.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/App.kt similarity index 100% rename from sample/src/main/java/com/anggrayudi/storage/sample/App.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/App.kt diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/StorageInfoAdapter.kt similarity index 83% rename from sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/StorageInfoAdapter.kt index 9b45777..cca5e9b 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/StorageInfoAdapter.kt +++ b/sample/src/main/kotlin/com/anggrayudi/storage/sample/StorageInfoAdapter.kt @@ -30,7 +30,10 @@ class StorageInfoAdapter( private val storageIds = DocumentFileCompat.getStorageIds(context) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.view_item_storage_info, parent, false)) + return ViewHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.view_item_storage_info, parent, false) + ) } @SuppressLint("SetTextI18n") @@ -38,9 +41,18 @@ class StorageInfoAdapter( ioScope.launch { val storageId = storageIds[position] val storageName = if (storageId == PRIMARY) "External Storage" else storageId - val storageCapacity = Formatter.formatFileSize(context, DocumentFileCompat.getStorageCapacity(context, storageId)) - val storageUsedSpace = Formatter.formatFileSize(context, DocumentFileCompat.getUsedSpace(context, storageId)) - val storageFreeSpace = Formatter.formatFileSize(context, DocumentFileCompat.getFreeSpace(context, storageId)) + val storageCapacity = Formatter.formatFileSize( + context, + DocumentFileCompat.getStorageCapacity(context, storageId) + ) + val storageUsedSpace = Formatter.formatFileSize( + context, + DocumentFileCompat.getUsedSpace(context, storageId) + ) + val storageFreeSpace = Formatter.formatFileSize( + context, + DocumentFileCompat.getFreeSpace(context, storageId) + ) uiScope.launch { holder.run { tvStorageName.text = storageName @@ -61,7 +73,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/BaseActivity.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/BaseActivity.kt similarity index 100% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/BaseActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/BaseActivity.kt diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt similarity index 61% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt index 4524134..fbfea82 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt +++ b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/FileCompressionActivity.kt @@ -40,8 +40,13 @@ class FileCompressionActivity : BaseActivity() { storageHelper.onFileSelected = { requestCode, files -> when (requestCode) { - REQUEST_CODE_PICK_MEDIA_1 -> binding.layoutCompressFilesSrcMedia1.tvFilePath.updateFileSelectionView(files) - REQUEST_CODE_PICK_MEDIA_2 -> binding.layoutCompressFilesSrcMedia2.tvFilePath.updateFileSelectionView(files) + REQUEST_CODE_PICK_MEDIA_1 -> binding.layoutCompressFilesSrcMedia1.tvFilePath.updateFileSelectionView( + files + ) + + REQUEST_CODE_PICK_MEDIA_2 -> binding.layoutCompressFilesSrcMedia2.tvFilePath.updateFileSelectionView( + files + ) } } binding.layoutCompressFilesSrcMedia1.btnBrowse.setOnClickListener { @@ -53,8 +58,13 @@ class FileCompressionActivity : BaseActivity() { storageHelper.onFolderSelected = { requestCode, folder -> when (requestCode) { - REQUEST_CODE_PICK_FOLDER_1 -> binding.layoutCompressFilesSrcFolder1.tvFilePath.updateFileSelectionView(folder) - REQUEST_CODE_PICK_FOLDER_2 -> binding.layoutCompressFilesSrcFolder2.tvFilePath.updateFileSelectionView(folder) + REQUEST_CODE_PICK_FOLDER_1 -> binding.layoutCompressFilesSrcFolder1.tvFilePath.updateFileSelectionView( + folder + ) + + REQUEST_CODE_PICK_FOLDER_2 -> binding.layoutCompressFilesSrcFolder2.tvFilePath.updateFileSelectionView( + folder + ) } } binding.layoutCompressFilesSrcFolder1.btnBrowse.setOnClickListener { @@ -84,33 +94,61 @@ class FileCompressionActivity : BaseActivity() { } val files = mutableListOf() - (binding.layoutCompressFilesSrcMedia1.tvFilePath.tag as? List)?.let { files.addAll(it) } - (binding.layoutCompressFilesSrcMedia2.tvFilePath.tag as? List)?.let { files.addAll(it) } + (binding.layoutCompressFilesSrcMedia1.tvFilePath.tag as? List)?.let { + files.addAll( + it + ) + } + (binding.layoutCompressFilesSrcMedia2.tvFilePath.tag as? List)?.let { + files.addAll( + it + ) + } (binding.layoutCompressFilesSrcFolder1.tvFilePath.tag as? DocumentFile)?.let { files.add(it) } (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 - } - - 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() - } - }) + files.compressToZip( + applicationContext, + targetZip, + callback = object : ZipCompressionCallback(uiScope) { + override fun onCountingFiles() { + // show a notification or dialog with indeterminate progress bar + } + + 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/kotlin/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt similarity index 52% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt index eae3d63..c80943a 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt +++ b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/FileDecompressionActivity.kt @@ -67,47 +67,59 @@ class FileDecompressionActivity : BaseActivity() { return } ioScope.launch { - zipFile.decompressZip(applicationContext, targetFolder, object : ZipDecompressionCallback(uiScope) { - var actionForAllConflicts: FileCallback.ConflictResolution? = null + zipFile.decompressZip( + applicationContext, + targetFolder, + object : ZipDecompressionCallback(uiScope) { + var actionForAllConflicts: FileCallback.ConflictResolution? = null - override fun onFileConflict(destinationFile: DocumentFile, action: FileCallback.FileConflictAction) { - actionForAllConflicts?.let { - action.confirmResolution(it) - return - } + override fun onFileConflict( + destinationFile: DocumentFile, + action: FileCallback.FileConflictAction + ) { + actionForAllConflicts?.let { + action.confirmResolution(it) + return + } - var doForAll = false - MaterialDialog(this@FileDecompressionActivity) - .cancelable(false) - .title(text = "Conflict Found") - .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] - if (doForAll) { - actionForAllConflicts = resolution + var doForAll = false + MaterialDialog(this@FileDecompressionActivity) + .cancelable(false) + .title(text = "Conflict Found") + .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] + if (doForAll) { + actionForAllConflicts = resolution + } + action.confirmResolution(resolution) } - action.confirmResolution(resolution) - } - .show() - } + .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 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() - } - }) + override fun onFailed(errorCode: ErrorCode) { + Toast.makeText(applicationContext, "$errorCode", Toast.LENGTH_SHORT).show() + } + }) } } } \ No newline at end of file diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/MainActivity.kt similarity index 75% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/MainActivity.kt index 0b84baf..a7a55db 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/MainActivity.kt +++ b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/MainActivity.kt @@ -62,11 +62,18 @@ class MainActivity : AppCompatActivity() { private val uiScope = CoroutineScope(Dispatchers.Main + job) private val permissionRequest = ActivityPermissionRequest.Builder(this) - .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermissions( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) .withCallback(object : PermissionCallback { override fun onPermissionsChecked(result: PermissionResult, fromSystemDialog: Boolean) { val grantStatus = if (result.areAllPermissionsGranted) "granted" else "denied" - Toast.makeText(baseContext, "Storage permissions are $grantStatus", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Storage permissions are $grantStatus", + Toast.LENGTH_SHORT + ).show() } override fun onShouldRedirectToSystemSettings(blockedPermissions: List) { @@ -131,7 +138,11 @@ class MainActivity : AppCompatActivity() { } binding.layoutBaseOperation.btnCreateFile.setOnClickListener { - storageHelper.createFile("text/plain", "Test create file", requestCode = REQUEST_CODE_CREATE_FILE) + storageHelper.createFile( + "text/plain", + "Test create file", + requestCode = REQUEST_CODE_CREATE_FILE + ) } binding.btnCompressFiles.setOnClickListener { @@ -160,17 +171,32 @@ class MainActivity : AppCompatActivity() { storageHelper.onStorageAccessGranted = { _, root -> Toast.makeText( this, - getString(com.anggrayudi.storage.R.string.ss_selecting_root_path_success_without_open_folder_picker, root.getAbsolutePath(this)), + getString( + com.anggrayudi.storage.R.string.ss_selecting_root_path_success_without_open_folder_picker, + root.getAbsolutePath(this) + ), Toast.LENGTH_SHORT ).show() } storageHelper.onFileSelected = { requestCode, files -> val file = files.first() when (requestCode) { - REQUEST_CODE_PICK_SOURCE_FILE_FOR_COPY -> binding.layoutCopySrcFile.tvFilePath.updateFileSelectionView(file) - REQUEST_CODE_PICK_SOURCE_FILE_FOR_MOVE -> binding.layoutMoveSrcFile.tvFilePath.updateFileSelectionView(file) - REQUEST_CODE_PICK_SOURCE_FILE_FOR_MULTIPLE_COPY -> binding.layoutCopyMultipleFilesSourceFile.tvFilePath.updateFileSelectionView(file) - REQUEST_CODE_PICK_SOURCE_FILE_FOR_MULTIPLE_MOVE -> binding.layoutMoveMultipleFilesSourceFile.tvFilePath.updateFileSelectionView(file) + REQUEST_CODE_PICK_SOURCE_FILE_FOR_COPY -> binding.layoutCopySrcFile.tvFilePath.updateFileSelectionView( + file + ) + + REQUEST_CODE_PICK_SOURCE_FILE_FOR_MOVE -> binding.layoutMoveSrcFile.tvFilePath.updateFileSelectionView( + file + ) + + REQUEST_CODE_PICK_SOURCE_FILE_FOR_MULTIPLE_COPY -> binding.layoutCopyMultipleFilesSourceFile.tvFilePath.updateFileSelectionView( + file + ) + + REQUEST_CODE_PICK_SOURCE_FILE_FOR_MULTIPLE_MOVE -> binding.layoutMoveMultipleFilesSourceFile.tvFilePath.updateFileSelectionView( + file + ) + REQUEST_CODE_PICK_FILE_FOR_RENAME -> renameFile(file) REQUEST_CODE_PICK_FILE_FOR_DELETE -> deleteFiles(files) else -> { @@ -181,24 +207,51 @@ class MainActivity : AppCompatActivity() { } storageHelper.onFolderSelected = { requestCode, folder -> when (requestCode) { - REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FILE_COPY -> binding.layoutCopyFileTargetFolder.tvFilePath.updateFolderSelectionView(folder) - REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FILE_MOVE -> binding.layoutMoveFileTargetFolder.tvFilePath.updateFolderSelectionView(folder) - REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_COPY -> binding.layoutCopyFolderSrcFolder.tvFilePath.updateFolderSelectionView(folder) - REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FOLDER_COPY -> binding.layoutCopyFolderTargetFolder.tvFilePath.updateFolderSelectionView(folder) - REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MOVE -> binding.layoutMoveFolderSrcFolder.tvFilePath.updateFolderSelectionView(folder) - REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FOLDER_MOVE -> binding.layoutMoveFolderTargetFolder.tvFilePath.updateFolderSelectionView(folder) - - REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MULTIPLE_COPY -> binding.layoutCopyMultipleFilesSourceFolder.tvFilePath.updateFolderSelectionView(folder) + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FILE_COPY -> binding.layoutCopyFileTargetFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FILE_MOVE -> binding.layoutMoveFileTargetFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_COPY -> binding.layoutCopyFolderSrcFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FOLDER_COPY -> binding.layoutCopyFolderTargetFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MOVE -> binding.layoutMoveFolderSrcFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_FOLDER_MOVE -> binding.layoutMoveFolderTargetFolder.tvFilePath.updateFolderSelectionView( + folder + ) + + REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MULTIPLE_COPY -> binding.layoutCopyMultipleFilesSourceFolder.tvFilePath.updateFolderSelectionView( + folder + ) + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_MULTIPLE_FILE_COPY -> binding.layoutCopyMultipleFilesTargetFolder.tvFilePath.updateFolderSelectionView( folder ) - REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MULTIPLE_MOVE -> binding.layoutMoveMultipleFilesSourceFolder.tvFilePath.updateFolderSelectionView(folder) + REQUEST_CODE_PICK_SOURCE_FOLDER_FOR_MULTIPLE_MOVE -> binding.layoutMoveMultipleFilesSourceFolder.tvFilePath.updateFolderSelectionView( + folder + ) + REQUEST_CODE_PICK_TARGET_FOLDER_FOR_MULTIPLE_FILE_MOVE -> binding.layoutMoveMultipleFilesTargetFolder.tvFilePath.updateFolderSelectionView( 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 -> @@ -226,7 +279,8 @@ class MainActivity : AppCompatActivity() { ioScope.launch { val newName = file.changeName(baseContext, text.toString())?.name uiScope.launch { - val message = if (newName != null) "File renamed to $newName" else "Failed to rename ${file.fullName}" + val message = + if (newName != null) "File renamed to $newName" else "Failed to rename ${file.fullName}" Toast.makeText(baseContext, message, Toast.LENGTH_SHORT).show() } } @@ -239,7 +293,11 @@ class MainActivity : AppCompatActivity() { ioScope.launch { val deleted = files.count { it.delete() } uiScope.launch { - Toast.makeText(baseContext, "Deleted $deleted of ${files.size} files", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Deleted $deleted of ${files.size} files", + Toast.LENGTH_SHORT + ).show() } } } @@ -265,22 +323,33 @@ class MainActivity : AppCompatActivity() { storageHelper.openFolderPicker(REQUEST_CODE_PICK_TARGET_FOLDER_FOR_MULTIPLE_FILE_COPY) } binding.btnStartCopyMultipleFiles.setOnClickListener { - val targetFolder = binding.layoutCopyMultipleFilesTargetFolder.tvFilePath.tag as? DocumentFile + val targetFolder = + binding.layoutCopyMultipleFilesTargetFolder.tvFilePath.tag as? DocumentFile if (targetFolder == null) { Toast.makeText(this, "Please select target folder", Toast.LENGTH_SHORT).show() return@setOnClickListener } - val sourceFolder = binding.layoutCopyMultipleFilesSourceFolder.tvFilePath.tag as? DocumentFile - val sourceFile = binding.layoutCopyMultipleFilesSourceFile.tvFilePath.tag as? DocumentFile + val sourceFolder = + binding.layoutCopyMultipleFilesSourceFolder.tvFilePath.tag as? DocumentFile + val sourceFile = + binding.layoutCopyMultipleFilesSourceFile.tvFilePath.tag as? DocumentFile val sources = listOfNotNull(sourceFolder, sourceFile) if (sources.isEmpty()) { - Toast.makeText(this, "Please select the source file and/or folder", Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + "Please select the source file and/or folder", + Toast.LENGTH_SHORT + ).show() return@setOnClickListener } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.copyTo(applicationContext, targetFolder, callback = createMultipleFileCallback(false)) + sources.copyTo( + applicationContext, + targetFolder, + callback = createMultipleFileCallback(false) + ) } } } @@ -296,62 +365,83 @@ class MainActivity : AppCompatActivity() { storageHelper.openFolderPicker(REQUEST_CODE_PICK_TARGET_FOLDER_FOR_MULTIPLE_FILE_MOVE) } binding.btnStartMoveMultipleFiles.setOnClickListener { - val targetFolder = binding.layoutMoveMultipleFilesTargetFolder.tvFilePath.tag as? DocumentFile + val targetFolder = + binding.layoutMoveMultipleFilesTargetFolder.tvFilePath.tag as? DocumentFile if (targetFolder == null) { Toast.makeText(this, "Please select target folder", Toast.LENGTH_SHORT).show() return@setOnClickListener } - val sourceFolder = binding.layoutMoveMultipleFilesSourceFolder.tvFilePath.tag as? DocumentFile - val sourceFile = binding.layoutMoveMultipleFilesSourceFile.tvFilePath.tag as? DocumentFile + val sourceFolder = + binding.layoutMoveMultipleFilesSourceFolder.tvFilePath.tag as? DocumentFile + val sourceFile = + binding.layoutMoveMultipleFilesSourceFile.tvFilePath.tag as? DocumentFile val sources = listOfNotNull(sourceFolder, sourceFile) if (sources.isEmpty()) { - Toast.makeText(this, "Please select the source file and/or folder", Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + "Please select the source file and/or folder", + Toast.LENGTH_SHORT + ).show() return@setOnClickListener } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - sources.moveTo(applicationContext, targetFolder, callback = createMultipleFileCallback(true)) + sources.moveTo( + applicationContext, + targetFolder, + callback = createMultipleFileCallback(true) + ) } } } - private fun createMultipleFileCallback(isMoveFileMode: Boolean) = object : MultipleFileCallback(uiScope) { - val mode = if (isMoveFileMode) "Moved" else "Copied" + 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 - } + override fun onStart( + files: List, + totalFilesToCopy: Int, + workerThread: Thread + ): Long { + return 1000 // update progress every 1 second + } - override fun onParentConflict( - destinationParentFolder: DocumentFile, - conflictedFolders: MutableList, - conflictedFiles: MutableList, - action: ParentFolderConflictAction - ) { - handleParentFolderConflict(conflictedFolders, conflictedFiles, action) - } + override fun onParentConflict( + destinationParentFolder: DocumentFile, + conflictedFolders: MutableList, + conflictedFiles: MutableList, + action: ParentFolderConflictAction + ) { + handleParentFolderConflict(conflictedFolders, conflictedFiles, action) + } - override fun onContentConflict( - destinationParentFolder: DocumentFile, - conflictedFiles: MutableList, - action: FolderCallback.FolderContentConflictAction - ) { - handleFolderContentConflict(action, conflictedFiles) - } + override fun onContentConflict( + destinationParentFolder: DocumentFile, + conflictedFiles: MutableList, + action: FolderCallback.FolderContentConflictAction + ) { + handleFolderContentConflict(action, conflictedFiles) + } - override fun onReport(report: Report) { - Timber.d("onReport() -> ${report.progress.toInt()}% | $mode ${report.fileCount} files") - } + 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 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() + override fun onFailed(errorCode: ErrorCode) { + Toast.makeText(baseContext, "An error has occurred: $errorCode", Toast.LENGTH_SHORT) + .show() + } } - } private fun setupFolderCopy() { binding.layoutCopyFolderSrcFolder.btnBrowse.setOnClickListener { @@ -373,7 +463,12 @@ 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(false) + ) } } } @@ -398,7 +493,12 @@ 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(true) + ) } } } @@ -414,11 +514,19 @@ class MainActivity : AppCompatActivity() { // Inform user that the app is counting & calculating files } - override fun onStart(folder: DocumentFile, totalFilesToCopy: Int, workerThread: Thread): Long { + override fun onStart( + folder: DocumentFile, + totalFilesToCopy: Int, + workerThread: Thread + ): Long { return 1000 // update progress every 1 second } - override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { + override fun onParentConflict( + destinationFolder: DocumentFile, + action: ParentFolderConflictAction, + canMerge: Boolean + ) { handleParentFolderConflict(destinationFolder, action, canMerge) } @@ -435,11 +543,16 @@ class MainActivity : AppCompatActivity() { } override fun onCompleted(result: Result) { - Toast.makeText(baseContext, "$mode ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + 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() + Toast.makeText(baseContext, "An error has occurred: $errorCode", Toast.LENGTH_SHORT) + .show() } } @@ -510,13 +623,15 @@ class MainActivity : AppCompatActivity() { .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%" - } - - progressBar = getCustomView().findViewById(R.id.progressCopy).apply { - isIndeterminate = true - } + tvStatus = + getCustomView().findViewById(R.id.tvProgressStatus).apply { + text = context.getString(R.string.copying_file, 0) + } + + progressBar = + getCustomView().findViewById(R.id.progressCopy).apply { + isIndeterminate = true + } show() } } @@ -524,17 +639,18 @@ class MainActivity : AppCompatActivity() { } override fun onReport(report: Report) { - tvStatus?.text = "Copying file: ${report.progress.toInt()}%" + tvStatus?.text = getString(R.string.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() + Toast.makeText(baseContext, "Failed copying file: $errorCode", Toast.LENGTH_SHORT) + .show() } - override fun onCompleted(result: Any) { + override fun onCompleted(result: FileCallback.Result) { dialog?.dismiss() Toast.makeText(baseContext, "File copied successfully", Toast.LENGTH_SHORT).show() } @@ -582,8 +698,15 @@ class MainActivity : AppCompatActivity() { .title(text = "Conflict Found") .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] + .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] newSolution.add(currentSolution) if (doForAll) { conflictedFolders.forEach { it.solution = currentSolution.solution } @@ -613,8 +736,15 @@ class MainActivity : AppCompatActivity() { .title(text = "Conflict Found") .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] + .listItems( + items = mutableListOf( + "Replace", + "Create New", + "Skip Duplicate" + ) + ) { _, index, _ -> + currentSolution.solution = + FolderCallback.ConflictResolution.values()[if (index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -627,22 +757,37 @@ class MainActivity : AppCompatActivity() { .show() } - private fun handleParentFolderConflict(destinationFolder: DocumentFile, action: FolderCallback.ParentFolderConflictAction, canMerge: Boolean) { + private fun handleParentFolderConflict( + destinationFolder: DocumentFile, + action: FolderCallback.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] + .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] action.confirmResolution(resolution) if (resolution == FolderCallback.ConflictResolution.SKIP) { - Toast.makeText(this, "Skipped duplicate folders & files", Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Skipped duplicate folders & files", Toast.LENGTH_SHORT) + .show() } } .show() } - private fun handleFolderContentConflict(action: FolderCallback.FolderContentConflictAction, conflictedFiles: MutableList) { + private fun handleFolderContentConflict( + action: FolderCallback.FolderContentConflictAction, + conflictedFiles: MutableList + ) { val newSolution = ArrayList(conflictedFiles.size) askSolution(action, conflictedFiles, newSolution) } @@ -694,9 +839,12 @@ class MainActivity : AppCompatActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.main, menu) - menu.findItem(R.id.action_open_fragment).intent = Intent(this, SampleFragmentActivity::class.java) - menu.findItem(R.id.action_pref_save_location).intent = Intent(this, SettingsActivity::class.java) - menu.findItem(R.id.action_settings).intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")) + menu.findItem(R.id.action_open_fragment).intent = + Intent(this, SampleFragmentActivity::class.java) + menu.findItem(R.id.action_pref_save_location).intent = + Intent(this, SettingsActivity::class.java) + menu.findItem(R.id.action_settings).intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")) menu.findItem(R.id.action_about).intent = Intent( Intent.ACTION_VIEW, Uri.parse("https://github.com/anggrayudi/SimpleStorage") @@ -714,7 +862,12 @@ class MainActivity : AppCompatActivity() { 0 -> "https://www.paypal.com/paypalme/hardiannicko" else -> "https://saweria.co/hardiannicko" } - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(url) + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) } .show() } @@ -759,9 +912,14 @@ 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() } + launchOnUiThread { + Toast.makeText( + context, + "Successfully created file \"${file.name}\"", + Toast.LENGTH_SHORT + ).show() + } } catch (e: IOException) { e.printStackTrace() } diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/SampleFragmentActivity.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/SampleFragmentActivity.kt similarity index 100% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/SampleFragmentActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/SampleFragmentActivity.kt diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/activity/SettingsActivity.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/SettingsActivity.kt similarity index 100% rename from sample/src/main/java/com/anggrayudi/storage/sample/activity/SettingsActivity.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/activity/SettingsActivity.kt diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt b/sample/src/main/kotlin/com/anggrayudi/storage/sample/fragment/SampleFragment.kt similarity index 73% rename from sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt rename to sample/src/main/kotlin/com/anggrayudi/storage/sample/fragment/SampleFragment.kt index bdcb32f..0bc8559 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SampleFragment.kt +++ b/sample/src/main/kotlin/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 @@ -24,11 +28,18 @@ class SampleFragment : Fragment(R.layout.incl_base_operation) { // In Fragment, build permissionRequest before onCreate() is called private val permissionRequest = FragmentPermissionRequest.Builder(this) - .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .withPermissions( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) .withCallback(object : PermissionCallback { override fun onPermissionsChecked(result: PermissionResult, fromSystemDialog: Boolean) { val grantStatus = if (result.areAllPermissionsGranted) "granted" else "denied" - Toast.makeText(requireContext(), "Storage permissions are $grantStatus", Toast.LENGTH_SHORT).show() + Toast.makeText( + requireContext(), + "Storage permissions are $grantStatus", + Toast.LENGTH_SHORT + ).show() } override fun onDisplayConsentDialog(request: PermissionRequest) { @@ -86,17 +97,29 @@ class SampleFragment : Fragment(R.layout.incl_base_operation) { } binding.btnCreateFile.setOnClickListener { - storageHelper.createFile("text/plain", "Test create file", requestCode = MainActivity.REQUEST_CODE_CREATE_FILE) + storageHelper.createFile( + "text/plain", + "Test create file", + requestCode = MainActivity.REQUEST_CODE_CREATE_FILE + ) } } private fun setupSimpleStorage(savedInstanceState: Bundle?) { storageHelper = SimpleStorageHelper(this, savedInstanceState) - storageHelper.onFileSelected = { requestCode, files -> - Toast.makeText(requireContext(), "File selected: ${files.first().fullName}", Toast.LENGTH_SHORT).show() + storageHelper.onFileSelected = { _, files -> + Toast.makeText( + requireContext(), + "File selected: ${files.first().fullName}", + Toast.LENGTH_SHORT + ).show() } - storageHelper.onFolderSelected = { requestCode, folder -> - Toast.makeText(requireContext(), folder.getAbsolutePath(requireContext()), Toast.LENGTH_SHORT).show() + storageHelper.onFolderSelected = { _, folder -> + Toast.makeText( + requireContext(), + folder.getAbsolutePath(requireContext()), + Toast.LENGTH_SHORT + ).show() } storageHelper.onFileCreated = { requestCode, file -> MainActivity.writeTestFile(requireContext().applicationContext, requestCode, file) diff --git a/sample/src/main/res/layout/dialog_copy_progress.xml b/sample/src/main/res/layout/dialog_copy_progress.xml index ea2012a..bd40b2f 100644 --- a/sample/src/main/res/layout/dialog_copy_progress.xml +++ b/sample/src/main/res/layout/dialog_copy_progress.xml @@ -4,8 +4,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingHorizontal="24dp" - android:orientation="vertical"> + android:orientation="vertical" + android:paddingHorizontal="24dp"> Simple Storage + Copying file: %1$s% \ No newline at end of file diff --git a/sample/src/test/java/com/anggrayudi/storage/sample/ExampleUnitTest.kt b/sample/src/test/java/com/anggrayudi/storage/sample/ExampleUnitTest.kt deleted file mode 100644 index cfee345..0000000 --- a/sample/src/test/java/com/anggrayudi/storage/sample/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.anggrayudi.storage.sample - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/storage/build.gradle b/storage/build.gradle index 1e17cfa..5a45e1b 100644 --- a/storage/build.gradle +++ b/storage/build.gradle @@ -12,7 +12,6 @@ android { buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -26,15 +25,14 @@ android { } dependencies { - api deps.appcompat - api deps.activity - api deps.core_ktx - api deps.fragment + implementation deps.appcompat + implementation deps.activity + implementation deps.core_ktx + implementation deps.fragment 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 deps.coroutines.android + implementation deps.material_dialogs_files + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3" testImplementation deps.junit testImplementation deps.mockk diff --git a/storage/proguard-rules.pro b/storage/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/storage/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/storage/src/main/AndroidManifest.xml b/storage/src/main/AndroidManifest.xml index b278484..e04e3a7 100644 --- a/storage/src/main/AndroidManifest.xml +++ b/storage/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - - 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) } - DOWNLOADS -> listOf(PublicDirectory.DOWNLOADS.file) - } - - val mimeType: String - get() = when (this) { - IMAGE -> MimeType.IMAGE - AUDIO -> MimeType.AUDIO - VIDEO -> MimeType.VIDEO - else -> MimeType.UNKNOWN - } -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/media/VideoMediaDirectory.kt b/storage/src/main/java/com/anggrayudi/storage/media/VideoMediaDirectory.kt deleted file mode 100644 index daaebb8..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/media/VideoMediaDirectory.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.anggrayudi.storage.media - -import android.os.Environment - -/** - * Created on 06/09/20 - * @author Anggrayudi H - */ -enum class VideoMediaDirectory(val folderName: String) { - MOVIES(Environment.DIRECTORY_MOVIES), - DCIM(Environment.DIRECTORY_DCIM) -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/ActivityWrapper.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/ActivityWrapper.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/ComponentActivityWrapper.kt similarity index 76% rename from storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/ComponentActivityWrapper.kt index f8e65bb..a454708 100644 --- a/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/ComponentActivityWrapper.kt @@ -10,14 +10,16 @@ import androidx.activity.result.contract.ActivityResultContracts * Created on 18/08/20 * @author Anggrayudi H */ -internal class ComponentActivityWrapper(private val _activity: ComponentActivity) : ComponentWrapper { +internal class ComponentActivityWrapper(private val _activity: ComponentActivity) : + ComponentWrapper { lateinit var storage: SimpleStorage var requestCode = 0 - private val activityResultLauncher = _activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - storage.onActivityResult(requestCode, it.resultCode, it.data) - } + private val activityResultLauncher = + _activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + storage.onActivityResult(requestCode, it.resultCode, it.data) + } override val context: Context get() = _activity diff --git a/storage/src/main/java/com/anggrayudi/storage/ComponentWrapper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/ComponentWrapper.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/ComponentWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/ComponentWrapper.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/FileWrapper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/FileWrapper.kt similarity index 81% rename from storage/src/main/java/com/anggrayudi/storage/FileWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/FileWrapper.kt index db607c7..6cac22d 100644 --- a/storage/src/main/java/com/anggrayudi/storage/FileWrapper.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/FileWrapper.kt @@ -4,7 +4,15 @@ import android.content.Context import android.net.Uri import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.file.* +import com.anggrayudi.storage.file.baseName +import com.anggrayudi.storage.file.extension +import com.anggrayudi.storage.file.getAbsolutePath +import com.anggrayudi.storage.file.getBasePath +import com.anggrayudi.storage.file.getRelativePath +import com.anggrayudi.storage.file.isEmpty +import com.anggrayudi.storage.file.mimeTypeByFileName +import com.anggrayudi.storage.file.openInputStream +import com.anggrayudi.storage.file.openOutputStream import com.anggrayudi.storage.media.MediaFile import java.io.InputStream import java.io.OutputStream @@ -71,7 +79,8 @@ interface FileWrapper { override fun getRelativePath(context: Context): String = mediaFile.relativePath - override fun openOutputStream(context: Context, append: Boolean): OutputStream? = mediaFile.openOutputStream(append) + override fun openOutputStream(context: Context, append: Boolean): OutputStream? = + mediaFile.openOutputStream(append) override fun openInputStream(context: Context): InputStream? = mediaFile.openInputStream() @@ -97,15 +106,19 @@ interface FileWrapper { override fun isEmpty(context: Context): Boolean = documentFile.isEmpty(context) - override fun getAbsolutePath(context: Context): String = documentFile.getAbsolutePath(context) + override fun getAbsolutePath(context: Context): String = + documentFile.getAbsolutePath(context) override fun getBasePath(context: Context): String = documentFile.getBasePath(context) - override fun getRelativePath(context: Context): String = documentFile.getRelativePath(context) + override fun getRelativePath(context: Context): String = + documentFile.getRelativePath(context) - override fun openOutputStream(context: Context, append: Boolean): OutputStream? = documentFile.openOutputStream(context, append) + override fun openOutputStream(context: Context, append: Boolean): OutputStream? = + documentFile.openOutputStream(context, append) - override fun openInputStream(context: Context): InputStream? = documentFile.openInputStream(context) + override fun openInputStream(context: Context): InputStream? = + documentFile.openInputStream(context) override fun delete(): Boolean = documentFile.delete() } diff --git a/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/FragmentWrapper.kt similarity index 81% rename from storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/FragmentWrapper.kt index 5cdea8e..12fc841 100644 --- a/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/FragmentWrapper.kt @@ -16,9 +16,10 @@ internal class FragmentWrapper(private val fragment: Fragment) : ComponentWrappe lateinit var storage: SimpleStorage var requestCode = 0 - private val activityResultLauncher = fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - storage.onActivityResult(requestCode, it.resultCode, it.data) - } + private val activityResultLauncher = + fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + storage.onActivityResult(requestCode, it.resultCode, it.data) + } override val context: Context get() = fragment.requireContext() diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorage.kt similarity index 77% rename from storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorage.kt index d20242d..24d37fc 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorage.kt @@ -23,10 +23,27 @@ 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 com.anggrayudi.storage.file.getStorageId import java.io.File import kotlin.concurrent.thread @@ -41,7 +58,9 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { savedState?.let { onRestoreInstanceState(it) } } - constructor(activity: ComponentActivity, savedState: Bundle? = null) : this(ComponentActivityWrapper(activity)) { + constructor(activity: ComponentActivity, savedState: Bundle? = null) : this( + ComponentActivityWrapper(activity) + ) { savedState?.let { onRestoreInstanceState(it) } (wrapper as ComponentActivityWrapper).storage = this } @@ -131,7 +150,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { * @return `true` if storage permissions and URI permissions are granted for read and write access. * @see [DocumentFileCompat.getStorageIds] */ - fun isStorageAccessGranted(storageId: String) = DocumentFileCompat.isAccessGranted(context, storageId) + fun isStorageAccessGranted(storageId: String) = + DocumentFileCompat.isAccessGranted(context, storageId) private var expectedStorageTypeForAccessRequest = StorageType.UNKNOWN @@ -161,7 +181,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { if (hasStoragePermission(context)) { if (expectedStorageType == StorageType.EXTERNAL && !isSdCardPresent) { - val root = DocumentFileCompat.getRootDocumentFile(context, PRIMARY, true) ?: return + val root = + DocumentFileCompat.getRootDocumentFile(context, PRIMARY, true) ?: return saveUriPermission(root.uri) storageAccessCallback?.onRootPathPermissionGranted(requestCode, root) return @@ -225,7 +246,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { */ @SuppressLint("InlinedApi") @JvmOverloads - fun openFolderPicker(requestCode: Int = requestCodeFolderPicker, initialPath: FileFullPath? = null) { + fun openFolderPicker( + requestCode: Int = requestCodeFolderPicker, + initialPath: FileFullPath? = null + ) { initialPath?.checkIfStorageIdIsAccessibleInSafSelector() requestCodeFolderPicker = requestCode @@ -258,7 +282,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } } - @Suppress("DEPRECATION") private var lastVisitedFolder: File = Environment.getExternalStorageDirectory() /** @@ -293,7 +316,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { private fun addInitialPathToIntent(intent: Intent, initialPath: FileFullPath?) { if (Build.VERSION.SDK_INT >= 26) { - initialPath?.toDocumentUri(context)?.let { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } + initialPath?.toDocumentUri(context) + ?.let { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } } } @@ -304,7 +328,9 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { val selectedFolder = context.fromTreeUri(uri) ?: return if (!expectedStorageTypeForAccessRequest.isExpected(storageType) || - !expectedBasePathForAccessRequest.isNullOrEmpty() && selectedFolder.getBasePath(context) != expectedBasePathForAccessRequest + !expectedBasePathForAccessRequest.isNullOrEmpty() && selectedFolder.getBasePath( + context + ) != expectedBasePathForAccessRequest ) { storageAccessCallback?.onExpectedStorageNotSelected( requestCode, @@ -317,14 +343,23 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } } else if (!expectedStorageTypeForAccessRequest.isExpected(storageType)) { val rootPath = context.fromTreeUri(uri)?.getAbsolutePath(context).orEmpty() - storageAccessCallback?.onRootPathNotSelected(requestCode, rootPath, uri, storageType, expectedStorageTypeForAccessRequest) + storageAccessCallback?.onRootPathNotSelected( + requestCode, + rootPath, + uri, + storageType, + expectedStorageTypeForAccessRequest + ) return } if (uri.isDownloadsDocument) { if (uri.toString() == DocumentFileCompat.DOWNLOADS_TREE_URI) { saveUriPermission(uri) - storageAccessCallback?.onRootPathPermissionGranted(requestCode, context.fromTreeUri(uri) ?: return) + storageAccessCallback?.onRootPathPermissionGranted( + requestCode, + context.fromTreeUri(uri) ?: return + ) } else { storageAccessCallback?.onRootPathNotSelected( requestCode, @@ -340,7 +375,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { if (uri.isDocumentsDocument) { if (uri.toString() == DocumentFileCompat.DOCUMENTS_TREE_URI) { saveUriPermission(uri) - storageAccessCallback?.onRootPathPermissionGranted(requestCode, context.fromTreeUri(uri) ?: return) + storageAccessCallback?.onRootPathPermissionGranted( + requestCode, + context.fromTreeUri(uri) ?: return + ) } else { storageAccessCallback?.onRootPathNotSelected( requestCode, @@ -354,23 +392,41 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R && !uri.isExternalStorageDocument) { - storageAccessCallback?.onRootPathNotSelected(requestCode, externalStoragePath, uri, StorageType.EXTERNAL, expectedStorageTypeForAccessRequest) + storageAccessCallback?.onRootPathNotSelected( + requestCode, + externalStoragePath, + uri, + StorageType.EXTERNAL, + expectedStorageTypeForAccessRequest + ) return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storageId == PRIMARY) { saveUriPermission(uri) - storageAccessCallback?.onRootPathPermissionGranted(requestCode, context.fromTreeUri(uri) ?: return) + storageAccessCallback?.onRootPathPermissionGranted( + requestCode, + context.fromTreeUri(uri) ?: return + ) return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || DocumentFileCompat.isRootUri(uri)) { if (saveUriPermission(uri)) { - storageAccessCallback?.onRootPathPermissionGranted(requestCode, context.fromTreeUri(uri) ?: return) + storageAccessCallback?.onRootPathPermissionGranted( + requestCode, + context.fromTreeUri(uri) ?: return + ) } else { storageAccessCallback?.onStoragePermissionDenied(requestCode) } } else { if (storageId == PRIMARY) { - storageAccessCallback?.onRootPathNotSelected(requestCode, externalStoragePath, uri, StorageType.EXTERNAL, expectedStorageTypeForAccessRequest) + storageAccessCallback?.onRootPathNotSelected( + requestCode, + externalStoragePath, + uri, + StorageType.EXTERNAL, + expectedStorageTypeForAccessRequest + ) } 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 @@ -382,7 +438,13 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { return } } - storageAccessCallback?.onRootPathNotSelected(requestCode, "/storage/$storageId", uri, StorageType.SD_CARD, expectedStorageTypeForAccessRequest) + storageAccessCallback?.onRootPathNotSelected( + requestCode, + "/storage/$storageId", + uri, + StorageType.SD_CARD, + expectedStorageTypeForAccessRequest + ) } } } @@ -396,7 +458,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { folderPickerCallback?.onStorageAccessDenied(requestCode, folder, storageType, storageId) return } - if (uri.toString().let { it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == DocumentFileCompat.DOCUMENTS_TREE_URI } + if (uri.toString() + .let { it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == DocumentFileCompat.DOCUMENTS_TREE_URI } || DocumentFileCompat.isRootUri(uri) && (Build.VERSION.SDK_INT < Build.VERSION_CODES.N && storageType == StorageType.SD_CARD || Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) && !DocumentFileCompat.isStorageUriPermissionGranted(context, storageId) @@ -429,8 +492,16 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { 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))) + 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 } @@ -508,8 +579,14 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { fun onSaveInstanceState(outState: Bundle) { outState.putString(KEY_LAST_VISITED_FOLDER, lastVisitedFolder.path) - outState.putString(KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST, expectedBasePathForAccessRequest) - outState.putInt(KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST, expectedStorageTypeForAccessRequest.ordinal) + outState.putString( + KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST, + expectedBasePathForAccessRequest + ) + outState.putInt( + KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST, + expectedStorageTypeForAccessRequest.ordinal + ) outState.putInt(KEY_REQUEST_CODE_STORAGE_ACCESS, requestCodeStorageAccess) outState.putInt(KEY_REQUEST_CODE_FOLDER_PICKER, requestCodeFolderPicker) outState.putInt(KEY_REQUEST_CODE_FILE_PICKER, requestCodeFilePicker) @@ -521,13 +598,31 @@ 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)] - 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) - requestCodeCreateFile = savedInstanceState.getInt(KEY_REQUEST_CODE_CREATE_FILE, DEFAULT_REQUEST_CODE_CREATE_FILE) - if (wrapper is FragmentWrapper && savedInstanceState.containsKey(KEY_REQUEST_CODE_FRAGMENT_PICKER)) { + expectedBasePathForAccessRequest = + savedInstanceState.getString(KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST) + expectedStorageTypeForAccessRequest = StorageType.values()[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 + ) + requestCodeCreateFile = savedInstanceState.getInt( + KEY_REQUEST_CODE_CREATE_FILE, + DEFAULT_REQUEST_CODE_CREATE_FILE + ) + if (wrapper is FragmentWrapper && savedInstanceState.containsKey( + KEY_REQUEST_CODE_FRAGMENT_PICKER + ) + ) { wrapper.requestCode = savedInstanceState.getInt(KEY_REQUEST_CODE_FRAGMENT_PICKER) } } @@ -551,7 +646,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { } private fun saveUriPermission(root: Uri) = try { - val writeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val writeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(root, writeFlags) cleanupRedundantUriPermissions(context.applicationContext) true @@ -561,14 +657,22 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { companion object { - private const val KEY_REQUEST_CODE_STORAGE_ACCESS = BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeStorageAccess" - private const val KEY_REQUEST_CODE_FOLDER_PICKER = BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFolderPicker" - private const val KEY_REQUEST_CODE_FILE_PICKER = BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFilePicker" - private const val KEY_REQUEST_CODE_CREATE_FILE = BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeCreateFile" - private const val KEY_REQUEST_CODE_FRAGMENT_PICKER = BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFragmentPicker" - private const val KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST = BuildConfig.LIBRARY_PACKAGE_NAME + ".expectedStorageTypeForAccessRequest" - private const val KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST = BuildConfig.LIBRARY_PACKAGE_NAME + ".expectedBasePathForAccessRequest" - private const val KEY_LAST_VISITED_FOLDER = BuildConfig.LIBRARY_PACKAGE_NAME + ".lastVisitedFolder" + private const val KEY_REQUEST_CODE_STORAGE_ACCESS = + BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeStorageAccess" + private const val KEY_REQUEST_CODE_FOLDER_PICKER = + BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFolderPicker" + private const val KEY_REQUEST_CODE_FILE_PICKER = + BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFilePicker" + private const val KEY_REQUEST_CODE_CREATE_FILE = + BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeCreateFile" + private const val KEY_REQUEST_CODE_FRAGMENT_PICKER = + BuildConfig.LIBRARY_PACKAGE_NAME + ".requestCodeFragmentPicker" + private const val KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST = + BuildConfig.LIBRARY_PACKAGE_NAME + ".expectedStorageTypeForAccessRequest" + private const val KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST = + BuildConfig.LIBRARY_PACKAGE_NAME + ".expectedBasePathForAccessRequest" + private const val KEY_LAST_VISITED_FOLDER = + BuildConfig.LIBRARY_PACKAGE_NAME + ".lastVisitedFolder" private const val TAG = "SimpleStorage" private const val DEFAULT_REQUEST_CODE_STORAGE_ACCESS: Int = 1 @@ -580,7 +684,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { const val KITKAT_SD_CARD_PATH = "/storage/$KITKAT_SD_CARD_ID" @JvmStatic - @Suppress("DEPRECATION") val externalStoragePath: String get() = Environment.getExternalStorageDirectory().absolutePath @@ -593,7 +696,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { fun getDefaultExternalStorageIntent(context: Context): Intent { return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { if (Build.VERSION.SDK_INT >= 26) { - putExtra(DocumentsContract.EXTRA_INITIAL_URI, context.fromTreeUri(DocumentFileCompat.createDocumentUri(PRIMARY))?.uri) + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + context.fromTreeUri(DocumentFileCompat.createDocumentUri(PRIMARY))?.uri + ) } } } @@ -603,7 +709,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { */ @JvmStatic fun hasStoragePermission(context: Context): Boolean { - return checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + return checkSelfPermission( + context, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED && hasStorageReadPermission(context) } @@ -612,12 +721,18 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { */ @JvmStatic fun hasStorageReadPermission(context: Context): Boolean { - return checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + return checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED } @JvmStatic fun hasFullDiskAccess(context: Context, storageId: String): Boolean { - return hasStorageAccess(context, DocumentFileCompat.buildAbsolutePath(context, storageId, "")) + return hasStorageAccess( + context, + DocumentFileCompat.buildAbsolutePath(context, storageId, "") + ) } /** @@ -631,10 +746,20 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { */ @JvmStatic @JvmOverloads - fun hasStorageAccess(context: Context, fullPath: String, requiresWriteAccess: Boolean = true): Boolean { - return DocumentFileCompat.getAccessibleRootDocumentFile(context, fullPath, requiresWriteAccess) != null + fun hasStorageAccess( + context: Context, + fullPath: String, + requiresWriteAccess: Boolean = true + ): Boolean { + return DocumentFileCompat.getAccessibleRootDocumentFile( + context, + fullPath, + requiresWriteAccess + ) != null && (Build.VERSION.SDK_INT > Build.VERSION_CODES.P - || requiresWriteAccess && hasStoragePermission(context) || !requiresWriteAccess && hasStorageReadPermission(context)) + || requiresWriteAccess && hasStoragePermission(context) || !requiresWriteAccess && hasStorageReadPermission( + context + )) } /** @@ -653,10 +778,17 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { val persistedUris = resolver.persistedUriPermissions .filter { it.isReadPermission && it.isWritePermission && it.uri.isExternalStorageDocument } .map { it.uri } - val writeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - val uniqueUriParents = DocumentFileCompat.findUniqueParents(context, persistedUris.mapNotNull { it.path?.substringAfter("/tree/") }) + val writeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val uniqueUriParents = DocumentFileCompat.findUniqueParents( + context, + persistedUris.mapNotNull { it.path?.substringAfter("/tree/") }) persistedUris.forEach { - if (DocumentFileCompat.buildAbsolutePath(context, it.path.orEmpty().substringAfter("/tree/")) !in uniqueUriParents) { + if (DocumentFileCompat.buildAbsolutePath( + context, + it.path.orEmpty().substringAfter("/tree/") + ) !in uniqueUriParents + ) { resolver.releasePersistableUriPermission(it, writeFlags) Log.d(TAG, "Removed redundant URI permission => $it") } diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorageHelper.kt similarity index 79% rename from storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorageHelper.kt index 5200e1c..a8717e8 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorageHelper.kt @@ -38,13 +38,18 @@ class SimpleStorageHelper { // For unknown Activity type @JvmOverloads - constructor(activity: Activity, requestCodeForPermissionDialog: Int, savedState: Bundle? = null) { + constructor( + activity: Activity, + requestCodeForPermissionDialog: Int, + savedState: Bundle? = null + ) { storage = SimpleStorage(activity) init(savedState) - permissionRequest = ActivityPermissionRequest.Builder(activity, requestCodeForPermissionDialog) - .withPermissions(*rwPermission) - .withCallback(permissionCallback) - .build() + permissionRequest = + ActivityPermissionRequest.Builder(activity, requestCodeForPermissionDialog) + .withPermissions(*rwPermission) + .withCallback(permissionCallback) + .build() } @JvmOverloads @@ -79,7 +84,12 @@ class SimpleStorageHelper { } @SuppressLint("NewApi") - override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType, storageId: String) { + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { if (storageType == StorageType.UNKNOWN) { onStoragePermissionDenied(requestCode) return @@ -89,7 +99,13 @@ class SimpleStorageHelper { .setMessage(R.string.ss_storage_access_denied_confirm) .setNegativeButton(android.R.string.cancel) { _, _ -> reset() } .setPositiveButton(android.R.string.ok) { _, _ -> - storage.requestStorageAccess(initialPath = FileFullPath(storage.context, storageId, "")) + storage.requestStorageAccess( + initialPath = FileFullPath( + storage.context, + storageId, + "" + ) + ) }.show() } @@ -107,11 +123,15 @@ class SimpleStorageHelper { } } - var onFileSelected: ((requestCode: Int, /* non-empty list */ files: List) -> Unit)? = null + var onFileSelected: ((requestCode: Int, /* non-empty list */ files: List) -> Unit)? = + null set(callback) { field = callback storage.filePickerCallback = object : FilePickerCallback { - override fun onStoragePermissionDenied(requestCode: Int, files: List?) { + override fun onStoragePermissionDenied( + requestCode: Int, + files: List? + ) { requestStoragePermission { if (it) storage.openFilePicker() else reset() } } @@ -174,7 +194,8 @@ class SimpleStorageHelper { selectedStorageType: StorageType, expectedStorageType: StorageType ) { - val storageType = if (expectedStorageType.isExpected(selectedStorageType)) selectedStorageType else expectedStorageType + val storageType = + if (expectedStorageType.isExpected(selectedStorageType)) selectedStorageType else expectedStorageType val messageRes = if (rootPath.isEmpty()) { storage.context.getString(if (storageType == StorageType.SD_CARD) R.string.ss_please_select_root_storage_sdcard else R.string.ss_please_select_root_storage_primary) } else { @@ -188,7 +209,11 @@ class SimpleStorageHelper { .setNegativeButton(android.R.string.cancel) { _, _ -> reset() } .setPositiveButton(android.R.string.ok) { _, _ -> storage.requestStorageAccess( - initialPath = FileFullPath(storage.context, uri.getStorageId(storage.context), ""), + initialPath = FileFullPath( + storage.context, + uri.getStorageId(storage.context), + "" + ), expectedStorageType = expectedStorageType ) }.show() @@ -206,24 +231,34 @@ class SimpleStorageHelper { val toastFilePicker: () -> Unit = { Toast.makeText( context, - context.getString(R.string.ss_selecting_root_path_success_with_open_folder_picker, root.getAbsolutePath(context)), + context.getString( + R.string.ss_selecting_root_path_success_with_open_folder_picker, + root.getAbsolutePath(context) + ), Toast.LENGTH_LONG ).show() } when (pickerToOpenOnceGranted) { TYPE_FILE_PICKER -> { - storage.openFilePicker(filterMimeTypes = filterMimeTypes.orEmpty().toTypedArray()) + storage.openFilePicker( + filterMimeTypes = filterMimeTypes.orEmpty().toTypedArray() + ) toastFilePicker() } + TYPE_FOLDER_PICKER -> { storage.openFolderPicker() toastFilePicker() } + else -> { Toast.makeText( context, - context.getString(R.string.ss_selecting_root_path_success_without_open_folder_picker, root.getAbsolutePath(context)), + context.getString( + R.string.ss_selecting_root_path_success_without_open_folder_picker, + root.getAbsolutePath(context) + ), Toast.LENGTH_SHORT ).show() } @@ -251,7 +286,11 @@ class SimpleStorageHelper { .setNegativeButton(android.R.string.cancel) { _, _ -> reset() } .setPositiveButton(android.R.string.ok) { _, _ -> storage.requestStorageAccess( - initialPath = FileFullPath(storage.context, expectedStorageType, expectedBasePath), + initialPath = FileFullPath( + storage.context, + expectedStorageType, + expectedBasePath + ), expectedStorageType = expectedStorageType, expectedBasePath = expectedBasePath ) @@ -295,7 +334,11 @@ class SimpleStorageHelper { override fun onPermissionsChecked(result: PermissionResult, fromSystemDialog: Boolean) { val granted = result.areAllPermissionsGranted if (!granted) { - Toast.makeText(storage.context, R.string.ss_please_grant_storage_permission, Toast.LENGTH_SHORT).show() + Toast.makeText( + storage.context, + R.string.ss_please_grant_storage_permission, + Toast.LENGTH_SHORT + ).show() } onPermissionsResult?.invoke(granted) onPermissionsResult = null @@ -309,7 +352,10 @@ class SimpleStorageHelper { } private val rwPermission: Array - get() = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + get() = arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.READ_EXTERNAL_STORAGE + ) private fun reset() { pickerToOpenOnceGranted = 0 @@ -319,11 +365,18 @@ class SimpleStorageHelper { private fun handleMissingActivityHandler() { reset() - Toast.makeText(storage.context, R.string.ss_missing_saf_activity_handler, Toast.LENGTH_SHORT).show() + Toast.makeText( + storage.context, + R.string.ss_missing_saf_activity_handler, + Toast.LENGTH_SHORT + ).show() } @JvmOverloads - fun openFolderPicker(requestCode: Int = storage.requestCodeFolderPicker, initialPath: FileFullPath? = null) { + fun openFolderPicker( + requestCode: Int = storage.requestCodeFolderPicker, + initialPath: FileFullPath? = null + ) { pickerToOpenOnceGranted = TYPE_FOLDER_PICKER originalRequestCode = requestCode storage.openFolderPicker(requestCode, initialPath) @@ -354,7 +407,12 @@ class SimpleStorageHelper { ) { pickerToOpenOnceGranted = 0 originalRequestCode = requestCode - storage.requestStorageAccess(requestCode, initialPath, expectedStorageType, expectedBasePath) + storage.requestStorageAccess( + requestCode, + initialPath, + expectedStorageType, + expectedBasePath + ) } @JvmOverloads @@ -394,9 +452,12 @@ class SimpleStorageHelper { const val TYPE_FILE_PICKER = 1 const val TYPE_FOLDER_PICKER = 2 - private const val KEY_OPEN_FOLDER_PICKER_ONCE_GRANTED = BuildConfig.LIBRARY_PACKAGE_NAME + ".pickerToOpenOnceGranted" - private const val KEY_ORIGINAL_REQUEST_CODE = BuildConfig.LIBRARY_PACKAGE_NAME + ".originalRequestCode" - private const val KEY_FILTER_MIME_TYPES = BuildConfig.LIBRARY_PACKAGE_NAME + ".filterMimeTypes" + private const val KEY_OPEN_FOLDER_PICKER_ONCE_GRANTED = + BuildConfig.LIBRARY_PACKAGE_NAME + ".pickerToOpenOnceGranted" + private const val KEY_ORIGINAL_REQUEST_CODE = + BuildConfig.LIBRARY_PACKAGE_NAME + ".originalRequestCode" + private const val KEY_FILTER_MIME_TYPES = + BuildConfig.LIBRARY_PACKAGE_NAME + ".filterMimeTypes" @JvmStatic fun redirectToSystemSettings(context: Context) { @@ -404,7 +465,10 @@ class SimpleStorageHelper { .setMessage(R.string.ss_storage_permission_permanently_disabled) .setNegativeButton(android.R.string.cancel) { _, _ -> } .setPositiveButton(android.R.string.ok) { _, _ -> - val intentSetting = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")) + val intentSetting = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}") + ) .addCategory(Intent.CATEGORY_DEFAULT) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intentSetting) diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/BaseFileCallback.kt similarity index 88% rename from storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/BaseFileCallback.kt index 4cf9888..238732b 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/BaseFileCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/BaseFileCallback.kt @@ -1,6 +1,5 @@ package com.anggrayudi.storage.callback -import androidx.annotation.RestrictTo import androidx.annotation.UiThread import androidx.annotation.WorkerThread import com.anggrayudi.storage.file.FileSize @@ -10,8 +9,8 @@ import kotlinx.coroutines.CoroutineScope * Created on 02/06/21 * @author Anggrayudi H */ -abstract class BaseFileCallback -@RestrictTo(RestrictTo.Scope.LIBRARY) constructor(var uiScope: CoroutineScope) { +abstract class BaseFileCallback(override val uiScope: CoroutineScope) : + ScopeHoldingCallback { @UiThread open fun onValidate() { diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/CreateFileCallback.kt similarity index 72% rename from storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/CreateFileCallback.kt index 9dc3f14..5a2fde9 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/CreateFileCallback.kt @@ -9,13 +9,9 @@ import androidx.documentfile.provider.DocumentFile */ interface CreateFileCallback { - fun onCanceledByUser(requestCode: Int) { - // default implementation - } + fun onCanceledByUser(requestCode: Int) - fun onActivityHandlerNotFound(requestCode: Int, intent: Intent) { - // default implementation - } + fun onActivityHandlerNotFound(requestCode: Int, intent: Intent) fun onFileCreated(requestCode: Int, file: DocumentFile) } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FileCallback.kt similarity index 74% rename from storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FileCallback.kt index b336d78..4d65cb2 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FileCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FileCallback.kt @@ -14,9 +14,7 @@ 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) { +abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor(uiScope: CoroutineScope = GlobalScope) : BaseFileCallback(uiScope) { /** * @param file can be [DocumentFile] or [MediaFile] @@ -39,18 +37,10 @@ abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads c 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) { - continuation.resumeWith(Result.success(resolution)) + continuation.resumeWith(kotlin.Result.success(resolution)) } } @@ -96,5 +86,22 @@ abstract class FileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads c * @param progress in percent * @param writeSpeed in bytes */ - class Report(val progress: Float, val bytesMoved: Long, val writeSpeed: Int) + data class Report(val progress: Float, val bytesMoved: Long, val writeSpeed: Int) + + sealed interface Result { + @JvmInline + value class MediaFile(val value: com.anggrayudi.storage.media.MediaFile) : Result + + @JvmInline + value class DocumentFile(val value: androidx.documentfile.provider.DocumentFile) : Result + + companion object { + internal fun get(value: Any): Result = + when (value) { + is com.anggrayudi.storage.media.MediaFile -> MediaFile(value) + is androidx.documentfile.provider.DocumentFile -> DocumentFile(value) + else -> throw IllegalArgumentException("Result must be either of type ${com.anggrayudi.storage.media.MediaFile::class.java.name} or ${androidx.documentfile.provider.DocumentFile::class.java.name}") + } + } + } } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FileConflictCallback.kt similarity index 93% rename from storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FileConflictCallback.kt index d9002e9..aa874ff 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FileConflictCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FileConflictCallback.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.GlobalScope * @author Anggrayudi Hardiannico A. */ abstract class FileConflictCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( - var uiScope: CoroutineScope = GlobalScope -) { + override val uiScope: CoroutineScope = GlobalScope +): ScopeHoldingCallback { /** * Do not call `super` when you override this function. diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FilePickerCallback.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/callback/FilePickerCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FilePickerCallback.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FileReceiverCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FileReceiverCallback.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/callback/FileReceiverCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FileReceiverCallback.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderCallback.kt similarity index 89% rename from storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderCallback.kt index e630ccc..88d7a75 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FolderCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderCallback.kt @@ -44,12 +44,20 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads * This happens if the destination is a file. */ @UiThread - open fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { + open fun onParentConflict( + destinationFolder: DocumentFile, + action: ParentFolderConflictAction, + canMerge: Boolean + ) { action.confirmResolution(ConflictResolution.CREATE_NEW) } @UiThread - open fun onContentConflict(destinationFolder: DocumentFile, conflictedFiles: MutableList, action: FolderContentConflictAction) { + open fun onContentConflict( + destinationFolder: DocumentFile, + conflictedFiles: MutableList, + action: FolderContentConflictAction + ) { action.confirmResolution(conflictedFiles) } @@ -104,7 +112,7 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads } } - class FileConflict( + data class FileConflict( val source: DocumentFile, val target: DocumentFile, var solution: FileCallback.ConflictResolution = FileCallback.ConflictResolution.CREATE_NEW @@ -129,7 +137,7 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads * @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) + data 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] @@ -139,5 +147,10 @@ abstract class FolderCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads * @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) + data 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/FolderPickerCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderPickerCallback.kt similarity index 86% rename from storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderPickerCallback.kt index 4dabc76..59ceb89 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/FolderPickerCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/FolderPickerCallback.kt @@ -27,7 +27,12 @@ interface FolderPickerCallback { * @param folder selected folder that has no read and write permission * @param storageType `null` if `folder`'s authority is not [DocumentFileCompat.EXTERNAL_STORAGE_AUTHORITY] */ - fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType, storageId: String) + fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) fun onFolderSelected(requestCode: Int, folder: DocumentFile) } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFileCallback.kt similarity index 88% rename from storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFileCallback.kt index 33ac178..8369ba7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFileCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFileCallback.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.GlobalScope */ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOverloads constructor( uiScope: CoroutineScope = GlobalScope -) : BaseFileCallback(uiScope) { +) : BaseFileCallback( + uiScope +) { /** * The reason can be one of: @@ -24,7 +26,10 @@ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOve * * [FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER] */ @UiThread - open fun onInvalidSourceFilesFound(invalidSourceFiles: Map, action: InvalidSourceFilesAction) { + open fun onInvalidSourceFilesFound( + invalidSourceFiles: Map, + action: InvalidSourceFilesAction + ) { action.confirmResolution(false) } @@ -39,7 +44,8 @@ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOve * Setting negative value will cancel the operation. */ @UiThread - open fun onStart(files: List, totalFilesToCopy: Int, workerThread: Thread): Long = 0 + open fun onStart(files: List, totalFilesToCopy: Int, workerThread: Thread): Long = + 0 /** * Do not call `super` when you override this function. @@ -85,7 +91,7 @@ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOve } } - class ParentConflict( + data class ParentConflict( val source: DocumentFile, val target: DocumentFile, val canMerge: Boolean, @@ -109,7 +115,7 @@ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOve * @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) + data 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] @@ -119,5 +125,10 @@ abstract class MultipleFileCallback @OptIn(DelicateCoroutinesApi::class) @JvmOve * @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) + data 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/kotlin/com/anggrayudi/storage/callback/ScopeHoldingCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ScopeHoldingCallback.kt new file mode 100644 index 0000000..399844b --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ScopeHoldingCallback.kt @@ -0,0 +1,12 @@ +package com.anggrayudi.storage.callback + +import com.anggrayudi.storage.extension.postToUi +import kotlinx.coroutines.CoroutineScope + +interface ScopeHoldingCallback { + val uiScope: CoroutineScope + + fun postToUiScope(action: () -> Unit) { + uiScope.postToUi(action) + } +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/StorageAccessCallback.kt similarity index 85% rename from storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/StorageAccessCallback.kt index 8c6dcdd..596acfa 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/StorageAccessCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/StorageAccessCallback.kt @@ -22,7 +22,13 @@ interface StorageAccessCallback { /** * Triggered on Android 10 and lower. */ - fun onRootPathNotSelected(requestCode: Int, rootPath: String, uri: Uri, selectedStorageType: StorageType, expectedStorageType: StorageType) + fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) /** * Triggered on Android 11 and higher. diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipCompressionCallback.kt similarity index 84% rename from storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipCompressionCallback.kt index f4f7d21..63dd4b7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/ZipCompressionCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipCompressionCallback.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.GlobalScope * @author Anggrayudi H */ abstract class ZipCompressionCallback @OptIn(DelicateCoroutinesApi::class) -@JvmOverloads constructor(var uiScope: CoroutineScope = GlobalScope) { +@JvmOverloads constructor(override val uiScope: CoroutineScope = GlobalScope): ScopeHoldingCallback { @UiThread open fun onCountingFiles() { @@ -57,7 +57,12 @@ abstract class ZipCompressionCallback @OptIn(DelicateCoroutinesApi::class) * @param compressionRate size reduction in percent, e.g. 23.5 */ @UiThread - open fun onCompleted(zipFile: DocumentFile, bytesCompressed: Long, totalFilesCompressed: Int, compressionRate: Float) { + open fun onCompleted( + zipFile: DocumentFile, + bytesCompressed: Long, + totalFilesCompressed: Int, + compressionRate: Float + ) { // default implementation } @@ -69,7 +74,12 @@ abstract class ZipCompressionCallback @OptIn(DelicateCoroutinesApi::class) /** * @param progress always `0` when compressing [MediaFile] */ - class Report(val progress: Float, val bytesCompressed: Long, val writeSpeed: Int, val fileCount: Int) + data class Report( + val progress: Float, + val bytesCompressed: Long, + val writeSpeed: Int, + val fileCount: Int + ) enum class ErrorCode { STORAGE_PERMISSION_DENIED, diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt similarity index 87% rename from storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt index 96cd853..90b17cb 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/ZipDecompressionCallback.kt @@ -56,7 +56,11 @@ abstract class ZipDecompressionCallback @OptIn(DelicateCoroutinesApi::class) * 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) { + open fun onCompleted( + zipFile: T, + targetFolder: DocumentFile, + decompressionInfo: DecompressionInfo + ) { // default implementation } @@ -71,14 +75,19 @@ abstract class ZipDecompressionCallback @OptIn(DelicateCoroutinesApi::class) * 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) + data 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) + data class DecompressionInfo( + val bytesDecompressed: Long, + val skippedDecompressedBytes: Long, + val totalFilesDecompressed: Int, + val decompressionRate: Float + ) enum class ErrorCode { STORAGE_PERMISSION_DENIED, diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/ContextExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/ContextExt.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/extension/ContextExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/ContextExt.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/CoroutineExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/CoroutineExt.kt similarity index 73% rename from storage/src/main/java/com/anggrayudi/storage/extension/CoroutineExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/CoroutineExt.kt index 06c7abd..ba70c03 100644 --- a/storage/src/main/java/com/anggrayudi/storage/extension/CoroutineExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/extension/CoroutineExt.kt @@ -2,7 +2,14 @@ package com.anggrayudi.storage.extension -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine /** * @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id) @@ -36,9 +43,13 @@ fun startCoroutineTimer( } @Suppress("OPT_IN_USAGE") -fun launchOnUiThread(action: suspend CoroutineScope.() -> Unit) = GlobalScope.launch(Dispatchers.Main, block = action) +fun launchOnUiThread(action: suspend CoroutineScope.() -> Unit) = + GlobalScope.launch(Dispatchers.Main, block = action) -inline fun awaitUiResultWithPending(uiScope: CoroutineScope, crossinline action: (CancellableContinuation) -> Unit): R { +inline fun awaitUiResultWithPending( + uiScope: CoroutineScope, + crossinline action: (CancellableContinuation) -> Unit +): R { return runBlocking { suspendCancellableCoroutine { uiScope.launch(Dispatchers.Main) { action(it) } diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/IOExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/IOExt.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/extension/IOExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/IOExt.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/PrimitivesExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/PrimitivesExt.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/extension/PrimitivesExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/PrimitivesExt.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/TextExt.kt similarity index 97% rename from storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/TextExt.kt index c74c06c..9b515b1 100644 --- a/storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/extension/TextExt.kt @@ -48,7 +48,9 @@ fun String.replaceCompletely(match: String, replaceWith: String) = let { @RestrictTo(RestrictTo.Scope.LIBRARY) fun String.isKitkatSdCardStorageId() = - Build.VERSION.SDK_INT < 21 && (this == StorageId.KITKAT_SDCARD || this.matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX)) + 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 { diff --git a/storage/src/main/java/com/anggrayudi/storage/extension/UriExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/extension/UriExt.kt similarity index 88% rename from storage/src/main/java/com/anggrayudi/storage/extension/UriExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/UriExt.kt index faaed3f..dcab145 100644 --- a/storage/src/main/java/com/anggrayudi/storage/extension/UriExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/extension/UriExt.kt @@ -11,7 +11,12 @@ import com.anggrayudi.storage.file.DocumentFileCompat import com.anggrayudi.storage.file.StorageId.PRIMARY import com.anggrayudi.storage.file.getStorageId import com.anggrayudi.storage.media.MediaFile -import java.io.* +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 12/15/20 @@ -67,7 +72,10 @@ fun Uri.openOutputStream(context: Context, append: Boolean = true): OutputStream if (isRawFile) { FileOutputStream(File(path ?: return null), append) } else { - context.contentResolver.openOutputStream(this, if (append && isTreeDocumentFile) "wa" else "w") + context.contentResolver.openOutputStream( + this, + if (append && isTreeDocumentFile) "wa" else "w" + ) } } catch (e: IOException) { null diff --git a/storage/src/main/java/com/anggrayudi/storage/file/CreateMode.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/CreateMode.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/file/CreateMode.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/CreateMode.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileCompat.kt similarity index 81% rename from storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileCompat.kt index 6abf04f..dd32084 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileCompat.kt @@ -16,7 +16,18 @@ 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.isKitkatSdCardStorageId +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 @@ -68,7 +79,8 @@ object DocumentFileCompat { 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('/')) + fun getKitkatSdCardRootFile(basePath: String = "") = + File(KITKAT_SD_CARD_PATH + "/$basePath".trimEnd('/')) @JvmStatic fun isRootUri(uri: Uri): Boolean { @@ -109,9 +121,15 @@ object DocumentFileCompat { val dataDir = context.dataDirectory.path val externalStoragePath = SimpleStorage.externalStoragePath when { - fullPath.startsWith(externalStoragePath) -> fullPath.substringAfter(externalStoragePath) + fullPath.startsWith(externalStoragePath) -> fullPath.substringAfter( + externalStoragePath + ) + fullPath.startsWith(dataDir) -> fullPath.substringAfter(dataDir) - fullPath.startsWith(KITKAT_SD_CARD_PATH) -> fullPath.substringAfter(KITKAT_SD_CARD_PATH) + 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 "" @@ -125,10 +143,17 @@ object DocumentFileCompat { @JvmStatic fun fromUri(context: Context, uri: Uri): DocumentFile? { 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 } + 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)) { + if (Build.VERSION.SDK_INT < 21 && it.getStorageId(context) + .matches(SD_CARD_STORAGE_ID_REGEX) + ) { DocumentFile.fromFile(getKitkatSdCardRootFile(it.getBasePath(context))) } else { it @@ -158,9 +183,18 @@ object DocumentFileCompat { return if (basePath.isEmpty() && storageId != HOME) { getRootDocumentFile(context, storageId, requiresWriteAccess, considerRawFile) } else { - val file = exploreFile(context, storageId, basePath, documentType, requiresWriteAccess, considerRawFile) + val file = exploreFile( + context, + storageId, + basePath, + documentType, + requiresWriteAccess, + considerRawFile + ) if (file == null && storageId == PRIMARY && basePath.hasParent(Environment.DIRECTORY_DOWNLOADS)) { - val downloads = context.fromTreeUri(Uri.parse(DOWNLOADS_TREE_URI))?.takeIf { it.canRead() } ?: return null + val downloads = + context.fromTreeUri(Uri.parse(DOWNLOADS_TREE_URI))?.takeIf { it.canRead() } + ?: return null downloads.child(context, basePath.substringAfter('/', ""))?.takeIf { documentType == DocumentFileType.ANY || documentType == DocumentFileType.FILE && it.isFile @@ -193,7 +227,14 @@ object DocumentFileCompat { fromFile(context, File(fullPath), documentType, requiresWriteAccess, considerRawFile) } else { // simple path - fromSimplePath(context, fullPath.substringBefore(':'), fullPath.substringAfter(':'), documentType, requiresWriteAccess, considerRawFile) + fromSimplePath( + context, + fullPath.substringBefore(':'), + fullPath.substringAfter(':'), + documentType, + requiresWriteAccess, + considerRawFile + ) } } @@ -216,16 +257,36 @@ 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 || Build.VERSION.SDK_INT < 21 + ) + ) { if (documentType == DocumentFileType.FILE && !file.isFile || documentType == DocumentFileType.FOLDER && !file.isDirectory) { null } else { DocumentFile.fromFile(file) } } else { - val basePath = file.getBasePath(context).removeForbiddenCharsFromFilename().trimFileSeparator() - exploreFile(context, file.getStorageId(context), basePath, documentType, requiresWriteAccess, considerRawFile) - ?: fromSimplePath(context, file.getStorageId(context), basePath, documentType, requiresWriteAccess, considerRawFile) + val basePath = + file.getBasePath(context).removeForbiddenCharsFromFilename().trimFileSeparator() + exploreFile( + context, + file.getStorageId(context), + basePath, + documentType, + requiresWriteAccess, + considerRawFile + ) + ?: fromSimplePath( + context, + file.getStorageId(context), + basePath, + documentType, + requiresWriteAccess, + considerRawFile + ) } } @@ -236,7 +297,6 @@ object DocumentFileCompat { */ @JvmOverloads @JvmStatic - @Suppress("DEPRECATION") fun fromPublicFolder( context: Context, type: PublicDirectory, @@ -313,7 +373,11 @@ object DocumentFileCompat { DocumentFile.fromFile(Environment.getExternalStorageDirectory()) } } else if (considerRawFile) { - getRootRawFile(context, storageId, requiresWriteAccess)?.let { DocumentFile.fromFile(it) } + getRootRawFile( + context, + storageId, + requiresWriteAccess + )?.let { DocumentFile.fromFile(it) } ?: context.fromTreeUri(createDocumentUri(storageId)) } else { context.fromTreeUri(createDocumentUri(storageId)) @@ -330,7 +394,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( @@ -369,7 +432,10 @@ object DocumentFileCompat { if (uriPath != null && it.uri.isExternalStorageDocument) { val currentStorageId = uriPath.substringBefore(':').substringAfterLast('/') val currentRootFolder = uriPath.substringAfter(':', "") - if (currentStorageId == storageId && (currentRootFolder.isEmpty() || cleanBasePath.hasParent(currentRootFolder))) { + if (currentStorageId == storageId && (currentRootFolder.isEmpty() || cleanBasePath.hasParent( + currentRootFolder + )) + ) { return context.fromTreeUri(it.uri) } } @@ -388,15 +454,22 @@ object DocumentFileCompat { */ @JvmOverloads @JvmStatic - @Suppress("DEPRECATION") - fun getRootRawFile(context: Context, storageId: String, requiresWriteAccess: Boolean = false): File? { + 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() else -> File("/storage/$storageId") } - return rootFile.takeIf { rootFile.canRead() && (requiresWriteAccess && rootFile.isWritable(context) || !requiresWriteAccess) } + return rootFile.takeIf { + rootFile.canRead() && (requiresWriteAccess && rootFile.isWritable( + context + ) || !requiresWriteAccess) + } } @JvmStatic @@ -429,7 +502,10 @@ object DocumentFileCompat { @JvmStatic fun buildSimplePath(context: Context, absolutePath: String): String { - return buildSimplePath(getStorageId(context, absolutePath), getBasePath(context, absolutePath)) + return buildSimplePath( + getStorageId(context, absolutePath), + getBasePath(context, absolutePath) + ) } @JvmOverloads @@ -444,10 +520,12 @@ object DocumentFileCompat { } @JvmStatic - fun doesExist(context: Context, fullPath: String) = fromFullPath(context, fullPath)?.exists() == true + fun doesExist(context: Context, fullPath: String) = + fromFullPath(context, fullPath)?.exists() == true @JvmStatic - fun delete(context: Context, fullPath: String) = fromFullPath(context, fullPath)?.delete() == true + fun delete(context: Context, fullPath: String) = + fromFullPath(context, fullPath)?.delete() == true /** * Check if storage has URI permission for read and write access. @@ -462,14 +540,17 @@ object DocumentFileCompat { isUriPermissionGranted(context, createDocumentUri(storageId, basePath)) @JvmStatic - fun isDownloadsUriPermissionGranted(context: Context) = isUriPermissionGranted(context, Uri.parse(DOWNLOADS_TREE_URI)) + fun isDownloadsUriPermissionGranted(context: Context) = + isUriPermissionGranted(context, Uri.parse(DOWNLOADS_TREE_URI)) @JvmStatic - fun isDocumentsUriPermissionGranted(context: Context) = isUriPermissionGranted(context, Uri.parse(DOCUMENTS_TREE_URI)) + fun isDocumentsUriPermissionGranted(context: Context) = + isUriPermissionGranted(context, Uri.parse(DOCUMENTS_TREE_URI)) - private fun isUriPermissionGranted(context: Context, uri: Uri) = context.contentResolver.persistedUriPermissions.any { - it.isReadPermission && it.isWritePermission && it.uri == uri - } + private fun isUriPermissionGranted(context: Context, uri: Uri) = + context.contentResolver.persistedUriPermissions.any { + it.isReadPermission && it.isWritePermission && it.uri == uri + } /** * Get all storage IDs on this device. The first index is primary storage. @@ -525,7 +606,11 @@ object DocumentFileCompat { val storageId = uriPath.substringBefore(':').substringAfterLast('/') val rootFolder = uriPath.substringAfter(':', "") if (storageId == PRIMARY) { - storages[PRIMARY]?.add("${Environment.getExternalStorageDirectory()}/$rootFolder".trimEnd('/')) + storages[PRIMARY]?.add( + "${Environment.getExternalStorageDirectory()}/$rootFolder".trimEnd( + '/' + ) + ) } else if (storageId.matches(SD_CARD_STORAGE_ID_REGEX)) { val paths = storages[storageId] ?: HashSet() paths.add("/storage/$storageId/$rootFolder".trimEnd('/')) @@ -574,7 +659,10 @@ object DocumentFileCompat { ): DocumentFile? { val tryCreateWithRawFile: () -> DocumentFile? = { val folder = File(fullPath.removeForbiddenCharsFromFilename()).apply { mkdirs() } - if (folder.isDirectory && folder.canRead() && (requiresWriteAccess && folder.isWritable(context) || !requiresWriteAccess)) { + if (folder.isDirectory && folder.canRead() && (requiresWriteAccess && folder.isWritable( + context + ) || !requiresWriteAccess) + ) { // Consider java.io.File for faster performance DocumentFile.fromFile(folder) } else null @@ -582,7 +670,9 @@ object DocumentFileCompat { if (considerRawFile && fullPath.startsWith('/') || fullPath.startsWith(context.dataDirectory.path)) { tryCreateWithRawFile()?.let { return it } } - var currentDirectory = getAccessibleRootDocumentFile(context, fullPath, requiresWriteAccess, considerRawFile) ?: return null + var currentDirectory = + getAccessibleRootDocumentFile(context, fullPath, requiresWriteAccess, considerRawFile) + ?: return null if (currentDirectory.isRawFile) { return tryCreateWithRawFile() } @@ -626,21 +716,42 @@ object DocumentFileCompat { 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 (shouldUseRawFile && 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 = "/"))) + 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, + shouldUseRawFile + ) ?: continue val isRawFile = currentDirectory.isRawFile val resolver = context.contentResolver getDirectorySequence(getBasePath(context, path)).forEach { try { - val directory = if (isRawFile) currentDirectory.quickFindRawFile(it) else currentDirectory.quickFindTreeFile(context, resolver, it) + val directory = + if (isRawFile) currentDirectory.quickFindRawFile(it) else currentDirectory.quickFindTreeFile( + context, + resolver, + it + ) if (directory == null) { - currentDirectory = currentDirectory.createDirectory(it) ?: return@forEach + currentDirectory = + currentDirectory.createDirectory(it) ?: return@forEach val fullPath = currentDirectory.getAbsolutePath(context) cleanedFullPaths.forEachIndexed { index, s -> if (fullPath == s) { @@ -669,22 +780,29 @@ object DocumentFileCompat { } @JvmStatic - fun createDownloadWithMediaStoreFallback(context: Context, file: FileDescription): FileWrapper? { - val publicFolder = fromPublicFolder(context, PublicDirectory.DOWNLOADS, requiresWriteAccess = true) + fun createDownloadWithMediaStoreFallback( + context: Context, + file: FileDescription + ): FileWrapper? { + val publicFolder = + fromPublicFolder(context, PublicDirectory.DOWNLOADS, requiresWriteAccess = true) return if (publicFolder == null && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { MediaStoreCompat.createDownload(context, file)?.let { FileWrapper.Media(it) } } else { - publicFolder?.makeFile(context, file.name, file.mimeType)?.let { FileWrapper.Document(it) } + publicFolder?.makeFile(context, file.name, file.mimeType) + ?.let { FileWrapper.Document(it) } } } @JvmStatic fun createPictureWithMediaStoreFallback(context: Context, file: FileDescription): FileWrapper? { - val publicFolder = fromPublicFolder(context, PublicDirectory.PICTURES, requiresWriteAccess = true) + val publicFolder = + fromPublicFolder(context, PublicDirectory.PICTURES, requiresWriteAccess = true) return if (publicFolder == null && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { MediaStoreCompat.createImage(context, file)?.let { FileWrapper.Media(it) } } else { - publicFolder?.makeFile(context, file.name, file.mimeType)?.let { FileWrapper.Document(it) } + publicFolder?.makeFile(context, file.name, file.mimeType) + ?.let { FileWrapper.Document(it) } } } @@ -718,9 +836,15 @@ object DocumentFileCompat { } } - private fun getParentPath(path: String): String? = getDirectorySequence(path).let { it.getOrNull(it.size - 2) } + private fun getParentPath(path: String): String? = + getDirectorySequence(path).let { it.getOrNull(it.size - 2) } - private fun mkdirsParentDirectory(context: Context, storageId: String, basePath: String, considerRawFile: Boolean): DocumentFile? { + private fun mkdirsParentDirectory( + context: Context, + storageId: String, + basePath: String, + considerRawFile: Boolean + ): DocumentFile? { val parentPath = getParentPath(basePath) return if (parentPath != null) { mkdirs(context, buildAbsolutePath(context, storageId, parentPath), considerRawFile) @@ -788,7 +912,11 @@ object DocumentFileCompat { } } val rawFile = File(buildAbsolutePath(context, storageId, basePath)) - if ((considerRawFile || storageId == DATA) && rawFile.canRead() && rawFile.shouldWritable(context, requiresWriteAccess)) { + if ((considerRawFile || storageId == DATA) && rawFile.canRead() && rawFile.shouldWritable( + context, + requiresWriteAccess + ) + ) { return if (documentType == DocumentFileType.ANY || documentType == DocumentFileType.FILE && rawFile.isFile || documentType == DocumentFileType.FOLDER && rawFile.isDirectory ) { @@ -797,35 +925,50 @@ object DocumentFileCompat { null } } - val file = if (Build.VERSION.SDK_INT == 29 && (storageId == HOME || storageId == PRIMARY && basePath.hasParent(Environment.DIRECTORY_DOCUMENTS))) { - getRootDocumentFile(context, storageId, requiresWriteAccess, considerRawFile)?.child(context, basePath) - ?: context.fromTreeUri(Uri.parse(DOCUMENTS_TREE_URI))?.child(context, basePath.substringAfter(Environment.DIRECTORY_DOCUMENTS)) - ?: return null - } else if (Build.VERSION.SDK_INT < 30) { - getRootDocumentFile(context, storageId, requiresWriteAccess, considerRawFile)?.child(context, basePath) ?: return null - } else { - val directorySequence = getDirectorySequence(basePath).toMutableList() - val parentTree = ArrayList(directorySequence.size) - var grantedFile: DocumentFile? = null - // Find granted file tree. - // For example, /storage/emulated/0/Music may not granted, but /storage/emulated/0/Music/Pop is granted by user. - while (directorySequence.isNotEmpty()) { - parentTree.add(directorySequence.removeFirst()) - val folderTree = parentTree.joinToString(separator = "/") - try { - grantedFile = context.fromTreeUri(createDocumentUri(storageId, folderTree)) - if (grantedFile?.canRead() == true) break - } catch (e: SecurityException) { - // ignore - } - } - if (grantedFile == null || directorySequence.isEmpty()) { - grantedFile + val file = + if (Build.VERSION.SDK_INT == 29 && (storageId == HOME || storageId == PRIMARY && basePath.hasParent( + Environment.DIRECTORY_DOCUMENTS + )) + ) { + getRootDocumentFile( + context, + storageId, + requiresWriteAccess, + considerRawFile + )?.child(context, basePath) + ?: context.fromTreeUri(Uri.parse(DOCUMENTS_TREE_URI)) + ?.child(context, basePath.substringAfter(Environment.DIRECTORY_DOCUMENTS)) + ?: return null + } else if (Build.VERSION.SDK_INT < 30) { + getRootDocumentFile( + context, + storageId, + requiresWriteAccess, + considerRawFile + )?.child(context, basePath) ?: return null } else { - val fileTree = directorySequence.joinToString(prefix = "/", separator = "/") - context.fromTreeUri(Uri.parse(grantedFile.uri.toString() + Uri.encode(fileTree))) + val directorySequence = getDirectorySequence(basePath).toMutableList() + val parentTree = ArrayList(directorySequence.size) + var grantedFile: DocumentFile? = null + // Find granted file tree. + // For example, /storage/emulated/0/Music may not granted, but /storage/emulated/0/Music/Pop is granted by user. + while (directorySequence.isNotEmpty()) { + parentTree.add(directorySequence.removeFirst()) + val folderTree = parentTree.joinToString(separator = "/") + try { + grantedFile = context.fromTreeUri(createDocumentUri(storageId, folderTree)) + if (grantedFile?.canRead() == true) break + } catch (e: SecurityException) { + // ignore + } + } + if (grantedFile == null || directorySequence.isEmpty()) { + grantedFile + } else { + val fileTree = directorySequence.joinToString(prefix = "/", separator = "/") + context.fromTreeUri(Uri.parse(grantedFile.uri.toString() + Uri.encode(fileTree))) + } } - } return file?.takeIf { it.canRead() && (documentType == DocumentFileType.ANY || documentType == DocumentFileType.FILE && it.isFile || documentType == DocumentFileType.FOLDER && it.isDirectory) @@ -857,7 +1000,10 @@ object DocumentFileCompat { * * `/storage/emulated/0/Alarm/Morning` */ @JvmStatic - fun findUniqueDeepestSubFolders(context: Context, folderFullPaths: Collection): List { + fun findUniqueDeepestSubFolders( + context: Context, + folderFullPaths: Collection + ): List { val paths = folderFullPaths.map { buildAbsolutePath(context, it) }.distinct() val results = ArrayList(paths) paths.forEach { path -> @@ -926,6 +1072,7 @@ object DocumentFileCompat { stats.f_bavail * stats.f_frsize } ?: 0 } + else -> 0 } } catch (e: Throwable) { @@ -945,6 +1092,7 @@ object DocumentFileCompat { stats.f_blocks * stats.f_frsize - stats.f_bavail * stats.f_frsize } ?: 0 } + else -> 0 } } catch (e: Throwable) { @@ -964,6 +1112,7 @@ object DocumentFileCompat { stats.f_blocks * stats.f_frsize } ?: 0 } + else -> 0 } } catch (e: Throwable) { @@ -991,7 +1140,11 @@ object DocumentFileCompat { if (folder.canRead()) { DocumentFile.fromFile(folder) } else { - getAccessibleRootDocumentFile(context, folder.absolutePath, considerRawFile = false) + getAccessibleRootDocumentFile( + context, + folder.absolutePath, + considerRawFile = false + ) } } } diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileExt.kt similarity index 70% rename from storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileExt.kt index 36c3cd5..51e2984 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -100,7 +100,8 @@ val DocumentFile.id: String val DocumentFile.rootId: String get() = DocumentsContract.getRootId(uri) -fun DocumentFile.isExternalStorageManager(context: Context) = isRawFile && File(uri.path!!).isExternalStorageManager(context) +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 { @@ -130,7 +131,13 @@ fun DocumentFile.isEmpty(context: Context): Boolean { toRawFile(context)?.list().isNullOrEmpty() } else try { val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, id) - context.contentResolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null)?.use { it.count == 0 } + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, + null, + null + )?.use { it.count == 0 } ?: true } catch (e: Exception) { true @@ -145,7 +152,7 @@ fun DocumentFile.isEmpty(context: Context): Boolean { @WorkerThread fun DocumentFile.getProperties(context: Context, callback: FileProperties.CalculationCallback) { when { - !canRead() -> callback.uiScope.postToUi { callback.onError() } + !canRead() -> callback.postToUiScope { callback.onError() } isDirectory -> { val properties = FileProperties( @@ -156,17 +163,18 @@ 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) } + callback.postToUiScope { callback.onComplete(properties) } } else { - val timer = if (callback.updateInterval < 1) null else startCoroutineTimer(repeatMillis = callback.updateInterval) { - callback.uiScope.postToUi { callback.onUpdate(properties) } - } + val timer = + if (callback.updateInterval < 1) null else startCoroutineTimer(repeatMillis = callback.updateInterval) { + callback.postToUiScope { callback.onUpdate(properties) } + } val thread = Thread.currentThread() walkFileTreeForInfo(properties, thread) 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 { + callback.postToUiScope { if (interrupted) { callback.onCanceled(properties) } else { @@ -184,7 +192,7 @@ 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) } + callback.postToUiScope { callback.onComplete(properties) } } } } @@ -232,8 +240,9 @@ fun DocumentFile.inInternalStorage(context: Context) = inInternalStorage(getStor * `true` if this file located in primary storage, i.e. external storage. * All files created by [DocumentFile.fromFile] are always treated from external storage. */ -fun DocumentFile.inPrimaryStorage(context: Context) = isTreeDocumentFile && getStorageId(context) == PRIMARY - || isRawFile && uri.path.orEmpty().startsWith(SimpleStorage.externalStoragePath) +fun DocumentFile.inPrimaryStorage(context: Context) = + isTreeDocumentFile && getStorageId(context) == PRIMARY + || isRawFile && uri.path.orEmpty().startsWith(SimpleStorage.externalStoragePath) /** * `true` if this file located in SD Card @@ -241,7 +250,8 @@ fun DocumentFile.inPrimaryStorage(context: Context) = isTreeDocumentFile && getS 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.inDataStorage(context: Context) = isRawFile && File(uri.path!!).inDataStorage(context) +fun DocumentFile.inDataStorage(context: Context) = + isRawFile && File(uri.path!!).inDataStorage(context) /** * `true` if this file was created with [File] @@ -286,7 +296,14 @@ val DocumentFile.mimeTypeByFileName: String? fun DocumentFile.toRawFile(context: Context): File? { return when { isRawFile -> File(uri.path ?: return null) - inPrimaryStorage(context) -> File("${SimpleStorage.externalStoragePath}/${getBasePath(context)}") + inPrimaryStorage(context) -> File( + "${SimpleStorage.externalStoragePath}/${ + getBasePath( + context + ) + }" + ) + else -> getStorageId(context).let { storageId -> if (storageId.isKitkatSdCardStorageId()) { DocumentFileCompat.getKitkatSdCardRootFile(getBasePath(context)) @@ -305,7 +322,11 @@ fun DocumentFile.toRawDocumentFile(context: Context): DocumentFile? { fun DocumentFile.toTreeDocumentFile(context: Context): DocumentFile? { return if (isRawFile) { - DocumentFileCompat.fromFile(context, toRawFile(context) ?: return null, considerRawFile = false) + DocumentFileCompat.fromFile( + context, + toRawFile(context) ?: return null, + considerRawFile = false + ) } else if (isTreeDocumentFile) { this } else { @@ -316,7 +337,8 @@ fun DocumentFile.toTreeDocumentFile(context: Context): DocumentFile? { } } -fun DocumentFile.toMediaFile(context: Context) = if (isTreeDocumentFile) null else MediaFile(context, uri) +fun DocumentFile.toMediaFile(context: Context) = + if (isTreeDocumentFile) null else MediaFile(context, uri) /** * It will try converting [androidx.documentfile.provider.SingleDocumentFile] @@ -326,7 +348,11 @@ fun DocumentFile.toMediaFile(context: Context) = if (isTreeDocumentFile) null el * @see toTreeDocumentFile */ @JvmOverloads -fun DocumentFile.changeName(context: Context, newBaseName: String, newExtension: String? = null): DocumentFile? { +fun DocumentFile.changeName( + context: Context, + newBaseName: String, + newExtension: String? = null +): DocumentFile? { val newFileExtension = newExtension ?: extension val newName = "$newBaseName.$newFileExtension".trimEnd('.') if (newName.isEmpty()) { @@ -358,7 +384,11 @@ fun DocumentFile.changeName(context: Context, newBaseName: String, newExtension: * @param path single file name or file path. Empty string returns to itself. */ @JvmOverloads -fun DocumentFile.child(context: Context, path: String, requiresWriteAccess: Boolean = false): DocumentFile? { +fun DocumentFile.child( + context: Context, + path: String, + requiresWriteAccess: Boolean = false +): DocumentFile? { return when { path.isEmpty() -> this isDirectory -> { @@ -368,7 +398,8 @@ fun DocumentFile.child(context: Context, path: String, requiresWriteAccess: Bool var currentDirectory = this val resolver = context.contentResolver DocumentFileCompat.getDirectorySequence(path).forEach { - val directory = currentDirectory.quickFindTreeFile(context, resolver, it) ?: return null + val directory = + currentDirectory.quickFindTreeFile(context, resolver, it) ?: return null if (directory.canRead()) { currentDirectory = directory } else { @@ -396,15 +427,26 @@ fun DocumentFile.quickFindRawFile(name: String): DocumentFile? { */ @SuppressLint("NewApi") @RestrictTo(RestrictTo.Scope.LIBRARY) -fun DocumentFile.quickFindTreeFile(context: Context, resolver: ContentResolver, name: String): DocumentFile? { +fun DocumentFile.quickFindTreeFile( + context: Context, + resolver: ContentResolver, + name: String +): DocumentFile? { try { // Optimized algorithm. Do not change unless you really know algorithm complexity. val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, id) - resolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null)?.use { + resolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, + null, + null + )?.use { val columnName = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) while (it.moveToNext()) { try { - val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, it.getString(0)) + val documentUri = + DocumentsContract.buildDocumentUriUsingTree(uri, it.getString(0)) resolver.query(documentUri, columnName, null, null, null)?.use { childCursor -> if (childCursor.moveToFirst() && name == childCursor.getString(0)) return context.fromTreeUri(documentUri) @@ -421,14 +463,23 @@ fun DocumentFile.quickFindTreeFile(context: Context, resolver: ContentResolver, } @RestrictTo(RestrictTo.Scope.LIBRARY) -fun DocumentFile.shouldWritable(context: Context, requiresWriteAccess: Boolean) = requiresWriteAccess && isWritable(context) || !requiresWriteAccess +fun DocumentFile.shouldWritable(context: Context, requiresWriteAccess: Boolean) = + requiresWriteAccess && isWritable(context) || !requiresWriteAccess @RestrictTo(RestrictTo.Scope.LIBRARY) -fun DocumentFile.takeIfWritable(context: Context, requiresWriteAccess: Boolean) = takeIf { it.shouldWritable(context, requiresWriteAccess) } +fun DocumentFile.takeIfWritable(context: Context, requiresWriteAccess: Boolean) = + takeIf { it.shouldWritable(context, requiresWriteAccess) } @RestrictTo(RestrictTo.Scope.LIBRARY) -fun DocumentFile.checkRequirements(context: Context, requiresWriteAccess: Boolean, considerRawFile: Boolean) = canRead() && - (considerRawFile || isExternalStorageManager(context)) && shouldWritable(context, requiresWriteAccess) +fun DocumentFile.checkRequirements( + context: Context, + requiresWriteAccess: Boolean, + considerRawFile: Boolean +) = canRead() && + (considerRawFile || isExternalStorageManager(context)) && shouldWritable( + context, + requiresWriteAccess +) /** * @return File path without storage ID. Returns empty `String` if: @@ -436,7 +487,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) @@ -444,7 +494,9 @@ fun DocumentFile.getBasePath(context: Context): String { isRawFile -> File(path).getBasePath(context) isDocumentsDocument -> { - "${Environment.DIRECTORY_DOCUMENTS}/${path.substringAfterLast("/home:", "")}".trimEnd('/') + "${Environment.DIRECTORY_DOCUMENTS}/${path.substringAfterLast("/home:", "")}".trimEnd( + '/' + ) } isExternalStorageDocument && path.contains("/document/$storageID:") -> { @@ -475,7 +527,8 @@ fun DocumentFile.getBasePath(context: Context): String { } } - else -> path.substringAfterLast(SimpleStorage.externalStoragePath, "").trimFileSeparator() + else -> path.substringAfterLast(SimpleStorage.externalStoragePath, "") + .trimFileSeparator() } } @@ -532,7 +585,8 @@ fun DocumentFile.getRootPath(context: Context) = when { else -> SimpleStorage.externalStoragePath } -fun DocumentFile.getRelativePath(context: Context) = getBasePath(context).substringBeforeLast('/', "") +fun DocumentFile.getRelativePath(context: Context) = + getBasePath(context).substringBeforeLast('/', "") /** * * For file in SD Card => `/storage/6881-2249/Music/song.mp3` @@ -548,7 +602,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) @@ -569,7 +622,8 @@ fun DocumentFile.getAbsolutePath(context: Context): String { } } - uri.toString().let { it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == "${DocumentFileCompat.DOWNLOADS_TREE_URI}/document/downloads" } -> + uri.toString() + .let { it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == "${DocumentFileCompat.DOWNLOADS_TREE_URI}/document/downloads" } -> PublicDirectory.DOWNLOADS.absolutePath isDownloadsDocument -> { @@ -587,7 +641,9 @@ fun DocumentFile.getAbsolutePath(context: Context): String { while (parent.parentFile?.also { parent = it } != null) { parentTree.add(parent.name.orEmpty()) } - "${SimpleStorage.externalStoragePath}/${parentTree.reversed().joinToString("/")}".trimEnd('/') + "${SimpleStorage.externalStoragePath}/${ + parentTree.reversed().joinToString("/") + }".trimEnd('/') } else { // we can't use msf/msd ID as MediaFile ID to fetch relative path, so just return empty String "" @@ -599,7 +655,10 @@ fun DocumentFile.getAbsolutePath(context: Context): String { } !isTreeDocumentFile -> "" - inPrimaryStorage(context) -> "${SimpleStorage.externalStoragePath}/${getBasePath(context)}".trimEnd('/') + inPrimaryStorage(context) -> "${SimpleStorage.externalStoragePath}/${getBasePath(context)}".trimEnd( + '/' + ) + else -> "/storage/$storageID/${getBasePath(context)}".trimEnd('/') } } @@ -607,7 +666,8 @@ fun DocumentFile.getAbsolutePath(context: Context): String { /** * @see getAbsolutePath */ -fun DocumentFile.getSimplePath(context: Context) = "${getStorageId(context)}:${getBasePath(context)}".removePrefix(":") +fun DocumentFile.getSimplePath(context: Context) = + "${getStorageId(context)}:${getBasePath(context)}".removePrefix(":") @JvmOverloads fun DocumentFile.findParent(context: Context, requiresWriteAccess: Boolean = true): DocumentFile? { @@ -616,15 +676,21 @@ fun DocumentFile.findParent(context: Context, requiresWriteAccess: Boolean = tru if (parentPath.isEmpty()) { null } else { - DocumentFileCompat.fromFullPath(context, parentPath, requiresWriteAccess = requiresWriteAccess)?.also { + DocumentFileCompat.fromFullPath( + context, + parentPath, + requiresWriteAccess = requiresWriteAccess + )?.also { try { val field = DocumentFile::class.java.getDeclaredField("mParent") field.isAccessible = true field.set(this, it) } catch (e: Exception) { Log.w( - "DocumentFileUtils", "Cannot modify field mParent in androidx.documentfile.provider.DocumentFile. " + - "Please exclude DocumentFile from obfuscation.", e + "DocumentFileUtils", + "Cannot modify field mParent in androidx.documentfile.provider.DocumentFile. " + + "Please exclude DocumentFile from obfuscation.", + e ) } } @@ -649,11 +715,21 @@ fun DocumentFile.recreateFile(context: Context): DocumentFile? { } @JvmOverloads -fun DocumentFile.getRootDocumentFile(context: Context, requiresWriteAccess: Boolean = false) = when { - isTreeDocumentFile -> DocumentFileCompat.getRootDocumentFile(context, getStorageId(context), requiresWriteAccess) - isRawFile -> uri.path?.run { File(this).getRootRawFile(context, requiresWriteAccess)?.let { DocumentFile.fromFile(it) } } - else -> null -} +fun DocumentFile.getRootDocumentFile(context: Context, requiresWriteAccess: Boolean = false) = + when { + isTreeDocumentFile -> DocumentFileCompat.getRootDocumentFile( + context, + getStorageId(context), + requiresWriteAccess + ) + + isRawFile -> uri.path?.run { + File(this).getRootRawFile(context, requiresWriteAccess) + ?.let { DocumentFile.fromFile(it) } + } + + else -> null + } /** * @return `true` if this file exists and writeable. [DocumentFile.canWrite] may return false if you have no URI permission for read & write access. @@ -664,13 +740,18 @@ fun DocumentFile.canModify(context: Context) = canRead() && isWritable(context) * Use it, because [DocumentFile.canWrite] is unreliable on Android 10. * Read [this issue](https://github.com/anggrayudi/SimpleStorage/issues/24#issuecomment-830000378) */ -fun DocumentFile.isWritable(context: Context) = if (isRawFile) File(uri.path!!).isWritable(context) else canWrite() +fun DocumentFile.isWritable(context: Context) = + if (isRawFile) File(uri.path!!).isWritable(context) else canWrite() fun DocumentFile.isRootUriPermissionGranted(context: Context): Boolean { - return isExternalStorageDocument && DocumentFileCompat.isStorageUriPermissionGranted(context, getStorageId(context)) + return isExternalStorageDocument && DocumentFileCompat.isStorageUriPermissionGranted( + context, + getStorageId(context) + ) } -fun DocumentFile.getFormattedSize(context: Context): String = Formatter.formatFileSize(context, length()) +fun DocumentFile.getFormattedSize(context: Context): String = + Formatter.formatFileSize(context, length()) /** * Avoid duplicate file name. @@ -688,7 +769,9 @@ fun DocumentFile.autoIncrementFileName(context: Context, filename: String): Stri val prefix = "$baseName (" var lastFileCount = files.filter { val name = it.name.orEmpty() - name.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches(name) + name.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches( + name + ) || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches(name)) }.maxOfOrNull { it.name.orEmpty().substringAfterLast('(', "") @@ -706,7 +789,11 @@ fun DocumentFile.autoIncrementFileName(context: Context, filename: String): Stri */ @WorkerThread @JvmOverloads -fun DocumentFile.createBinaryFile(context: Context, name: String, mode: CreateMode = CreateMode.CREATE_NEW) = +fun DocumentFile.createBinaryFile( + context: Context, + name: String, + mode: CreateMode = CreateMode.CREATE_NEW +) = makeFile(context, name, MimeType.BINARY_FILE, mode) /** @@ -739,11 +826,12 @@ fun DocumentFile.makeFile( val filename = cleanName.substringAfterLast('/') val extensionByName = MimeType.getExtensionFromFileName(cleanName) - val extension = if (extensionByName.isNotEmpty() && (mimeType == null || mimeType == MimeType.UNKNOWN || mimeType == MimeType.BINARY_FILE)) { - extensionByName - } else { - MimeType.getExtensionFromMimeTypeOrFileName(mimeType, cleanName) - } + val extension = + if (extensionByName.isNotEmpty() && (mimeType == null || mimeType == MimeType.UNKNOWN || mimeType == MimeType.BINARY_FILE)) { + extensionByName + } else { + MimeType.getExtensionFromMimeTypeOrFileName(mimeType, cleanName) + } val baseFileName = filename.removeSuffix(".$extension") val fullFileName = "$baseFileName.$extension".trimEnd('.') @@ -770,7 +858,14 @@ fun DocumentFile.makeFile( if (isRawFile) { // RawDocumentFile does not avoid duplicate file name, but TreeDocumentFile does. - return DocumentFile.fromFile(toRawFile(context)?.makeFile(context, cleanName, mimeType, createMode) ?: return null) + return DocumentFile.fromFile( + toRawFile(context)?.makeFile( + context, + cleanName, + mimeType, + createMode + ) ?: return null + ) } val correctMimeType = MimeType.getMimeTypeFromExtension(extension).let { @@ -802,20 +897,28 @@ fun DocumentFile.makeFolder( } if (isRawFile) { - return DocumentFile.fromFile(toRawFile(context)?.makeFolder(context, name, mode) ?: return null) + return DocumentFile.fromFile( + toRawFile(context)?.makeFolder(context, name, mode) ?: return null + ) } // if name is "Aduhhh/Now/Dee", system will convert it to Aduhhh_Now_Dee, so create a sequence - val directorySequence = DocumentFileCompat.getDirectorySequence(name.removeForbiddenCharsFromFilename()).toMutableList() + val directorySequence = + DocumentFileCompat.getDirectorySequence(name.removeForbiddenCharsFromFilename()) + .toMutableList() val folderNameLevel1 = directorySequence.removeFirstOrNull() ?: return null - var currentDirectory = if (isDownloadsDocument && isTreeDocumentFile) (toWritableDownloadsDocumentFile(context) ?: return null) else this + var currentDirectory = + if (isDownloadsDocument && isTreeDocumentFile) (toWritableDownloadsDocumentFile(context) + ?: return null) else this val folderLevel1 = currentDirectory.child(context, folderNameLevel1) currentDirectory = if (folderLevel1 == null || mode == CreateMode.CREATE_NEW) { currentDirectory.createDirectory(folderNameLevel1) ?: return null } else if (mode == CreateMode.REPLACE) { folderLevel1.forceDelete(context, true) - if (folderLevel1.isDirectory) folderLevel1 else currentDirectory.createDirectory(folderNameLevel1) ?: return null + if (folderLevel1.isDirectory) folderLevel1 else currentDirectory.createDirectory( + folderNameLevel1 + ) ?: return null } else if (mode != CreateMode.SKIP_IF_EXISTS && folderLevel1.isDirectory && folderLevel1.canRead()) { folderLevel1 } else { @@ -848,13 +951,23 @@ fun DocumentFile.toWritableDownloadsDocumentFile(context: Context): DocumentFile return if (isDownloadsDocument) { val path = uri.path.orEmpty() when { - uri.toString() == "${DocumentFileCompat.DOWNLOADS_TREE_URI}/document/downloads" -> takeIf { it.isWritable(context) } + uri.toString() == "${DocumentFileCompat.DOWNLOADS_TREE_URI}/document/downloads" -> takeIf { + it.isWritable( + context + ) + } // content://com.android.providers.downloads.documents/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2Fscreenshot.jpeg // content://com.android.providers.downloads.documents/tree/downloads/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2FIKO5 // raw:/storage/emulated/0/Download/IKO5 - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (path.startsWith("/tree/downloads/document/raw:") || path.startsWith("/document/raw:")) -> { - val downloads = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, considerRawFile = false) ?: return null + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && (path.startsWith("/tree/downloads/document/raw:") || path.startsWith( + "/document/raw:" + )) -> { + val downloads = DocumentFileCompat.fromPublicFolder( + context, + PublicDirectory.DOWNLOADS, + considerRawFile = false + ) ?: return null val fullPath = path.substringAfterLast("/document/raw:") val subFile = fullPath.substringAfter("/${Environment.DIRECTORY_DOWNLOADS}", "") downloads.child(context, subFile, true) @@ -892,7 +1005,10 @@ fun DocumentFile.toWritableDownloadsDocumentFile(context: Context): DocumentFile /** * @param names full file names, with their extension */ -fun DocumentFile.findFiles(names: Array, documentType: DocumentFileType = DocumentFileType.ANY): List { +fun DocumentFile.findFiles( + names: Array, + documentType: DocumentFileType = DocumentFileType.ANY +): List { val files = listFiles().filter { it.name in names } return when (documentType) { DocumentFileType.FILE -> files.filter { it.isFile } @@ -901,12 +1017,14 @@ fun DocumentFile.findFiles(names: Array, documentType: DocumentFileType } } -fun DocumentFile.findFolder(name: String): DocumentFile? = listFiles().find { it.name == name && it.isDirectory } +fun DocumentFile.findFolder(name: String): DocumentFile? = + listFiles().find { it.name == name && it.isDirectory } /** * Expect the file is a file literally, not a folder. */ -fun DocumentFile.findFileLiterally(name: String): DocumentFile? = listFiles().find { it.name == name && it.isFile } +fun DocumentFile.findFileLiterally(name: String): DocumentFile? = + listFiles().find { it.name == name && it.isFile } /** * @param recursive walk into sub folders @@ -938,22 +1056,28 @@ fun DocumentFile.search( if (regex != null) { sequence = sequence.filter { regex.matches(it.name.orEmpty()) } } - val hasMimeTypeFilter = !mimeTypes.isNullOrEmpty() && !mimeTypes.any { it == MimeType.UNKNOWN } + val hasMimeTypeFilter = + !mimeTypes.isNullOrEmpty() && !mimeTypes.any { it == MimeType.UNKNOWN } when { - hasMimeTypeFilter || documentType == DocumentFileType.FILE -> sequence = sequence.filter { it.isFile } - documentType == DocumentFileType.FOLDER -> sequence = sequence.filter { it.isDirectory } + hasMimeTypeFilter || documentType == DocumentFileType.FILE -> sequence = + sequence.filter { it.isFile } + + documentType == DocumentFileType.FOLDER -> sequence = + sequence.filter { it.isDirectory } } if (hasMimeTypeFilter) { sequence = sequence.filter { it.matchesMimeTypes(mimeTypes!!) } } val result = sequence.toList() - if (name.isEmpty()) result else result.firstOrNull { it.name == name }?.let { listOf(it) } ?: emptyList() + if (name.isEmpty()) result else result.firstOrNull { it.name == name } + ?.let { listOf(it) } ?: emptyList() } } } private fun DocumentFile.matchesMimeTypes(filterMimeTypes: Array): Boolean { - return filterMimeTypes.isEmpty() || !MimeTypeFilter.matches(mimeTypeByFileName, filterMimeTypes).isNullOrEmpty() + return filterMimeTypes.isEmpty() || !MimeTypeFilter.matches(mimeTypeByFileName, filterMimeTypes) + .isNullOrEmpty() } private fun DocumentFile.walkFileTreeForSearch( @@ -982,11 +1106,22 @@ private fun DocumentFile.walkFileTreeForSearch( } else { if (documentType != DocumentFileType.FILE) { val folderName = file.name.orEmpty() - if ((nameFilter.isEmpty() || folderName == nameFilter) && (regex == null || regex.matches(folderName))) { + if ((nameFilter.isEmpty() || folderName == nameFilter) && (regex == null || regex.matches( + folderName + )) + ) { fileTree.add(file) } } - fileTree.addAll(file.walkFileTreeForSearch(documentType, mimeTypes, nameFilter, regex, thread)) + fileTree.addAll( + file.walkFileTreeForSearch( + documentType, + mimeTypes, + nameFilter, + regex, + thread + ) + ) } } return fileTree @@ -1070,20 +1205,29 @@ private fun DocumentFile.walkFileTreeAndDeleteEmptyFolders(): List */ @JvmOverloads @WorkerThread -fun DocumentFile.openOutputStream(context: Context, append: Boolean = true) = uri.openOutputStream(context, append) +fun DocumentFile.openOutputStream(context: Context, append: Boolean = true) = + uri.openOutputStream(context, append) @WorkerThread fun DocumentFile.openInputStream(context: Context) = uri.openInputStream(context) @UiThread fun DocumentFile.openFileIntent(context: Context, authority: String) = Intent(Intent.ACTION_VIEW) - .setData(if (isRawFile) FileProvider.getUriForFile(context, authority, File(uri.path!!)) else uri) + .setData( + if (isRawFile) FileProvider.getUriForFile( + context, + authority, + File(uri.path!!) + ) else uri + ) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) -fun DocumentFile.hasParent(context: Context, parent: DocumentFile) = getAbsolutePath(context).hasParent(parent.getAbsolutePath(context)) +fun DocumentFile.hasParent(context: Context, parent: DocumentFile) = + getAbsolutePath(context).hasParent(parent.getAbsolutePath(context)) -fun DocumentFile.childOf(context: Context, parent: DocumentFile) = getAbsolutePath(context).childOf(parent.getAbsolutePath(context)) +fun DocumentFile.childOf(context: Context, parent: DocumentFile) = + getAbsolutePath(context).childOf(parent.getAbsolutePath(context)) private fun DocumentFile.walkFileTree(context: Context): List { val fileTree = mutableListOf() @@ -1136,7 +1280,7 @@ fun List.compressToZip( deleteSourceWhenComplete: Boolean = false, callback: ZipCompressionCallback ) { - callback.uiScope.postToUi { callback.onCountingFiles() } + callback.postToUiScope { callback.onCountingFiles() } val treeFiles = ArrayList(size) val mediaFiles = ArrayList(size) var foldersBasePath = mutableListOf() @@ -1144,8 +1288,11 @@ 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}") + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, + "Can't read file: ${srcFile.uri}" + ) } return } else if (srcFile.isFile) { @@ -1158,7 +1305,12 @@ fun List.compressToZip( directories.add(srcFile) } } else { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, "File not found: ${srcFile.uri}") } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, + "File not found: ${srcFile.uri}" + ) + } return } } @@ -1171,11 +1323,15 @@ fun List.compressToZip( */ class EntryFile(val file: DocumentFile, var path: String) { - override fun equals(other: Any?) = this === other || other is EntryFile && path == other.path + override fun equals(other: Any?) = + this === other || other is EntryFile && path == other.path + override fun hashCode() = path.hashCode() } - val srcFolders = directories.map { EntryFile(it, it.getAbsolutePath(context)) }.distinctBy { it.path }.toMutableList() + val srcFolders = + directories.map { EntryFile(it, it.getAbsolutePath(context)) }.distinctBy { it.path } + .toMutableList() DocumentFileCompat.findUniqueParents(context, srcFolders.map { it.path }).forEach { parent -> srcFolders.removeAll { it.path.childOf(parent) } } @@ -1192,7 +1348,12 @@ 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") } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, + "No entry files found" + ) + } return } @@ -1200,15 +1361,24 @@ fun List.compressToZip( 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) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + targetZipFile.getStorageId(context) + ), actualFilesSize + ) + ) { + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } val entryFiles = ArrayList(totalFiles) treeFiles.forEach { entryFiles.add(EntryFile(it, it.getBasePath(context))) } - val parentPaths = DocumentFileCompat.findUniqueParents(context, entryFiles.map { "/" + it.path.substringBeforeLast('/') }).map { it.trim('/') } - foldersBasePath = DocumentFileCompat.findUniqueParents(context, foldersBasePath.map { "/$it" }).map { it.trim('/') }.toMutableList() + val parentPaths = DocumentFileCompat.findUniqueParents( + context, + entryFiles.map { "/" + it.path.substringBeforeLast('/') }).map { it.trim('/') } + foldersBasePath = DocumentFileCompat.findUniqueParents(context, foldersBasePath.map { "/$it" }) + .map { it.trim('/') }.toMutableList() entryFiles.forEach { entry -> for (parentPath in parentPaths) { if (entry.path.startsWith(parentPath)) { @@ -1225,27 +1395,37 @@ 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 }}") + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.DUPLICATE_ENTRY_FILE, + "Found duplicate entry files: ${duplicateFiles.keys.map { it.file.uri }}" + ) } return } var zipFile: DocumentFile? = targetZipFile if (!targetZipFile.exists() || targetZipFile.isDirectory) { - zipFile = targetZipFile.findParent(context)?.makeFile(context, targetZipFile.fullName, MimeType.ZIP) + 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) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } if (!zipFile.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable") } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, + "Destination ZIP file is not writable" + ) + } return } val thread = Thread.currentThread() - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(entryFiles.map { it.file }, thread) } + val reportInterval = + awaitUiResult(callback.uiScope) { callback.onStart(entryFiles.map { it.file }, thread) } if (reportInterval < 0) return var success = false @@ -1259,8 +1439,13 @@ fun List.compressToZip( // 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) } + val report = ZipCompressionCallback.Report( + bytesCompressed * 100f / actualFilesSize, + bytesCompressed, + writeSpeed, + fileCompressedCount + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -1280,13 +1465,28 @@ fun List.compressToZip( } success = true } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, e.message) } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, + e.message + ) + } } catch (e: IOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR, e.message) } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR, + e.message + ) + } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, e.message) } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + } } finally { timer?.cancel() zos.closeEntryQuietly() @@ -1294,11 +1494,18 @@ fun List.compressToZip( } if (success) { if (deleteSourceWhenComplete) { - callback.uiScope.postToUi { callback.onDeleteEntryFiles() } + callback.postToUiScope { callback.onDeleteEntryFiles() } forEach { it.forceDelete(context) } } val sizeReduction = (actualFilesSize - zipFile.length()).toFloat() / actualFilesSize * 100 - callback.uiScope.postToUi { callback.onCompleted(zipFile, actualFilesSize, totalFiles, sizeReduction) } + callback.postToUiScope { + callback.onCompleted( + zipFile, + actualFilesSize, + totalFiles, + sizeReduction + ) + } } else { zipFile.delete() } @@ -1314,22 +1521,22 @@ fun DocumentFile.decompressZip( targetFolder: DocumentFile, callback: ZipDecompressionCallback ) { - callback.uiScope.postToUi { callback.onValidate() } + callback.postToUiScope { callback.onValidate() } if (exists()) { if (!canRead()) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return } else if (isFile) { if (type != MimeType.ZIP && name?.endsWith(".zip", ignoreCase = true) != false) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } return } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } return } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } return } @@ -1338,13 +1545,19 @@ 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) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return } 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) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), zipSize + ) + ) { + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } @@ -1365,8 +1578,12 @@ fun DocumentFile.decompressZip( // 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) } + val report = ZipDecompressionCallback.Report( + bytesDecompressed, + writeSpeed, + fileDecompressedCount + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -1378,12 +1595,16 @@ fun DocumentFile.decompressZip( destFolder.makeFolder(context, entry.name, CreateMode.REUSE) } else { val folder = entry.name.substringBeforeLast('/', "").let { - if (it.isEmpty()) destFolder else destFolder.makeFolder(context, it, CreateMode.REUSE) + 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) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } canSuccess = false break } @@ -1408,17 +1629,17 @@ fun DocumentFile.decompressZip( } success = canSuccess } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } } finally { timer?.cancel() zis.closeEntryQuietly() @@ -1427,8 +1648,13 @@ 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) } + val info = ZipDecompressionCallback.DecompressionInfo( + bytesDecompressed, + skippedDecompressedBytes, + fileDecompressedCount, + sizeExpansion + ) + callback.postToUiScope { callback.onCompleted(this, destFolder, info) } } else { targetFile?.delete() } @@ -1463,26 +1689,38 @@ private fun List.copyTo( ) { val pair = doesMeetCopyRequirements(context, targetParentFolder, callback) ?: return - callback.uiScope.postToUi { callback.onPrepare() } + callback.postToUiScope { callback.onPrepare() } 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, callback) + ?: return + validSources.removeAll(conflictResolutions.filter { it.solution == FolderCallback.ConflictResolution.SKIP } + .map { it.source }) if (validSources.isEmpty()) { return } - callback.uiScope.postToUi { callback.onCountingFiles() } + callback.postToUiScope { callback.onCountingFiles() } - 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: FolderCallback.ConflictResolution + ) val sourceInfos = validSources.associateWith { src -> - val resolution = conflictResolutions.find { it.source == src }?.solution ?: FolderCallback.ConflictResolution.CREATE_NEW + 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) + val children = + if (skipEmptyFiles) src.walkFileTreeAndSkipEmptyFiles() else src.walkFileTree( + context + ) var totalFilesToCopy = 0 var totalSizeToCopy = 0L children.forEach { @@ -1494,11 +1732,12 @@ private fun List.copyTo( SourceInfo(children, totalSizeToCopy, totalFilesToCopy, resolution) } // allow empty folders, but empty files need check - }.filterValues { it.children != null || (skipEmptyFiles && it.size > 0 || !skipEmptyFiles) }.toMutableMap() + }.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) } + callback.postToUiScope { callback.onCompleted(result) } return } @@ -1525,7 +1764,7 @@ private fun List.copyTo( FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED -> MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED else -> return } - callback.uiScope.postToUi { callback.onFailed(errorCode) } + callback.postToUiScope { callback.onFailed(errorCode) } return } } @@ -1539,22 +1778,39 @@ private fun List.copyTo( } if (sourceInfos.isEmpty()) { - val result = MultipleFileCallback.Result(results.map { it.value }, copiedFiles, copiedFiles, true) - callback.uiScope.postToUi { callback.onCompleted(result) } + val result = MultipleFileCallback.Result( + results.map { it.value }, + copiedFiles, + copiedFiles, + true + ) + callback.postToUiScope { callback.onCompleted(result) } return } } 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) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + writableTargetParentFolder.getStorageId(context) + ), totalSizeToCopy + ) + ) { + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } val thread = Thread.currentThread() val totalFilesToCopy = sourceInfos.values.sumOf { it.totalFiles } - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(sourceInfos.map { it.key }, totalFilesToCopy, thread) } + val reportInterval = awaitUiResult(callback.uiScope) { + callback.onStart( + sourceInfos.map { it.key }, + totalFilesToCopy, + thread + ) + } if (reportInterval < 0) return var totalCopiedFiles = 0 @@ -1564,8 +1820,13 @@ private fun List.copyTo( 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) } + val report = MultipleFileCallback.Report( + bytesMoved * 100f / totalSizeToCopy, + bytesMoved, + writeSpeed, + totalCopiedFiles + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -1573,14 +1834,20 @@ private fun List.copyTo( startTimer(totalSizeToCopy > 10 * FileSize.MB) 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 + 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 -> if (!canceled) { canceled = true timer?.cancel() targetFile?.delete() - val result = MultipleFileCallback.Result(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, false) - callback.uiScope.postToUi { + val result = MultipleFileCallback.Result( + results.map { it.value }, + totalFilesToCopy, + totalCopiedFiles, + false + ) + callback.postToUiScope { callback.onFailed(errorCode) callback.onCompleted(result) } @@ -1617,7 +1884,7 @@ private fun List.copyTo( true } else { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(errorCode) } + callback.postToUiScope { callback.onFailed(errorCode) } false } } @@ -1631,11 +1898,15 @@ private fun List.copyTo( } val mode = info.conflictResolution.toCreateMode() val targetRootFile = writableTargetParentFolder.let { - if (src.isDirectory) it.makeFolder(context, src.fullName, mode) else it.makeFile(context, src.fullName, src.mimeType, mode) + if (src.isDirectory) it.makeFolder( + context, + src.fullName, + mode + ) else it.makeFile(context, src.fullName, src.mimeType, mode) } if (targetRootFile == null) { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } @@ -1657,7 +1928,8 @@ private fun List.copyTo( continue } - val filename = sourceFile.getSubPath(context, srcParentAbsolutePath) ?: sourceFile.fullName + val filename = + sourceFile.getSubPath(context, srcParentAbsolutePath) ?: sourceFile.fullName if (filename.isEmpty()) continue if (sourceFile.isDirectory) { @@ -1669,7 +1941,8 @@ private fun List.copyTo( continue } - targetFile = targetRootFile.makeFile(context, filename, sourceFile.type, CreateMode.REUSE) + targetFile = + targetRootFile.makeFile(context, filename, sourceFile.type, CreateMode.REUSE) if (targetFile != null && targetFile.length() > 0) { conflictedFiles.add(FolderCallback.FileConflict(sourceFile, targetFile)) continue @@ -1696,15 +1969,24 @@ private fun List.copyTo( if (deleteSourceWhenComplete && success) { sourceInfos.forEach { (src, _) -> src.forceDelete(context) } } - val result = MultipleFileCallback.Result(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, success) - callback.uiScope.postToUi { callback.onCompleted(result) } + val result = MultipleFileCallback.Result( + results.map { it.value }, + totalFilesToCopy, + totalCopiedFiles, + success + ) + callback.postToUiScope { callback.onCompleted(result) } true } else false } if (finalize()) return val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(writableTargetParentFolder, conflictedFiles, FolderCallback.FolderContentConflictAction(it)) + callback.onContentConflict( + writableTargetParentFolder, + conflictedFiles, + FolderCallback.FolderContentConflictAction(it) + ) }.filter { // free up space first, by deleting some files if (it.solution == FileCallback.ConflictResolution.SKIP) { @@ -1753,14 +2035,14 @@ private fun List.doesMeetCopyRequirements( targetParentFolder: DocumentFile, callback: MultipleFileCallback ): Pair>? { - callback.uiScope.postToUi { callback.onValidate() } + callback.postToUiScope { callback.onValidate() } if (!targetParentFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.INVALID_TARGET_FOLDER) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.INVALID_TARGET_FOLDER) } return null } if (!targetParentFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return null } @@ -1771,7 +2053,10 @@ private fun List.doesMeetCopyRequirements( !it.exists() -> Pair(it, FolderCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) !it.canRead() -> Pair(it, FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) targetParentFolderPath == it.parentFile?.getAbsolutePath(context) -> - Pair(it, FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) + Pair( + it, + FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER + ) else -> null } @@ -1779,25 +2064,41 @@ private fun List.doesMeetCopyRequirements( if (invalidSourceFiles.isNotEmpty()) { val abort = awaitUiResultWithPending(callback.uiScope) { - callback.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFileCallback.InvalidSourceFilesAction(it)) + callback.onInvalidSourceFilesFound( + invalidSourceFiles, + MultipleFileCallback.InvalidSourceFilesAction(it) + ) } if (abort) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.CANCELED) } return null } if (invalidSourceFiles.size == size) { - callback.uiScope.postToUi { callback.onCompleted(MultipleFileCallback.Result(emptyList(), 0, 0, true)) } + callback.postToUiScope { + callback.onCompleted( + MultipleFileCallback.Result( + emptyList(), + 0, + 0, + true + ) + ) + } return null } } - val writableFolder = targetParentFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } + 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) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return null } - return Pair(writableFolder, sourceFiles.toMutableList().apply { removeAll(invalidSourceFiles.map { it.key }) }) + return Pair( + writableFolder, + sourceFiles.toMutableList().apply { removeAll(invalidSourceFiles.map { it.key }) }) } private fun DocumentFile.tryMoveFolderByRenamingPath( @@ -1822,9 +2123,15 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( } if (isExternalStorageManager(context)) { - val sourceFile = toRawFile(context) ?: return FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED + val sourceFile = + toRawFile(context) ?: return FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED writableTargetParentFolder.toRawFile(context)?.let { destinationFolder -> - sourceFile.moveTo(context, destinationFolder, targetFolderParentName, conflictResolution.toFileConflictResolution())?.let { + sourceFile.moveTo( + context, + destinationFolder, + targetFolderParentName, + conflictResolution.toFileConflictResolution() + )?.let { if (skipEmptyFiles) it.deleteEmptyFolders(context) return DocumentFile.fromFile(it) } @@ -1833,11 +2140,20 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !isRawFile && writableTargetParentFolder.isTreeDocumentFile) { - val movedFileUri = parentFile?.uri?.let { DocumentsContract.moveDocument(context.contentResolver, uri, it, writableTargetParentFolder.uri) } + val movedFileUri = parentFile?.uri?.let { + DocumentsContract.moveDocument( + context.contentResolver, + uri, + it, + writableTargetParentFolder.uri + ) + } if (movedFileUri != null) { val newFile = context.fromTreeUri(movedFileUri) return if (newFile != null && newFile.isDirectory) { - if (newFolderNameInTargetPath != null) newFile.renameTo(targetFolderParentName) + if (newFolderNameInTargetPath != null) newFile.renameTo( + targetFolderParentName + ) if (skipEmptyFiles) newFile.deleteEmptyFolders(context) newFile } else { @@ -1860,7 +2176,14 @@ fun DocumentFile.moveFolderTo( newFolderNameInTargetPath: String? = null, callback: FolderCallback ) { - copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, callback) + copyFolderTo( + context, + targetParentFolder, + skipEmptyFiles, + newFolderNameInTargetPath, + true, + callback + ) } @WorkerThread @@ -1871,7 +2194,14 @@ fun DocumentFile.copyFolderTo( newFolderNameInTargetPath: String? = null, callback: FolderCallback ) { - copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, callback) + copyFolderTo( + context, + targetParentFolder, + skipEmptyFiles, + newFolderNameInTargetPath, + false, + callback + ) } /** @@ -1885,26 +2215,44 @@ private fun DocumentFile.copyFolderTo( deleteSourceWhenComplete: Boolean, callback: FolderCallback ) { - val writableTargetParentFolder = doesMeetCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, callback) ?: return + val writableTargetParentFolder = + doesMeetCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, callback) + ?: return - callback.uiScope.postToUi { callback.onPrepare() } + callback.postToUiScope { callback.onPrepare() } - val targetFolderParentName = (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, callback) + val targetFolderParentName = + (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename() + .trimFileSeparator() + val conflictResolution = + handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, callback) if (conflictResolution == FolderCallback.ConflictResolution.SKIP) { return } - callback.uiScope.postToUi { callback.onCountingFiles() } + callback.postToUiScope { callback.onCountingFiles() } val filesToCopy = if (skipEmptyFiles) walkFileTreeAndSkipEmptyFiles() else walkFileTree(context) if (filesToCopy.isEmpty()) { - val targetFolder = writableTargetParentFolder.makeFolder(context, targetFolderParentName, conflictResolution.toCreateMode()) + val targetFolder = writableTargetParentFolder.makeFolder( + context, + targetFolderParentName, + conflictResolution.toCreateMode() + ) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { if (deleteSourceWhenComplete) delete() - callback.uiScope.postToUi { callback.onCompleted(FolderCallback.Result(targetFolder, 0, 0, true)) } + callback.postToUiScope { + callback.onCompleted( + FolderCallback.Result( + targetFolder, + 0, + 0, + true + ) + ) + } } return } @@ -1920,7 +2268,7 @@ private fun DocumentFile.copyFolderTo( val thread = Thread.currentThread() if (thread.isInterrupted) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANCELED) } return } @@ -1934,28 +2282,48 @@ private fun DocumentFile.copyFolderTo( conflictResolution )) { is DocumentFile -> { - callback.uiScope.postToUi { callback.onCompleted(FolderCallback.Result(result, totalFilesToCopy, totalFilesToCopy, true)) } + callback.postToUiScope { + callback.onCompleted( + FolderCallback.Result( + result, + totalFilesToCopy, + totalFilesToCopy, + true + ) + ) + } return } is FolderCallback.ErrorCode -> { - callback.uiScope.postToUi { callback.onFailed(result) } + callback.postToUiScope { callback.onFailed(result) } return } } } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + writableTargetParentFolder.getStorageId(context) + ), totalSizeToCopy + ) + ) { + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } - val reportInterval = awaitUiResult(callback.uiScope) { callback.onStart(this, totalFilesToCopy, thread) } + val reportInterval = + awaitUiResult(callback.uiScope) { callback.onStart(this, totalFilesToCopy, thread) } if (reportInterval < 0) return - val targetFolder = writableTargetParentFolder.makeFolder(context, targetFolderParentName, conflictResolution.toCreateMode()) + val targetFolder = writableTargetParentFolder.makeFolder( + context, + targetFolderParentName, + conflictResolution.toCreateMode() + ) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } @@ -1966,8 +2334,13 @@ private fun DocumentFile.copyFolderTo( 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) } + val report = FolderCallback.Report( + bytesMoved * 100f / totalSizeToCopy, + bytesMoved, + writeSpeed, + totalCopiedFiles + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -1975,15 +2348,23 @@ private fun DocumentFile.copyFolderTo( startTimer(totalSizeToCopy > 10 * FileSize.MB) 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 + 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 -> if (!canceled) { canceled = true timer?.cancel() targetFile?.delete() - callback.uiScope.postToUi { + callback.postToUiScope { callback.onFailed(errorCode) - callback.onCompleted(FolderCallback.Result(targetFolder, totalFilesToCopy, totalCopiedFiles, false)) + callback.onCompleted( + FolderCallback.Result( + targetFolder, + totalFilesToCopy, + totalCopiedFiles, + false + ) + ) } } } @@ -2018,7 +2399,7 @@ private fun DocumentFile.copyFolderTo( true } else { timer?.cancel() - callback.uiScope.postToUi { callback.onFailed(errorCode) } + callback.postToUiScope { callback.onFailed(errorCode) } false } } @@ -2069,14 +2450,27 @@ 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)) } + callback.postToUiScope { + callback.onCompleted( + FolderCallback.Result( + targetFolder, + totalFilesToCopy, + totalCopiedFiles, + success + ) + ) + } true } else false } if (finalize()) return val solutions = awaitUiResultWithPending>(callback.uiScope) { - callback.onContentConflict(targetFolder, conflictedFiles, FolderCallback.FolderContentConflictAction(it)) + callback.onContentConflict( + targetFolder, + conflictedFiles, + FolderCallback.FolderContentConflictAction(it) + ) }.filter { // free up space first, by deleting some files if (it.solution == FileCallback.ConflictResolution.SKIP) { @@ -2098,7 +2492,8 @@ private fun DocumentFile.copyFolderTo( continue } val filename = conflict.target.name.orEmpty() - targetFile = conflict.target.findParent(context)?.makeFile(context, filename, mode = conflict.solution.toCreateMode()) + targetFile = conflict.target.findParent(context) + ?.makeFile(context, filename, mode = conflict.solution.toCreateMode()) if (targetFile == null) { notifyCanceled(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) return @@ -2138,31 +2533,33 @@ private fun DocumentFile.doesMeetCopyRequirements( newFolderNameInTargetPath: String?, callback: FolderCallback ): DocumentFile? { - callback.uiScope.postToUi { callback.onValidate() } + callback.postToUiScope { callback.onValidate() } if (!isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.SOURCE_FOLDER_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.SOURCE_FOLDER_NOT_FOUND) } return null } if (!targetParentFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.INVALID_TARGET_FOLDER) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.INVALID_TARGET_FOLDER) } return null } if (!canRead() || !targetParentFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.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) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) } return null } - val writableFolder = targetParentFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } + 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) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } } return writableFolder } @@ -2193,7 +2590,7 @@ fun DocumentFile.copyFileTo( ) { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { copyFileTo(context, targetFolder, fileDescription, callback) } @@ -2210,13 +2607,26 @@ fun DocumentFile.copyFileTo( callback: FileCallback ) { if (fileDescription?.subFolder.isNullOrEmpty()) { - copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, callback) + copyFileTo( + context, + targetFolder, + fileDescription?.name, + fileDescription?.mimeType, + callback + ) } else { - val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { - copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, callback) + copyFileTo( + context, + targetDirectory, + fileDescription?.name, + fileDescription?.mimeType, + callback + ) } } } @@ -2228,18 +2638,29 @@ private fun DocumentFile.copyFileTo( newMimeTypeInTargetPath: String?, callback: FileCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return + val writableTargetFolder = + doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return - callback.uiScope.postToUi { callback.onPrepare() } + callback.postToUiScope { callback.onPrepare() } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, writableTargetFolder.getStorageId(context)), length())) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + writableTargetFolder.getStorageId(context) + ), length() + ) + ) { + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } - val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) + val cleanFileName = MimeType.getFullFileName( + newFilenameInTargetPath ?: name.orEmpty(), + newMimeTypeInTargetPath ?: mimeTypeByFileName + ) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, callback) + val fileConflictResolution = + handleFileConflict(context, writableTargetFolder, cleanFileName, callback) if (fileConflictResolution == FileCallback.ConflictResolution.SKIP) { return } @@ -2251,14 +2672,26 @@ private fun DocumentFile.copyFileTo( try { val targetFile = createTargetFile( - context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), callback + context, + writableTargetFolder, + cleanFileName, + newMimeTypeInTargetPath ?: mimeTypeByFileName, + fileConflictResolution.toCreateMode(), + callback ) ?: return createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, false, callback) + copyFileStream( + inputStream, + outputStream, + targetFile, + watchProgress, + reportInterval, + false, + callback + ) } } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } } @@ -2271,31 +2704,32 @@ private fun DocumentFile.doesMeetCopyRequirements( newFilenameInTargetPath: String?, callback: FileCallback ): DocumentFile? { - callback.uiScope.postToUi { callback.onValidate() } + callback.postToUiScope { callback.onValidate() } if (!isFile) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } return null } if (!targetFolder.isDirectory) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FOLDER_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.TARGET_FOLDER_NOT_FOUND) } return null } if (!canRead() || !targetFolder.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER) } return null } - val writableFolder = targetFolder.let { if (it.isDownloadsDocument) it.toWritableDownloadsDocumentFile(context) else it } + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } } return writableFolder } @@ -2315,7 +2749,7 @@ private fun createFileStreams( is FolderCallback -> FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET else -> FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND } - callback.uiScope.postToUi { callback.onFailed(errorCode as Enum) } + callback.postToUiScope { callback.onFailed(errorCode as Enum) } return } @@ -2327,7 +2761,7 @@ private fun createFileStreams( is FolderCallback -> FolderCallback.ErrorCode.SOURCE_FILE_NOT_FOUND else -> FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND } - callback.uiScope.postToUi { callback.onFailed(errorCode as Enum) } + callback.postToUiScope { callback.onFailed(errorCode as Enum) } return } @@ -2343,14 +2777,14 @@ private inline fun createFileStreams( ) { val outputStream = targetFile.openOutputStream() if (outputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } + callback.postToUiScope { 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } return } @@ -2367,7 +2801,7 @@ private fun createTargetFile( ): DocumentFile? { val targetFile = targetFolder.makeFile(context, newFilenameInTargetPath, mimeType, mode) if (targetFile == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } return targetFile } @@ -2392,8 +2826,9 @@ private fun DocumentFile.copyFileStream( // 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) } + val report = + FileCallback.Report(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -2412,7 +2847,7 @@ private fun DocumentFile.copyFileStream( if (targetFile is MediaFile) { targetFile.length = srcSize } - callback.uiScope.postToUi { callback.onCompleted(targetFile) } + callback.postToUiScope { callback.onCompleted(FileCallback.Result.get(targetFile)) } } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -2446,7 +2881,7 @@ fun DocumentFile.moveFileTo( ) { val targetFolder = DocumentFileCompat.mkdirs(context, targetFolderAbsolutePath, true) if (targetFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { moveFileTo(context, targetFolder, fileDescription, callback) } @@ -2463,13 +2898,26 @@ fun DocumentFile.moveFileTo( callback: FileCallback ) { if (fileDescription?.subFolder.isNullOrEmpty()) { - moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, callback) + moveFileTo( + context, + targetFolder, + fileDescription?.name, + fileDescription?.mimeType, + callback + ) } else { - val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { - moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, callback) + moveFileTo( + context, + targetDirectory, + fileDescription?.name, + fileDescription?.mimeType, + callback + ) } } } @@ -2481,20 +2929,36 @@ private fun DocumentFile.moveFileTo( newMimeTypeInTargetPath: String?, callback: FileCallback ) { - val writableTargetFolder = doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return + val writableTargetFolder = + doesMeetCopyRequirements(context, targetFolder, newFilenameInTargetPath, callback) ?: return - callback.uiScope.postToUi { callback.onPrepare() } + callback.postToUiScope { callback.onPrepare() } - val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) + val cleanFileName = MimeType.getFullFileName( + newFilenameInTargetPath ?: name.orEmpty(), + newMimeTypeInTargetPath ?: mimeTypeByFileName + ) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, callback) + val fileConflictResolution = + handleFileConflict(context, writableTargetFolder, cleanFileName, callback) if (fileConflictResolution == FileCallback.ConflictResolution.SKIP) { return } if (inInternalStorage(context)) { - toRawFile(context)?.moveTo(context, writableTargetFolder.getAbsolutePath(context), cleanFileName, fileConflictResolution)?.let { - callback.uiScope.postToUi { callback.onCompleted(DocumentFile.fromFile(it)) } + toRawFile(context)?.moveTo( + context, + writableTargetFolder.getAbsolutePath(context), + cleanFileName, + fileConflictResolution + )?.let { + callback.postToUiScope { + callback.onCompleted( + FileCallback.Result.DocumentFile( + DocumentFile.fromFile(it) + ) + ) + } return } } @@ -2503,38 +2967,59 @@ 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return } writableTargetFolder.toRawFile(context)?.let { destinationFolder -> - sourceFile.moveTo(context, destinationFolder, cleanFileName, fileConflictResolution)?.let { - callback.uiScope.postToUi { callback.onCompleted(DocumentFile.fromFile(it)) } - return - } + sourceFile.moveTo(context, destinationFolder, cleanFileName, fileConflictResolution) + ?.let { + callback.postToUiScope { callback.onCompleted(FileCallback.Result.DocumentFile(DocumentFile.fromFile(it))) } + return + } } } try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !isRawFile && writableTargetFolder.isTreeDocumentFile && getStorageId(context) == targetStorageId) { - val movedFileUri = parentFile?.uri?.let { DocumentsContract.moveDocument(context.contentResolver, uri, it, writableTargetFolder.uri) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && !isRawFile && writableTargetFolder.isTreeDocumentFile && getStorageId( + context + ) == targetStorageId + ) { + val movedFileUri = parentFile?.uri?.let { + DocumentsContract.moveDocument( + context.contentResolver, + uri, + it, + writableTargetFolder.uri + ) + } if (movedFileUri != null) { val newFile = context.fromTreeUri(movedFileUri) if (newFile != null && newFile.isFile) { if (newFilenameInTargetPath != null) newFile.renameTo(cleanFileName) - callback.uiScope.postToUi { callback.onCompleted(newFile) } + callback.postToUiScope { + callback.onCompleted( + FileCallback.Result.DocumentFile( + newFile + ) + ) + } } else { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.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 (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace(context, targetStorageId), + length() + ) + ) { + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } } catch (e: Throwable) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return } @@ -2545,14 +3030,26 @@ private fun DocumentFile.moveFileTo( try { val targetFile = createTargetFile( - context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), callback + context, + writableTargetFolder, + cleanFileName, + newMimeTypeInTargetPath ?: mimeTypeByFileName, + fileConflictResolution.toCreateMode(), + callback ) ?: return createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, true, callback) + copyFileStream( + inputStream, + outputStream, + targetFile, + watchProgress, + reportInterval, + true, + callback + ) } } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } } @@ -2561,11 +3058,11 @@ private fun DocumentFile.moveFileTo( */ private fun DocumentFile.simpleCheckSourceFile(callback: FileCallback): Boolean { if (!isFile) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } return true } if (!canRead()) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return true } return false @@ -2581,20 +3078,26 @@ private fun DocumentFile.copyFileToMedia( ) { if (simpleCheckSourceFile(callback)) return - val publicFolder = DocumentFileCompat.fromPublicFolder(context, publicDirectory, fileDescription.subFolder, true) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } } else { - fileDescription.name = publicFolder.autoIncrementFileName(context, it.name.orEmpty()) + fileDescription.name = + publicFolder.autoIncrementFileName(context, it.name.orEmpty()) } } fileDescription.subFolder = "" @@ -2611,7 +3114,7 @@ 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { copyFileTo(context, mediaFile, deleteSourceFileWhenComplete, callback) } @@ -2620,25 +3123,45 @@ private fun DocumentFile.copyFileToMedia( @WorkerThread @JvmOverloads -fun DocumentFile.copyFileToDownloadMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { +fun DocumentFile.copyFileToDownloadMedia( + context: Context, + fileDescription: FileDescription, + callback: FileCallback, + mode: CreateMode = CreateMode.CREATE_NEW +) { copyFileToMedia(context, fileDescription, callback, PublicDirectory.DOWNLOADS, false, mode) } @WorkerThread @JvmOverloads -fun DocumentFile.copyFileToPictureMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { +fun DocumentFile.copyFileToPictureMedia( + context: Context, + fileDescription: FileDescription, + callback: FileCallback, + mode: CreateMode = CreateMode.CREATE_NEW +) { copyFileToMedia(context, fileDescription, callback, PublicDirectory.PICTURES, false, mode) } @WorkerThread @JvmOverloads -fun DocumentFile.moveFileToDownloadMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { +fun DocumentFile.moveFileToDownloadMedia( + context: Context, + fileDescription: FileDescription, + callback: FileCallback, + mode: CreateMode = CreateMode.CREATE_NEW +) { copyFileToMedia(context, fileDescription, callback, PublicDirectory.DOWNLOADS, true, mode) } @WorkerThread @JvmOverloads -fun DocumentFile.moveFileToPictureMedia(context: Context, fileDescription: FileDescription, callback: FileCallback, mode: CreateMode = CreateMode.CREATE_NEW) { +fun DocumentFile.moveFileToPictureMedia( + context: Context, + fileDescription: FileDescription, + callback: FileCallback, + mode: CreateMode = CreateMode.CREATE_NEW +) { copyFileToMedia(context, fileDescription, callback, PublicDirectory.PICTURES, true, mode) } @@ -2667,7 +3190,7 @@ private fun DocumentFile.copyFileTo( if (simpleCheckSourceFile(callback)) return if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, PRIMARY), length())) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } @@ -2678,10 +3201,18 @@ private fun DocumentFile.copyFileTo( try { createFileStreams(context, this, targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, deleteSourceFileWhenComplete, callback) + copyFileStream( + inputStream, + outputStream, + targetFile, + watchProgress, + reportInterval, + deleteSourceFileWhenComplete, + callback + ) } } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } } @@ -2704,9 +3235,9 @@ private fun handleFileConflict( callback.onConflict(targetFile, FileCallback.FileConflictAction(it)) } if (resolution == FileCallback.ConflictResolution.REPLACE) { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + callback.postToUiScope { callback.onDeleteConflictedFiles() } if (!targetFile.forceDelete(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FileCallback.ConflictResolution.SKIP } } @@ -2727,24 +3258,30 @@ private fun handleParentFolderConflict( return FolderCallback.ConflictResolution.MERGE } - val resolution = awaitUiResultWithPending(callback.uiScope) { - callback.onParentConflict(targetFolder, FolderCallback.ParentFolderConflictAction(it), canMerge) - } + val resolution = + awaitUiResultWithPending(callback.uiScope) { + callback.onParentConflict( + targetFolder, + FolderCallback.ParentFolderConflictAction(it), + canMerge + ) + } when (resolution) { FolderCallback.ConflictResolution.REPLACE -> { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + callback.postToUiScope { callback.onDeleteConflictedFiles() } val isFolder = targetFolder.isDirectory if (targetFolder.forceDelete(context, true)) { if (!isFolder) { - val newFolder = targetFolder.parentFile?.createDirectory(targetFolderParentName) + val newFolder = + targetFolder.parentFile?.createDirectory(targetFolderParentName) if (newFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FolderCallback.ConflictResolution.SKIP } } } else { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FolderCallback.ConflictResolution.SKIP } } @@ -2752,13 +3289,14 @@ private fun handleParentFolderConflict( FolderCallback.ConflictResolution.MERGE -> { if (targetFolder.isFile) { if (targetFolder.delete()) { - val newFolder = targetFolder.parentFile?.createDirectory(targetFolderParentName) + val newFolder = + targetFolder.parentFile?.createDirectory(targetFolderParentName) if (newFolder == null) { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FolderCallback.ConflictResolution.SKIP } } else { - callback.uiScope.postToUi { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FolderCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FolderCallback.ConflictResolution.SKIP } } @@ -2783,31 +3321,39 @@ private fun List.handleParentFolderConflict( 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 + val solution = + if (canMerge && it.isEmpty(context)) FolderCallback.ConflictResolution.MERGE else FolderCallback.ConflictResolution.CREATE_NEW MultipleFileCallback.ParentConflict(sourceFile, it, canMerge, solution) } - val unresolvedConflicts = conflicts.filter { it.solution != FolderCallback.ConflictResolution.MERGE }.toMutableList() + val unresolvedConflicts = + conflicts.filter { it.solution != FolderCallback.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, + MultipleFileCallback.ParentFolderConflictAction(it) + ) + } if (resolution.any { it.solution == FolderCallback.ConflictResolution.REPLACE }) { - callback.uiScope.postToUi { callback.onDeleteConflictedFiles() } + callback.postToUiScope { callback.onDeleteConflictedFiles() } } 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) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return null } } FolderCallback.ConflictResolution.MERGE -> { if (conflict.target.isFile && !conflict.target.delete()) { - callback.uiScope.postToUi { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(MultipleFileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return null } } @@ -2817,7 +3363,8 @@ 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 == FolderCallback.ConflictResolution.MERGE }) } } return emptyList() } \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileType.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileType.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/file/DocumentFileType.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileType.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileExt.kt similarity index 99% rename from storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/FileExt.kt index 82ea05a..62422cd 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileExt.kt @@ -151,7 +151,6 @@ fun File.inKitkatSdCard() = * @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)) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileFullPath.kt similarity index 90% rename from storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/FileFullPath.kt index 9d58460..3f105dc 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileFullPath.kt @@ -55,6 +55,7 @@ class FileFullPath { simplePath = "$storageId:$basePath" absolutePath = "$rootPath/$basePath".trimEnd('/') } + fullPath.startsWith(context.dataDirectory.path) -> { storageId = StorageId.DATA val rootPath = context.dataDirectory.path @@ -62,15 +63,18 @@ 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() + basePath = + fullPath.substringAfter("/storage/$storageId", "").trimFileSeparator() simplePath = "$storageId:$basePath" absolutePath = "/storage/$storageId/$basePath".trimEnd('/') } else { @@ -109,11 +113,12 @@ class FileFullPath { constructor(context: Context, file: File) : this(context, file.path.orEmpty()) - private fun buildAbsolutePath(context: Context, storageId: String, basePath: String) = if (storageId.isEmpty()) "" else when (storageId) { - StorageId.PRIMARY -> "${SimpleStorage.externalStoragePath}/$basePath".trimEnd('/') - StorageId.DATA -> "${context.dataDirectory.path}/$basePath".trimEnd('/') - else -> "/storage/$storageId/$basePath".trimEnd('/') - } + private fun buildAbsolutePath(context: Context, storageId: String, basePath: String) = + if (storageId.isEmpty()) "" else when (storageId) { + StorageId.PRIMARY -> "${SimpleStorage.externalStoragePath}/$basePath".trimEnd('/') + StorageId.DATA -> "${context.dataDirectory.path}/$basePath".trimEnd('/') + else -> "/storage/$storageId/$basePath".trimEnd('/') + } private fun buildBaseAndAbsolutePaths(context: Context) { absolutePath = buildAbsolutePath(context, storageId, basePath) @@ -121,7 +126,10 @@ class FileFullPath { } val uri: Uri? - get() = if (storageId.isEmpty()) null else DocumentFileCompat.createDocumentUri(storageId, basePath) + get() = if (storageId.isEmpty()) null else DocumentFileCompat.createDocumentUri( + storageId, + basePath + ) fun toDocumentUri(context: Context): Uri? { return context.fromTreeUri(uri ?: return null)?.uri diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileProperties.kt similarity index 88% rename from storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/FileProperties.kt index 41363a4..4ed79f0 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileProperties.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileProperties.kt @@ -3,10 +3,11 @@ package com.anggrayudi.storage.file import android.content.Context import android.text.format.Formatter import androidx.annotation.UiThread +import com.anggrayudi.storage.callback.ScopeHoldingCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope -import java.util.* +import java.util.Date /** * Created on 03/06/21 @@ -29,8 +30,8 @@ data class FileProperties( abstract class CalculationCallback( val updateInterval: Long = 500, // 500ms @OptIn(DelicateCoroutinesApi::class) - var uiScope: CoroutineScope = GlobalScope - ) { + override val uiScope: CoroutineScope = GlobalScope + ) : ScopeHoldingCallback { @UiThread open fun onUpdate(properties: FileProperties) { diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileSize.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileSize.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/file/FileSize.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/FileSize.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/file/MimeType.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/MimeType.kt similarity index 78% rename from storage/src/main/java/com/anggrayudi/storage/file/MimeType.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/MimeType.kt index b587617..fe7ce0e 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/MimeType.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/MimeType.kt @@ -38,7 +38,11 @@ object MimeType { return cleanName } } - return getExtensionFromMimeType(mimeType).let { if (it.isEmpty() || cleanName.endsWith(".$it")) cleanName else "$cleanName.$it".trimEnd('.') } + return getExtensionFromMimeType(mimeType).let { + if (it.isEmpty() || cleanName.endsWith(".$it")) cleanName else "$cleanName.$it".trimEnd( + '.' + ) + } } /** @@ -48,12 +52,16 @@ object MimeType { */ @JvmStatic fun getExtensionFromMimeType(mimeType: String?): String { - return mimeType?.let { if (it == BINARY_FILE) "bin" else MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }.orEmpty() + return mimeType?.let { + if (it == BINARY_FILE) "bin" else MimeTypeMap.getSingleton() + .getExtensionFromMimeType(it) + }.orEmpty() } @JvmStatic fun getBaseFileName(filename: String?): String { - return if (hasExtension(filename)) filename.orEmpty().substringBeforeLast('.') else filename.orEmpty() + return if (hasExtension(filename)) filename.orEmpty() + .substringBeforeLast('.') else filename.orEmpty() } @JvmStatic @@ -74,7 +82,9 @@ object MimeType { */ @JvmStatic fun getExtensionFromMimeTypeOrFileName(mimeType: String?, filename: String): String { - return if (mimeType == null || mimeType == UNKNOWN) getExtensionFromFileName(filename) else getExtensionFromMimeType(mimeType) + return if (mimeType == null || mimeType == UNKNOWN) getExtensionFromFileName(filename) else getExtensionFromMimeType( + mimeType + ) } /** @@ -82,7 +92,11 @@ object MimeType { */ @JvmStatic fun getMimeTypeFromExtension(fileExtension: String): String { - return if (fileExtension.equals("bin", ignoreCase = true)) BINARY_FILE else MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension) + return if (fileExtension.equals( + "bin", + ignoreCase = true + ) + ) BINARY_FILE else MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension) ?: UNKNOWN } diff --git a/storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/PublicDirectory.kt similarity index 98% rename from storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt rename to storage/src/main/kotlin/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/kotlin/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/kotlin/com/anggrayudi/storage/file/StorageId.kt similarity index 95% rename from storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/StorageId.kt index 2fb429f..53477f6 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/StorageId.kt @@ -28,7 +28,7 @@ object StorageId { /** * For `/storage/emulated/0/Documents` - * It is only exists on API 29- + * Only exists on API 29- */ @RestrictTo(RestrictTo.Scope.LIBRARY) const val HOME = "home" diff --git a/storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt b/storage/src/main/kotlin/com/anggrayudi/storage/file/StorageType.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/StorageType.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/media/FileDescription.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/FileDescription.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/media/FileDescription.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/media/FileDescription.kt diff --git a/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaDirectory.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaDirectory.kt new file mode 100644 index 0000000..7def07b --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaDirectory.kt @@ -0,0 +1,37 @@ +package com.anggrayudi.storage.media + +import android.os.Environment + +sealed interface MediaDirectory { + val folderName: String + + /** + * Created on 06/09/20 + * @author Anggrayudi H + */ + enum class Image(override val folderName: String) : MediaDirectory { + PICTURES(Environment.DIRECTORY_PICTURES), + DCIM(Environment.DIRECTORY_DCIM) + } + + /** + * Created on 06/09/20 + * @author Anggrayudi H + */ + enum class Video(override val folderName: String) : MediaDirectory { + MOVIES(Environment.DIRECTORY_MOVIES), + DCIM(Environment.DIRECTORY_DCIM) + } + + /** + * Created on 06/09/20 + * @author Anggrayudi H + */ + enum class Audio(override val folderName: String) : MediaDirectory { + MUSIC(Environment.DIRECTORY_MUSIC), + PODCASTS(Environment.DIRECTORY_PODCASTS), + RINGTONES(Environment.DIRECTORY_RINGTONES), + ALARMS(Environment.DIRECTORY_ALARMS), + NOTIFICATIONS(Environment.DIRECTORY_NOTIFICATIONS) + } +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFile.kt similarity index 63% rename from storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFile.kt index 0f75c23..3621b02 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFile.kt @@ -18,11 +18,43 @@ 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.extension.awaitUiResult +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.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.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 kotlinx.coroutines.Job -import java.io.* +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 @@ -35,22 +67,6 @@ class MediaFile(context: Context, val uri: Uri) { private val context = context.applicationContext - interface AccessCallback { - - /** - * When this function called, you can ask user's consent to modify other app's files. - * @see RecoverableSecurityException - * @see [android.app.Activity.startIntentSenderForResult] - */ - fun onWriteAccessDenied(mediaFile: MediaFile, sender: IntentSender) - } - - /** - * Only useful for Android 10 and higher. - * @see RecoverableSecurityException - */ - var accessCallback: AccessCallback? = null - /** * Some media files do not return file extension. This function helps you to fix this kind of issue. */ @@ -79,7 +95,13 @@ class MediaFile(context: Context, val uri: Uri) { * @see [mimeType] */ val type: String? - get() = toRawFile()?.name?.let { MimeType.getMimeTypeFromExtension(MimeType.getExtensionFromFileName(it)) } + get() = toRawFile()?.name?.let { + MimeType.getMimeTypeFromExtension( + MimeType.getExtensionFromFileName( + it + ) + ) + } ?: getColumnInfoString(MediaStore.MediaColumns.MIME_TYPE) /** @@ -93,7 +115,8 @@ class MediaFile(context: Context, val uri: Uri) { get() = toRawFile()?.length() ?: getColumnInfoLong(MediaStore.MediaColumns.SIZE) set(value) { try { - val contentValues = ContentValues(1).apply { put(MediaStore.MediaColumns.SIZE, value) } + val contentValues = + ContentValues(1).apply { put(MediaStore.MediaColumns.SIZE, value) } context.contentResolver.update(uri, contentValues, null, null) } catch (e: SecurityException) { handleSecurityException(e) @@ -145,10 +168,15 @@ class MediaFile(context: Context, val uri: Uri) { * from [Intent.ACTION_OPEN_DOCUMENT] or [Intent.ACTION_CREATE_DOCUMENT]. * @see toDocumentFile */ - @Deprecated("Accessing files with java.io.File only works on app private directory since Android 10.") + @Deprecated("Accessing files with java.io.File only works on app-private directories since Android 10.") fun toRawFile() = if (isRawFile) uri.path?.let { File(it) } else null - fun toDocumentFile() = absolutePath.let { if (it.isEmpty()) null else DocumentFileCompat.fromFullPath(context, it) } + fun toDocumentFile() = absolutePath.let { + if (it.isEmpty()) null else DocumentFileCompat.fromFullPath( + context, + it + ) + } val absolutePath: String @SuppressLint("InlinedApi") @@ -158,7 +186,13 @@ class MediaFile(context: Context, val uri: Uri) { file != null -> file.path Build.VERSION.SDK_INT < Build.VERSION_CODES.Q -> { try { - context.contentResolver.query(uri, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)?.use { cursor -> + context.contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.DATA), + null, + null, + null + )?.use { cursor -> if (cursor.moveToFirst()) { cursor.getString(MediaStore.MediaColumns.DATA) } else "" @@ -167,15 +201,24 @@ 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 -> - if (cursor.moveToFirst()) { - val relativePath = cursor.getString(MediaStore.MediaColumns.RELATIVE_PATH) ?: return "" - val name = cursor.getString(MediaStore.MediaColumns.DISPLAY_NAME) - "${SimpleStorage.externalStoragePath}/$relativePath/$name".trimEnd('/').replaceCompletely("//", "/") - } else "" - }.orEmpty() + val projection = arrayOf( + MediaStore.MediaColumns.RELATIVE_PATH, + MediaStore.MediaColumns.DISPLAY_NAME + ) + context.contentResolver.query(uri, projection, null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val relativePath = + cursor.getString(MediaStore.MediaColumns.RELATIVE_PATH) + ?: return "" + val name = cursor.getString(MediaStore.MediaColumns.DISPLAY_NAME) + "${SimpleStorage.externalStoragePath}/$relativePath/$name".trimEnd( + '/' + ).replaceCompletely("//", "/") + } else "" + }.orEmpty() } } } @@ -192,28 +235,43 @@ class MediaFile(context: Context, val uri: Uri) { val file = toRawFile() return when { file != null -> { - file.path.substringBeforeLast('/').replaceFirst(SimpleStorage.externalStoragePath, "").trimFileSeparator() + "/" + 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 -> + context.contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.DATA), + null, + null, + null + )?.use { cursor -> if (cursor.moveToFirst()) { val realFolderAbsolutePath = - cursor.getString(MediaStore.MediaColumns.DATA).orEmpty().substringBeforeLast('/') - realFolderAbsolutePath.replaceFirst(SimpleStorage.externalStoragePath, "").trimFileSeparator() + "/" + cursor.getString(MediaStore.MediaColumns.DATA).orEmpty() + .substringBeforeLast('/') + realFolderAbsolutePath.replaceFirst( + SimpleStorage.externalStoragePath, + "" + ).trimFileSeparator() + "/" } else "" }.orEmpty() } catch (e: Exception) { "" } } + else -> { val projection = arrayOf(MediaStore.MediaColumns.RELATIVE_PATH) - context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - cursor.getString(MediaStore.MediaColumns.RELATIVE_PATH) - } else "" - }.orEmpty() + context.contentResolver.query(uri, projection, null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(MediaStore.MediaColumns.RELATIVE_PATH) + } else "" + }.orEmpty() } } } @@ -236,7 +294,8 @@ class MediaFile(context: Context, val uri: Uri) { */ fun renameTo(newName: String): Boolean { val file = toRawFile() - val contentValues = ContentValues(1).apply { put(MediaStore.MediaColumns.DISPLAY_NAME, newName) } + val contentValues = + ContentValues(1).apply { put(MediaStore.MediaColumns.DISPLAY_NAME, newName) } return if (file != null) { context.contentResolver.update(uri, contentValues, null, null) file.renameTo(File(file.parent, newName)) @@ -254,7 +313,8 @@ class MediaFile(context: Context, val uri: Uri) { get() = getColumnInfoInt(MediaStore.MediaColumns.IS_PENDING) == 1 @RequiresApi(Build.VERSION_CODES.Q) set(value) { - val contentValues = ContentValues(1).apply { put(MediaStore.MediaColumns.IS_PENDING, value.toInt()) } + val contentValues = + ContentValues(1).apply { put(MediaStore.MediaColumns.IS_PENDING, value.toInt()) } try { context.contentResolver.update(uri, contentValues, null, null) } catch (e: SecurityException) { @@ -262,17 +322,32 @@ class MediaFile(context: Context, val uri: Uri) { } } - private fun handleSecurityException(e: SecurityException, callback: FileCallback? = null) { + /** + * @param onWriteAccessDenied To ask for the user's consent to modify other app's files. Only called starting from Android 10. + * @see RecoverableSecurityException + * @see [android.app.Activity.startIntentSenderForResult] + */ + private fun handleSecurityException( + e: SecurityException, + callback: FileCallback? = null, + onWriteAccessDenied: ((MediaFile, IntentSender) -> Unit)? = null + ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && e is RecoverableSecurityException) { - accessCallback?.onWriteAccessDenied(this, e.userAction.actionIntent.intentSender) + onWriteAccessDenied?.invoke(this, e.userAction.actionIntent.intentSender) } else { - callback?.uiScope?.postToUi { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback?.postToUiScope { callback.onFailed(FileCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } } } @UiThread fun openFileIntent(authority: String) = Intent(Intent.ACTION_VIEW) - .setData(if (isRawFile) FileProvider.getUriForFile(context, authority, File(uri.path!!)) else uri) + .setData( + if (isRawFile) FileProvider.getUriForFile( + context, + authority, + File(uri.path!!) + ) else uri + ) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -310,7 +385,8 @@ class MediaFile(context: Context, val uri: Uri) { @TargetApi(Build.VERSION_CODES.Q) fun moveTo(relativePath: String): Boolean { - val contentValues = ContentValues(1).apply { put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) } + val contentValues = + ContentValues(1).apply { put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) } return try { context.contentResolver.update(uri, contentValues, null, null) > 0 } catch (e: SecurityException) { @@ -320,31 +396,47 @@ class MediaFile(context: Context, val uri: Uri) { } @WorkerThread - fun moveTo(targetFolder: DocumentFile, fileDescription: FileDescription? = null, callback: FileCallback) { - val sourceFile = toDocumentFile() - if (sourceFile != null) { - sourceFile.moveFileTo(context, targetFolder, fileDescription, callback) + fun moveTo( + targetFolder: DocumentFile, + fileDescription: FileDescription? = null, + callback: FileCallback + ) { + toDocumentFile()?.let { + it.moveFileTo(context, targetFolder, fileDescription, callback) return } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), length + ) + ) { + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } val targetDirectory = if (fileDescription?.subFolder.isNullOrEmpty()) { targetFolder } else { - val directory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } else { directory } } - val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) + val cleanFileName = MimeType.getFullFileName( + fileDescription?.name ?: name.orEmpty(), + fileDescription?.mimeType ?: type + ) .removeForbiddenCharsFromFilename().trimFileSeparator() val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, callback) if (conflictResolution == FileCallback.ConflictResolution.SKIP) { @@ -361,41 +453,66 @@ class MediaFile(context: Context, val uri: Uri) { conflictResolution.toCreateMode(), callback ) ?: return createFileStreams(targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, true, callback) + copyFileStream( + inputStream, + outputStream, + targetFile, + watchProgress, + reportInterval, + true, + callback + ) } } catch (e: SecurityException) { handleSecurityException(e, callback) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } } @WorkerThread - fun copyTo(targetFolder: DocumentFile, fileDescription: FileDescription? = null, callback: FileCallback) { + fun copyTo( + targetFolder: DocumentFile, + fileDescription: FileDescription? = null, + callback: FileCallback + ) { val sourceFile = toDocumentFile() if (sourceFile != null) { sourceFile.copyFileTo(context, targetFolder, fileDescription, callback) return } - if (!callback.onCheckFreeSpace(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + if (!callback.onCheckFreeSpace( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), length + ) + ) { + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } return } val targetDirectory = if (fileDescription?.subFolder.isNullOrEmpty()) { targetFolder } else { - val directory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } else { directory } } - val cleanFileName = MimeType.getFullFileName(fileDescription?.name ?: name.orEmpty(), fileDescription?.mimeType ?: type) + val cleanFileName = MimeType.getFullFileName( + fileDescription?.name ?: name.orEmpty(), + fileDescription?.mimeType ?: type + ) .removeForbiddenCharsFromFilename().trimFileSeparator() val conflictResolution = handleFileConflict(targetDirectory, cleanFileName, callback) if (conflictResolution == FileCallback.ConflictResolution.SKIP) { @@ -411,12 +528,20 @@ class MediaFile(context: Context, val uri: Uri) { conflictResolution.toCreateMode(), callback ) ?: return createFileStreams(targetFile, callback) { inputStream, outputStream -> - copyFileStream(inputStream, outputStream, targetFile, watchProgress, reportInterval, false, callback) + copyFileStream( + inputStream, + outputStream, + targetFile, + watchProgress, + reportInterval, + false, + callback + ) } } catch (e: SecurityException) { handleSecurityException(e, callback) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } } @@ -428,23 +553,27 @@ class MediaFile(context: Context, val uri: Uri) { callback: FileCallback ): DocumentFile? { try { - val absolutePath = DocumentFileCompat.buildAbsolutePath(context, targetDirectory.getStorageId(context), targetDirectory.getBasePath(context)) + 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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.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) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } } else { return targetFile } } catch (e: SecurityException) { handleSecurityException(e, callback) } catch (e: Exception) { - callback.uiScope.postToUi { callback.onFailed(e.toFileCallbackErrorCode()) } + callback.postToUiScope { callback.onFailed(e.toFileCallbackErrorCode()) } } return null } @@ -456,13 +585,13 @@ class MediaFile(context: Context, val uri: Uri) { ) { val outputStream = targetFile.openOutputStream(context) if (outputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.TARGET_FILE_NOT_FOUND) } return } val inputStream = openInputStream() if (inputStream == null) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.SOURCE_FILE_NOT_FOUND) } outputStream.closeStreamQuietly() return } @@ -487,8 +616,9 @@ class MediaFile(context: Context, val uri: Uri) { // 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) } + val report = + FileCallback.Report(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -504,7 +634,13 @@ class MediaFile(context: Context, val uri: Uri) { if (deleteSourceFileWhenComplete) { delete() } - callback.uiScope.postToUi { callback.onCompleted(targetFile) } + callback.postToUiScope { + callback.onCompleted( + FileCallback.Result.DocumentFile( + targetFile + ) + ) + } } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -523,7 +659,7 @@ class MediaFile(context: Context, val uri: Uri) { } if (resolution == FileCallback.ConflictResolution.REPLACE) { if (!targetFile.forceDelete(context)) { - callback.uiScope.postToUi { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } + callback.postToUiScope { callback.onFailed(FileCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return FileCallback.ConflictResolution.SKIP } } diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFileExt.kt similarity index 58% rename from storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFileExt.kt index 5ab23a3..e5021ac 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFileExt.kt @@ -7,8 +7,19 @@ 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.awaitUiResult +import com.anggrayudi.storage.extension.closeEntryQuietly +import com.anggrayudi.storage.extension.closeStreamQuietly +import com.anggrayudi.storage.extension.postToUi +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 kotlinx.coroutines.Job import java.io.FileNotFoundException import java.io.IOException @@ -21,7 +32,6 @@ import java.util.zip.ZipOutputStream * Created on 21/01/22 * @author Anggrayudi H */ - @WorkerThread fun List.compressToZip( context: Context, @@ -29,23 +39,35 @@ fun List.compressToZip( deleteSourceWhenComplete: Boolean = false, callback: ZipCompressionCallback ) { - callback.uiScope.postToUi { callback.onCountingFiles() } + callback.uiScope.postToUi { } + callback.postToUiScope { callback.onCountingFiles() } 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") } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, + "No entry files found" + ) + } return } var zipFile: DocumentFile? = targetZipFile if (!targetZipFile.exists() || targetZipFile.isDirectory) { - zipFile = targetZipFile.findParent(context)?.makeFile(context, targetZipFile.fullName, MimeType.ZIP) + 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) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } return } if (!zipFile.isWritable(context)) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable") } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, + "Destination ZIP file is not writable" + ) + } return } @@ -63,8 +85,13 @@ fun List.compressToZip( var fileCompressedCount = 0 if (reportInterval > 0) { timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipCompressionCallback.Report(0f, bytesCompressed, writeSpeed, fileCompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + val report = ZipCompressionCallback.Report( + 0f, + bytesCompressed, + writeSpeed, + fileCompressedCount + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -84,17 +111,27 @@ fun List.compressToZip( } success = true } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.CANCELED) } } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.MISSING_ENTRY_FILE, e.message) } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.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) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } } else { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + callback.postToUiScope { callback.onFailed(ZipCompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, e.message) } + callback.postToUiScope { + callback.onFailed( + ZipCompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + } } finally { timer?.cancel() zos.closeEntryQuietly() @@ -102,11 +139,18 @@ fun List.compressToZip( } if (success) { if (deleteSourceWhenComplete) { - callback.uiScope.postToUi { callback.onDeleteEntryFiles() } + callback.postToUiScope { callback.onDeleteEntryFiles() } forEach { it.delete() } } val sizeReduction = (bytesCompressed - zipFile.length()).toFloat() / bytesCompressed * 100 - callback.uiScope.postToUi { callback.onCompleted(zipFile, bytesCompressed, entryFiles.size, sizeReduction) } + callback.postToUiScope { + callback.onCompleted( + zipFile, + bytesCompressed, + entryFiles.size, + sizeReduction + ) + } } else { zipFile.delete() } @@ -118,13 +162,13 @@ fun MediaFile.decompressZip( targetFolder: DocumentFile, callback: ZipDecompressionCallback ) { - callback.uiScope.postToUi { callback.onValidate() } + callback.postToUiScope { callback.onValidate() } if (isEmpty) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } return } if (mimeType != MimeType.ZIP) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NOT_A_ZIP_FILE) } return } @@ -133,7 +177,7 @@ 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) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } return } @@ -153,8 +197,12 @@ fun MediaFile.decompressZip( var writeSpeed = 0 if (reportInterval > 0) { timer = startCoroutineTimer(repeatMillis = reportInterval) { - val report = ZipDecompressionCallback.Report(bytesDecompressed, writeSpeed, fileDecompressedCount) - callback.uiScope.postToUi { callback.onReport(report) } + val report = ZipDecompressionCallback.Report( + bytesDecompressed, + writeSpeed, + fileDecompressedCount + ) + callback.postToUiScope { callback.onReport(report) } writeSpeed = 0 } } @@ -166,12 +214,16 @@ fun MediaFile.decompressZip( destFolder.makeFolder(context, entry.name, CreateMode.REUSE) } else { val folder = entry.name.substringBeforeLast('/', "").let { - if (it.isEmpty()) destFolder else destFolder.makeFolder(context, it, CreateMode.REUSE) + if (it.isEmpty()) destFolder else destFolder.makeFolder( + context, + it, + CreateMode.REUSE + ) } ?: throw IOException() 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) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANNOT_CREATE_FILE_IN_TARGET) } canSuccess = false break } @@ -196,25 +248,30 @@ fun MediaFile.decompressZip( } success = canSuccess } catch (e: InterruptedIOException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.CANCELED) } } catch (e: FileNotFoundException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.MISSING_ZIP_FILE) } } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH) } } else { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.UNKNOWN_IO_ERROR) } } } catch (e: SecurityException) { - callback.uiScope.postToUi { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } + callback.postToUiScope { callback.onFailed(ZipDecompressionCallback.ErrorCode.STORAGE_PERMISSION_DENIED) } } 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) } + val info = ZipDecompressionCallback.DecompressionInfo( + bytesDecompressed, + skippedDecompressedBytes, + fileDecompressedCount, + 0f + ) + callback.postToUiScope { callback.onCompleted(this, destFolder, info) } } else { targetFile?.delete() } diff --git a/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaStoreCompat.kt similarity index 68% rename from storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/media/MediaStoreCompat.kt index 03b9944..055c955 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt +++ b/storage/src/main/kotlin/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 /** @@ -30,8 +40,18 @@ object MediaStoreCompat { @JvmStatic @JvmOverloads - fun createDownload(context: Context, file: FileDescription, mode: CreateMode = CreateMode.CREATE_NEW): MediaFile? { - return createMedia(context, MediaType.DOWNLOADS, Environment.DIRECTORY_DOWNLOADS, file, mode) + fun createDownload( + context: Context, + file: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW + ): MediaFile? { + return createMedia( + context, + MediaType.DOWNLOADS, + Environment.DIRECTORY_DOWNLOADS, + file, + mode + ) } @JvmOverloads @@ -39,7 +59,7 @@ object MediaStoreCompat { fun createImage( context: Context, file: FileDescription, - relativeParentDirectory: ImageMediaDirectory = ImageMediaDirectory.PICTURES, + relativeParentDirectory: MediaDirectory.Image = MediaDirectory.Image.PICTURES, mode: CreateMode = CreateMode.CREATE_NEW ): MediaFile? { return createMedia(context, MediaType.IMAGE, relativeParentDirectory.folderName, file, mode) @@ -50,7 +70,7 @@ object MediaStoreCompat { fun createAudio( context: Context, file: FileDescription, - relativeParentDirectory: AudioMediaDirectory = AudioMediaDirectory.MUSIC, + relativeParentDirectory: MediaDirectory.Audio = MediaDirectory.Audio.MUSIC, mode: CreateMode = CreateMode.CREATE_NEW ): MediaFile? { return createMedia(context, MediaType.AUDIO, relativeParentDirectory.folderName, file, mode) @@ -61,7 +81,7 @@ object MediaStoreCompat { fun createVideo( context: Context, file: FileDescription, - relativeParentDirectory: VideoMediaDirectory = VideoMediaDirectory.MOVIES, + relativeParentDirectory: MediaDirectory.Video = MediaDirectory.Video.MOVIES, mode: CreateMode = CreateMode.CREATE_NEW ): MediaFile? { return createMedia(context, MediaType.VIDEO, relativeParentDirectory.folderName, file, mode) @@ -69,7 +89,12 @@ object MediaStoreCompat { @JvmStatic @JvmOverloads - fun createMedia(context: Context, fullPath: String, file: FileDescription, mode: CreateMode = CreateMode.CREATE_NEW): MediaFile? { + fun createMedia( + context: Context, + fullPath: String, + file: FileDescription, + mode: CreateMode = CreateMode.CREATE_NEW + ): MediaFile? { val basePath = DocumentFileCompat.getBasePath(context, fullPath).trimFileSeparator() if (basePath.isEmpty()) { return null @@ -77,9 +102,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 MediaDirectory.Image.values().map { it.folderName } -> MediaType.IMAGE + in MediaDirectory.Audio.values().map { it.folderName } -> MediaType.AUDIO + in MediaDirectory.Video.values().map { it.folderName } -> MediaType.VIDEO else -> return null } val subFolder = basePath.substringAfter('/', "") @@ -87,14 +112,23 @@ object MediaStoreCompat { return createMedia(context, mediaType, mediaFolder, file, mode) } - private fun createMedia(context: Context, mediaType: MediaType, folderName: String, file: FileDescription, mode: CreateMode): MediaFile? { + private fun createMedia( + context: Context, + mediaType: MediaType, + folderName: String, + file: FileDescription, + mode: CreateMode + ): MediaFile? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val fullName = file.fullName val mimeType = file.mimeType val baseName = MimeType.getBaseFileName(fullName) val ext = MimeType.getExtensionFromFileName(fullName) val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, if (mimeType == MimeType.BINARY_FILE) fullName else baseName) + put( + MediaStore.MediaColumns.DISPLAY_NAME, + if (mimeType == MimeType.BINARY_FILE) fullName else baseName + ) put(MediaStore.MediaColumns.MIME_TYPE, mimeType) val dateCreated = System.currentTimeMillis() put(MediaStore.MediaColumns.DATE_ADDED, dateCreated) @@ -123,14 +157,22 @@ object MediaStoreCompat { // Android R+ already has this check, thus no need to check empty media files for reuse val prefix = "$baseName (" fromFileNameContains(context, mediaType, baseName).asSequence() - .filter { relativePath.isBlank() || relativePath == it.relativePath.removeSuffix("/") } + .filter { + relativePath.isBlank() || relativePath == it.relativePath.removeSuffix( + "/" + ) + } .filter { val name = it.name if (name.isNullOrEmpty() || MimeType.getExtensionFromFileName(name) != ext) false else { - name.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches(name) - || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches(name)) + name.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches( + name + ) + || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches( + name + )) } } // Use existing empty media file @@ -140,10 +182,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 @@ -165,9 +207,16 @@ object MediaStoreCompat { } } - private fun tryInsertMediaFile(context: Context, mediaType: MediaType, contentValues: ContentValues): MediaFile? { + private fun tryInsertMediaFile( + context: Context, + mediaType: MediaType, + contentValues: ContentValues + ): MediaFile? { return try { - MediaFile(context, context.contentResolver.insert(mediaType.writeUri!!, contentValues) ?: return null) + MediaFile( + context, + context.contentResolver.insert(mediaType.writeUri!!, contentValues) ?: return null + ) } catch (e: Exception) { e.printStackTrace() null @@ -203,13 +252,18 @@ 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 } } else { val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ?" - context.contentResolver.query(mediaType.readUri ?: return null, arrayOf(BaseColumns._ID), selection, arrayOf(name), null)?.use { + context.contentResolver.query( + mediaType.readUri ?: return null, + arrayOf(BaseColumns._ID), + selection, + arrayOf(name), + null + )?.use { fromCursorToMediaFile(context, mediaType, it) } } @@ -223,16 +277,25 @@ 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 } + File( + Environment.getExternalStorageDirectory(), + cleanBasePath + ).let { if (it.isFile && it.canRead()) MediaFile(context, it) else null } } else { val relativePath = cleanBasePath.substringBeforeLast('/', "") if (relativePath.isEmpty()) { return null } val filename = cleanBasePath.substringAfterLast('/') - val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?" - context.contentResolver.query(mediaType.readUri ?: return null, arrayOf(BaseColumns._ID), selection, arrayOf(filename, "$relativePath/"), null) + val selection = + "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?" + context.contentResolver.query( + mediaType.readUri ?: return null, + arrayOf(BaseColumns._ID), + selection, + arrayOf(filename, "$relativePath/"), + null + ) ?.use { fromCursorToMediaFile(context, mediaType, it) } } } @@ -243,6 +306,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 } @@ -251,7 +315,8 @@ object MediaStoreCompat { * @see MediaStore.MediaColumns.RELATIVE_PATH */ @JvmStatic - fun fromRelativePath(context: Context, publicDirectory: PublicDirectory) = fromRelativePath(context, publicDirectory.folderName) + fun fromRelativePath(context: Context, publicDirectory: PublicDirectory) = + fromRelativePath(context, publicDirectory.folderName) /** * @see MediaStore.MediaColumns.RELATIVE_PATH @@ -260,8 +325,12 @@ 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)) + DocumentFile.fromFile( + File( + Environment.getExternalStorageDirectory(), + cleanRelativePath + ) + ) .search(true, DocumentFileType.FILE) .map { MediaFile(context, File(it.uri.path!!)) } } else { @@ -269,7 +338,13 @@ object MediaStoreCompat { val relativePathWithSlashSuffix = relativePath.trimEnd('/') + '/' val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" val selectionArgs = arrayOf(relativePathWithSlashSuffix, cleanRelativePath) - return context.contentResolver.query(mediaType.readUri ?: return emptyList(), arrayOf(BaseColumns._ID), selection, selectionArgs, null)?.use { + return context.contentResolver.query( + mediaType.readUri ?: return emptyList(), + arrayOf(BaseColumns._ID), + selection, + selectionArgs, + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } @@ -282,34 +357,58 @@ 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)) + DocumentFile.fromFile( + File( + Environment.getExternalStorageDirectory(), + cleanRelativePath + ) + ) .search(true, DocumentFileType.FILE, name = name) .map { MediaFile(context, File(it.uri.path!!)) } .firstOrNull() } else { val mediaType = mediaTypeFromRelativePath(cleanRelativePath) ?: return null val relativePathWithSlashSuffix = relativePath.trimEnd('/') + '/' - val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" + val selection = + "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" val selectionArgs = arrayOf(name, relativePathWithSlashSuffix, cleanRelativePath) - return context.contentResolver.query(mediaType.readUri ?: return null, arrayOf(BaseColumns._ID), selection, selectionArgs, null)?.use { + return context.contentResolver.query( + mediaType.readUri ?: return null, + arrayOf(BaseColumns._ID), + selection, + selectionArgs, + null + )?.use { fromCursorToMediaFile(context, mediaType, it) } } } @JvmStatic - fun fromFileNameContains(context: Context, mediaType: MediaType, containsName: String): List { + 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)) + .search( + true, + regex = Regex("^.*$containsName.*\$"), + mimeTypes = arrayOf(mediaType.mimeType) + ) .map { MediaFile(context, File(it.uri.path!!)) } }.flatten() } else { val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '%$containsName%'" - return context.contentResolver.query(mediaType.readUri ?: return emptyList(), arrayOf(BaseColumns._ID), selection, null, null)?.use { + return context.contentResolver.query( + mediaType.readUri ?: return emptyList(), + arrayOf(BaseColumns._ID), + selection, + null, + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } @@ -319,14 +418,19 @@ 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!!)) } }.flatten() } else { val selection = "${MediaStore.MediaColumns.MIME_TYPE} = ?" - return context.contentResolver.query(mediaType.readUri ?: return emptyList(), arrayOf(BaseColumns._ID), selection, arrayOf(mimeType), null)?.use { + return context.contentResolver.query( + mediaType.readUri ?: return emptyList(), + arrayOf(BaseColumns._ID), + selection, + arrayOf(mimeType), + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } @@ -336,19 +440,28 @@ 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!!)) } }.flatten() } else { - return context.contentResolver.query(mediaType.readUri ?: return emptyList(), arrayOf(BaseColumns._ID), null, null, null)?.use { + return context.contentResolver.query( + mediaType.readUri ?: return emptyList(), + arrayOf(BaseColumns._ID), + null, + null, + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } } - private fun fromCursorToMediaFiles(context: Context, mediaType: MediaType, cursor: Cursor): List { + private fun fromCursorToMediaFiles( + context: Context, + mediaType: MediaType, + cursor: Cursor + ): List { if (cursor.moveToFirst()) { val mediaFiles = ArrayList(cursor.count) do { @@ -361,7 +474,11 @@ object MediaStoreCompat { return emptyList() } - private fun fromCursorToMediaFile(context: Context, mediaType: MediaType, cursor: Cursor): MediaFile? { + private fun fromCursorToMediaFile( + context: Context, + mediaType: MediaType, + cursor: Cursor + ): MediaFile? { return if (cursor.moveToFirst()) { cursor.getString(BaseColumns._ID)?.let { fromMediaId(context, mediaType, it) } } else null diff --git a/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaType.kt b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaType.kt new file mode 100644 index 0000000..f0578c9 --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaType.kt @@ -0,0 +1,55 @@ +package com.anggrayudi.storage.media + +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.anggrayudi.storage.file.MimeType +import com.anggrayudi.storage.file.PublicDirectory +import java.io.File + +/** + * Created on 06/09/20 + * @author Anggrayudi H + */ +enum class MediaType(val readUri: Uri?, val writeUri: Uri?, val mimeType: String) { + IMAGE( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + MediaStore.Images.Media.getContentUri(MediaStoreCompat.volumeName), + MimeType.IMAGE + ), + AUDIO( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + MediaStore.Audio.Media.getContentUri(MediaStoreCompat.volumeName), + MimeType.AUDIO + ), + VIDEO( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + MediaStore.Video.Media.getContentUri(MediaStoreCompat.volumeName), + MimeType.VIDEO + ), + DOWNLOADS( + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) null else MediaStore.Downloads.EXTERNAL_CONTENT_URI, + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) null else MediaStore.Downloads.getContentUri( + MediaStoreCompat.volumeName + ), + MimeType.UNKNOWN + ); + + /** + * Directories associated with this media type. + */ + val directories: List + get() = when (this) { + IMAGE -> MediaDirectory.Image.values() + .map { Environment.getExternalStoragePublicDirectory(it.folderName) } + + AUDIO -> MediaDirectory.Audio.values() + .map { Environment.getExternalStoragePublicDirectory(it.folderName) } + + VIDEO -> MediaDirectory.Video.values() + .map { Environment.getExternalStoragePublicDirectory(it.folderName) } + + DOWNLOADS -> listOf(PublicDirectory.DOWNLOADS.file) + } +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt similarity index 73% rename from storage/src/main/java/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt index c96833f..3f06faf 100644 --- a/storage/src/main/java/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/permission/ActivityPermissionRequest.kt @@ -18,12 +18,19 @@ class ActivityPermissionRequest private constructor( private val callback: PermissionCallback ) : PermissionRequest { - private val launcher = if (activity is ComponentActivity) activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + private val launcher = if (activity is ComponentActivity) activity.registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { onRequestPermissionsResult(it) } else null override fun check() { - if (permissions.all { ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED }) { + if (permissions.all { + ContextCompat.checkSelfPermission( + activity, + it + ) == PackageManager.PERMISSION_GRANTED + }) { callback.onPermissionsChecked( PermissionResult(permissions.map { PermissionReport(it, isGranted = true, deniedPermanently = false) @@ -49,14 +56,25 @@ class ActivityPermissionRequest private constructor( val reports = permissions.mapIndexed { index, permission -> val isGranted = grantResults[index] == PackageManager.PERMISSION_GRANTED - PermissionReport(permission, isGranted, !isGranted && !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) + PermissionReport( + permission, + isGranted, + !isGranted && !ActivityCompat.shouldShowRequestPermissionRationale( + activity, + permission + ) + ) } reportResult(reports) } private fun onRequestPermissionsResult(result: Map) { val reports = result.map { - PermissionReport(it.key, it.value, !it.value && !ActivityCompat.shouldShowRequestPermissionRationale(activity, it.key)) + PermissionReport( + it.key, + it.value, + !it.value && !ActivityCompat.shouldShowRequestPermissionRationale(activity, it.key) + ) } reportResult(reports) } @@ -80,11 +98,20 @@ class ActivityPermissionRequest private constructor( */ override fun continueToPermissionRequest() { permissions.forEach { - if (ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED) { + if (ContextCompat.checkSelfPermission( + activity, + it + ) != PackageManager.PERMISSION_GRANTED + ) { if (launcher != null) { launcher.launch(permissions) } else { - ActivityCompat.requestPermissions(activity, permissions, requestCode ?: throw IllegalStateException("Request code hasn't been set yet")) + ActivityCompat.requestPermissions( + activity, + permissions, + requestCode + ?: throw IllegalStateException("Request code hasn't been set yet") + ) } return } @@ -123,7 +150,8 @@ class ActivityPermissionRequest private constructor( this.callback = callback } - fun build() = ActivityPermissionRequest(activity, permissions.toTypedArray(), requestCode, callback!!) + fun build() = + ActivityPermissionRequest(activity, permissions.toTypedArray(), requestCode, callback!!) fun check() = build().check() } diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt similarity index 76% rename from storage/src/main/java/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt index b5f70d8..3039f1f 100644 --- a/storage/src/main/java/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/permission/FragmentPermissionRequest.kt @@ -18,13 +18,19 @@ class FragmentPermissionRequest private constructor( private val callback: PermissionCallback ) : PermissionRequest { - private val launcher = fragment.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - onRequestPermissionsResult(it) - } + private val launcher = + fragment.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + onRequestPermissionsResult(it) + } override fun check() { val context = fragment.requireContext() - if (permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }) { + if (permissions.all { + ContextCompat.checkSelfPermission( + context, + it + ) == PackageManager.PERMISSION_GRANTED + }) { callback.onPermissionsChecked( PermissionResult(permissions.map { PermissionReport(it, isGranted = true, deniedPermanently = false) @@ -42,7 +48,11 @@ class FragmentPermissionRequest private constructor( } val activity = fragment.requireActivity() val reports = result.map { - PermissionReport(it.key, it.value, !it.value && !ActivityCompat.shouldShowRequestPermissionRationale(activity, it.key)) + PermissionReport( + it.key, + it.value, + !it.value && !ActivityCompat.shouldShowRequestPermissionRationale(activity, it.key) + ) } val blockedPermissions = reports.filter { it.deniedPermanently } if (blockedPermissions.isEmpty()) { @@ -59,7 +69,11 @@ class FragmentPermissionRequest private constructor( override fun continueToPermissionRequest() { val activity = fragment.requireActivity() permissions.forEach { - if (ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED) { + if (ContextCompat.checkSelfPermission( + activity, + it + ) != PackageManager.PERMISSION_GRANTED + ) { launcher.launch(permissions, options) return } @@ -91,7 +105,8 @@ class FragmentPermissionRequest private constructor( this.options = options } - fun build() = FragmentPermissionRequest(fragment, permissions.toTypedArray(), options, callback!!) + fun build() = + FragmentPermissionRequest(fragment, permissions.toTypedArray(), options, callback!!) fun check() = build().check() } diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/PermissionCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionCallback.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/permission/PermissionCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionCallback.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/PermissionReport.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionReport.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/permission/PermissionReport.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionReport.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/PermissionRequest.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionRequest.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/permission/PermissionRequest.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionRequest.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/permission/PermissionResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionResult.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/permission/PermissionResult.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionResult.kt diff --git a/storage/src/test/java/com/anggrayudi/storage/ExampleUnitTest.kt b/storage/src/test/java/com/anggrayudi/storage/ExampleUnitTest.kt deleted file mode 100644 index 14ce55c..0000000 --- a/storage/src/test/java/com/anggrayudi/storage/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.anggrayudi.storage - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/storage/src/test/java/com/anggrayudi/storage/DocumentFileCompatTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/DocumentFileCompatTest.kt similarity index 100% rename from storage/src/test/java/com/anggrayudi/storage/DocumentFileCompatTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/DocumentFileCompatTest.kt diff --git a/storage/src/test/java/com/anggrayudi/storage/SimpleStorageTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/SimpleStorageTest.kt similarity index 95% rename from storage/src/test/java/com/anggrayudi/storage/SimpleStorageTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/SimpleStorageTest.kt index cd950d3..cc40e6b 100644 --- a/storage/src/test/java/com/anggrayudi/storage/SimpleStorageTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/SimpleStorageTest.kt @@ -79,7 +79,12 @@ class SimpleStorageTest { SimpleStorage.cleanupRedundantUriPermissions(context) assertEquals(revokedUris, capturedUris) - verify(exactly = revokedUris.size) { resolver.releasePersistableUriPermission(any(), any()) } + verify(exactly = revokedUris.size) { + resolver.releasePersistableUriPermission( + any(), + any() + ) + } verify { resolver.persistedUriPermissions } confirmVerified(resolver) } diff --git a/storage/src/test/java/com/anggrayudi/storage/extension/TextExtKtTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/extension/TextExtKtTest.kt similarity index 80% rename from storage/src/test/java/com/anggrayudi/storage/extension/TextExtKtTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/extension/TextExtKtTest.kt index f5b4fbc..bff56ba 100644 --- a/storage/src/test/java/com/anggrayudi/storage/extension/TextExtKtTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/extension/TextExtKtTest.kt @@ -3,7 +3,9 @@ package com.anggrayudi.storage.extension import android.os.Environment import io.mockk.every import io.mockk.mockkStatic -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.io.File @@ -27,7 +29,10 @@ class TextExtKtTest { assertEquals(6, "87jkakkubaakjnaaa".count("a")) assertEquals(0, "87jkakku baakjnaaa".count("")) assertEquals(0, "87jka kkubaakjnaaa".count("abc")) - assertEquals(1, "primary:DCIM/document/primary:DCIM/document/assas/document/as".count("/document/") % 2) + assertEquals( + 1, + "primary:DCIM/document/primary:DCIM/document/assas/document/as".count("/document/") % 2 + ) } fun String.splitToPairAt(text: String, occurence: Int): Pair? { @@ -51,14 +56,20 @@ class TextExtKtTest { @Test fun splitAt() { - assertEquals(Pair("asosdisf/doc", "safsfsfaf/doc/8hhyjbh"), "asosdisf/doc/safsfsfaf/doc/8hhyjbh".splitToPairAt("/", 2)) + assertEquals( + Pair("asosdisf/doc", "safsfsfaf/doc/8hhyjbh"), + "asosdisf/doc/safsfsfaf/doc/8hhyjbh".splitToPairAt("/", 2) + ) } @Test fun replaceCompletely() { assertEquals("/storage/ABC//Movie/", "/storage/ABC////Movie/".replace("//", "/")) assertEquals("/storage/ABC/Movie/", "/storage/ABC///Movie/".replaceCompletely("//", "/")) - assertEquals("/storage/ABC/Movie/", "/storage////ABC///Movie//".replaceCompletely("//", "/")) + assertEquals( + "/storage/ABC/Movie/", + "/storage////ABC///Movie//".replaceCompletely("//", "/") + ) assertEquals("BB", "aaaaaaaaBaaaaaaBa".replaceCompletely("a", "")) } @@ -89,7 +100,10 @@ class TextExtKtTest { assertEquals("/storage/AAAA-BBBB", "/storage/AAAA-BBBB/abc.txt".parent()) assertEquals("", "/storage/AAAA-BBBB".parent()) - assertEquals("/storage/emulated/0/Download", "/storage/emulated/0/Download/abc.txt".parent()) + assertEquals( + "/storage/emulated/0/Download", + "/storage/emulated/0/Download/abc.txt".parent() + ) assertEquals("/storage/emulated/0", "/storage/emulated/0/abc.txt".parent()) assertEquals("", "/storage/emulated/0".parent()) } diff --git a/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/file/DocumentFileCompatTest.kt similarity index 93% rename from storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/file/DocumentFileCompatTest.kt index 58b6041..34c5064 100644 --- a/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/file/DocumentFileCompatTest.kt @@ -38,7 +38,10 @@ class DocumentFileCompatTest { assertEquals(PRIMARY, DocumentFileCompat.getStorageId(context, "/storage/emulated/0")) assertEquals(PRIMARY, DocumentFileCompat.getStorageId(context, "/storage/emulated/0/Music")) assertEquals(PRIMARY, DocumentFileCompat.getStorageId(context, "primary:Music")) - assertEquals("AAAA-BBBB", DocumentFileCompat.getStorageId(context, "/storage/AAAA-BBBB/Music")) + assertEquals( + "AAAA-BBBB", + DocumentFileCompat.getStorageId(context, "/storage/AAAA-BBBB/Music") + ) assertEquals("AAAA-BBBB", DocumentFileCompat.getStorageId(context, "AAAA-BBBB:Music")) } @@ -48,7 +51,10 @@ class DocumentFileCompatTest { assertEquals("", DocumentFileCompat.getBasePath(context, "AAAA-BBBB:")) assertEquals("Music", DocumentFileCompat.getBasePath(context, "/storage/emulated/0/Music")) assertEquals("Music", DocumentFileCompat.getBasePath(context, "primary:Music")) - assertEquals("Music/Pop", DocumentFileCompat.getBasePath(context, "/storage/AAAA-BBBB//Music///Pop/")) + assertEquals( + "Music/Pop", + DocumentFileCompat.getBasePath(context, "/storage/AAAA-BBBB//Music///Pop/") + ) assertEquals("Music", DocumentFileCompat.getBasePath(context, "AAAA-BBBB:Music")) } diff --git a/storage/src/test/java/com/anggrayudi/storage/file/FileExtKtTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/file/FileExtKtTest.kt similarity index 95% rename from storage/src/test/java/com/anggrayudi/storage/file/FileExtKtTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/file/FileExtKtTest.kt index 51f4774..9542254 100644 --- a/storage/src/test/java/com/anggrayudi/storage/file/FileExtKtTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/file/FileExtKtTest.kt @@ -92,8 +92,12 @@ class FileExtKtTest { val ext = MimeType.getExtensionFromFileName(filename) val prefix = "$baseName (" var lastFileCount = list().orEmpty().filter { - it.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches(it) - || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches(it)) + it.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches( + it + ) + || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches( + it + )) }.maxOfOrNull { it.substringAfterLast('(', "") .substringBefore(')', "") diff --git a/storage/src/test/java/com/anggrayudi/storage/file/MimeTypeTest.kt b/storage/src/test/kotlin/com/anggrayudi/storage/file/MimeTypeTest.kt similarity index 100% rename from storage/src/test/java/com/anggrayudi/storage/file/MimeTypeTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/file/MimeTypeTest.kt diff --git a/versions.gradle b/versions.gradle index 9caa4ec..ad19ee9 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" @@ -31,9 +31,7 @@ deps.multidex = "androidx.multidex:multidex:$versions.multidex" deps.documentfile = "androidx.documentfile:documentfile:$versions.documentfile" def coroutines = [:] -coroutines.core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" coroutines.android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines" -coroutines.test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines" deps.coroutines = coroutines // Testing ------------------------------ @@ -42,14 +40,14 @@ deps.robolectric = "org.robolectric:robolectric:$versions.robolectric" deps.mockk = "io.mockk:mockk:$versions.mockk" // Others ------------------------------- -deps.material_dialogs = "com.afollestad.material-dialogs:core:$versions.material_dialogs" +deps.material_dialogs_files = "com.afollestad.material-dialogs:files:$versions.material_dialogs" deps.material_progressbar = "me.zhanghai.android.materialprogressbar:library:$versions.material_progressbar" deps.timber = "com.jakewharton.timber:timber:$versions.timber" // End of dependencies ------------------ ext.deps = deps -def addRepos(RepositoryHandler handler) { +static def addRepos(RepositoryHandler handler) { handler.google() handler.mavenCentral() handler.maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }