From 3a5f07cac946cc7c59c678a43bccb0158fe3f37b Mon Sep 17 00:00:00 2001 From: w2sv Date: Thu, 4 Jul 2024 20:12:27 +0200 Subject: [PATCH 01/16] Update gradle to 8.8, agp to 8.3.2, update classpath dependencies, remove ExampleUnitTest & fix some gradle deprecation issues --- build.gradle | 19 +- gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 301 +++++++++++------- gradlew.bat | 76 +++-- .../com/anggrayudi/storage/ExampleUnitTest.kt | 17 - 6 files changed, 244 insertions(+), 174 deletions(-) delete mode 100644 storage/src/test/java/com/anggrayudi/storage/ExampleUnitTest.kt diff --git a/build.gradle b/build.gradle index 73850d9..6d979e0 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' @@ -7,10 +9,10 @@ buildscript { ext.kotlin_version = '1.8.22' dependencies { - classpath 'com.android.tools.build:gradle:8.0.2' + classpath 'com.android.tools.build:gradle:8.3.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.vanniktech:gradle-maven-publish-plugin:0.22.0' - classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.7.20' + classpath 'com.vanniktech:gradle-maven-publish-plugin:0.28.0' + classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.9.20' } } @@ -18,7 +20,7 @@ allprojects { addRepos(repositories) //Support @JvmDefault - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + tasks.withType(KotlinCompile).configureEach { kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all', '-opt-in=kotlin.RequiresOptIn'] jvmTarget = '1.8' @@ -41,11 +43,10 @@ subprojects { afterEvaluate { android { - compileSdkVersion 33 + compileSdkVersion 34 defaultConfig { minSdkVersion 19 - targetSdkVersion 33 versionCode 1 versionName "$VERSION_NAME" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -61,7 +62,7 @@ subprojects { buildConfig true } } - configurations.all { + configurations.configureEach { resolutionStrategy { // Force Kotlin to use current version force "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" @@ -79,6 +80,6 @@ subprojects { } } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f6b961fd5a86aa5fbfe90f707c3138408be7c718..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 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 literal 54329 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

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