diff --git a/build.gradle b/build.gradle index b0e0fde..ba0eea2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +1,5 @@ +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' @@ -9,8 +11,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.4.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.vanniktech:gradle-maven-publish-plugin:0.22.0' - classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.7.20' + 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).configureEach { + tasks.withType(KotlinCompile).configureEach { kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all', '-opt-in=kotlin.RequiresOptIn'] jvmTarget = '1.8' @@ -80,5 +82,5 @@ subprojects { } tasks.register('clean', Delete) { - delete rootProject.buildDir + 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 befbbe6..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.6-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 a9e7c86..71b86ee 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -78,10 +78,6 @@ dependencies { implementation deps.timber implementation deps.material_progressbar + implementation deps.material_dialogs_files implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'com.afollestad.material-dialogs:files:3.3.0' - - //test - testImplementation deps.junit - testImplementation deps.mockk } \ No newline at end of file diff --git a/sample/proguard-rules.pro b/sample/proguard-rules.pro deleted file mode 100644 index 481bb43..0000000 --- a/sample/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/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java index a281dc4..77a007c 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/activity/JavaActivity.java @@ -1,5 +1,18 @@ package com.anggrayudi.storage.sample.activity; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_CREATE_FILE; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FILE; +import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FOLDER; + +import android.Manifest; +import android.os.Build; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + import com.anggrayudi.storage.SimpleStorageHelper; import com.anggrayudi.storage.file.DocumentFileUtils; import com.anggrayudi.storage.permission.ActivityPermissionRequest; @@ -10,21 +23,8 @@ import org.jetbrains.annotations.NotNull; -import android.Manifest; -import android.os.Build; -import android.os.Bundle; -import android.widget.Toast; - import java.util.List; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; - -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_CREATE_FILE; -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FILE; -import static com.anggrayudi.storage.sample.activity.MainActivity.REQUEST_CODE_PICK_FOLDER; - /** * Created on 17/07/21 * 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 cf77da6..4d7c4d2 100644 --- a/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java +++ b/sample/src/main/java/com/anggrayudi/storage/sample/fragment/SettingsFragment.java @@ -1,10 +1,5 @@ package com.anggrayudi.storage.sample.fragment; -import com.anggrayudi.storage.SimpleStorageHelper; -import com.anggrayudi.storage.file.DocumentFileUtils; -import com.anggrayudi.storage.file.PublicDirectory; -import com.anggrayudi.storage.sample.R; - import android.content.SharedPreferences; import android.os.Bundle; @@ -13,6 +8,11 @@ import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; +import com.anggrayudi.storage.SimpleStorageHelper; +import com.anggrayudi.storage.file.DocumentFileUtils; +import com.anggrayudi.storage.file.PublicDirectory; +import com.anggrayudi.storage.sample.R; + /** * Created on 08/08/21 * @@ -35,6 +35,7 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { // Use 'Download' as default save location String downloadsFolder = PublicDirectory.DOWNLOADS.getAbsolutePath(); Preference saveLocationPref = findPreference(PREF_SAVE_LOCATION); + assert saveLocationPref != null; saveLocationPref.setSummary(preferences.getString(PREF_SAVE_LOCATION, downloadsFolder)); saveLocationPref.setOnPreferenceClickListener(preference -> { storageHelper.openFolderPicker(); 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 8f4b297..f2357a6 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 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 78% 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 2c5f5ec..34fa9b4 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,8 +94,16 @@ 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) } @@ -102,8 +120,15 @@ class FileCompressionActivity : BaseActivity() { } is ZipCompressionResult.Completed -> uiScope.launch { - Timber.d("onCompleted() -> Compressed ${result.totalFilesCompressed} with compression rate %.2f", result.compressionRate) - Toast.makeText(applicationContext, "Successfully compressed ${result.totalFilesCompressed} files", Toast.LENGTH_SHORT).show() + Timber.d( + "onCompleted() -> Compressed ${result.totalFilesCompressed} with compression rate %.2f", + result.compressionRate + ) + Toast.makeText( + applicationContext, + "Successfully compressed ${result.totalFilesCompressed} files", + Toast.LENGTH_SHORT + ).show() } is ZipCompressionResult.DeletingEntryFiles -> { @@ -112,7 +137,11 @@ class FileCompressionActivity : BaseActivity() { is ZipCompressionResult.Error -> uiScope.launch { Timber.d("onFailed() -> ${result.errorCode}: ${result.message}") - Toast.makeText(applicationContext, "Error compressing files: ${result.errorCode}", Toast.LENGTH_SHORT).show() + Toast.makeText( + applicationContext, + "Error compressing files: ${result.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 66% 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 068247c..eca4cbd 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 @@ -68,31 +68,43 @@ class FileDecompressionActivity : BaseActivity() { return } ioScope.launch { - zipFile.decompressZip(applicationContext, targetFolder, onConflict = object : SingleFileConflictCallback(uiScope) { - var actionForAllConflicts: ConflictResolution? = null + zipFile.decompressZip( + applicationContext, + targetFolder, + onConflict = object : SingleFileConflictCallback(uiScope) { + var actionForAllConflicts: ConflictResolution? = null - override fun onFileConflict(destinationFile: DocumentFile, action: FileConflictAction) { - actionForAllConflicts?.let { - action.confirmResolution(it) - return - } + override fun onFileConflict( + destinationFile: DocumentFile, + action: 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 = ConflictResolution.entries[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 = ConflictResolution.entries[index] + if (doForAll) { + actionForAllConflicts = resolution + } + action.confirmResolution(resolution) } - action.confirmResolution(resolution) - } - .show() - } - }).collect { + .show() + } + }).collect { when (it) { is ZipDecompressionResult.Validating -> Timber.d("Validating") is ZipDecompressionResult.Decompressing -> Timber.d("Decompressing") @@ -105,7 +117,8 @@ class FileDecompressionActivity : BaseActivity() { } is ZipDecompressionResult.Error -> uiScope.launch { - Toast.makeText(applicationContext, "${it.errorCode}", Toast.LENGTH_SHORT).show() + Toast.makeText(applicationContext, "${it.errorCode}", Toast.LENGTH_SHORT) + .show() } } } 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 77% 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 5811c75..bde973c 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 @@ -66,11 +66,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) { @@ -112,7 +119,11 @@ class MainActivity : AppCompatActivity() { isEnabled = Build.VERSION.SDK_INT in 23..28 } - binding.layoutBaseOperation.btnRequestStorageAccess.setOnClickListener { storageHelper.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS) } + binding.layoutBaseOperation.btnRequestStorageAccess.setOnClickListener { + storageHelper.requestStorageAccess( + REQUEST_CODE_STORAGE_ACCESS + ) + } binding.layoutBaseOperation.btnRequestFullStorageAccess.run { isEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -132,7 +143,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 { @@ -161,17 +176,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 -> { @@ -182,25 +212,49 @@ 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() + Toast.makeText(baseContext, folder.getAbsolutePath(this), Toast.LENGTH_SHORT) + .show() } } } @@ -229,7 +283,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() } } @@ -242,7 +297,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() } } } @@ -268,22 +327,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, onConflict = createMultipleFileCallback()) + sources.copyTo( + applicationContext, + targetFolder, + onConflict = createMultipleFileCallback() + ) .onCompletion { if (it is CancellationException) { Timber.d("Multiple copies is aborted") @@ -298,12 +368,20 @@ class MainActivity : AppCompatActivity() { is MultipleFilesResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") is MultipleFilesResult.Completed -> uiScope.launch { Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") - Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", + Toast.LENGTH_SHORT + ).show() } is MultipleFilesResult.Error -> uiScope.launch { Timber.e(result.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${result.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -322,22 +400,33 @@ 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, onConflict = createMultipleFileCallback()) + sources.moveTo( + applicationContext, + targetFolder, + onConflict = createMultipleFileCallback() + ) .onCompletion { if (it is CancellationException) { Timber.d("Multiple file moves is aborted") @@ -352,12 +441,20 @@ class MainActivity : AppCompatActivity() { is MultipleFilesResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") is MultipleFilesResult.Completed -> uiScope.launch { Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") - Toast.makeText(baseContext, "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", + Toast.LENGTH_SHORT + ).show() } is MultipleFilesResult.Error -> uiScope.launch { Timber.e(result.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${result.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -404,7 +501,12 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Copying...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.copyFolderTo(applicationContext, targetFolder, false, onConflict = createFolderCallback()) + folder.copyFolderTo( + applicationContext, + targetFolder, + false, + onConflict = createFolderCallback() + ) .onCompletion { if (it is CancellationException) { Timber.d("Folder copy is aborted") @@ -415,16 +517,23 @@ class MainActivity : AppCompatActivity() { is SingleFolderResult.Preparing -> Timber.d("Preparing...") is SingleFolderResult.CountingFiles -> Timber.d("Counting files...") is SingleFolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") - is SingleFolderResult.Starting -> Timber.d("Starting...") is SingleFolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") is SingleFolderResult.Completed -> uiScope.launch { Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") - Toast.makeText(baseContext, "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Copied ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", + Toast.LENGTH_SHORT + ).show() } is SingleFolderResult.Error -> uiScope.launch { Timber.e(result.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${result.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -452,7 +561,12 @@ class MainActivity : AppCompatActivity() { } Toast.makeText(this, "Moving...", Toast.LENGTH_SHORT).show() ioScope.launch { - folder.moveFolderTo(applicationContext, targetFolder, false, onConflict = createFolderCallback()) + folder.moveFolderTo( + applicationContext, + targetFolder, + false, + onConflict = createFolderCallback() + ) .onCompletion { if (it is CancellationException) { Timber.d("Folder move is aborted") @@ -463,16 +577,23 @@ class MainActivity : AppCompatActivity() { is SingleFolderResult.Preparing -> Timber.d("Preparing...") is SingleFolderResult.CountingFiles -> Timber.d("Counting files...") is SingleFolderResult.DeletingConflictedFiles -> Timber.d("Deleting conflicted files...") - is SingleFolderResult.Starting -> Timber.d("Starting...") is SingleFolderResult.InProgress -> Timber.d("Progress: ${result.progress.toInt()}% | ${result.fileCount} files") is SingleFolderResult.Completed -> uiScope.launch { Timber.d("Completed: ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files") - Toast.makeText(baseContext, "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Moved ${result.totalCopiedFiles} of ${result.totalFilesToCopy} files", + Toast.LENGTH_SHORT + ).show() } is SingleFolderResult.Error -> uiScope.launch { Timber.e(result.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${result.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -481,7 +602,11 @@ class MainActivity : AppCompatActivity() { } private fun createFolderCallback() = object : SingleFolderConflictCallback(uiScope) { - override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) { + override fun onParentConflict( + destinationFolder: DocumentFile, + action: ParentFolderConflictAction, + canMerge: Boolean + ) { handleParentFolderConflict(destinationFolder, action, canMerge) } @@ -523,18 +648,24 @@ class MainActivity : AppCompatActivity() { when (it) { is SingleFileResult.Validating -> Timber.d("Validating...") is SingleFileResult.Preparing -> Timber.d("Preparing...") - is SingleFileResult.CountingFiles -> Timber.d("Counting files...") is SingleFileResult.DeletingConflictedFile -> Timber.d("Deleting conflicted file...") - is SingleFileResult.Starting -> Timber.d("Starting...") is SingleFileResult.InProgress -> Timber.d("Progress: ${it.progress.toInt()}%") is SingleFileResult.Completed -> uiScope.launch { Timber.d("Completed") - Toast.makeText(baseContext, "Copied successfully", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Copied successfully", + Toast.LENGTH_SHORT + ).show() } is SingleFileResult.Error -> uiScope.launch { Timber.e(it.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${it.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${it.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -578,9 +709,7 @@ class MainActivity : AppCompatActivity() { when (result) { is SingleFileResult.Validating -> Timber.d("Validating...") is SingleFileResult.Preparing -> Timber.d("Preparing...") - is SingleFileResult.CountingFiles -> Timber.d("Counting files...") is SingleFileResult.DeletingConflictedFile -> Timber.d("Deleting conflicted file...") - is SingleFileResult.Starting -> Timber.d("Starting...") is SingleFileResult.InProgress -> uiScope.launch { Timber.d("Progress: ${result.progress.toInt()}%") if (dialog == null) { @@ -588,29 +717,46 @@ class MainActivity : AppCompatActivity() { .cancelable(false) .positiveButton(android.R.string.cancel) { job?.cancel() } .customView(R.layout.dialog_copy_progress).apply { - tvStatus = getCustomView().findViewById(R.id.tvProgressStatus).apply { - text = "Copying file: 0%" - } - - progressBar = getCustomView().findViewById(R.id.progressCopy).apply { - isIndeterminate = true - } + 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() } } - tvStatus?.text = "Copying file: ${result.progress.toInt()}%" + tvStatus?.text = + getString(R.string.copying_file, result.progress.toInt()) progressBar?.isIndeterminate = false progressBar?.progress = result.progress.toInt() } is SingleFileResult.Completed -> uiScope.launch { Timber.d("Completed") - Toast.makeText(baseContext, "Moved successfully", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "Moved successfully", + Toast.LENGTH_SHORT + ).show() } is SingleFileResult.Error -> uiScope.launch { Timber.e(result.errorCode.name) - Toast.makeText(baseContext, "An error has occurred: ${result.errorCode.name}", Toast.LENGTH_SHORT).show() + Toast.makeText( + baseContext, + "An error has occurred: ${result.errorCode.name}", + Toast.LENGTH_SHORT + ).show() } } } @@ -644,7 +790,8 @@ class MainActivity : AppCompatActivity() { conflictedFiles: MutableList, action: MultipleFilesConflictCallback.ParentFolderConflictAction ) { - val newSolution = ArrayList(conflictedFiles.size) + val newSolution = + ArrayList(conflictedFiles.size) askFolderSolution(action, conflictedFolders, conflictedFiles, newSolution) } @@ -666,8 +813,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 = SingleFolderConflictCallback.ConflictResolution.entries[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 = + SingleFolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFolders.forEach { it.solution = currentSolution.solution } @@ -697,8 +851,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 = SingleFolderConflictCallback.ConflictResolution.entries[if (index > 0) index + 1 else index] + .listItems( + items = mutableListOf( + "Replace", + "Create New", + "Skip Duplicate" + ) + ) { _, index, _ -> + currentSolution.solution = + SingleFolderConflictCallback.ConflictResolution.entries[if (index > 0) index + 1 else index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -720,11 +881,19 @@ class MainActivity : AppCompatActivity() { .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 = SingleFolderConflictCallback.ConflictResolution.entries[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 = + SingleFolderConflictCallback.ConflictResolution.entries[if (!canMerge && index > 0) index + 1 else index] action.confirmResolution(resolution) if (resolution == SingleFolderConflictCallback.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() @@ -755,7 +924,8 @@ class MainActivity : AppCompatActivity() { .message(text = "File \"${currentSolution.target.name}\" already exists in destination. What's your action?") .checkBoxPrompt(text = "Apply to all") { doForAll = it } .listItems(items = listOf("Replace", "Create New", "Skip")) { _, index, _ -> - currentSolution.solution = SingleFileConflictCallback.ConflictResolution.entries[index] + currentSolution.solution = + SingleFileConflictCallback.ConflictResolution.entries[index] newSolution.add(currentSolution) if (doForAll) { conflictedFiles.forEach { it.solution = currentSolution.solution } @@ -785,9 +955,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") @@ -805,7 +978,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() } @@ -851,7 +1029,13 @@ class MainActivity : AppCompatActivity() { file.openOutputStream(context)?.use { try { 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 78% 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 769b5ee..5da2ff4 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 @@ -28,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) { @@ -67,7 +74,11 @@ class SampleFragment : Fragment(R.layout.incl_base_operation) { isEnabled = Build.VERSION.SDK_INT in 23..28 } - binding.btnRequestStorageAccess.setOnClickListener { storageHelper.requestStorageAccess(MainActivity.REQUEST_CODE_STORAGE_ACCESS) } + binding.btnRequestStorageAccess.setOnClickListener { + storageHelper.requestStorageAccess( + MainActivity.REQUEST_CODE_STORAGE_ACCESS + ) + } binding.btnRequestFullStorageAccess.run { isEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -87,17 +98,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() + 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() + 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 8ba4bf7..1d4b40c 100644 --- a/storage/build.gradle +++ b/storage/build.gradle @@ -12,28 +12,22 @@ android { buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } lint { abortOnError false } - - buildFeatures { - viewBinding = true - } } dependencies { - api deps.appcompat - api deps.activity - api deps.core_ktx - api deps.fragment api deps.documentfile - api deps.coroutines.core - api deps.coroutines.android - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2" + implementation deps.appcompat + implementation deps.activity + implementation deps.core_ktx + implementation deps.fragment + implementation deps.coroutines.android + 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.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } - AUDIO -> AudioMediaDirectory.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } - VIDEO -> VideoMediaDirectory.entries.map { Environment.getExternalStoragePublicDirectory(it.folderName) } - DOWNLOADS -> listOf(PublicDirectory.DOWNLOADS.file) - } - - 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/result/SingleFileResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt deleted file mode 100644 index d31b8bb..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/result/SingleFileResult.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.anggrayudi.storage.result - -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.media.MediaFile - -/** - * Created on 7/6/24 - * @author Anggrayudi Hardiannico A. - */ -sealed class SingleFileResult { - data object Validating : SingleFileResult() - data object Preparing : SingleFileResult() - data object CountingFiles : SingleFileResult() - data object DeletingConflictedFile : SingleFileResult() - data class Starting(val files: List, val totalFilesToCopy: Int) : SingleFileResult() - data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int) : SingleFileResult() - - /** - * @param result can be [DocumentFile] or [MediaFile] - */ - data class Completed(val result: Any) : SingleFileResult() - data class Error(val errorCode: SingleFileErrorCode, val message: String? = null) : SingleFileResult() -} - -enum class SingleFileErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - SOURCE_FILE_NOT_FOUND, - TARGET_FILE_NOT_FOUND, - TARGET_FOLDER_NOT_FOUND, - UNKNOWN_IO_ERROR, - CANCELED, - TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, - NO_SPACE_LEFT_ON_TARGET_PATH -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt b/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt deleted file mode 100644 index c4427e2..0000000 --- a/storage/src/main/java/com/anggrayudi/storage/result/ZipCompressionResult.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.anggrayudi.storage.result - -import androidx.documentfile.provider.DocumentFile - -/** - * Created on 7/6/24 - * @author Anggrayudi Hardiannico A. - */ -sealed class ZipCompressionResult { - data object CountingFiles : ZipCompressionResult() - data class Compressing(val progress: Float, val bytesCompressed: Long, val writeSpeed: Int, val fileCount: Int) : ZipCompressionResult() - data class Completed(val zipFile: DocumentFile, val bytesCompressed: Long, val totalFilesCompressed: Int, val compressionRate: Float) : - ZipCompressionResult() - - data object DeletingEntryFiles : ZipCompressionResult() - - data class Error(val errorCode: ZipCompressionErrorCode, val message: String? = null) : ZipCompressionResult() -} - -enum class ZipCompressionErrorCode { - STORAGE_PERMISSION_DENIED, - CANNOT_CREATE_FILE_IN_TARGET, - MISSING_ENTRY_FILE, - DUPLICATE_ENTRY_FILE, - UNKNOWN_IO_ERROR, - CANCELED, - NO_SPACE_LEFT_ON_TARGET_PATH -} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/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 77% rename from storage/src/main/java/com/anggrayudi/storage/FileWrapper.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/FileWrapper.kt index db607c7..d428320 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 @@ -46,7 +54,8 @@ interface FileWrapper { fun delete(): Boolean - class Media(val mediaFile: MediaFile) : FileWrapper { + @JvmInline + value class Media(val mediaFile: MediaFile) : FileWrapper { override val uri: Uri get() = mediaFile.uri @@ -71,14 +80,16 @@ 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() override fun delete(): Boolean = mediaFile.delete() } - class Document(val documentFile: DocumentFile) : FileWrapper { + @JvmInline + value class Document(val documentFile: DocumentFile) : FileWrapper { override val uri: Uri get() = documentFile.uri @@ -97,15 +108,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 80% rename from storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorage.kt index f98bf90..f4dd044 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorage.kt @@ -56,7 +56,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 } @@ -146,7 +148,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 @@ -175,7 +178,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 @@ -239,7 +243,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 @@ -287,7 +294,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) } } } @@ -299,7 +307,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, @@ -312,14 +322,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, @@ -335,7 +354,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, @@ -349,23 +371,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 @@ -376,7 +416,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 + ) } } } @@ -390,7 +436,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) @@ -497,8 +544,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) @@ -510,13 +563,32 @@ 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.entries.toTypedArray()[savedInstanceState.getInt(KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST)] - requestCodeStorageAccess = savedInstanceState.getInt(KEY_REQUEST_CODE_STORAGE_ACCESS, DEFAULT_REQUEST_CODE_STORAGE_ACCESS) - requestCodeFolderPicker = savedInstanceState.getInt(KEY_REQUEST_CODE_FOLDER_PICKER, DEFAULT_REQUEST_CODE_FOLDER_PICKER) - requestCodeFilePicker = savedInstanceState.getInt(KEY_REQUEST_CODE_FILE_PICKER, DEFAULT_REQUEST_CODE_FILE_PICKER) - 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.entries.toTypedArray()[savedInstanceState.getInt( + KEY_EXPECTED_STORAGE_TYPE_FOR_ACCESS_REQUEST + )] + requestCodeStorageAccess = savedInstanceState.getInt( + KEY_REQUEST_CODE_STORAGE_ACCESS, + DEFAULT_REQUEST_CODE_STORAGE_ACCESS + ) + requestCodeFolderPicker = savedInstanceState.getInt( + KEY_REQUEST_CODE_FOLDER_PICKER, + DEFAULT_REQUEST_CODE_FOLDER_PICKER + ) + requestCodeFilePicker = savedInstanceState.getInt( + KEY_REQUEST_CODE_FILE_PICKER, + DEFAULT_REQUEST_CODE_FILE_PICKER + ) + 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) } } @@ -538,7 +610,13 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { requestCodeCreateFile = DEFAULT_REQUEST_CODE_CREATE_FILE } - if (setOf(requestCodeFilePicker, requestCodeFolderPicker, requestCodeStorageAccess, requestCodeCreateFile).size < 4) { + if (setOf( + requestCodeFilePicker, + requestCodeFolderPicker, + requestCodeStorageAccess, + requestCodeCreateFile + ).size < 4 + ) { throw IllegalArgumentException( "Request codes must be unique. File picker=$requestCodeFilePicker, Folder picker=$requestCodeFolderPicker, " + "Storage access=$requestCodeStorageAccess, Create file=$requestCodeCreateFile" @@ -547,7 +625,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) thread { cleanupRedundantUriPermissions(context.applicationContext) } true @@ -557,14 +636,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 @@ -585,7 +672,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 + ) } } } @@ -595,7 +685,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) } @@ -604,12 +697,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, "") + ) } /** @@ -623,10 +722,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 + )) } /** @@ -646,10 +755,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") } @@ -668,7 +784,8 @@ 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 writeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION persistedUris.forEach { if (DocumentFileCompat.fromUri(context, it)?.isWritable(context) != true) { resolver.releasePersistableUriPermission(it, writeFlags) 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 9c5d214..bd5299d 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/SimpleStorageHelper.kt @@ -46,13 +46,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 @@ -87,7 +92,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 @@ -97,7 +107,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() } @@ -115,11 +131,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() } } @@ -182,7 +202,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 { @@ -196,7 +217,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() @@ -214,24 +239,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() } @@ -259,7 +294,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 ) @@ -303,7 +342,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 @@ -317,7 +360,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 @@ -327,11 +373,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) @@ -361,7 +414,12 @@ class SimpleStorageHelper { ) { pickerToOpenOnceGranted = 0 originalRequestCode = requestCode - storage.requestStorageAccess(requestCode, initialPath, expectedStorageType, expectedBasePath) + storage.requestStorageAccess( + requestCode, + initialPath, + expectedStorageType, + expectedBasePath + ) } @JvmOverloads @@ -401,9 +459,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) { @@ -411,7 +472,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/CreateFileCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/CreateFileCallback.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/callback/CreateFileCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/CreateFileCallback.kt 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/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/MultipleFilesConflictCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFilesConflictCallback.kt similarity index 95% rename from storage/src/main/java/com/anggrayudi/storage/callback/MultipleFilesConflictCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFilesConflictCallback.kt index e22ca67..424ac51 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/MultipleFilesConflictCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/MultipleFilesConflictCallback.kt @@ -24,7 +24,10 @@ abstract class MultipleFilesConflictCallback @OptIn(DelicateCoroutinesApi::class * * [FolderErrorCode.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) } diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt similarity index 100% rename from storage/src/main/java/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/SingleFileConflictCallback.kt diff --git a/storage/src/main/java/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt b/storage/src/main/kotlin/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt similarity index 91% rename from storage/src/main/java/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt index 0531ce8..cdd7f76 100644 --- a/storage/src/main/java/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/callback/SingleFolderConflictCallback.kt @@ -31,12 +31,20 @@ abstract class SingleFolderConflictCallback @OptIn(DelicateCoroutinesApi::class) * 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) } 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/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 91% 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 b5c987a..2fafbb7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/extension/CoroutineExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/extension/CoroutineExt.kt @@ -45,9 +45,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 100% rename from storage/src/main/java/com/anggrayudi/storage/extension/TextExt.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/extension/TextExt.kt 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 82% 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 9669ae9..43f1783 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileCompat.kt @@ -112,7 +112,10 @@ 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) else -> if (fullPath.matches(SD_CARD_STORAGE_PATH_REGEX)) { fullPath.substringAfter("/storage/", "").substringAfter('/', "") @@ -127,8 +130,13 @@ 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) } } @@ -154,9 +162,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 @@ -189,7 +206,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 + ) } } @@ -219,9 +243,24 @@ object DocumentFileCompat { 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 + ) } } @@ -305,7 +344,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)) @@ -360,7 +403,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) } } @@ -379,13 +425,21 @@ object DocumentFileCompat { */ @JvmOverloads @JvmStatic - 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, HOME -> Environment.getExternalStorageDirectory() DATA -> context.dataDirectory 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 @@ -418,7 +472,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 @@ -433,10 +490,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. @@ -451,14 +510,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. @@ -514,7 +576,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('/')) @@ -560,7 +626,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 @@ -568,7 +637,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() } @@ -614,18 +685,36 @@ object DocumentFileCompat { if (considerRawFile && folder.isDirectory && folder.canRead() || path.startsWith(dataDir)) { cleanedFullPaths.forEachIndexed { index, s -> if (path.hasParent(s)) { - results[index] = DocumentFile.fromFile(File(getDirectorySequence(s).joinToString(prefix = "/", separator = "/"))) + results[index] = DocumentFile.fromFile( + File( + getDirectorySequence(s).joinToString( + prefix = "/", + separator = "/" + ) + ) + ) } } } else { - var currentDirectory = getAccessibleRootDocumentFile(context, path, requiresWriteAccess, considerRawFile) ?: continue + var currentDirectory = getAccessibleRootDocumentFile( + context, + path, + requiresWriteAccess, + considerRawFile + ) ?: 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) { @@ -654,22 +743,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) } } } @@ -699,9 +795,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) @@ -759,7 +861,11 @@ object DocumentFileCompat { considerRawFile: Boolean ): DocumentFile? { 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 ) { @@ -768,35 +874,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) @@ -828,7 +949,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 -> @@ -953,7 +1077,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 77% 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 ab616c5..18b3a58 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -48,6 +48,7 @@ import com.anggrayudi.storage.file.StorageId.PRIMARY import com.anggrayudi.storage.media.FileDescription import com.anggrayudi.storage.media.MediaFile import com.anggrayudi.storage.media.MediaStoreCompat +import com.anggrayudi.storage.result.DecompressedZipFile import com.anggrayudi.storage.result.FileProperties import com.anggrayudi.storage.result.FilePropertiesResult import com.anggrayudi.storage.result.FolderErrorCode @@ -78,7 +79,8 @@ import java.util.zip.ZipOutputStream typealias CheckFileSize = (freeSpace: Long, fileSize: Long) -> Boolean -internal val defaultFileSizeChecker: CheckFileSize = { freeSpace, fileSize -> fileSize + 100 * FileSize.MB < freeSpace /* 100MB tolerance */ } +internal val defaultFileSizeChecker: CheckFileSize = + { freeSpace, fileSize -> fileSize + 100 * FileSize.MB < freeSpace /* 100MB tolerance */ } /** * Created on 16/08/20 @@ -115,7 +117,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) /** * Some media files do not return file extension from [DocumentFile.getName]. This function helps you to fix this kind of issue. @@ -140,7 +143,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 @@ -190,9 +199,10 @@ fun DocumentFile.getProperties( if (isEmpty(context)) { send(FilePropertiesResult.Completed(properties)) } else { - val timer = if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { - trySend(FilePropertiesResult.Updating(properties)) - } + val timer = + if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { + trySend(FilePropertiesResult.Updating(properties)) + } walkFileTreeForInfo(properties, this) timer?.cancel() send(FilePropertiesResult.Completed(properties)) @@ -214,7 +224,10 @@ fun DocumentFile.getProperties( } @OptIn(DelicateCoroutinesApi::class) -private fun DocumentFile.walkFileTreeForInfo(properties: FileProperties, scope: ProducerScope) { +private fun DocumentFile.walkFileTreeForInfo( + properties: FileProperties, + scope: ProducerScope +) { val list = listFiles() if (list.isEmpty()) { properties.emptyFolders++ @@ -257,15 +270,18 @@ 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 */ -fun DocumentFile.inSdCardStorage(context: Context) = getStorageId(context).matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX) +fun DocumentFile.inSdCardStorage(context: Context) = + getStorageId(context).matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX) -fun DocumentFile.inDataStorage(context: Context) = isRawFile && File(uri.path!!).inDataStorage(context) +fun DocumentFile.inDataStorage(context: Context) = + isRawFile && File(uri.path!!).inDataStorage(context) /** * `true` if this file was created with [File] @@ -310,7 +326,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.isNotEmpty()) { File("/storage/$storageId/${getBasePath(context)}") @@ -327,7 +350,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 { @@ -338,7 +365,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] @@ -348,7 +376,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()) { @@ -380,7 +412,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 -> { @@ -390,7 +426,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 { @@ -424,15 +461,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) @@ -449,14 +497,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: @@ -471,7 +528,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:") -> { @@ -502,7 +561,8 @@ fun DocumentFile.getBasePath(context: Context): String { } } - else -> path.substringAfterLast(SimpleStorage.externalStoragePath, "").trimFileSeparator() + else -> path.substringAfterLast(SimpleStorage.externalStoragePath, "") + .trimFileSeparator() } } @@ -559,7 +619,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` @@ -595,7 +656,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 -> { @@ -613,7 +675,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 "" @@ -625,7 +689,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('/') } } @@ -633,7 +700,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? { @@ -642,15 +710,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 ) } } @@ -675,11 +749,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. @@ -690,13 +774,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. @@ -714,7 +803,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('(', "") @@ -732,7 +823,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) /** @@ -765,11 +860,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('.') @@ -779,7 +875,10 @@ fun DocumentFile.makeFile( parent.child(context, fullFileName)?.let { targetFile -> existingFile = targetFile createMode = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) + onConflict.onFileConflict( + targetFile, + SingleFileConflictCallback.FileConflictAction(it) + ) }.toCreateMode(true) } } @@ -796,7 +895,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 { @@ -828,20 +934,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 { @@ -874,13 +988,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) @@ -918,7 +1042,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 = children.filter { it.name in names } return when (documentType) { DocumentFileType.FILE -> files.filter { it.isFile } @@ -927,12 +1054,14 @@ fun DocumentFile.findFiles(names: Array, documentType: DocumentFileType } } -fun DocumentFile.findFolder(name: String): DocumentFile? = children.find { it.name == name && it.isDirectory } +fun DocumentFile.findFolder(name: String): DocumentFile? = + children.find { it.name == name && it.isDirectory } /** * Expect the file is a file literally, not a folder. */ -fun DocumentFile.findFileLiterally(name: String): DocumentFile? = children.find { it.name == name && it.isFile } +fun DocumentFile.findFileLiterally(name: String): DocumentFile? = + children.find { it.name == name && it.isFile } /** * @param recursive walk into sub folders @@ -955,9 +1084,10 @@ fun DocumentFile.search( !isDirectory || !canRead() -> send(emptyList()) recursive -> { val fileTree = mutableListOf() - val timer = if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { - trySend(fileTree) - } + val timer = + if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { + trySend(fileTree) + } if (mimeTypes.isNullOrEmpty() || mimeTypes.any { it == MimeType.UNKNOWN }) { walkFileTreeForSearch(fileTree, documentType, emptyArray(), name, regex, this) } else { @@ -972,10 +1102,14 @@ 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!!) } @@ -985,9 +1119,10 @@ fun DocumentFile.search( } val fileTree = mutableListOf() - val timer = if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { - trySend(fileTree) - } + val timer = + if (updateInterval < 1) null else startCoroutineTimer(repeatMillis = updateInterval) { + trySend(fileTree) + } sequence.forEach { if (isClosedForSend) { return@forEach @@ -1002,7 +1137,8 @@ fun DocumentFile.search( } private fun DocumentFile.matchesMimeTypes(filterMimeTypes: Array): Boolean { - return filterMimeTypes.isEmpty() || !MimeTypeFilter.matches(mimeTypeByFileName, filterMimeTypes).isNullOrEmpty() + return filterMimeTypes.isEmpty() || !MimeTypeFilter.matches(mimeTypeByFileName, filterMimeTypes) + .isNullOrEmpty() } @OptIn(DelicateCoroutinesApi::class) @@ -1032,11 +1168,23 @@ 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(fileTree, documentType, mimeTypes, nameFilter, regex, scope)) + fileTree.addAll( + file.walkFileTreeForSearch( + fileTree, + documentType, + mimeTypes, + nameFilter, + regex, + scope + ) + ) } } return fileTree @@ -1120,20 +1268,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() @@ -1195,7 +1352,12 @@ fun List.compressToZip( for (srcFile in distinctBy { it.uri }) { if (srcFile.exists()) { if (!srcFile.canRead()) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Can't read file: ${srcFile.uri}")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Can't read file: ${srcFile.uri}" + ) + ) return@callbackFlow } else if (srcFile.isFile) { if (srcFile.isTreeDocumentFile || srcFile.isRawFile) { @@ -1207,7 +1369,12 @@ fun List.compressToZip( directories.add(srcFile) } } else { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "File not found: ${srcFile.uri}")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.MISSING_ENTRY_FILE, + "File not found: ${srcFile.uri}" + ) + ) return@callbackFlow } } @@ -1220,11 +1387,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) } } @@ -1241,7 +1412,12 @@ fun List.compressToZip( val totalFiles = treeFiles.size + mediaFiles.size if (totalFiles == 0) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "No entry files found")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.MISSING_ENTRY_FILE, + "No entry files found" + ) + ) return@callbackFlow } @@ -1249,15 +1425,24 @@ fun List.compressToZip( treeFiles.forEach { actualFilesSize += it.length() } mediaFiles.forEach { actualFilesSize += it.length() } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetZipFile.getStorageId(context)), actualFilesSize)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + targetZipFile.getStorageId(context) + ), actualFilesSize + ) + ) { sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } 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)) { @@ -1285,14 +1470,20 @@ fun List.compressToZip( 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) { sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return@callbackFlow } if (!zipFile.isWritable(context)) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Destination ZIP file is not writable" + ) + ) return@callbackFlow } @@ -1307,7 +1498,14 @@ fun List.compressToZip( // using timer on small file is useless. We set minimum 10MB. if (updateInterval > 0 && actualFilesSize > 10 * FileSize.MB) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(ZipCompressionResult.Compressing(bytesCompressed * 100f / actualFilesSize, bytesCompressed, writeSpeed, fileCompressedCount)) + trySend( + ZipCompressionResult.Compressing( + bytesCompressed * 100f / actualFilesSize, + bytesCompressed, + writeSpeed, + fileCompressedCount + ) + ) writeSpeed = 0 } } @@ -1333,7 +1531,12 @@ fun List.compressToZip( } catch (e: IOException) { send(ZipCompressionResult.Error(ZipCompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } catch (e: SecurityException) { - send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) + send( + ZipCompressionResult.Error( + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + ) } finally { timer?.cancel() zos.closeEntryQuietly() @@ -1367,19 +1570,39 @@ fun DocumentFile.decompressZip( send(ZipDecompressionResult.Validating) if (exists()) { if (!canRead()) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Can't read file: $uri")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Can't read file: $uri" + ) + ) return@callbackFlow } else if (isFile) { if (type != MimeType.ZIP && name?.endsWith(".zip", ignoreCase = true) != false) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NOT_A_ZIP_FILE, "Not a ZIP file: $uri")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.NOT_A_ZIP_FILE, + "Not a ZIP file: $uri" + ) + ) return@callbackFlow } } else { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NOT_A_ZIP_FILE, "Not a ZIP file: $uri")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.NOT_A_ZIP_FILE, + "Not a ZIP file: $uri" + ) + ) return@callbackFlow } } else { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, "ZIP file not found: $uri")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.MISSING_ZIP_FILE, + "ZIP file not found: $uri" + ) + ) return@callbackFlow } @@ -1388,12 +1611,23 @@ fun DocumentFile.decompressZip( destFolder = targetFolder.findParent(context)?.makeFolder(context, targetFolder.fullName) } if (destFolder == null || !destFolder.isWritable(context)) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination folder is not writable")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Destination folder is not writable" + ) + ) return@callbackFlow } val zipSize = length() - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), zipSize)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), zipSize + ) + ) { sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } @@ -1411,7 +1645,13 @@ fun DocumentFile.decompressZip( // using timer on small file is useless. We set minimum 10MB. if (updateInterval > 0 && zipSize > 10 * FileSize.MB) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(ZipDecompressionResult.Decompressing(bytesDecompressed, writeSpeed, fileDecompressedCount)) + trySend( + ZipDecompressionResult.Decompressing( + bytesDecompressed, + writeSpeed, + fileDecompressedCount + ) + ) writeSpeed = 0 } } @@ -1423,7 +1663,11 @@ 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 = onConflict) @@ -1460,10 +1704,20 @@ fun DocumentFile.decompressZip( if (e.message?.contains("no space", true) == true) { send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) } else { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, + e.message + ) + ) } } catch (e: SecurityException) { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + ) } finally { timer?.cancel() zis.closeEntryQuietly() @@ -1472,7 +1726,16 @@ 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 - send(ZipDecompressionResult.Completed(this, destFolder, bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, sizeExpansion)) + send( + ZipDecompressionResult.Completed( + DecompressedZipFile.DocumentFile(this@decompressZip), + destFolder, + bytesDecompressed, + skippedDecompressedBytes, + fileDecompressedCount, + sizeExpansion + ) + ) } else { targetFile?.delete() } @@ -1488,7 +1751,15 @@ fun List.moveTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: MultipleFilesConflictCallback ): Flow { - return copyTo(context, targetParentFolder, skipEmptyFiles, true, updateInterval, isFileSizeAllowed, onConflict) + return copyTo( + context, + targetParentFolder, + skipEmptyFiles, + true, + updateInterval, + isFileSizeAllowed, + onConflict + ) } @WorkerThread @@ -1500,7 +1771,15 @@ fun List.copyTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: MultipleFilesConflictCallback ): Flow { - return copyTo(context, targetParentFolder, skipEmptyFiles, false, updateInterval, isFileSizeAllowed, onConflict) + return copyTo( + context, + targetParentFolder, + skipEmptyFiles, + false, + updateInterval, + isFileSizeAllowed, + onConflict + ) } @OptIn(DelicateCoroutinesApi::class) @@ -1523,12 +1802,18 @@ private fun List.copyTo( val validSources = pair.second val writableTargetParentFolder = pair.first - val conflictResolutions = validSources.handleParentFolderConflict(context, writableTargetParentFolder, this, onConflict) + val conflictResolutions = validSources.handleParentFolderConflict( + context, + writableTargetParentFolder, + this, + onConflict + ) if (conflictResolutions == null) { close() return@callbackFlow } - validSources.removeAll(conflictResolutions.filter { it.solution == SingleFolderConflictCallback.ConflictResolution.SKIP }.map { it.source }) + validSources.removeAll(conflictResolutions.filter { it.solution == SingleFolderConflictCallback.ConflictResolution.SKIP } + .map { it.source }) if (validSources.isEmpty()) { close() return@callbackFlow @@ -1544,11 +1829,15 @@ private fun List.copyTo( ) val sourceInfos = validSources.associateWith { src -> - val resolution = conflictResolutions.find { it.source == src }?.solution ?: SingleFolderConflictCallback.ConflictResolution.CREATE_NEW + val resolution = conflictResolutions.find { it.source == src }?.solution + ?: SingleFolderConflictCallback.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 { @@ -1560,7 +1849,8 @@ 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()) { sendAndClose(MultipleFilesResult.Completed(emptyList(), 0, 0, true)) @@ -1607,13 +1897,26 @@ private fun List.copyTo( } if (sourceInfos.isEmpty()) { - sendAndClose(MultipleFilesResult.Completed(results.map { it.value }, copiedFiles, copiedFiles, true)) + sendAndClose( + MultipleFilesResult.Completed( + results.map { it.value }, + copiedFiles, + copiedFiles, + true + ) + ) return@callbackFlow } } val totalSizeToCopy = sourceInfos.values.sumOf { it.size } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + writableTargetParentFolder.getStorageId(context) + ), totalSizeToCopy + ) + ) { sendAndClose(MultipleFilesResult.Error(MultipleFilesErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } @@ -1628,7 +1931,14 @@ private fun List.copyTo( val startTimer: (Boolean) -> Unit = { start -> if (start && updateInterval > 0) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(MultipleFilesResult.InProgress(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles)) + trySend( + MultipleFilesResult.InProgress( + bytesMoved * 100f / totalSizeToCopy, + bytesMoved, + writeSpeed, + totalCopiedFiles + ) + ) writeSpeed = 0 } } @@ -1636,7 +1946,8 @@ 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: (MultipleFilesErrorCode) -> Unit = { errorCode -> if (!canceled) { canceled = true @@ -1645,7 +1956,12 @@ private fun List.copyTo( trySend( MultipleFilesResult.Error( errorCode, - completedData = MultipleFilesResult.Completed(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, false) + completedData = MultipleFilesResult.Completed( + results.map { it.value }, + totalFilesToCopy, + totalCopiedFiles, + false + ) ) ) } @@ -1704,7 +2020,11 @@ 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() @@ -1731,7 +2051,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) { @@ -1743,9 +2064,15 @@ 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(SingleFolderConflictCallback.FileConflict(sourceFile, targetFile)) + conflictedFiles.add( + SingleFolderConflictCallback.FileConflict( + sourceFile, + targetFile + ) + ) continue } @@ -1774,7 +2101,14 @@ private fun List.copyTo( if (deleteSourceWhenComplete && success) { sourceInfos.forEach { (src, _) -> src.forceDelete(context) } } - trySend(MultipleFilesResult.Completed(results.map { it.value }, totalFilesToCopy, totalCopiedFiles, success)) + trySend( + MultipleFilesResult.Completed( + results.map { it.value }, + totalFilesToCopy, + totalCopiedFiles, + success + ) + ) true } else false } @@ -1784,7 +2118,11 @@ private fun List.copyTo( } val solutions = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onContentConflict(writableTargetParentFolder, conflictedFiles, SingleFolderConflictCallback.FolderContentConflictAction(it)) + onConflict.onContentConflict( + writableTargetParentFolder, + conflictedFiles, + SingleFolderConflictCallback.FolderContentConflictAction(it) + ) }.filter { // free up space first, by deleting some files if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { @@ -1863,7 +2201,10 @@ private fun List.doesMeetCopyRequirements( if (invalidSourceFiles.isNotEmpty()) { val abort = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onInvalidSourceFilesFound(invalidSourceFiles, MultipleFilesConflictCallback.InvalidSourceFilesAction(it)) + onConflict.onInvalidSourceFilesFound( + invalidSourceFiles, + MultipleFilesConflictCallback.InvalidSourceFilesAction(it) + ) } if (abort) { scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.CANCELED)) @@ -1875,13 +2216,17 @@ private fun List.doesMeetCopyRequirements( } } - 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 || !writableFolder.isDirectory) { scope.trySend(MultipleFilesResult.Error(MultipleFilesErrorCode.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( @@ -1908,7 +2253,12 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( if (isExternalStorageManager(context)) { val sourceFile = toRawFile(context) ?: return FolderErrorCode.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) } @@ -1917,11 +2267,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 { @@ -1946,7 +2305,16 @@ fun DocumentFile.moveFolderTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFolderConflictCallback ): Flow { - return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, true, updateInterval, isFileSizeAllowed, onConflict) + return copyFolderTo( + context, + targetParentFolder, + skipEmptyFiles, + newFolderNameInTargetPath, + true, + updateInterval, + isFileSizeAllowed, + onConflict + ) } @WorkerThread @@ -1959,7 +2327,16 @@ fun DocumentFile.copyFolderTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFolderConflictCallback ): Flow { - return copyFolderTo(context, targetParentFolder, skipEmptyFiles, newFolderNameInTargetPath, false, updateInterval, isFileSizeAllowed, onConflict) + return copyFolderTo( + context, + targetParentFolder, + skipEmptyFiles, + newFolderNameInTargetPath, + false, + updateInterval, + isFileSizeAllowed, + onConflict + ) } /** @@ -1976,7 +2353,8 @@ private fun DocumentFile.copyFolderTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFolderConflictCallback ): Flow = callbackFlow { - val writableTargetParentFolder = doesMeetFolderCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) + val writableTargetParentFolder = + doesMeetFolderCopyRequirements(context, targetParentFolder, newFolderNameInTargetPath, this) if (writableTargetParentFolder == null) { close() return@callbackFlow @@ -1984,8 +2362,16 @@ private fun DocumentFile.copyFolderTo( send(SingleFolderResult.Preparing) - val targetFolderParentName = (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename().trimFileSeparator() - val conflictResolution = handleParentFolderConflict(context, targetParentFolder, targetFolderParentName, this, onConflict) + val targetFolderParentName = + (newFolderNameInTargetPath ?: name.orEmpty()).removeForbiddenCharsFromFilename() + .trimFileSeparator() + val conflictResolution = handleParentFolderConflict( + context, + targetParentFolder, + targetFolderParentName, + this, + onConflict + ) if (conflictResolution == SingleFolderConflictCallback.ConflictResolution.SKIP) { close() return@callbackFlow @@ -1995,7 +2381,11 @@ private fun DocumentFile.copyFolderTo( 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) { sendAndClose(SingleFolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { @@ -2028,7 +2418,14 @@ private fun DocumentFile.copyFolderTo( conflictResolution )) { is DocumentFile -> { - sendAndClose(SingleFolderResult.Completed(result, totalFilesToCopy, totalFilesToCopy, true)) + sendAndClose( + SingleFolderResult.Completed( + result, + totalFilesToCopy, + totalFilesToCopy, + true + ) + ) return@callbackFlow } @@ -2039,12 +2436,22 @@ private fun DocumentFile.copyFolderTo( } } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetParentFolder.getStorageId(context)), totalSizeToCopy)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + writableTargetParentFolder.getStorageId(context) + ), totalSizeToCopy + ) + ) { sendAndClose(SingleFolderResult.Error(FolderErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } - val targetFolder = writableTargetParentFolder.makeFolder(context, targetFolderParentName, conflictResolution.toCreateMode()) + val targetFolder = writableTargetParentFolder.makeFolder( + context, + targetFolderParentName, + conflictResolution.toCreateMode() + ) if (targetFolder == null) { sendAndClose(SingleFolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return@callbackFlow @@ -2057,7 +2464,14 @@ private fun DocumentFile.copyFolderTo( val startTimer: (Boolean) -> Unit = { start -> if (start && updateInterval > 0) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(SingleFolderResult.InProgress(bytesMoved * 100f / totalSizeToCopy, bytesMoved, writeSpeed, totalCopiedFiles)) + trySend( + SingleFolderResult.InProgress( + bytesMoved * 100f / totalSizeToCopy, + bytesMoved, + writeSpeed, + totalCopiedFiles + ) + ) writeSpeed = 0 } } @@ -2065,13 +2479,24 @@ 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: (FolderErrorCode) -> Unit = { errorCode -> if (!canceled) { canceled = true timer?.cancel() targetFile?.delete() - trySend(SingleFolderResult.Error(errorCode, completedData = SingleFolderResult.Completed(targetFolder, totalFilesToCopy, totalCopiedFiles, false))) + trySend( + SingleFolderResult.Error( + errorCode, + completedData = SingleFolderResult.Completed( + targetFolder, + totalFilesToCopy, + totalCopiedFiles, + false + ) + ) + ) } } @@ -2145,7 +2570,12 @@ private fun DocumentFile.copyFolderTo( targetFile = targetFolder.makeFile(context, filename, sourceFile.type, CreateMode.REUSE) if (targetFile != null && targetFile.length() > 0) { - conflictedFiles.add(SingleFolderConflictCallback.FileConflict(sourceFile, targetFile)) + conflictedFiles.add( + SingleFolderConflictCallback.FileConflict( + sourceFile, + targetFile + ) + ) continue } @@ -2170,7 +2600,14 @@ private fun DocumentFile.copyFolderTo( timer?.cancel() if (!success || conflictedFiles.isEmpty()) { if (deleteSourceWhenComplete && success) forceDelete(context) - trySend(SingleFolderResult.Completed(targetFolder, totalFilesToCopy, totalCopiedFiles, success)) + trySend( + SingleFolderResult.Completed( + targetFolder, + totalFilesToCopy, + totalCopiedFiles, + success + ) + ) true } else false } @@ -2180,7 +2617,11 @@ private fun DocumentFile.copyFolderTo( } val solutions = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onContentConflict(targetFolder, conflictedFiles, SingleFolderConflictCallback.FolderContentConflictAction(it)) + onConflict.onContentConflict( + targetFolder, + conflictedFiles, + SingleFolderConflictCallback.FolderContentConflictAction(it) + ) }.filter { // free up space first, by deleting some files if (it.solution == SingleFileConflictCallback.ConflictResolution.SKIP) { @@ -2202,7 +2643,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(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET) close() @@ -2269,7 +2711,9 @@ private fun DocumentFile.doesMeetFolderCopyRequirements( 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) { scope.trySend(SingleFolderResult.Error(FolderErrorCode.STORAGE_PERMISSION_DENIED)) } @@ -2288,7 +2732,14 @@ fun DocumentFile.copyFileTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback ): Flow { - return copyFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, onConflict) + return copyFileTo( + context, + targetFolder.absolutePath, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) } /** @@ -2308,7 +2759,16 @@ fun DocumentFile.copyFileTo( if (targetFolder == null) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - sendAll(copyFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict)) + sendAll( + copyFileTo( + context, + targetFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) + ) } } @@ -2325,13 +2785,32 @@ fun DocumentFile.copyFileTo( onConflict: SingleFileConflictCallback ): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { - copyFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileTo( + context, + targetFolder, + fileDescription?.name, + fileDescription?.mimeType, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) } else { - val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + val targetDirectory = + targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileTo( + context, + targetDirectory, + fileDescription?.name, + fileDescription?.mimeType, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) } } close() @@ -2347,26 +2826,42 @@ private fun DocumentFile.copyFileTo( isFileSizeAllowed: CheckFileSize, onConflict: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return + val writableTargetFolder = + doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) + ?: return scope.trySend(SingleFileResult.Preparing) - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, writableTargetFolder.getStorageId(context)), length())) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + writableTargetFolder.getStorageId(context) + ), length() + ) + ) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return } - val cleanFileName = MimeType.getFullFileName(newFilenameInTargetPath ?: name.orEmpty(), newMimeTypeInTargetPath ?: mimeTypeByFileName) + val cleanFileName = MimeType.getFullFileName( + newFilenameInTargetPath ?: name.orEmpty(), + newMimeTypeInTargetPath ?: mimeTypeByFileName + ) .removeForbiddenCharsFromFilename().trimFileSeparator() - val fileConflictResolution = handleFileConflict(context, writableTargetFolder, cleanFileName, scope, onConflict) + val fileConflictResolution = + handleFileConflict(context, writableTargetFolder, cleanFileName, scope, onConflict) if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } try { val targetFile = createTargetFile( - context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), scope + context, + writableTargetFolder, + cleanFileName, + newMimeTypeInTargetPath ?: mimeTypeByFileName, + fileConflictResolution.toCreateMode(), + scope ) ?: return val outputStream = targetFile.openOutputStream(context) if (outputStream == null) { @@ -2416,7 +2911,8 @@ private fun DocumentFile.doesMeetFileCopyRequirements( 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) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) } @@ -2457,7 +2953,13 @@ private fun DocumentFile.copyFileStream( // using timer on small file is useless. We set minimum 10MB. if (updateInterval > 0 && srcSize > 10 * FileSize.MB) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - scope.trySend(SingleFileResult.InProgress(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed)) + scope.trySend( + SingleFileResult.InProgress( + bytesMoved * 100f / srcSize, + bytesMoved, + writeSpeed + ) + ) writeSpeed = 0 } } @@ -2476,7 +2978,7 @@ private fun DocumentFile.copyFileStream( if (targetFile is MediaFile) { targetFile.length = srcSize } - scope.trySend(SingleFileResult.Completed(targetFile)) + scope.trySend(SingleFileResult.Completed.get(targetFile)) } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -2496,7 +2998,14 @@ fun DocumentFile.moveFileTo( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback ): Flow { - return moveFileTo(context, targetFolder.absolutePath, fileDescription, updateInterval, isFileSizeAllowed, onConflict) + return moveFileTo( + context, + targetFolder.absolutePath, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) } /** @@ -2516,7 +3025,16 @@ fun DocumentFile.moveFileTo( if (targetFolder == null) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - sendAll(moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict)) + sendAll( + moveFileTo( + context, + targetFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) + ) } } @@ -2533,13 +3051,32 @@ fun DocumentFile.moveFileTo( onConflict: SingleFileConflictCallback ): Flow = callbackFlow { if (fileDescription?.subFolder.isNullOrEmpty()) { - moveFileTo(context, targetFolder, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, onConflict) + moveFileTo( + context, + targetFolder, + fileDescription?.name, + fileDescription?.mimeType, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) } else { - val targetDirectory = targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) + val targetDirectory = + targetFolder.makeFolder(context, fileDescription?.subFolder.orEmpty(), CreateMode.REUSE) if (targetDirectory == null) { send(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - moveFileTo(context, targetDirectory, fileDescription?.name, fileDescription?.mimeType, updateInterval, this, isFileSizeAllowed, onConflict) + moveFileTo( + context, + targetDirectory, + fileDescription?.name, + fileDescription?.mimeType, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) } } close() @@ -2555,20 +3092,31 @@ private fun DocumentFile.moveFileTo( isFileSizeAllowed: CheckFileSize, onConflict: SingleFileConflictCallback ) { - val writableTargetFolder = doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) ?: return + val writableTargetFolder = + doesMeetFileCopyRequirements(context, targetFolder, newFilenameInTargetPath, scope) + ?: return scope.trySend(SingleFileResult.Preparing) - 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, scope, onConflict) + val fileConflictResolution = + handleFileConflict(context, writableTargetFolder, cleanFileName, scope, onConflict) if (fileConflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { return } if (inInternalStorage(context)) { - toRawFile(context)?.moveTo(context, writableTargetFolder.getAbsolutePath(context), cleanFileName, fileConflictResolution)?.let { - scope.trySend(SingleFileResult.Completed(DocumentFile.fromFile(it))) + toRawFile(context)?.moveTo( + context, + writableTargetFolder.getAbsolutePath(context), + cleanFileName, + fileConflictResolution + )?.let { + scope.trySend(SingleFileResult.Completed.DocumentFile(DocumentFile.fromFile(it))) return } } @@ -2581,21 +3129,32 @@ private fun DocumentFile.moveFileTo( return } writableTargetFolder.toRawFile(context)?.let { destinationFolder -> - sourceFile.moveTo(context, destinationFolder, cleanFileName, fileConflictResolution)?.let { - scope.trySend(SingleFileResult.Completed(DocumentFile.fromFile(it))) - return - } + sourceFile.moveTo(context, destinationFolder, cleanFileName, fileConflictResolution) + ?.let { + scope.trySend(SingleFileResult.Completed.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) - scope.trySend(SingleFileResult.Completed(newFile)) + scope.trySend(SingleFileResult.Completed.DocumentFile(newFile)) } else { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.TARGET_FILE_NOT_FOUND)) } @@ -2603,7 +3162,11 @@ private fun DocumentFile.moveFileTo( } } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetStorageId), length())) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace(context, targetStorageId), + length() + ) + ) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return } @@ -2614,8 +3177,12 @@ private fun DocumentFile.moveFileTo( try { val targetFile = createTargetFile( - context, writableTargetFolder, cleanFileName, newMimeTypeInTargetPath ?: mimeTypeByFileName, - fileConflictResolution.toCreateMode(), scope + context, + writableTargetFolder, + cleanFileName, + newMimeTypeInTargetPath ?: mimeTypeByFileName, + fileConflictResolution.toCreateMode(), + scope ) ?: return val outputStream = targetFile.openOutputStream(context) if (outputStream == null) { @@ -2662,7 +3229,12 @@ private fun DocumentFile.copyFileToMedia( ) { if (simpleCheckSourceFile(scope)) 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) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) @@ -2675,14 +3247,29 @@ private fun DocumentFile.copyFileToMedia( return } } else { - fileDescription.name = publicFolder.autoIncrementFileName(context, it.name.orEmpty()) + fileDescription.name = + publicFolder.autoIncrementFileName(context, it.name.orEmpty()) } } fileDescription.subFolder = "" if (deleteSourceFileWhenComplete) { - moveFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) + moveFileTo( + context, + publicFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) } else { - copyFileTo(context, publicFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict) + copyFileTo( + context, + publicFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) } } else { val validMode = if (mode == CreateMode.REUSE) CreateMode.CREATE_NEW else mode @@ -2694,7 +3281,14 @@ private fun DocumentFile.copyFileToMedia( if (mediaFile == null) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) } else { - copyFileTo(context, mediaFile, deleteSourceFileWhenComplete, updateInterval, scope, isFileSizeAllowed) + copyFileTo( + context, + mediaFile, + deleteSourceFileWhenComplete, + updateInterval, + scope, + isFileSizeAllowed + ) } } } @@ -2709,7 +3303,17 @@ fun DocumentFile.copyFileToDownloadMedia( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback, ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, false, mode, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileToMedia( + context, + fileDescription, + PublicDirectory.DOWNLOADS, + false, + mode, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) close() } @@ -2723,7 +3327,17 @@ fun DocumentFile.copyFileToPictureMedia( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback, ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, false, mode, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileToMedia( + context, + fileDescription, + PublicDirectory.PICTURES, + false, + mode, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) close() } @@ -2737,7 +3351,17 @@ fun DocumentFile.moveFileToDownloadMedia( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.DOWNLOADS, true, mode, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileToMedia( + context, + fileDescription, + PublicDirectory.DOWNLOADS, + true, + mode, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) close() } @@ -2751,7 +3375,17 @@ fun DocumentFile.moveFileToPictureMedia( isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - copyFileToMedia(context, fileDescription, PublicDirectory.PICTURES, true, mode, updateInterval, this, isFileSizeAllowed, onConflict) + copyFileToMedia( + context, + fileDescription, + PublicDirectory.PICTURES, + true, + mode, + updateInterval, + this, + isFileSizeAllowed, + onConflict + ) close() } @@ -2810,7 +3444,14 @@ private fun DocumentFile.copyFileTo( scope.trySend(SingleFileResult.Error(SingleFileErrorCode.SOURCE_FILE_NOT_FOUND)) return } - copyFileStream(inputStream, outputStream, targetFile, updateInterval, deleteSourceFileWhenComplete, scope) + copyFileStream( + inputStream, + outputStream, + targetFile, + updateInterval, + deleteSourceFileWhenComplete, + scope + ) } catch (e: Exception) { scope.trySend(SingleFileResult.Error(e.toFileCallbackErrorCode())) } @@ -2861,7 +3502,11 @@ private fun handleParentFolderConflict( } val resolution = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onParentConflict(targetFolder, SingleFolderConflictCallback.ParentFolderConflictAction(it), canMerge) + onConflict.onParentConflict( + targetFolder, + SingleFolderConflictCallback.ParentFolderConflictAction(it), + canMerge + ) } when (resolution) { @@ -2870,7 +3515,8 @@ private fun handleParentFolderConflict( 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) { scope.trySend(SingleFolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return SingleFolderConflictCallback.ConflictResolution.SKIP @@ -2885,7 +3531,8 @@ private fun handleParentFolderConflict( SingleFolderConflictCallback.ConflictResolution.MERGE -> { if (targetFolder.isFile) { if (targetFolder.delete()) { - val newFolder = targetFolder.parentFile?.createDirectory(targetFolderParentName) + val newFolder = + targetFolder.parentFile?.createDirectory(targetFolderParentName) if (newFolder == null) { scope.trySend(SingleFolderResult.Error(FolderErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return SingleFolderConflictCallback.ConflictResolution.SKIP @@ -2921,12 +3568,19 @@ private fun List.handleParentFolderConflict( if (canMerge && it.isEmpty(context)) SingleFolderConflictCallback.ConflictResolution.MERGE else SingleFolderConflictCallback.ConflictResolution.CREATE_NEW MultipleFilesConflictCallback.ParentConflict(sourceFile, it, canMerge, solution) } - val unresolvedConflicts = conflicts.filter { it.solution != SingleFolderConflictCallback.ConflictResolution.MERGE }.toMutableList() + val unresolvedConflicts = + conflicts.filter { it.solution != SingleFolderConflictCallback.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(onConflict.uiScope) { - onConflict.onParentConflict(targetParentFolder, unresolvedFolders, unresolvedFiles, MultipleFilesConflictCallback.ParentFolderConflictAction(it)) + onConflict.onParentConflict( + targetParentFolder, + unresolvedFolders, + unresolvedFiles, + MultipleFilesConflictCallback.ParentFolderConflictAction(it) + ) } if (resolution.any { it.solution == SingleFolderConflictCallback.ConflictResolution.REPLACE }) { scope.trySend(MultipleFilesResult.DeletingConflictedFiles) @@ -2952,7 +3606,8 @@ private fun List.handleParentFolderConflict( } } } - return resolution.toMutableList().apply { addAll(conflicts.filter { it.solution == SingleFolderConflictCallback.ConflictResolution.MERGE }) } + return resolution.toMutableList() + .apply { addAll(conflicts.filter { it.solution == SingleFolderConflictCallback.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 83% 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 025304f..7637369 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileExt.kt @@ -42,7 +42,8 @@ val File.inPrimaryStorage: Boolean fun File.inDataStorage(context: Context) = path.startsWith(context.dataDirectory.path) -fun File.inSdCardStorage(context: Context) = getStorageId(context).let { it != PRIMARY && it != DATA && path.startsWith("/storage/$it") } +fun File.inSdCardStorage(context: Context) = + getStorageId(context).let { it != PRIMARY && it != DATA && path.startsWith("/storage/$it") } fun File.inSameMountPointWith(context: Context, file: File): Boolean { val storageId1 = getStorageId(context) @@ -92,7 +93,8 @@ fun File.getRootPath(context: Context): String { } } -fun File.getSimplePath(context: Context) = "${getStorageId(context)}:${getBasePath(context)}".removePrefix(":") +fun File.getSimplePath(context: Context) = + "${getStorageId(context)}:${getBasePath(context)}".removePrefix(":") /** * Returns: @@ -103,11 +105,12 @@ val File.mimeType: String? get() = if (isFile) MimeType.getMimeTypeFromExtension(extension) else null @JvmOverloads -fun File.getRootRawFile(context: Context, requiresWriteAccess: Boolean = false) = getRootPath(context).let { - if (it.isEmpty()) null else File(it).run { - takeIfWritable(context, requiresWriteAccess) +fun File.getRootRawFile(context: Context, requiresWriteAccess: Boolean = false) = + getRootPath(context).let { + if (it.isEmpty()) null else File(it).run { + takeIfWritable(context, requiresWriteAccess) + } } -} fun File.isReadOnly(context: Context) = canRead() && !isWritable(context) @@ -117,13 +120,19 @@ val File.isEmpty: Boolean get() = isFile && length() == 0L || isDirectory && list().isNullOrEmpty() @RestrictTo(RestrictTo.Scope.LIBRARY) -fun File.shouldWritable(context: Context, requiresWriteAccess: Boolean) = requiresWriteAccess && isWritable(context) || !requiresWriteAccess +fun File.shouldWritable(context: Context, requiresWriteAccess: Boolean) = + requiresWriteAccess && isWritable(context) || !requiresWriteAccess @RestrictTo(RestrictTo.Scope.LIBRARY) -fun File.takeIfWritable(context: Context, requiresWriteAccess: Boolean) = takeIf { it.canRead() && it.shouldWritable(context, requiresWriteAccess) } +fun File.takeIfWritable(context: Context, requiresWriteAccess: Boolean) = + takeIf { it.canRead() && it.shouldWritable(context, requiresWriteAccess) } @RestrictTo(RestrictTo.Scope.LIBRARY) -fun File.checkRequirements(context: Context, requiresWriteAccess: Boolean, considerRawFile: Boolean) = canRead() && shouldWritable(context, requiresWriteAccess) +fun File.checkRequirements( + context: Context, + requiresWriteAccess: Boolean, + considerRawFile: Boolean +) = canRead() && shouldWritable(context, requiresWriteAccess) && (considerRawFile || isExternalStorageManager(context)) fun File.createNewFileIfPossible(): Boolean = try { @@ -142,9 +151,12 @@ fun File.isWritable(context: Context) = canWrite() && (isFile || isExternalStora * @return `true` if you have full disk access * @see Environment.isExternalStorageManager */ -fun File.isExternalStorageManager(context: Context) = Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && Environment.isExternalStorageManager(this) - || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && path.startsWith(SimpleStorage.externalStoragePath) && SimpleStorage.hasStoragePermission(context) - || context.writableDirs.any { path.startsWith(it.path) } +fun File.isExternalStorageManager(context: Context) = + Build.VERSION.SDK_INT > Build.VERSION_CODES.Q && Environment.isExternalStorageManager(this) + || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && path.startsWith(SimpleStorage.externalStoragePath) && SimpleStorage.hasStoragePermission( + context + ) + || context.writableDirs.any { path.startsWith(it.path) } /** * These directories do not require storage permissions. They are always writable with full disk access. @@ -182,11 +194,12 @@ fun File.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('.') @@ -207,7 +220,10 @@ fun File.makeFile( } return try { - File(parent, autoIncrementFileName(fullFileName)).let { if (it.createNewFile()) it else null } + File( + parent, + autoIncrementFileName(fullFileName) + ).let { if (it.createNewFile()) it else null } } catch (e: IOException) { null } @@ -218,14 +234,21 @@ fun File.makeFile( */ @WorkerThread @JvmOverloads -fun File.makeFolder(context: Context, name: String, mode: CreateMode = CreateMode.CREATE_NEW): File? { +fun File.makeFolder( + context: Context, + name: String, + mode: CreateMode = CreateMode.CREATE_NEW +): File? { if (!isDirectory || !isWritable(context)) { return null } - val directorySequence = DocumentFileCompat.getDirectorySequence(name.removeForbiddenCharsFromFilename()).toMutableList() + val directorySequence = + DocumentFileCompat.getDirectorySequence(name.removeForbiddenCharsFromFilename()) + .toMutableList() val folderNameLevel1 = directorySequence.removeFirstOrNull() ?: return null - val incrementedFolderNameLevel1 = if (mode == CreateMode.CREATE_NEW) autoIncrementFileName(folderNameLevel1) else folderNameLevel1 + val incrementedFolderNameLevel1 = + if (mode == CreateMode.CREATE_NEW) autoIncrementFileName(folderNameLevel1) else folderNameLevel1 val folderLevel1 = child(incrementedFolderNameLevel1) if (mode == CreateMode.REPLACE) { @@ -235,11 +258,16 @@ fun File.makeFolder(context: Context, name: String, mode: CreateMode = CreateMod } folderLevel1.mkdir() - val folder = folderLevel1.let { if (directorySequence.isEmpty()) it else it.child(directorySequence.joinToString("/")).apply { mkdirs() } } + val folder = folderLevel1.let { + if (directorySequence.isEmpty()) it else it.child( + directorySequence.joinToString("/") + ).apply { mkdirs() } + } return if (folder.isDirectory) folder else null } -fun File.toDocumentFile(context: Context) = if (canRead()) DocumentFileCompat.fromFile(context, this) else null +fun File.toDocumentFile(context: Context) = + if (canRead()) DocumentFileCompat.fromFile(context, this) else null fun File.deleteEmptyFolders(context: Context): Boolean { return if (isDirectory && isWritable(context)) { @@ -299,7 +327,9 @@ fun File.autoIncrementFileName(filename: String): String { 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) + it.startsWith(prefix) && (DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITH_EXTENSION.matches( + it + ) || DocumentFileCompat.FILE_NAME_DUPLICATION_REGEX_WITHOUT_EXTENSION.matches(it)) }.maxOfOrNull { it.substringAfterLast('(', "") 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 89% 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 b0ae8be..4d1d0d7 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileFullPath.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/file/FileFullPath.kt @@ -53,6 +53,7 @@ class FileFullPath { simplePath = "$storageId:$basePath" absolutePath = "$rootPath/$basePath".trimEnd('/') } + fullPath.startsWith(context.dataDirectory.path) -> { storageId = StorageId.DATA val rootPath = context.dataDirectory.path @@ -60,9 +61,11 @@ class FileFullPath { simplePath = "$storageId:$basePath" absolutePath = "$rootPath/$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 { @@ -101,11 +104,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) @@ -113,7 +117,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/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 100% rename from storage/src/main/java/com/anggrayudi/storage/file/PublicDirectory.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/PublicDirectory.kt 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 100% rename from storage/src/main/java/com/anggrayudi/storage/file/StorageId.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/file/StorageId.kt 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 74% 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 08ad6be..4e63093 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFile.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFile.kt @@ -64,31 +64,35 @@ import java.io.IOException import java.io.InputStream import java.io.OutputStream +typealias OnWriteAccessDenied = (mediaFile: MediaFile, sender: IntentSender) -> Unit + /** * Created on 06/09/20 * @author Anggrayudi H + * + * @param onWriteAccessDenied For inquiring the user's consent to modify other app's files upon write access having been denied + * whilst carrying out a file operation depending on it. Only called starting from Android 10. + * @see RecoverableSecurityException + * @see [android.app.Activity.startIntentSenderForResult] */ -class MediaFile(context: Context, val uri: Uri) { - - constructor(context: Context, rawFile: File) : this(context, Uri.fromFile(rawFile)) - - 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) - } +class MediaFile @JvmOverloads constructor( + context: Context, + val uri: Uri, + var onWriteAccessDenied: OnWriteAccessDenied? = null +) { /** - * Only useful for Android 10 and higher. - * @see RecoverableSecurityException + * For construction from a [File]. + * @see MediaFile */ - var accessCallback: AccessCallback? = null + @JvmOverloads + constructor( + context: Context, + rawFile: File, + onWriteAccessDenied: OnWriteAccessDenied? = null + ) : this(context, Uri.fromFile(rawFile)) + + private val context = context.applicationContext /** * Some media files do not return file extension. This function helps you to fix this kind of issue. @@ -121,7 +125,13 @@ class MediaFile(context: Context, val uri: Uri) { */ @Suppress("DEPRECATION") 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) /** @@ -136,7 +146,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) @@ -192,7 +203,12 @@ class MediaFile(context: Context, val uri: Uri) { @Deprecated("Accessing files with java.io.File only works on app private directory 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 + ) + } @Suppress("DEPRECATION") val absolutePath: String @@ -203,7 +219,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 "" @@ -214,14 +236,22 @@ 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() } } } @@ -239,16 +269,28 @@ 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) { @@ -258,11 +300,12 @@ class MediaFile(context: Context, val uri: Uri) { else -> { val projection = arrayOf(MediaStore.MediaColumns.RELATIVE_PATH) - context.contentResolver.query(uri, projection, null, null, null)?.use { cursor -> - 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() } } } @@ -287,7 +330,8 @@ class MediaFile(context: Context, val uri: Uri) { @Suppress("DEPRECATION") 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)) @@ -305,7 +349,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) { @@ -313,9 +358,12 @@ class MediaFile(context: Context, val uri: Uri) { } } - private fun handleSecurityException(e: SecurityException, scope: ProducerScope? = null) { + private fun handleSecurityException( + e: SecurityException, + scope: ProducerScope? = null, + ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && e is RecoverableSecurityException) { - accessCallback?.onWriteAccessDenied(this, e.userAction.actionIntent.intentSender) + onWriteAccessDenied?.invoke(this, e.userAction.actionIntent.intentSender) } else { scope?.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) } @@ -323,7 +371,13 @@ class MediaFile(context: Context, val uri: Uri) { @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) @@ -363,7 +417,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) { @@ -380,13 +435,27 @@ class MediaFile(context: Context, val uri: Uri) { isFileSizeAllowed: CheckFileSize = defaultFileSizeChecker, onConflict: SingleFileConflictCallback ): Flow = callbackFlow { - val sourceFile = toDocumentFile() - if (sourceFile != null) { - sendAll(sourceFile.moveFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict)) + toDocumentFile()?.let { + sendAll( + it.moveFileTo( + context, + targetFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) + ) return@callbackFlow } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), length + ) + ) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } @@ -394,7 +463,11 @@ class MediaFile(context: Context, val uri: Uri) { 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) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return@callbackFlow @@ -403,9 +476,13 @@ class MediaFile(context: Context, val uri: Uri) { } } - 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, this, onConflict) + val conflictResolution = + handleFileConflict(targetDirectory, cleanFileName, this, onConflict) if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { close() return@callbackFlow @@ -441,11 +518,26 @@ class MediaFile(context: Context, val uri: Uri) { ): Flow = callbackFlow { val sourceFile = toDocumentFile() if (sourceFile != null) { - sendAll(sourceFile.copyFileTo(context, targetFolder, fileDescription, updateInterval, isFileSizeAllowed, onConflict)) + sendAll( + sourceFile.copyFileTo( + context, + targetFolder, + fileDescription, + updateInterval, + isFileSizeAllowed, + onConflict + ) + ) return@callbackFlow } - if (!isFileSizeAllowed(DocumentFileCompat.getFreeSpace(context, targetFolder.getStorageId(context)), length)) { + if (!isFileSizeAllowed( + DocumentFileCompat.getFreeSpace( + context, + targetFolder.getStorageId(context) + ), length + ) + ) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH)) return@callbackFlow } @@ -453,7 +545,11 @@ class MediaFile(context: Context, val uri: Uri) { 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) { sendAndClose(SingleFileResult.Error(SingleFileErrorCode.CANNOT_CREATE_FILE_IN_TARGET)) return@callbackFlow @@ -462,9 +558,13 @@ class MediaFile(context: Context, val uri: Uri) { } } - 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, this, onConflict) + val conflictResolution = + handleFileConflict(targetDirectory, cleanFileName, this, onConflict) if (conflictResolution == SingleFileConflictCallback.ConflictResolution.SKIP) { close() return@callbackFlow @@ -498,7 +598,11 @@ class MediaFile(context: Context, val uri: Uri) { scope: ProducerScope, ): 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) { scope.trySend(SingleFileResult.Error(SingleFileErrorCode.STORAGE_PERMISSION_DENIED)) @@ -556,7 +660,13 @@ class MediaFile(context: Context, val uri: Uri) { // using timer on small file is useless. We set minimum 10MB. if (updateInterval > 0 && srcSize > 10 * FileSize.MB) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - scope.trySend(SingleFileResult.InProgress(bytesMoved * 100f / srcSize, bytesMoved, writeSpeed)) + scope.trySend( + SingleFileResult.InProgress( + bytesMoved * 100f / srcSize, + bytesMoved, + writeSpeed + ) + ) writeSpeed = 0 } } @@ -572,7 +682,7 @@ class MediaFile(context: Context, val uri: Uri) { if (deleteSourceFileWhenComplete) { delete() } - scope.trySend(SingleFileResult.Completed(targetFile)) + scope.trySend(SingleFileResult.Completed.DocumentFile(targetFile)) } finally { timer?.cancel() inputStream.closeStreamQuietly() @@ -588,7 +698,10 @@ class MediaFile(context: Context, val uri: Uri) { ): SingleFileConflictCallback.ConflictResolution { targetFolder.child(context, fileName)?.let { targetFile -> val resolution = awaitUiResultWithPending(onConflict.uiScope) { - onConflict.onFileConflict(targetFile, SingleFileConflictCallback.FileConflictAction(it)) + onConflict.onFileConflict( + targetFile, + SingleFileConflictCallback.FileConflictAction(it) + ) } if (resolution == SingleFileConflictCallback.ConflictResolution.REPLACE) { if (!targetFile.forceDelete(context)) { 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 65% 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 87d392f..2b12a5c 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaFileExt.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaFileExt.kt @@ -17,6 +17,7 @@ import com.anggrayudi.storage.file.isWritable import com.anggrayudi.storage.file.makeFile import com.anggrayudi.storage.file.makeFolder import com.anggrayudi.storage.file.openOutputStream +import com.anggrayudi.storage.result.DecompressedZipFile import com.anggrayudi.storage.result.ZipCompressionErrorCode import com.anggrayudi.storage.result.ZipCompressionResult import com.anggrayudi.storage.result.ZipDecompressionErrorCode @@ -46,20 +47,36 @@ fun List.compressToZip( send(ZipCompressionResult.CountingFiles) val entryFiles = distinctBy { it.uri }.filter { !it.isEmpty } if (entryFiles.isEmpty()) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, "No entry files found")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.MISSING_ENTRY_FILE, + "No entry files found" + ) + ) return@callbackFlow } 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) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, "Cannot create ZIP file in target")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, + "Cannot create ZIP file in target" + ) + ) return@callbackFlow } if (!zipFile.isWritable(context)) { - sendAndClose(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination ZIP file is not writable")) + sendAndClose( + ZipCompressionResult.Error( + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Destination ZIP file is not writable" + ) + ) return@callbackFlow } @@ -73,7 +90,14 @@ fun List.compressToZip( var fileCompressedCount = 0 if (updateInterval > 0) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(ZipCompressionResult.Compressing(0f, bytesCompressed, writeSpeed, fileCompressedCount)) + trySend( + ZipCompressionResult.Compressing( + 0f, + bytesCompressed, + writeSpeed, + fileCompressedCount + ) + ) writeSpeed = 0 } } @@ -98,12 +122,22 @@ fun List.compressToZip( send(ZipCompressionResult.Error(ZipCompressionErrorCode.MISSING_ENTRY_FILE, e.message)) } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - send(ZipCompressionResult.Error(ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, e.message)) + send( + ZipCompressionResult.Error( + ZipCompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, + e.message + ) + ) } else { send(ZipCompressionResult.Error(ZipCompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) } } catch (e: SecurityException) { - send(ZipCompressionResult.Error(ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) + send( + ZipCompressionResult.Error( + ZipCompressionErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + ) } finally { timer?.cancel() zos.closeEntryQuietly() @@ -115,7 +149,14 @@ fun List.compressToZip( forEach { it.delete() } } val sizeReduction = (bytesCompressed - zipFile.length()).toFloat() / bytesCompressed * 100 - send(ZipCompressionResult.Completed(zipFile, bytesCompressed, entryFiles.size, sizeReduction)) + send( + ZipCompressionResult.Completed( + zipFile, + bytesCompressed, + entryFiles.size, + sizeReduction + ) + ) } else { zipFile.delete() } @@ -130,11 +171,21 @@ fun MediaFile.decompressZip( ): Flow = callbackFlow { send(ZipDecompressionResult.Validating) if (isEmpty) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, "No zip file found")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.MISSING_ZIP_FILE, + "No zip file found" + ) + ) return@callbackFlow } if (mimeType != MimeType.ZIP) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NOT_A_ZIP_FILE, "Not a ZIP file")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.NOT_A_ZIP_FILE, + "Not a ZIP file" + ) + ) return@callbackFlow } @@ -143,7 +194,12 @@ fun MediaFile.decompressZip( destFolder = targetFolder.findParent(context)?.makeFolder(context, targetFolder.fullName) } if (destFolder == null || !destFolder.isWritable(context)) { - sendAndClose(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, "Destination folder is not writable")) + sendAndClose( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, + "Destination folder is not writable" + ) + ) return@callbackFlow } @@ -159,7 +215,13 @@ fun MediaFile.decompressZip( var writeSpeed = 0 if (updateInterval > 0) { timer = startCoroutineTimer(repeatMillis = updateInterval) { - trySend(ZipDecompressionResult.Decompressing(bytesDecompressed, writeSpeed, fileDecompressedCount)) + trySend( + ZipDecompressionResult.Decompressing( + bytesDecompressed, + writeSpeed, + fileDecompressedCount + ) + ) writeSpeed = 0 } } @@ -171,12 +233,21 @@ 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) { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, "Cannot create file in target")) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.CANNOT_CREATE_FILE_IN_TARGET, + "Cannot create file in target" + ) + ) canSuccess = false break } @@ -201,24 +272,53 @@ fun MediaFile.decompressZip( } success = canSuccess } catch (e: InterruptedIOException) { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.CANCELED, "Decompression canceled")) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.CANCELED, + "Decompression canceled" + ) + ) } catch (e: FileNotFoundException) { send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.MISSING_ZIP_FILE, e.message)) } catch (e: IOException) { if (e.message?.contains("no space", true) == true) { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, e.message)) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH, + e.message + ) + ) } else { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, e.message)) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.UNKNOWN_IO_ERROR, + e.message + ) + ) } } catch (e: SecurityException) { - send(ZipDecompressionResult.Error(ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, e.message)) + send( + ZipDecompressionResult.Error( + ZipDecompressionErrorCode.STORAGE_PERMISSION_DENIED, + e.message + ) + ) } finally { timer?.cancel() zis.closeEntryQuietly() zis.closeStreamQuietly() } if (success) { - send(ZipDecompressionResult.Completed(this, destFolder, bytesDecompressed, skippedDecompressedBytes, fileDecompressedCount, 0f)) + send( + ZipDecompressionResult.Completed( + DecompressedZipFile.MediaFile(this@decompressZip), + destFolder, + bytesDecompressed, + skippedDecompressedBytes, + fileDecompressedCount, + 0f + ) + ) } 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 63% 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 120fff4..f5f3aed 100644 --- a/storage/src/main/java/com/anggrayudi/storage/media/MediaStoreCompat.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaStoreCompat.kt @@ -42,8 +42,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 @@ -51,7 +61,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) @@ -62,7 +72,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) @@ -73,7 +83,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) @@ -81,7 +91,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 @@ -89,9 +104,9 @@ object MediaStoreCompat { val mediaFolder = basePath.substringBefore('/') val mediaType = when (mediaFolder) { Environment.DIRECTORY_DOWNLOADS -> MediaType.DOWNLOADS - in ImageMediaDirectory.entries.map { it.folderName } -> MediaType.IMAGE - in AudioMediaDirectory.entries.map { it.folderName } -> MediaType.AUDIO - in VideoMediaDirectory.entries.map { it.folderName } -> MediaType.VIDEO + in MediaDirectory.Image.entries.map { it.folderName } -> MediaType.IMAGE + in MediaDirectory.Audio.entries.map { it.folderName } -> MediaType.AUDIO + in MediaDirectory.Video.entries.map { it.folderName } -> MediaType.VIDEO else -> return null } val subFolder = basePath.substringAfter('/', "") @@ -99,14 +114,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) @@ -135,14 +159,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 @@ -177,9 +209,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 @@ -220,7 +259,13 @@ object MediaStoreCompat { } } 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) } } @@ -234,15 +279,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) { - 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) } } } @@ -262,7 +317,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 @@ -271,16 +327,28 @@ object MediaStoreCompat { fun fromRelativePath(context: Context, relativePath: String): List = runBlocking { val cleanRelativePath = relativePath.trimFileSeparator() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - DocumentFile.fromFile(File(Environment.getExternalStorageDirectory(), cleanRelativePath)) + DocumentFile.fromFile( + File( + Environment.getExternalStorageDirectory(), + cleanRelativePath + ) + ) .search(true, DocumentFileType.FILE) .first() .map { MediaFile(context, File(it.uri.path!!)) } } else { - val mediaType = mediaTypeFromRelativePath(cleanRelativePath) ?: return@runBlocking emptyList() + val mediaType = + mediaTypeFromRelativePath(cleanRelativePath) ?: return@runBlocking emptyList() val relativePathWithSlashSuffix = relativePath.trimEnd('/') + '/' val selection = "${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" val selectionArgs = arrayOf(relativePathWithSlashSuffix, cleanRelativePath) - context.contentResolver.query(mediaType.readUri ?: return@runBlocking emptyList(), arrayOf(BaseColumns._ID), selection, selectionArgs, null)?.use { + context.contentResolver.query( + mediaType.readUri ?: return@runBlocking emptyList(), + arrayOf(BaseColumns._ID), + selection, + selectionArgs, + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } @@ -290,59 +358,94 @@ object MediaStoreCompat { * @see MediaStore.MediaColumns.RELATIVE_PATH */ @JvmStatic - fun fromRelativePath(context: Context, relativePath: String, name: String): MediaFile? = runBlocking { - val cleanRelativePath = relativePath.trimFileSeparator() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - DocumentFile.fromFile(File(Environment.getExternalStorageDirectory(), cleanRelativePath)) - .search(true, DocumentFileType.FILE, name = name) - .first() - .map { MediaFile(context, File(it.uri.path!!)) } - .firstOrNull() - } else { - val mediaType = mediaTypeFromRelativePath(cleanRelativePath) ?: return@runBlocking null - val relativePathWithSlashSuffix = relativePath.trimEnd('/') + '/' - val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" - val selectionArgs = arrayOf(name, relativePathWithSlashSuffix, cleanRelativePath) - context.contentResolver.query(mediaType.readUri ?: return@runBlocking null, arrayOf(BaseColumns._ID), selection, selectionArgs, null)?.use { - fromCursorToMediaFile(context, mediaType, it) + fun fromRelativePath(context: Context, relativePath: String, name: String): MediaFile? = + runBlocking { + val cleanRelativePath = relativePath.trimFileSeparator() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + DocumentFile.fromFile( + File( + Environment.getExternalStorageDirectory(), + cleanRelativePath + ) + ) + .search(true, DocumentFileType.FILE, name = name) + .first() + .map { MediaFile(context, File(it.uri.path!!)) } + .firstOrNull() + } else { + val mediaType = + mediaTypeFromRelativePath(cleanRelativePath) ?: return@runBlocking null + val relativePathWithSlashSuffix = relativePath.trimEnd('/') + '/' + val selection = + "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} IN(?, ?)" + val selectionArgs = arrayOf(name, relativePathWithSlashSuffix, cleanRelativePath) + context.contentResolver.query( + mediaType.readUri ?: return@runBlocking null, + arrayOf(BaseColumns._ID), + selection, + selectionArgs, + null + )?.use { + fromCursorToMediaFile(context, mediaType, it) + } } } - } @JvmStatic - fun fromFileNameContains(context: Context, mediaType: MediaType, containsName: String): List = runBlocking { + fun fromFileNameContains( + context: Context, + mediaType: MediaType, + containsName: String + ): List = runBlocking { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { mediaType.directories.map { directory -> DocumentFile.fromFile(directory) - .search(true, regex = Regex("^.*$containsName.*\$"), mimeTypes = arrayOf(mediaType.mimeType)) + .search( + true, + regex = Regex("^.*$containsName.*\$"), + mimeTypes = arrayOf(mediaType.mimeType) + ) .first() .map { MediaFile(context, File(it.uri.path!!)) } }.flatten() } else { val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE '%$containsName%'" - context.contentResolver.query(mediaType.readUri ?: return@runBlocking emptyList(), arrayOf(BaseColumns._ID), selection, null, null)?.use { + context.contentResolver.query( + mediaType.readUri ?: return@runBlocking emptyList(), + arrayOf(BaseColumns._ID), + selection, + null, + null + )?.use { fromCursorToMediaFiles(context, mediaType, it) }.orEmpty() } } @JvmStatic - fun fromMimeType(context: Context, mediaType: MediaType, mimeType: String): List = runBlocking { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - mediaType.directories.map { directory -> - DocumentFile.fromFile(directory) - .search(true, DocumentFileType.FILE, arrayOf(mimeType)) - .first() - .map { MediaFile(context, File(it.uri.path!!)) } - }.flatten() - } else { - val selection = "${MediaStore.MediaColumns.MIME_TYPE} = ?" - context.contentResolver.query(mediaType.readUri ?: return@runBlocking emptyList(), arrayOf(BaseColumns._ID), selection, arrayOf(mimeType), null) - ?.use { - fromCursorToMediaFiles(context, mediaType, it) - }.orEmpty() + fun fromMimeType(context: Context, mediaType: MediaType, mimeType: String): List = + runBlocking { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + mediaType.directories.map { directory -> + DocumentFile.fromFile(directory) + .search(true, DocumentFileType.FILE, arrayOf(mimeType)) + .first() + .map { MediaFile(context, File(it.uri.path!!)) } + }.flatten() + } else { + val selection = "${MediaStore.MediaColumns.MIME_TYPE} = ?" + context.contentResolver.query( + mediaType.readUri ?: return@runBlocking emptyList(), + arrayOf(BaseColumns._ID), + selection, + arrayOf(mimeType), + null + ) + ?.use { + fromCursorToMediaFiles(context, mediaType, it) + }.orEmpty() + } } - } @JvmStatic fun fromMediaType(context: Context, mediaType: MediaType): List = runBlocking { @@ -354,13 +457,23 @@ object MediaStoreCompat { .map { MediaFile(context, File(it.uri.path!!)) } }.flatten() } else { - context.contentResolver.query(mediaType.readUri ?: return@runBlocking emptyList(), arrayOf(BaseColumns._ID), null, null, null)?.use { + context.contentResolver.query( + mediaType.readUri ?: return@runBlocking 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 { @@ -373,7 +486,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..1ad0289 --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/media/MediaType.kt @@ -0,0 +1,64 @@ +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.entries.map { + Environment.getExternalStoragePublicDirectory( + it.folderName + ) + } + + AUDIO -> MediaDirectory.Audio.entries.map { + Environment.getExternalStoragePublicDirectory( + it.folderName + ) + } + + VIDEO -> MediaDirectory.Video.entries.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 86% rename from storage/src/main/java/com/anggrayudi/storage/permission/PermissionReport.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionReport.kt index b2568eb..12f3455 100644 --- a/storage/src/main/java/com/anggrayudi/storage/permission/PermissionReport.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/permission/PermissionReport.kt @@ -4,7 +4,7 @@ package com.anggrayudi.storage.permission * Created on 12/13/20 * @author Anggrayudi H */ -class PermissionReport( +data class PermissionReport( val permission: String, val isGranted: Boolean, val deniedPermanently: Boolean 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/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/FilePropertiesResult.kt similarity index 68% rename from storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/result/FilePropertiesResult.kt index 9f0e57e..0d3f5d8 100644 --- a/storage/src/main/java/com/anggrayudi/storage/result/FilePropertiesResult.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/FilePropertiesResult.kt @@ -23,8 +23,12 @@ data class FileProperties( fun formattedSize(context: Context): String = Formatter.formatFileSize(context, size) } -sealed class FilePropertiesResult { - data class Updating(val properties: FileProperties) : FilePropertiesResult() - data class Completed(val properties: FileProperties) : FilePropertiesResult() - data object Error : FilePropertiesResult() +sealed interface FilePropertiesResult { + @JvmInline + value class Updating(val properties: FileProperties) : FilePropertiesResult + + @JvmInline + value class Completed(val properties: FileProperties) : FilePropertiesResult + + data object Error : FilePropertiesResult } diff --git a/storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/MultipleFilesResult.kt similarity index 54% rename from storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/result/MultipleFilesResult.kt index b73f72a..fdd23ce 100644 --- a/storage/src/main/java/com/anggrayudi/storage/result/MultipleFilesResult.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/MultipleFilesResult.kt @@ -1,5 +1,6 @@ package com.anggrayudi.storage.result +import androidx.annotation.FloatRange import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.callback.SingleFolderConflictCallback.ConflictResolution @@ -7,17 +8,23 @@ import com.anggrayudi.storage.callback.SingleFolderConflictCallback.ConflictReso * Created on 7/6/24 * @author Anggrayudi Hardiannico A. */ -sealed class MultipleFilesResult { - data object Validating : MultipleFilesResult() - data object Preparing : MultipleFilesResult() - data object CountingFiles : MultipleFilesResult() - data object DeletingConflictedFiles : MultipleFilesResult() - data class Starting(val files: List, val totalFilesToCopy: Int) : MultipleFilesResult() +sealed interface MultipleFilesResult { + data object Validating : MultipleFilesResult + data object Preparing : MultipleFilesResult + data object CountingFiles : MultipleFilesResult + data object DeletingConflictedFiles : MultipleFilesResult + data class Starting(val files: List, val totalFilesToCopy: Int) : + MultipleFilesResult /** * @param fileCount total files/folders that are successfully copied/moved */ - data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) : MultipleFilesResult() + data class InProgress( + @FloatRange(0.0, 100.0) val progress: Float, + val bytesMoved: Long, + val writeSpeed: Int, + val fileCount: Int + ) : MultipleFilesResult /** * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] @@ -26,8 +33,18 @@ sealed class MultipleFilesResult { * @param totalFilesToCopy total files, not folders * @param totalCopiedFiles total files, not folders */ - data class Completed(val files: List, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) : MultipleFilesResult() - data class Error(val errorCode: MultipleFilesErrorCode, val message: String? = null, val completedData: Completed? = null) : MultipleFilesResult() + data class Completed( + val files: List, + val totalFilesToCopy: Int, + val totalCopiedFiles: Int, + val success: Boolean + ) : MultipleFilesResult + + data class Error( + val errorCode: MultipleFilesErrorCode, + val message: String? = null, + val completedData: Completed? = null + ) : MultipleFilesResult } enum class MultipleFilesErrorCode { diff --git a/storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFileResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFileResult.kt new file mode 100644 index 0000000..c2af602 --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFileResult.kt @@ -0,0 +1,62 @@ +package com.anggrayudi.storage.result + +import androidx.annotation.FloatRange + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed interface SingleFileResult { + + /** + * Emitted when check whether file copy requirements are met is started. + */ + data object Validating : SingleFileResult + + /** + * Emitted after check whether file copy requirements are met successfully completed. + */ + data object Preparing : SingleFileResult + + data object DeletingConflictedFile : SingleFileResult + + data class InProgress( + @FloatRange(0.0, 100.0) val progress: Float, + val bytesMoved: Long, + val writeSpeed: Int + ) : + SingleFileResult + + sealed interface Completed : SingleFileResult { + + @JvmInline + value class MediaFile(val value: com.anggrayudi.storage.media.MediaFile) : Completed + + @JvmInline + value class DocumentFile(val value: androidx.documentfile.provider.DocumentFile) : Completed + + companion object { + internal fun get(file: Any): Completed = + when (file) { + is com.anggrayudi.storage.media.MediaFile -> MediaFile(file) + is androidx.documentfile.provider.DocumentFile -> DocumentFile(file) + else -> throw IllegalArgumentException("File must be either of type ${com.anggrayudi.storage.media.MediaFile::class.java.name} or ${androidx.documentfile.provider.DocumentFile::class.java.name}") + } + } + } + + data class Error(val errorCode: SingleFileErrorCode, val message: String? = null) : + SingleFileResult +} + +enum class SingleFileErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + SOURCE_FILE_NOT_FOUND, + TARGET_FILE_NOT_FOUND, + TARGET_FOLDER_NOT_FOUND, + UNKNOWN_IO_ERROR, + CANCELED, + TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER, + NO_SPACE_LEFT_ON_TARGET_PATH +} diff --git a/storage/src/main/java/com/anggrayudi/storage/result/SingleFolderResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFolderResult.kt similarity index 62% rename from storage/src/main/java/com/anggrayudi/storage/result/SingleFolderResult.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFolderResult.kt index 00b2da8..c7c660c 100644 --- a/storage/src/main/java/com/anggrayudi/storage/result/SingleFolderResult.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/SingleFolderResult.kt @@ -1,5 +1,6 @@ package com.anggrayudi.storage.result +import androidx.annotation.FloatRange import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.callback.SingleFileConflictCallback import com.anggrayudi.storage.callback.SingleFolderConflictCallback @@ -9,21 +10,28 @@ import com.anggrayudi.storage.callback.SingleFolderConflictCallback.ConflictReso * Created on 7/6/24 * @author Anggrayudi Hardiannico A. */ -sealed class SingleFolderResult { - data object Validating : SingleFolderResult() - data object Preparing : SingleFolderResult() - data object CountingFiles : SingleFolderResult() +sealed interface SingleFolderResult { + + data object Validating : SingleFolderResult + + data object Preparing : SingleFolderResult + + data object CountingFiles : SingleFolderResult /** * Called after the user chooses [SingleFolderConflictCallback.ConflictResolution.REPLACE] or [SingleFileConflictCallback.ConflictResolution.REPLACE] */ - data object DeletingConflictedFiles : SingleFolderResult() - data class Starting(val files: List, val totalFilesToCopy: Int) : SingleFolderResult() + data object DeletingConflictedFiles : SingleFolderResult /** * @param fileCount total files/folders that are successfully copied/moved */ - data class InProgress(val progress: Float, val bytesMoved: Long, val writeSpeed: Int, val fileCount: Int) : SingleFolderResult() + data class InProgress( + @FloatRange(0.0, 100.0) val progress: Float, + val bytesMoved: Long, + val writeSpeed: Int, + val fileCount: Int + ) : SingleFolderResult /** * If `totalCopiedFiles` are less than `totalFilesToCopy`, then some files cannot be copied/moved or the files are skipped due to [ConflictResolution.MERGE] @@ -32,8 +40,18 @@ sealed class SingleFolderResult { * @param totalFilesToCopy total files, not folders * @param totalCopiedFiles total files, not folders */ - data class Completed(val folder: DocumentFile, val totalFilesToCopy: Int, val totalCopiedFiles: Int, val success: Boolean) : SingleFolderResult() - data class Error(val errorCode: FolderErrorCode, val message: String? = null, val completedData: Completed? = null) : SingleFolderResult() + data class Completed( + val folder: DocumentFile, + val totalFilesToCopy: Int, + val totalCopiedFiles: Int, + val success: Boolean + ) : SingleFolderResult + + data class Error( + val errorCode: FolderErrorCode, + val message: String? = null, + val completedData: Completed? = null + ) : SingleFolderResult } enum class FolderErrorCode { diff --git a/storage/src/main/kotlin/com/anggrayudi/storage/result/ZipCompressionResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/ZipCompressionResult.kt new file mode 100644 index 0000000..3e83a8a --- /dev/null +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/ZipCompressionResult.kt @@ -0,0 +1,43 @@ +package com.anggrayudi.storage.result + +import androidx.annotation.FloatRange +import androidx.documentfile.provider.DocumentFile + +/** + * Created on 7/6/24 + * @author Anggrayudi Hardiannico A. + */ +sealed interface ZipCompressionResult { + + data object CountingFiles : ZipCompressionResult + + data class Compressing( + @FloatRange(0.0, 100.0) val progress: Float, + val bytesCompressed: Long, + val writeSpeed: Int, + val fileCount: Int + ) : ZipCompressionResult + + data class Completed( + val zipFile: DocumentFile, + val bytesCompressed: Long, + val totalFilesCompressed: Int, + val compressionRate: Float + ) : + ZipCompressionResult + + data object DeletingEntryFiles : ZipCompressionResult + + data class Error(val errorCode: ZipCompressionErrorCode, val message: String? = null) : + ZipCompressionResult +} + +enum class ZipCompressionErrorCode { + STORAGE_PERMISSION_DENIED, + CANNOT_CREATE_FILE_IN_TARGET, + MISSING_ENTRY_FILE, + DUPLICATE_ENTRY_FILE, + UNKNOWN_IO_ERROR, + CANCELED, + NO_SPACE_LEFT_ON_TARGET_PATH +} \ No newline at end of file diff --git a/storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt b/storage/src/main/kotlin/com/anggrayudi/storage/result/ZipDecompressionResult.kt similarity index 55% rename from storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt rename to storage/src/main/kotlin/com/anggrayudi/storage/result/ZipDecompressionResult.kt index d635033..27a568c 100644 --- a/storage/src/main/java/com/anggrayudi/storage/result/ZipDecompressionResult.kt +++ b/storage/src/main/kotlin/com/anggrayudi/storage/result/ZipDecompressionResult.kt @@ -1,29 +1,39 @@ package com.anggrayudi.storage.result import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.media.MediaFile /** * Created on 7/6/24 * @author Anggrayudi Hardiannico A. */ -sealed class ZipDecompressionResult { - data object Validating : ZipDecompressionResult() - data class Decompressing(val bytesDecompressed: Long, val writeSpeed: Int, val fileCount: Int) : ZipDecompressionResult() +sealed interface ZipDecompressionResult { + + data object Validating : ZipDecompressionResult + + data class Decompressing(val bytesDecompressed: Long, val writeSpeed: Int, val fileCount: Int) : + ZipDecompressionResult - /** - * @param zipFile can be [DocumentFile] or [MediaFile] - */ data class Completed( - val zipFile: Any, + val zipFile: DecompressedZipFile, val targetFolder: DocumentFile, val bytesDecompressed: Long, val skippedDecompressedBytes: Long, val totalFilesDecompressed: Int, val decompressionRate: Float - ) : ZipDecompressionResult() + ) : ZipDecompressionResult + + data class Error(val errorCode: ZipDecompressionErrorCode, val message: String? = null) : + ZipDecompressionResult +} + +sealed interface DecompressedZipFile { + + @JvmInline + value class MediaFile(val value: com.anggrayudi.storage.media.MediaFile) : DecompressedZipFile - data class Error(val errorCode: ZipDecompressionErrorCode, val message: String? = null) : ZipDecompressionResult() + @JvmInline + value class DocumentFile(val value: androidx.documentfile.provider.DocumentFile) : + DecompressedZipFile } enum class ZipDecompressionErrorCode { 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 92% rename from storage/src/test/java/com/anggrayudi/storage/SimpleStorageTest.kt rename to storage/src/test/kotlin/com/anggrayudi/storage/SimpleStorageTest.kt index cd950d3..95bdd66 100644 --- a/storage/src/test/java/com/anggrayudi/storage/SimpleStorageTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/SimpleStorageTest.kt @@ -5,7 +5,11 @@ import android.content.Context import android.content.UriPermission import android.net.Uri import android.os.Build -import io.mockk.* +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -79,7 +83,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 796e44b..02ff0d9 100644 --- a/storage/src/test/java/com/anggrayudi/storage/file/DocumentFileCompatTest.kt +++ b/storage/src/test/kotlin/com/anggrayudi/storage/file/DocumentFileCompatTest.kt @@ -37,7 +37,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")) } @@ -47,7 +50,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 bfd6581..3bdb94c 100644 --- a/versions.gradle +++ b/versions.gradle @@ -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 ------------------------------ @@ -43,13 +41,14 @@ 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' }