diff --git a/.run/publishComposeToCentral.run.xml b/.run/publishComposeToCentral.run.xml
new file mode 100644
index 00000000..dceaf408
--- /dev/null
+++ b/.run/publishComposeToCentral.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/publishToCentral.run.xml b/.run/publishStorageToCentral.run.xml
similarity index 86%
rename from .run/publishToCentral.run.xml
rename to .run/publishStorageToCentral.run.xml
index da54406b..ac2ae05f 100644
--- a/.run/publishToCentral.run.xml
+++ b/.run/publishStorageToCentral.run.xml
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/.run/publishToLocal.run.xml b/.run/publishToLocal.run.xml
index 916ce8d4..aab48e9f 100644
--- a/.run/publishToLocal.run.xml
+++ b/.run/publishToLocal.run.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/LICENSE b/LICENSE
index 424c2c34..9fdb0d2d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright © 2020-2023 Anggrayudi Hardiannico A.
+ Copyright © 2020-2025 Anggrayudi Hardiannico A.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 1bb30a0b..77621f9e 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
### Table of Contents
* [Overview](#overview)
+ [Java Compatibility](#java-compatibility)
+ + [Jetpack Compose](#jetpack-compose)
* [Terminology](#terminology)
* [Check Accessible Paths](#check-accessible-paths)
* [Read Files](#read-files)
@@ -16,6 +17,7 @@
+ [`DocumentFile`](#documentfile)
+ [`MediaFile`](#mediafile)
* [Request Storage Access, Pick Folder & Files, Request Create File, etc.](#request-storage-access-pick-folder--files-request-create-file-etc)
+* [Activity Result Contracts](#activity-result-contracts)
* [Move & Copy: Files & Folders](#move--copy-files--folders)
* [Search: Files & Folders](#search-files--folders)
* [Compress & Unzip: Files & Folders](#compress--unzip-files--folders)
@@ -40,11 +42,17 @@ Adding Simple Storage into your project is pretty simple:
```groovy
implementation "com.anggrayudi:storage:X.Y.Z"
+
+// For Jetpack Compose
+implementation "com.anggrayudi:storage-compose:X.Y.Z"
```
Where `X.Y.Z` is the library version: 
-All versions can be found [here](https://oss.sonatype.org/#nexus-search;gav~com.anggrayudi~storage~~~~kw,versionexpand).
+All versions can be found here:
+- [Simple Storage Core](https://central.sonatype.com/artifact/com.anggrayudi/storage/versions)
+- [Simple Storage Jetpack Compose](https://central.sonatype.com/artifact/com.anggrayudi/storage-compose/versions)
+
To use `SNAPSHOT` version, you need to add this URL to the root Gradle:
```groovy
@@ -53,7 +61,7 @@ allprojects {
google()
mavenCentral()
// add this line
- maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
+ maven { url "https://central.sonatype.com/repository/maven-snapshots/" }
}
}
```
@@ -67,6 +75,24 @@ They are powered by Kotlin Coroutines & Flow, which are easy to use.
You can still use these Java features in your project, but you will need [v1.5.6](https://github.com/anggrayudi/SimpleStorage/releases/tag/1.5.6) which is the latest version that
supports Java.
+### Jetpack Compose
+
+`SimpleStorageHelper` is a traditional class that helps you to request storage access, pick folders/files, and create files.
+This class has interactive dialogs, so you don't have to handle storage access & permissions manually.
+In Jetpack Compose, you can achieve the same thing with [`SimpleStorageCompose.kt`](storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt).
+This class contains composable functions:
+- `rememberLauncherForStoragePermission()`
+- `rememberLauncherForStorageAccess()`
+- `rememberLauncherForFolderPicker()`
+- `rememberLauncherForFilePicker()`
+
+If you think these composable functions has too many UI manipulations and don't suit your needs, then
+you can copy the logic from [`SimpleStorageCompose.kt`](storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt)
+and create your own composable functions. Because you might need custom dialogs, custom strings, etc.
+
+For file creation, you can use `rememberLauncherForActivityResult(FileCreationContract(context))`.
+Check all available contracts in the [`SimpleStorageResultContracts.kt`](storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt)
+
## Terminology

@@ -241,6 +267,59 @@ Simple, right?
This helper class contains default styles for managing storage access.
If you want to use custom dialogs for `SimpleStorageHelper`, just copy the logic from this class.
+## Activity Result Contracts
+
+If you want to use `ActivityResultContract` instead of `SimpleStorageHelper`, you can use contracts
+provided in [`SimpleStorageResultContracts.kt`](storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt):
+- `RequestStorageAccessContract`
+- `StoragePermissionContract`
+- `FileCreationContract`
+- `OpenFilePickerContract`
+- `OpenFolderPickerContract`
+
+Then use them like this:
+```kotlin
+class MainActivity : AppCompatActivity() {
+ lateinit var requestStorageAccessLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // setContentView(R.layout.activity_main)
+ val contract = RequestStorageAccessContract(
+ expectedStorageId = StorageId.PRIMARY,
+ expectedBasePath = "Documents"
+ )
+ requestStorageAccessLauncher = registerForActivityResult(contract) { result ->
+ when (result) {
+ is RequestStorageAccessResult.RootPathNotSelected -> {
+ // Ask user to select the root path.
+ }
+ is RequestStorageAccessResult.ExpectedStorageNotSelected -> {
+ // Ask the user to select the expected storage.
+ // This can happen if you set expectedBasePath or expectedStorageType to the contract.
+ }
+ is RequestStorageAccessResult.RootPathPermissionGranted -> {
+ // Access granted to the root path
+ }
+ }
+ }
+
+ btnRequestStorageAccess.setOnClickListener {
+ val options = RequestStorageAccessContract.Options(
+ initialPath = FileFullPath(
+ baseContext,
+ storageId = StorageId.PRIMARY,
+ basePath = "Documents"
+ )
+ )
+ requestStorageAccessLauncher.launch(options)
+ }
+ }
+}
+```
+
+This way, you don't need to maintain the instance of `SimpleStorageHelper`, dealing with `onActivityResult()`, `onSaveInstanceState()`, etc.
+
## Move & Copy: Files & Folders
Simple Storage helps you in copying/moving files & folders via:
@@ -402,7 +481,7 @@ Check how these repositories use it:
## License
- Copyright © 2020-2024 Anggrayudi Hardiannico A.
+ Copyright © 2020-2025 Anggrayudi Hardiannico A.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/gradle.properties b/gradle.properties
index e8e8cba7..c1850dbc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,11 +22,7 @@ kotlin.code.style=official
org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers
# For publishing:
GROUP=com.anggrayudi
-POM_ARTIFACT_ID=storage
-VERSION_NAME=2.1.0-SNAPSHOT
-RELEASE_SIGNING_ENABLED=false
-SONATYPE_AUTOMATIC_RELEASE=true
-SONATYPE_HOST=DEFAULT
+VERSION_NAME=2.2.0-SNAPSHOT
POM_NAME=storage
POM_DESCRIPTION=Simplify Android Storage Access Framework for file management across API levels.
POM_INCEPTION_YEAR=2020
@@ -40,3 +36,6 @@ POM_SCM_DEV_CONNECTION=scm:git:ssh://github.com:anggrayudi/SimpleStorage.git
POM_DEVELOPER_ID=anggrayudi
POM_DEVELOPER_NAME=Anggrayudi H
POM_DEVELOPER_URL=https://github.com/anggrayudi/
+mavenCentralAutomaticPublishing=true
+mavenCentralPublishing=true
+signAllPublications=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 89b2d466..1f45233b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,6 @@
[versions]
-kotlin = "2.1.20"
+agp = "8.11.1"
+kotlin = "2.2.0"
activityCompose = "1.10.1"
coroutines = "1.10.2"
mockito = "3.10.0"
@@ -10,11 +11,11 @@ androidx-core = { group = "androidx.core", name = "core-ktx", version = "1.16.0"
junit = { group = "junit", name = "junit", version = "4.13.2" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
-androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version = "2.9.1" }
+androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version = "2.9.2" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.1" }
androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
-androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.06.00" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version = "2025.07.00" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
@@ -23,7 +24,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-multidex = { group = "androidx.multidex", name = "multidex", version = "2.0.1" }
-androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.9.0" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.9.2" }
androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences-android", version = "1.1.7" }
androidx-preference = { group = "androidx.preference", name = "preference-ktx", version = "1.2.1" }
androidx-document-file = { group = "androidx.documentfile", name = "documentfile", version = "1.1.0" }
@@ -36,9 +37,9 @@ timber = { group = "com.jakewharton.timber", name = "timber", version = "5.0.1"
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
-mockk = { group = "io.mockk", name = "mockk", version = "1.13.17" }
+mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
-robolectric = { group = "org.robolectric", name = "robolectric", version = "4.10.3" }
+robolectric = { group = "org.robolectric", name = "robolectric", version = "4.15.1" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockito" }
mockito-all = { group = "org.mockito", name = "mockito-all", version = "1.10.19" }
@@ -47,9 +48,10 @@ powermock-junit4 = { group = "org.powermock", name = "powermock-module-junit4",
powermock-api-mockito = { group = "org.powermock", name = "powermock-api-mockito2", version.ref = "powermock" }
[plugins]
-android-application = { id = "com.android.application", version = "8.9.3" }
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "2.1.20-1.0.32" }
-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.22.0" }
+maven-publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }
dokka = { id = "org.jetbrains.dokka", version = "2.0.0" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 343461a4..d7406330 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Tue Jun 10 19:14:26 WIB 2025
+#Sat Jul 26 20:40:46 WIB 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index 9fb955ed..469f3987 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -7,7 +9,7 @@ plugins {
android {
namespace = "com.anggrayudi.storage.sample"
- compileSdk = 35
+ compileSdk = 36
signingConfigs {
val debugKeystore =
@@ -31,7 +33,7 @@ android {
defaultConfig {
applicationId = "com.anggrayudi.storage.sample"
minSdk = 21
- targetSdk = 35
+ targetSdk = 36
versionCode = 1
versionName = rootProject.extra["VERSION_NAME"] as String
multiDexEnabled = true
@@ -56,7 +58,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions { jvmTarget = "11" }
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ }
+ }
flavorDimensions += "libSource"
productFlavors {
@@ -78,8 +84,8 @@ android {
}
dependencies {
- implementation(project(":storage"))
- // implementation("com.anggrayudi:storage:${rootProject.extra["VERSION_NAME"]}")
+ implementation(project(":storage-compose"))
+ // implementation("com.anggrayudi:storage-compose:${rootProject.extra["VERSION_NAME"]}")
implementation(libs.androidx.core)
implementation(libs.androidx.lifecycle.runtime)
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index cdb74836..1f9f9153 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -36,6 +36,11 @@
+
+
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 1eb65d9a..e4e5bcae 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
@@ -15,6 +15,7 @@ import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.checkbox.checkBoxPrompt
@@ -23,6 +24,7 @@ import com.afollestad.materialdialogs.customview.getCustomView
import com.afollestad.materialdialogs.input.input
import com.afollestad.materialdialogs.list.listItems
import com.anggrayudi.storage.SimpleStorageHelper
+import com.anggrayudi.storage.callback.FileReceiverCallback
import com.anggrayudi.storage.callback.MultipleFilesConflictCallback
import com.anggrayudi.storage.callback.SingleFileConflictCallback
import com.anggrayudi.storage.callback.SingleFolderConflictCallback
@@ -47,6 +49,7 @@ import com.anggrayudi.storage.result.SingleFileResult
import com.anggrayudi.storage.result.SingleFolderResult
import com.anggrayudi.storage.sample.R
import com.anggrayudi.storage.sample.StorageInfoAdapter
+import com.anggrayudi.storage.sample.compose.StorageComposeActivity
import com.anggrayudi.storage.sample.databinding.ActivityMainBinding
import java.io.IOException
import kotlin.concurrent.thread
@@ -237,19 +240,8 @@ class MainActivity : AppCompatActivity() {
storageHelper.onFileCreated = { requestCode, file ->
writeTestFile(applicationContext, requestCode, file)
}
- storageHelper.onFileReceived =
- object : SimpleStorageHelper.OnFileReceived {
- override fun onFileReceived(files: List) {
- val names = files.joinToString(", ") { it.fullName }
- Toast.makeText(baseContext, "File received: $names", Toast.LENGTH_SHORT).show()
- }
-
- override fun onNonFileReceived(intent: Intent) {
- Toast.makeText(baseContext, "Non-file is received", Toast.LENGTH_SHORT).show()
- }
- }
if (savedInstanceState == null) {
- storageHelper.storage.checkIfFileReceived(intent)
+ storageHelper.storage.checkIfFileReceived(intent, createFileReceiverCallback())
}
}
@@ -952,9 +944,21 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- storageHelper.storage.checkIfFileReceived(intent)
+ storageHelper.storage.checkIfFileReceived(intent, createFileReceiverCallback())
}
+ private fun createFileReceiverCallback() =
+ object : SimpleStorageHelper.OnFileReceived, FileReceiverCallback {
+ override fun onNonFileReceived(intent: Intent) {
+ Toast.makeText(baseContext, "Non-file is received", Toast.LENGTH_SHORT).show()
+ }
+
+ override fun onFileReceived(files: List) {
+ val names = files.joinToString(", ") { it.fullName }
+ Toast.makeText(baseContext, "File received: $names", Toast.LENGTH_SHORT).show()
+ }
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
@@ -967,14 +971,16 @@ class MainActivity : AppCompatActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main, menu)
+ menu.findItem(R.id.action_open_compose).intent =
+ Intent(this, StorageComposeActivity::class.java)
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"))
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri())
menu.findItem(R.id.action_about).intent =
- Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/anggrayudi/SimpleStorage"))
+ Intent(Intent.ACTION_VIEW, "https://github.com/anggrayudi/SimpleStorage".toUri())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return super.onCreateOptionsMenu(menu)
}
diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeActivity.kt b/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeActivity.kt
new file mode 100644
index 00000000..4d9a5b9c
--- /dev/null
+++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeActivity.kt
@@ -0,0 +1,14 @@
+package com.anggrayudi.storage.sample.compose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import com.anggrayudi.storage.sample.compose.theme.StorageAppTheme
+
+class StorageComposeActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { StorageAppTheme(darkTheme = false) { StorageComposeApp() } }
+ }
+}
diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeApp.kt b/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeApp.kt
new file mode 100644
index 00000000..c22f5a69
--- /dev/null
+++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeApp.kt
@@ -0,0 +1,128 @@
+package com.anggrayudi.storage.sample.compose
+
+import android.widget.Toast
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.anggrayudi.storage.R
+import com.anggrayudi.storage.compose.rememberLauncherForFilePicker
+import com.anggrayudi.storage.compose.rememberLauncherForFolderPicker
+import com.anggrayudi.storage.compose.rememberLauncherForStorageAccess
+import com.anggrayudi.storage.compose.rememberLauncherForStoragePermission
+import com.anggrayudi.storage.contract.FileCreationContract
+import com.anggrayudi.storage.contract.FileCreationResult
+import com.anggrayudi.storage.file.fullName
+import com.anggrayudi.storage.file.getAbsolutePath
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StorageComposeApp(modifier: Modifier = Modifier) {
+ val activity = LocalActivity.current ?: return
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text("Storage Compose App") },
+ navigationIcon = {
+ IconButton(onClick = { activity.finish() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier =
+ Modifier.padding(innerPadding)
+ .padding(horizontal = 16.dp)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ val folderPickerLauncher = rememberLauncherForFolderPicker { folder ->
+ Toast.makeText(activity, folder.getAbsolutePath(activity), Toast.LENGTH_SHORT).show()
+ }
+ val filePickerLauncher =
+ rememberLauncherForFilePicker(allowMultiple = true) { files ->
+ val names = files.joinToString(", ") { it.fullName }
+ Toast.makeText(activity, "File selected: $names", Toast.LENGTH_SHORT).show()
+ }
+ val fileCreationLauncher =
+ rememberLauncherForActivityResult(FileCreationContract(activity)) { result ->
+ when (result) {
+ is FileCreationResult.Created -> {
+ Toast.makeText(activity, "File created: ${result.file.name}", Toast.LENGTH_SHORT)
+ .show()
+ }
+ is FileCreationResult.StoragePermissionDenied -> {
+ Toast.makeText(
+ activity,
+ "Storage permission denied. Please grant storage permission to create file.",
+ Toast.LENGTH_SHORT,
+ )
+ .show()
+ }
+ is FileCreationResult.CanceledByUser -> Unit
+ }
+ }
+ val storagePermissionLauncher = rememberLauncherForStoragePermission { result ->
+ val textResult = if (result) "granted" else "denied"
+ Toast.makeText(activity, "Storage permission is $textResult", Toast.LENGTH_SHORT).show()
+ }
+ val storageAccessLauncher = rememberLauncherForStorageAccess { root ->
+ Toast.makeText(
+ activity,
+ activity.getString(
+ R.string.ss_selecting_root_path_success_without_open_folder_picker,
+ root.getAbsolutePath(activity),
+ ),
+ Toast.LENGTH_SHORT,
+ )
+ .show()
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { storagePermissionLauncher.launch(Unit) },
+ ) {
+ Text("Request storage permission")
+ }
+ Button(modifier = Modifier.fillMaxWidth(), onClick = { storageAccessLauncher.launch() }) {
+ Text("Request storage access")
+ }
+ Button(modifier = Modifier.fillMaxWidth(), onClick = { folderPickerLauncher.launch() }) {
+ Text("Select folder")
+ }
+ Button(modifier = Modifier.fillMaxWidth(), onClick = { filePickerLauncher.launch() }) {
+ Text("Select file")
+ }
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = { fileCreationLauncher.launch(FileCreationContract.Options("text/plain")) },
+ ) {
+ Text("Create file")
+ }
+ }
+ }
+}
diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Color.kt b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Color.kt
new file mode 100644
index 00000000..4868eb83
--- /dev/null
+++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Color.kt
@@ -0,0 +1,29 @@
+package com.anggrayudi.storage.sample.compose.theme
+
+import androidx.compose.ui.graphics.Color
+
+object AppColor {
+ val Purple80 = Color(0xFFD0BCFF)
+ val PurpleGrey80 = Color(0xFFCCC2DC)
+ val Pink80 = Color(0xFFEFB8C8)
+
+ val Purple40 = Color(0xFF6650a4)
+ val PurpleGrey40 = Color(0xFF625b71)
+ val Pink40 = Color(0xFF7D5260)
+
+ val IconDefault = Color(0xFF494A4A)
+ val InputArea = Color.LightGray.copy(alpha = 0.3f)
+ val Overlay = Color(0xFF1C1D1D).copy(alpha = 0.6f)
+ val Primary = Color(0xFF6200EE)
+ val PrimaryVariant = Color(0xFF3700B3)
+ val Secondary = Color(0xFF03DAC5)
+ val SecondaryVariant = Color(0xFF018786)
+ val Background = Color(0xFFF2F2F2)
+ val Surface = Color(0xFFFFFFFF)
+ val Error = Color(0xFFB00020)
+ val OnPrimary = Color(0xFFFFFFFF)
+ val OnSecondary = Color(0xFF000000)
+ val OnBackground = Color(0xFF000000)
+ val OnSurface = Color(0xFF000000)
+ val OnError = Color(0xFFFFFFFF)
+}
diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Theme.kt b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Theme.kt
new file mode 100644
index 00000000..84425a84
--- /dev/null
+++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Theme.kt
@@ -0,0 +1,56 @@
+package com.anggrayudi.storage.sample.compose.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme =
+ darkColorScheme(
+ primary = AppColor.Purple80,
+ secondary = AppColor.PurpleGrey80,
+ tertiary = AppColor.Pink80,
+ )
+
+private val LightColorScheme =
+ lightColorScheme(
+ primary = AppColor.Purple40,
+ secondary = AppColor.PurpleGrey40,
+ tertiary = AppColor.Pink40,
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+ )
+
+@Composable
+fun StorageAppTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
+}
diff --git a/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Type.kt b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Type.kt
new file mode 100644
index 00000000..7e649908
--- /dev/null
+++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Type.kt
@@ -0,0 +1,35 @@
+package com.anggrayudi.storage.sample.compose.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val AppTypography =
+ Typography(
+ bodyLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ titleLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ labelSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ )
diff --git a/sample/src/main/res/menu/main.xml b/sample/src/main/res/menu/main.xml
index 1a72160a..9df955cc 100644
--- a/sample/src/main/res/menu/main.xml
+++ b/sample/src/main/res/menu/main.xml
@@ -4,6 +4,11 @@
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="HardcodedText">
+
+
-
Simple Storage
+ Compose Screen
\ No newline at end of file
diff --git a/sample/src/main/res/values/styles.xml b/sample/src/main/res/values/styles.xml
index fac92916..a3c26654 100644
--- a/sample/src/main/res/values/styles.xml
+++ b/sample/src/main/res/values/styles.xml
@@ -7,4 +7,5 @@
- @color/colorAccent
+
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2b2c09d1..438a03de 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -17,10 +17,10 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
- maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots") }
+ maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") }
}
}
rootProject.name = "SimpleStorage"
-include(":sample", ":storage")
+include(":sample", ":storage", ":storage-compose")
diff --git a/storage-compose/.gitignore b/storage-compose/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/storage-compose/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/storage-compose/build.gradle.kts b/storage-compose/build.gradle.kts
new file mode 100644
index 00000000..9b7b5f35
--- /dev/null
+++ b/storage-compose/build.gradle.kts
@@ -0,0 +1,71 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.plugin.parcelize")
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.dokka)
+ alias(libs.plugins.maven.publish)
+}
+
+android {
+ namespace = "com.anggrayudi.storage.compose"
+ compileSdk = 36
+ resourcePrefix = "ss_"
+
+ defaultConfig {
+ minSdk = 21
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ testOptions { targetSdk = 36 }
+ lint { targetSdk = 36 }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ // Support @JvmDefault
+ freeCompilerArgs = listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn")
+ }
+ }
+}
+
+dependencies {
+ api(project(":storage"))
+ // api("com.anggrayudi:storage:${rootProject.extra["VERSION_NAME"]}")
+
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.material3)
+
+ implementation(libs.coroutines.android)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlin.test)
+
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
diff --git a/storage-compose/consumer-rules.pro b/storage-compose/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/storage-compose/gradle.properties b/storage-compose/gradle.properties
new file mode 100644
index 00000000..2ceab47f
--- /dev/null
+++ b/storage-compose/gradle.properties
@@ -0,0 +1 @@
+POM_ARTIFACT_ID=storage-compose
diff --git a/storage-compose/proguard-rules.pro b/storage-compose/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/storage-compose/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/storage-compose/src/main/AndroidManifest.xml b/storage-compose/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..44008a43
--- /dev/null
+++ b/storage-compose/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt b/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt
new file mode 100644
index 00000000..ac665d39
--- /dev/null
+++ b/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt
@@ -0,0 +1,482 @@
+package com.anggrayudi.storage.compose
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Parcelable
+import android.widget.Toast
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.documentfile.provider.DocumentFile
+import com.anggrayudi.storage.R
+import com.anggrayudi.storage.SimpleStorageHelper.Companion.redirectToSystemSettings
+import com.anggrayudi.storage.contract.FilePickerResult
+import com.anggrayudi.storage.contract.FolderPickerResult
+import com.anggrayudi.storage.contract.OpenFilePickerContract
+import com.anggrayudi.storage.contract.OpenFolderPickerContract
+import com.anggrayudi.storage.contract.RequestStorageAccessContract
+import com.anggrayudi.storage.contract.RequestStorageAccessResult
+import com.anggrayudi.storage.contract.StoragePermissionContract
+import com.anggrayudi.storage.contract.StoragePermissionDeniedException
+import com.anggrayudi.storage.extension.getStorageId
+import com.anggrayudi.storage.file.DocumentFileCompat
+import com.anggrayudi.storage.file.FileFullPath
+import com.anggrayudi.storage.file.StorageId
+import com.anggrayudi.storage.file.StorageType
+import com.anggrayudi.storage.file.getStorageId
+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 kotlinx.parcelize.Parcelize
+
+class PermissionRequestCompose(
+ private val context: Activity,
+ private val permissions: Array,
+ private val callback: PermissionCallback,
+) : PermissionRequest {
+
+ private lateinit var launcher:
+ ManagedActivityResultLauncher>
+
+ @Composable
+ fun registerLauncher():
+ ManagedActivityResultLauncher> {
+ launcher =
+ rememberLauncherForActivityResult(StoragePermissionContract()) { result ->
+ onRequestPermissionsResult(result)
+ }
+ return launcher
+ }
+
+ override fun check() {
+ if (
+ permissions.all {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ }
+ ) {
+ callback.onPermissionsChecked(
+ PermissionResult(
+ permissions.map { PermissionReport(it, isGranted = true, deniedPermanently = false) }
+ ),
+ false,
+ )
+ } else {
+ callback.onDisplayConsentDialog(this)
+ }
+ }
+
+ private fun onRequestPermissionsResult(result: Map) {
+ if (result.isEmpty()) {
+ callback.onPermissionRequestInterrupted(permissions)
+ return
+ }
+ val reports =
+ result.map {
+ PermissionReport(
+ it.key,
+ it.value,
+ !it.value && !ActivityCompat.shouldShowRequestPermissionRationale(context, it.key),
+ )
+ }
+ val blockedPermissions = reports.filter { it.deniedPermanently }
+ if (blockedPermissions.isEmpty()) {
+ callback.onPermissionsChecked(PermissionResult(reports), true)
+ } else {
+ callback.onShouldRedirectToSystemSettings(blockedPermissions)
+ }
+ }
+
+ /**
+ * If you override [PermissionCallback.onDisplayConsentDialog], then call this method in the
+ * `onPositive` callback of the dialog.
+ */
+ override fun continueToPermissionRequest() {
+ permissions.forEach {
+ if (ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED) {
+ launcher.launch(Unit)
+ return
+ }
+ }
+ callback.onPermissionsChecked(
+ PermissionResult(
+ permissions.map { PermissionReport(it, isGranted = true, deniedPermanently = false) }
+ ),
+ false,
+ )
+ }
+}
+
+private fun handleMissingActivityHandler(context: Context) {
+ Toast.makeText(context, R.string.ss_missing_saf_activity_handler, Toast.LENGTH_SHORT).show()
+}
+
+@Composable
+fun rememberLauncherForStoragePermission(
+ onPermissionsResult: (isGranted: Boolean) -> Unit
+): ManagedActivityResultLauncher> {
+ val context = LocalActivity.current!!
+ val currentOnPermissionsCallback = rememberUpdatedState(onPermissionsResult)
+
+ val request =
+ PermissionRequestCompose(
+ context,
+ arrayOf(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ ),
+ callback =
+ object : PermissionCallback {
+ override fun onPermissionsChecked(result: PermissionResult, fromSystemDialog: Boolean) {
+ val granted = result.areAllPermissionsGranted
+ if (!granted) {
+ Toast.makeText(
+ context,
+ R.string.ss_please_grant_storage_permission,
+ Toast.LENGTH_SHORT,
+ )
+ .show()
+ }
+ currentOnPermissionsCallback.value(granted)
+ }
+
+ override fun onShouldRedirectToSystemSettings(
+ blockedPermissions: List
+ ) {
+ redirectToSystemSettings(context)
+ currentOnPermissionsCallback.value(false)
+ }
+ },
+ )
+ return request.registerLauncher()
+}
+
+class StorageAccessLauncher
+internal constructor(
+ private val context: Context,
+ internal var initialPath: FileFullPath?,
+ private val permissionLauncher:
+ ManagedActivityResultLauncher>,
+) {
+
+ internal lateinit var launcher:
+ ManagedActivityResultLauncher
+
+ fun launch() {
+ try {
+ launcher.launch(RequestStorageAccessContract.Options(initialPath))
+ } catch (_: ActivityNotFoundException) {
+ handleMissingActivityHandler(context)
+ } catch (_: StoragePermissionDeniedException) {
+ permissionLauncher.launch(Unit)
+ }
+ }
+}
+
+@Parcelize
+internal data class StorageAccessDialogData(
+ val message: String,
+ val initialPath: FileFullPath? = null,
+) : Parcelable
+
+@Composable
+fun rememberLauncherForStorageAccess(
+ expectedStorageType: StorageType = StorageType.UNKNOWN,
+ expectedBasePath: String = "",
+ /** It only takes effect on API 26+ */
+ initialPath: FileFullPath? = null,
+ onStorageAccessGranted: (root: DocumentFile) -> Unit,
+): StorageAccessLauncher {
+ val context = LocalActivity.current!!
+ val currentStorageAccessCallback = rememberUpdatedState(onStorageAccessGranted)
+ val permissionLauncher = rememberLauncherForStoragePermission { granted ->
+ if (granted) {
+ currentStorageAccessCallback.value(
+ DocumentFileCompat.getRootDocumentFile(
+ context,
+ StorageId.PRIMARY,
+ requiresWriteAccess = true,
+ ) ?: return@rememberLauncherForStoragePermission
+ )
+ }
+ }
+
+ val accessLauncher = remember { StorageAccessLauncher(context, initialPath, permissionLauncher) }
+
+ var dialogData by rememberSaveable { mutableStateOf(null) }
+ if (dialogData != null) {
+ AlertDialog(
+ text = { Text(dialogData!!.message) },
+ properties = DialogProperties(dismissOnClickOutside = false),
+ onDismissRequest = { dialogData = null },
+ dismissButton = {
+ FilledTonalButton(onClick = { dialogData = null }) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ accessLauncher.initialPath = dialogData!!.initialPath
+ accessLauncher.launch()
+ dialogData = null
+ }
+ ) {
+ Text(stringResource(android.R.string.ok))
+ }
+ },
+ )
+ }
+
+ accessLauncher.launcher =
+ rememberLauncherForActivityResult(
+ RequestStorageAccessContract(context, expectedStorageType, expectedBasePath)
+ ) { result ->
+ when (result) {
+ is RequestStorageAccessResult.RootPathNotSelected -> {
+ val storageType =
+ if (expectedStorageType.isExpected(result.selectedStorageType)) {
+ result.selectedStorageType
+ } else {
+ expectedStorageType
+ }
+ val messageRes =
+ if (result.rootPath.isEmpty()) {
+ 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 {
+ val resId =
+ if (storageType == StorageType.SD_CARD) {
+ R.string.ss_please_select_root_storage_sdcard_with_location
+ } else {
+ R.string.ss_please_select_root_storage_primary_with_location
+ }
+ context.getString(resId, result.rootPath)
+ }
+ dialogData =
+ StorageAccessDialogData(
+ message = messageRes,
+ initialPath = FileFullPath(context, result.uri.getStorageId(context), ""),
+ )
+ }
+
+ is RequestStorageAccessResult.RootPathPermissionGranted -> {
+ currentStorageAccessCallback.value(result.root)
+ }
+
+ is RequestStorageAccessResult.ExpectedStorageNotSelected -> {
+ val message =
+ context.getString(
+ when (expectedStorageType) {
+ StorageType.EXTERNAL ->
+ R.string.ss_please_select_base_path_with_storage_type_primary
+ StorageType.SD_CARD -> R.string.ss_please_select_base_path_with_storage_type_sd_card
+ else -> R.string.ss_please_select_base_path
+ },
+ expectedBasePath,
+ )
+ @SuppressLint("NewApi")
+ dialogData =
+ StorageAccessDialogData(
+ message = message,
+ initialPath =
+ FileFullPath(
+ context,
+ if (expectedStorageType == StorageType.UNKNOWN) result.selectedStorageType
+ else expectedStorageType,
+ expectedBasePath,
+ ),
+ )
+ }
+
+ is RequestStorageAccessResult.StoragePermissionDenied -> {
+ permissionLauncher.launch(Unit)
+ }
+
+ is RequestStorageAccessResult.CanceledByUser -> {
+ // no-op, just dismiss the dialog
+ }
+ }
+ }
+ return accessLauncher
+}
+
+class FilePickerLauncher
+internal constructor(
+ private val context: Context,
+ private val allowMultiple: Boolean = false,
+ private val initialPath: FileFullPath? = null,
+ private val filterMimeTypes: Set = emptySet(),
+) {
+
+ internal lateinit var launcher:
+ ManagedActivityResultLauncher
+
+ fun launch() {
+ try {
+ launcher.launch(OpenFilePickerContract.Options(allowMultiple, initialPath, filterMimeTypes))
+ } catch (_: ActivityNotFoundException) {
+ handleMissingActivityHandler(context)
+ }
+ }
+}
+
+@Composable
+fun rememberLauncherForFilePicker(
+ allowMultiple: Boolean = false,
+ /** It only takes effect on API 26+ */
+ initialPath: FileFullPath? = null,
+ filterMimeTypes: Set = emptySet(),
+ onFilesPicked: (files: List) -> Unit,
+): FilePickerLauncher {
+ val activity = LocalActivity.current!!
+ val currentFilePickerCallback = rememberUpdatedState(onFilesPicked)
+ val filePickerLauncher = remember {
+ FilePickerLauncher(activity, allowMultiple, initialPath, filterMimeTypes)
+ }
+
+ var expectedStorageType by remember { mutableStateOf(StorageType.UNKNOWN) }
+ val storageAccessLauncher =
+ rememberLauncherForStorageAccess(expectedStorageType = expectedStorageType) { root ->
+ filePickerLauncher.launch()
+ }
+
+ filePickerLauncher.launcher =
+ rememberLauncherForActivityResult(OpenFilePickerContract(activity)) { result ->
+ when (result) {
+ is FilePickerResult.CanceledByUser -> {
+ // no-op, just dismiss the dialog
+ }
+
+ is FilePickerResult.Picked -> {
+ currentFilePickerCallback.value(result.files)
+ }
+
+ is FilePickerResult.StoragePermissionDenied -> {
+ expectedStorageType =
+ result.files.firstOrNull()?.getStorageId(activity)?.let {
+ StorageType.fromStorageId(it)
+ } ?: StorageType.EXTERNAL
+ storageAccessLauncher.launch()
+ }
+ }
+ }
+ return filePickerLauncher
+}
+
+class FolderPickerLauncher
+internal constructor(
+ private val context: Context,
+ private val initialPath: FileFullPath? = null,
+ private val permissionLauncher:
+ ManagedActivityResultLauncher>,
+) {
+
+ internal lateinit var launcher:
+ ManagedActivityResultLauncher
+
+ fun launch() {
+ try {
+ launcher.launch(OpenFolderPickerContract.Options(initialPath))
+ } catch (_: ActivityNotFoundException) {
+ handleMissingActivityHandler(context)
+ } catch (_: StoragePermissionDeniedException) {
+ permissionLauncher.launch(Unit)
+ }
+ }
+}
+
+@Composable
+fun rememberLauncherForFolderPicker(
+ /** It only takes effect on API 26+ */
+ initialPath: FileFullPath? = null,
+ onFolderPicked: (folder: DocumentFile) -> Unit,
+): FolderPickerLauncher {
+ val activity = LocalActivity.current!!
+ val currentFolderPickerCallback = rememberUpdatedState(onFolderPicked)
+
+ val folderPickerLauncher = remember { mutableStateOf(null) }
+ val permissionLauncher = rememberLauncherForStoragePermission { granted ->
+ if (granted) {
+ folderPickerLauncher.value?.launch()
+ }
+ }
+ folderPickerLauncher.value = FolderPickerLauncher(activity, initialPath, permissionLauncher)
+
+ var initialPath by remember { mutableStateOf(null) }
+ val storageAccessLauncher =
+ rememberLauncherForStorageAccess(initialPath = initialPath) { root ->
+ folderPickerLauncher.value?.launch()
+ }
+
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ if (showDialog) {
+ AlertDialog(
+ text = { Text(stringResource(R.string.ss_storage_access_denied_confirm)) },
+ properties = DialogProperties(dismissOnClickOutside = false),
+ onDismissRequest = { showDialog = false },
+ dismissButton = {
+ FilledTonalButton(onClick = { showDialog = false }) {
+ Text(stringResource(android.R.string.cancel))
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ storageAccessLauncher.launch()
+ showDialog = false
+ }
+ ) {
+ Text(stringResource(android.R.string.ok))
+ }
+ },
+ )
+ }
+
+ folderPickerLauncher.value?.launcher =
+ rememberLauncherForActivityResult(OpenFolderPickerContract(activity)) { result ->
+ when (result) {
+ is FolderPickerResult.CanceledByUser -> {
+ // no-op, just dismiss the dialog
+ }
+
+ is FolderPickerResult.Picked -> {
+ currentFolderPickerCallback.value(result.folder)
+ }
+
+ is FolderPickerResult.AccessDenied -> {
+ if (result.storageType == StorageType.UNKNOWN) {
+ storageAccessLauncher.launch()
+ return@rememberLauncherForActivityResult
+ }
+ initialPath = FileFullPath(activity, result.storageId, "")
+ showDialog = true
+ }
+ }
+ }
+ return folderPickerLauncher.value!!
+}
diff --git a/storage-compose/src/test/java/com/anggrayudi/storage/compose/ExampleUnitTest.kt b/storage-compose/src/test/java/com/anggrayudi/storage/compose/ExampleUnitTest.kt
new file mode 100644
index 00000000..6dfdb57d
--- /dev/null
+++ b/storage-compose/src/test/java/com/anggrayudi/storage/compose/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.anggrayudi.storage.compose
+
+import org.junit.Assert.*
+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)
+ }
+}
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index 7bb03dc3..91dff20a 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
plugins {
id("com.android.library")
alias(libs.plugins.kotlin.android)
@@ -7,7 +9,7 @@ plugins {
android {
namespace = "com.anggrayudi.storage"
- compileSdk = 35
+ compileSdk = 36
resourcePrefix = "ss_"
defaultConfig {
@@ -15,9 +17,8 @@ android {
consumerProguardFiles("consumer-rules.pro")
}
- testOptions { targetSdk = 35 }
-
- lint { targetSdk = 35 }
+ testOptions { targetSdk = 36 }
+ lint { targetSdk = 36 }
buildTypes {
release {
@@ -33,10 +34,12 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}
- kotlinOptions {
- jvmTarget = "11"
- // Support @JvmDefault
- freeCompilerArgs = listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn")
+ kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_11
+ // Support @JvmDefault
+ freeCompilerArgs = listOf("-Xjvm-default=all", "-opt-in=kotlin.RequiresOptIn")
+ }
}
}
diff --git a/storage/gradle.properties b/storage/gradle.properties
new file mode 100644
index 00000000..584229ed
--- /dev/null
+++ b/storage/gradle.properties
@@ -0,0 +1 @@
+POM_ARTIFACT_ID=storage
diff --git a/storage/src/main/AndroidManifest.xml b/storage/src/main/AndroidManifest.xml
index e04e3a7d..c04287e8 100644
--- a/storage/src/main/AndroidManifest.xml
+++ b/storage/src/main/AndroidManifest.xml
@@ -7,4 +7,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt b/storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt
index 1788c6ac..b8779fe4 100644
--- a/storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt
+++ b/storage/src/main/java/com/anggrayudi/storage/ActivityWrapper.kt
@@ -22,7 +22,7 @@ internal class ActivityWrapper(private val _activity: Activity) : ComponentWrapp
return try {
_activity.startActivityForResult(intent, requestCode)
true
- } catch (e: ActivityNotFoundException) {
+ } catch (_: ActivityNotFoundException) {
false
}
}
diff --git a/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt b/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt
index 7f84a0ad..86312fc3 100644
--- a/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt
+++ b/storage/src/main/java/com/anggrayudi/storage/ComponentActivityWrapper.kt
@@ -4,7 +4,15 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.anggrayudi.storage.contract.FileCreationContract
+import com.anggrayudi.storage.contract.OpenFilePickerContract
+import com.anggrayudi.storage.contract.OpenFolderPickerContract
+import com.anggrayudi.storage.contract.RequestStorageAccessContract
+import com.anggrayudi.storage.file.StorageType
/**
* Created on 18/08/20
@@ -12,15 +20,52 @@ import androidx.activity.result.contract.ActivityResultContracts
* @author Anggrayudi H
*/
internal class ComponentActivityWrapper(private val _activity: ComponentActivity) :
- ComponentWrapper {
+ ComponentWrapper, DefaultLifecycleObserver {
+
+ init {
+ _activity.lifecycle.addObserver(this)
+ }
lateinit var storage: SimpleStorage
- var requestCode = 0
- private val activityResultLauncher =
- _activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- storage.onActivityResult(requestCode, it.resultCode, it.data)
- }
+ lateinit var storageAccessContract: RequestStorageAccessContract
+ lateinit var requestStorageAccessLauncher:
+ ActivityResultLauncher
+
+ lateinit var fileCreationContract: FileCreationContract
+ lateinit var requestFileCreationLauncher: ActivityResultLauncher
+
+ lateinit var filePickerContract: OpenFilePickerContract
+ lateinit var requestFilePickerLauncher: ActivityResultLauncher
+
+ lateinit var folderPickerContract: OpenFolderPickerContract
+ lateinit var requestFolderPickerLauncher: ActivityResultLauncher
+
+ override fun onCreate(owner: LifecycleOwner) {
+ storageAccessContract = RequestStorageAccessContract(_activity, StorageType.UNKNOWN, "")
+ requestStorageAccessLauncher =
+ _activity.registerForActivityResult(storageAccessContract) { result ->
+ storage.onRequestStorageAccessResult(result)
+ }
+
+ fileCreationContract = FileCreationContract(_activity)
+ requestFileCreationLauncher =
+ _activity.registerForActivityResult(fileCreationContract) { result ->
+ storage.onFileCreationResult(result)
+ }
+
+ filePickerContract = OpenFilePickerContract(_activity)
+ requestFilePickerLauncher =
+ _activity.registerForActivityResult(filePickerContract) { result ->
+ storage.onFilePickedResult(result)
+ }
+
+ folderPickerContract = OpenFolderPickerContract(_activity)
+ requestFolderPickerLauncher =
+ _activity.registerForActivityResult(folderPickerContract) { result ->
+ storage.onFolderPickedResult(result)
+ }
+ }
override val context: Context
get() = _activity
@@ -28,12 +73,18 @@ internal class ComponentActivityWrapper(private val _activity: ComponentActivity
override val activity: ComponentActivity
get() = _activity
+ var requestCode = 0
+ private val activityResultLauncher =
+ _activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ storage.onActivityResult(requestCode, it.resultCode, it.data)
+ }
+
override fun startActivityForResult(intent: Intent, requestCode: Int): Boolean {
return try {
activityResultLauncher.launch(intent)
this.requestCode = requestCode
true
- } catch (e: ActivityNotFoundException) {
+ } catch (_: ActivityNotFoundException) {
false
}
}
diff --git a/storage/src/main/java/com/anggrayudi/storage/EmptyActivity.kt b/storage/src/main/java/com/anggrayudi/storage/EmptyActivity.kt
new file mode 100644
index 00000000..b7a94ac4
--- /dev/null
+++ b/storage/src/main/java/com/anggrayudi/storage/EmptyActivity.kt
@@ -0,0 +1,13 @@
+package com.anggrayudi.storage
+
+import android.app.Activity
+import android.os.Bundle
+
+internal class EmptyActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setResult(RESULT_OK, intent)
+ finish()
+ }
+}
diff --git a/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt b/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt
index 909a9b2c..a209a0fa 100644
--- a/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt
+++ b/storage/src/main/java/com/anggrayudi/storage/FragmentWrapper.kt
@@ -33,7 +33,7 @@ internal class FragmentWrapper(private val fragment: Fragment) : ComponentWrappe
activityResultLauncher.launch(intent)
this.requestCode = requestCode
true
- } catch (e: ActivityNotFoundException) {
+ } catch (_: ActivityNotFoundException) {
false
}
}
diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt
index e5476623..2be5caf6 100644
--- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt
+++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt
@@ -3,14 +3,13 @@ package com.anggrayudi.storage
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
+import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
-import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
@@ -19,31 +18,31 @@ import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat.checkSelfPermission
-import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
+import com.anggrayudi.storage.SimpleStorage.Companion.hasStoragePermission
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.contract.FileCreationContract
+import com.anggrayudi.storage.contract.FileCreationResult
+import com.anggrayudi.storage.contract.FilePickerResult
+import com.anggrayudi.storage.contract.FolderPickerResult
+import com.anggrayudi.storage.contract.OpenFilePickerContract
+import com.anggrayudi.storage.contract.OpenFolderPickerContract
+import com.anggrayudi.storage.contract.RequestStorageAccessContract
+import com.anggrayudi.storage.contract.RequestStorageAccessResult
+import com.anggrayudi.storage.contract.StoragePermissionDeniedException
+import com.anggrayudi.storage.contract.intentToDocumentFiles
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.isWritable
import java.io.File
-import kotlin.concurrent.thread
/**
* @author Anggrayudi Hardiannico A. (anggrayudi.hardiannico@dana.id)
@@ -77,8 +76,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
var createFileCallback: CreateFileCallback? = null
- var fileReceiverCallback: FileReceiverCallback? = null
-
var requestCodeStorageAccess = DEFAULT_REQUEST_CODE_STORAGE_ACCESS
set(value) {
field = value
@@ -106,43 +103,6 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
val context: Context
get() = wrapper.context
- /** It returns an intent to be dispatched via [Activity.startActivityForResult] */
- private val externalStorageRootAccessIntent: Intent
- get() =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
- sm.primaryStorageVolume.createOpenDocumentTreeIntent()
- } else {
- getDefaultExternalStorageIntent(context)
- }
-
- /**
- * It returns an intent to be dispatched via [Activity.startActivityForResult] to access to the
- * first removable no primary storage. This function requires at least Nougat because on previous
- * Android versions there's no reliable way to get the volume/path of SdCard, and of course,
- * SdCard != External Storage.
- */
- @Suppress("DEPRECATION")
- private val sdCardRootAccessIntent: Intent
- @RequiresApi(api = Build.VERSION_CODES.N)
- get() {
- val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
- return sm.storageVolumes
- .firstOrNull { it.isRemovable }
- ?.let {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- it.createOpenDocumentTreeIntent()
- } else {
- // Access to the entire volume is only available for non-primary volumes
- if (it.isPrimary) {
- getDefaultExternalStorageIntent(context)
- } else {
- it.createAccessIntent(null)
- }
- }
- } ?: getDefaultExternalStorageIntent(context)
- }
-
/**
* Even though storage permission has been granted via [hasStoragePermission], read and write
* access may have not been granted yet.
@@ -170,6 +130,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
* @param expectedBasePath applicable for API 30+ only, because Android 11 does not allow
* selecting the root path.
*/
+ @Deprecated(
+ "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.",
+ ReplaceWith("RequestStorageAccessContract() with ActivityResultLauncher"),
+ )
@JvmOverloads
fun requestStorageAccess(
requestCode: Int = requestCodeStorageAccess,
@@ -177,36 +141,29 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
expectedStorageType: StorageType = StorageType.UNKNOWN,
expectedBasePath: String = "",
) {
- initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
- if (expectedStorageType == StorageType.DATA) {
- throw IllegalArgumentException(
- "Cannot use StorageType.DATA because it is never available in Storage Access Framework's folder selector."
- )
- }
-
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- if (hasStoragePermission(context)) {
- if (expectedStorageType == StorageType.EXTERNAL && !isSdCardPresent) {
- val root = DocumentFileCompat.getRootDocumentFile(context, PRIMARY, true) ?: return
- saveUriPermission(root.uri)
- storageAccessCallback?.onRootPathPermissionGranted(requestCode, root)
- return
- }
- } else {
- storageAccessCallback?.onStoragePermissionDenied(requestCode)
- return
+ val options = RequestStorageAccessContract.Options(initialPath)
+ if (wrapper is ComponentActivityWrapper) {
+ try {
+ wrapper.storageAccessContract.expectedStorageType = expectedStorageType
+ wrapper.storageAccessContract.expectedBasePath = expectedBasePath
+ wrapper.requestStorageAccessLauncher.launch(options)
+ requestCodeStorageAccess = requestCode
+ expectedStorageTypeForAccessRequest = expectedStorageType
+ expectedBasePathForAccessRequest = expectedBasePath
+ } catch (_: ActivityNotFoundException) {
+ storageAccessCallback?.onActivityHandlerNotFound(requestCode, Intent())
}
+ return
}
+ val contract =
+ RequestStorageAccessContract(wrapper.context, expectedStorageType, expectedBasePath)
val intent =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- externalStorageRootAccessIntent.also { addInitialPathToIntent(it, initialPath) }
- } else if (
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && expectedStorageType == StorageType.SD_CARD
- ) {
- sdCardRootAccessIntent
- } else {
- externalStorageRootAccessIntent
+ try {
+ contract.createIntent(wrapper.context, options)
+ } catch (_: StoragePermissionDeniedException) {
+ storageAccessCallback?.onStoragePermissionDenied(requestCode)
+ return
}
if (wrapper.startActivityForResult(intent, requestCode)) {
@@ -236,6 +193,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
*
* @param initialPath only takes effect on API 26+
*/
+ @Deprecated(
+ "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.",
+ ReplaceWith("FileCreationContract() with ActivityResultLauncher"),
+ )
@JvmOverloads
fun createFile(
mimeType: String,
@@ -243,43 +204,66 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
initialPath: FileFullPath? = null,
requestCode: Int = requestCodeCreateFile,
) {
- initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
+ val options = FileCreationContract.Options(mimeType, fileName, initialPath)
+ if (wrapper is ComponentActivityWrapper) {
+ try {
+ wrapper.requestFileCreationLauncher.launch(options)
+ requestCodeCreateFile = requestCode
+ } catch (_: ActivityNotFoundException) {
+ createFileCallback?.onActivityHandlerNotFound(requestCode, Intent())
+ }
+ return
+ }
+
+ val contract = FileCreationContract(wrapper.context)
+ val intent = contract.createIntent(wrapper.context, options)
requestCodeCreateFile = requestCode
- val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).setType(mimeType)
- addInitialPathToIntent(intent, initialPath)
- fileName?.let { intent.putExtra(Intent.EXTRA_TITLE, it) }
if (!wrapper.startActivityForResult(intent, requestCode))
createFileCallback?.onActivityHandlerNotFound(requestCode, intent)
}
/** @param initialPath only works for API 26+ */
+ @Deprecated(
+ "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.",
+ ReplaceWith("OpenFolderPickerContract() with ActivityResultLauncher"),
+ )
@SuppressLint("InlinedApi")
@JvmOverloads
fun openFolderPicker(
requestCode: Int = requestCodeFolderPicker,
initialPath: FileFullPath? = null,
) {
- initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
- requestCodeFolderPicker = requestCode
-
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || hasStoragePermission(context)) {
- val intent =
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
- } else {
- externalStorageRootAccessIntent
- }
- addInitialPathToIntent(intent, initialPath)
- if (!wrapper.startActivityForResult(intent, requestCode))
- folderPickerCallback?.onActivityHandlerNotFound(requestCode, intent)
- } else {
- folderPickerCallback?.onStoragePermissionDenied(requestCode)
+ val options = OpenFolderPickerContract.Options(initialPath)
+ if (wrapper is ComponentActivityWrapper) {
+ try {
+ wrapper.requestFolderPickerLauncher.launch(options)
+ requestCodeFolderPicker = requestCode
+ } catch (_: ActivityNotFoundException) {
+ folderPickerCallback?.onActivityHandlerNotFound(requestCode, Intent())
+ }
+ return
}
+
+ val contract = OpenFolderPickerContract(wrapper.context)
+ val intent =
+ try {
+ contract.createIntent(wrapper.context, options)
+ } catch (_: StoragePermissionDeniedException) {
+ folderPickerCallback?.onStoragePermissionDenied(requestCode)
+ return
+ }
+ requestCodeFolderPicker = requestCode
+ if (!wrapper.startActivityForResult(intent, requestCode))
+ folderPickerCallback?.onActivityHandlerNotFound(requestCode, intent)
}
private var lastVisitedFolder: File = Environment.getExternalStorageDirectory()
/** @param initialPath only takes effect on API 26+ */
+ @Deprecated(
+ "This function doesn't follow Google's latest method, because it still uses startActivityForResult() manually.",
+ ReplaceWith("OpenFilePickerContract() with ActivityResultLauncher"),
+ )
@JvmOverloads
fun openFilePicker(
requestCode: Int = requestCodeFilePicker,
@@ -287,257 +271,52 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
initialPath: FileFullPath? = null,
vararg filterMimeTypes: String,
) {
- initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
- requestCodeFilePicker = requestCode
-
- val intent =
- Intent(Intent.ACTION_OPEN_DOCUMENT).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple)
- if (filterMimeTypes.size > 1) {
- intent.setType(MimeType.UNKNOWN).putExtra(Intent.EXTRA_MIME_TYPES, filterMimeTypes)
- } else {
- intent.type = filterMimeTypes.firstOrNull() ?: MimeType.UNKNOWN
- }
- addInitialPathToIntent(intent, initialPath)
- if (!wrapper.startActivityForResult(intent, requestCode))
- filePickerCallback?.onActivityHandlerNotFound(requestCode, intent)
- }
-
- private fun addInitialPathToIntent(intent: Intent, initialPath: FileFullPath?) {
- if (Build.VERSION.SDK_INT >= 26) {
- initialPath?.toDocumentUri(context)?.let {
- intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it)
- }
- }
- }
-
- @Suppress("DEPRECATION")
- private fun handleActivityResultForStorageAccess(requestCode: Int, uri: Uri) {
- val storageId = uri.getStorageId(context)
- val storageType = StorageType.fromStorageId(storageId)
-
- 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
- ) {
- storageAccessCallback?.onExpectedStorageNotSelected(
- requestCode,
- selectedFolder,
- storageType,
- expectedBasePathForAccessRequest!!,
- expectedStorageTypeForAccessRequest,
- )
- return
- }
- } else if (!expectedStorageTypeForAccessRequest.isExpected(storageType)) {
- val rootPath = context.fromTreeUri(uri)?.getAbsolutePath(context).orEmpty()
- 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,
- )
- } else {
- storageAccessCallback?.onRootPathNotSelected(
- requestCode,
- PublicDirectory.DOWNLOADS.absolutePath,
- uri,
- StorageType.EXTERNAL,
- expectedStorageTypeForAccessRequest,
- )
- }
- return
- }
-
- if (uri.isDocumentsDocument) {
- if (uri.toString() == DocumentFileCompat.DOCUMENTS_TREE_URI) {
- saveUriPermission(uri)
- storageAccessCallback?.onRootPathPermissionGranted(
- requestCode,
- context.fromTreeUri(uri) ?: return,
- )
- } else {
- storageAccessCallback?.onRootPathNotSelected(
- requestCode,
- PublicDirectory.DOCUMENTS.absolutePath,
- uri,
- StorageType.EXTERNAL,
- expectedStorageTypeForAccessRequest,
- )
- }
- return
- }
-
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R && !uri.isExternalStorageDocument) {
- 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,
- )
- return
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || DocumentFileCompat.isRootUri(uri)) {
- if (saveUriPermission(uri)) {
- storageAccessCallback?.onRootPathPermissionGranted(
- requestCode,
- context.fromTreeUri(uri) ?: return,
- )
- } else {
- storageAccessCallback?.onStoragePermissionDenied(requestCode)
- }
- } else {
- if (storageId == PRIMARY) {
- 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
- sm.storageVolumes
- .firstOrNull { !it.isPrimary }
- ?.createAccessIntent(null)
- ?.let {
- if (!wrapper.startActivityForResult(it, requestCode)) {
- storageAccessCallback?.onActivityHandlerNotFound(requestCode, it)
- }
- return
- }
- }
- storageAccessCallback?.onRootPathNotSelected(
- requestCode,
- "/storage/$storageId",
- uri,
- StorageType.SD_CARD,
- expectedStorageTypeForAccessRequest,
- )
+ val options =
+ OpenFilePickerContract.Options(allowMultiple, initialPath, filterMimeTypes.toSet())
+ if (wrapper is ComponentActivityWrapper) {
+ try {
+ wrapper.requestFilePickerLauncher.launch(options)
+ requestCodeFilePicker = requestCode
+ } catch (_: ActivityNotFoundException) {
+ filePickerCallback?.onActivityHandlerNotFound(requestCode, Intent())
}
- }
- }
-
- private fun handleActivityResultForFolderPicker(requestCode: Int, uri: Uri) {
- val folder = context.fromTreeUri(uri)
- val storageId = uri.getStorageId(context)
- val storageType = StorageType.fromStorageId(storageId)
-
- if (folder == null || !folder.canModify(context)) {
- folderPickerCallback?.onStorageAccessDenied(requestCode, folder, storageType, storageId)
return
}
- 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)
- ) {
- saveUriPermission(uri)
- }
- if (
- Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storageType == StorageType.EXTERNAL ||
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && saveUriPermission(uri) ||
- folder.canModify(context) && (uri.isDocumentsDocument || !uri.isExternalStorageDocument) ||
- DocumentFileCompat.isStorageUriPermissionGranted(context, storageId)
- ) {
- folderPickerCallback?.onFolderSelected(requestCode, folder)
- } else {
- folderPickerCallback?.onStorageAccessDenied(requestCode, folder, storageType, storageId)
- }
- }
-
- private fun intentToDocumentFiles(intent: Intent?): List {
- val uris =
- intent?.clipData?.run {
- val list = mutableListOf()
- for (i in 0 until itemCount) {
- list.add(getItemAt(i).uri)
- }
- list.takeIf { it.isNotEmpty() }
- } ?: listOf(intent?.data ?: return emptyList())
- return uris
- .mapNotNull { uri ->
- if (
- uri.isDownloadsDocument &&
- Build.VERSION.SDK_INT < 28 &&
- uri.path?.startsWith("/document/raw:") == true
- ) {
- val fullPath = uri.path.orEmpty().substringAfterLast("/document/raw:")
- DocumentFile.fromFile(File(fullPath))
- } else {
- context.fromSingleUri(uri)
- }
- }
- .filter { it.isFile }
+ val contract = OpenFilePickerContract(wrapper.context)
+ val intent = contract.createIntent(wrapper.context, options)
+ requestCodeFilePicker = requestCode
+ if (!wrapper.startActivityForResult(intent, requestCode))
+ filePickerCallback?.onActivityHandlerNotFound(requestCode, intent)
}
- fun checkIfFileReceived(intent: Intent?) {
+ fun checkIfFileReceived(intent: Intent?, callback: FileReceiverCallback?) {
when (intent?.action) {
Intent.ACTION_SEND,
Intent.ACTION_SEND_MULTIPLE -> {
- val files = intentToDocumentFiles(intent)
+ val files = intentToDocumentFiles(context, intent)
if (files.isEmpty()) {
- fileReceiverCallback?.onNonFileReceived(intent)
+ callback?.onNonFileReceived(intent)
} else {
- fileReceiverCallback?.onFileReceived(files)
+ callback?.onFileReceived(files)
}
}
}
}
- private fun handleActivityResultForFilePicker(requestCode: Int, data: Intent) {
- val files = intentToDocumentFiles(data)
- if (files.isNotEmpty() && files.all { it.canRead() }) {
- filePickerCallback?.onFileSelected(requestCode, files)
- } else {
- filePickerCallback?.onStoragePermissionDenied(requestCode, files)
- }
- }
-
- private fun handleActivityResultForCreateFile(requestCode: Int, uri: Uri) {
- DocumentFileCompat.fromUri(context, uri)?.let {
- createFileCallback?.onFileCreated(requestCode, it)
- }
- }
-
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
checkRequestCode()
when (requestCode) {
requestCodeStorageAccess -> {
if (resultCode == Activity.RESULT_OK) {
- handleActivityResultForStorageAccess(requestCode, data?.data ?: return)
+ val contract =
+ RequestStorageAccessContract(
+ wrapper.context,
+ expectedStorageTypeForAccessRequest,
+ expectedBasePathForAccessRequest.orEmpty(),
+ )
+ onRequestStorageAccessResult(contract.parseResult(resultCode, data))
} else {
storageAccessCallback?.onCanceledByUser(requestCode)
}
@@ -545,7 +324,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
requestCodeFolderPicker -> {
if (resultCode == Activity.RESULT_OK) {
- handleActivityResultForFolderPicker(requestCode, data?.data ?: return)
+ val contract = OpenFolderPickerContract(wrapper.context)
+ onFolderPickedResult(contract.parseResult(resultCode, data))
} else {
folderPickerCallback?.onCanceledByUser(requestCode)
}
@@ -553,7 +333,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
requestCodeFilePicker -> {
if (resultCode == Activity.RESULT_OK) {
- handleActivityResultForFilePicker(requestCode, data ?: return)
+ val contract = OpenFilePickerContract(wrapper.context)
+ onFilePickedResult(contract.parseResult(resultCode, data))
} else {
filePickerCallback?.onCanceledByUser(requestCode)
}
@@ -563,7 +344,8 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
// resultCode is always OK for creating files
val uri = data?.data
if (uri != null) {
- handleActivityResultForCreateFile(requestCode, uri)
+ val contract = FileCreationContract(wrapper.context)
+ onFileCreationResult(contract.parseResult(resultCode, data))
} else {
createFileCallback?.onCanceledByUser(requestCode)
}
@@ -571,6 +353,105 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
}
}
+ internal fun onRequestStorageAccessResult(result: RequestStorageAccessResult) {
+ when (result) {
+ is RequestStorageAccessResult.CanceledByUser -> {
+ storageAccessCallback?.onCanceledByUser(requestCodeStorageAccess)
+ }
+
+ is RequestStorageAccessResult.StoragePermissionDenied -> {
+ storageAccessCallback?.onStoragePermissionDenied(requestCodeStorageAccess)
+ }
+
+ is RequestStorageAccessResult.RootPathNotSelected -> {
+ if (result.expectedIntent != null) {
+ if (!wrapper.startActivityForResult(result.expectedIntent, requestCodeStorageAccess)) {
+ storageAccessCallback?.onActivityHandlerNotFound(
+ requestCodeStorageAccess,
+ result.expectedIntent,
+ )
+ }
+ return
+ }
+ storageAccessCallback?.onRootPathNotSelected(
+ requestCodeStorageAccess,
+ result.rootPath,
+ result.uri,
+ result.selectedStorageType,
+ expectedStorageTypeForAccessRequest,
+ )
+ }
+
+ is RequestStorageAccessResult.ExpectedStorageNotSelected -> {
+ storageAccessCallback?.onExpectedStorageNotSelected(
+ requestCodeStorageAccess,
+ result.selectedFolder,
+ result.selectedStorageType,
+ result.expectedBasePath,
+ expectedStorageTypeForAccessRequest,
+ )
+ }
+
+ is RequestStorageAccessResult.RootPathPermissionGranted -> {
+ storageAccessCallback?.onRootPathPermissionGranted(requestCodeStorageAccess, result.root)
+ }
+ }
+ }
+
+ internal fun onFolderPickedResult(result: FolderPickerResult) {
+ when (result) {
+ is FolderPickerResult.Picked -> {
+ folderPickerCallback?.onFolderSelected(requestCodeFolderPicker, result.folder)
+ }
+
+ is FolderPickerResult.AccessDenied -> {
+ folderPickerCallback?.onStorageAccessDenied(
+ requestCodeFolderPicker,
+ result.folder,
+ result.storageType,
+ result.storageId,
+ )
+ }
+
+ FolderPickerResult.CanceledByUser -> {
+ folderPickerCallback?.onCanceledByUser(requestCodeFolderPicker)
+ }
+ }
+ }
+
+ internal fun onFilePickedResult(result: FilePickerResult) {
+ when (result) {
+ is FilePickerResult.Picked -> {
+ filePickerCallback?.onFileSelected(requestCodeFilePicker, result.files)
+ }
+
+ is FilePickerResult.CanceledByUser -> {
+ filePickerCallback?.onCanceledByUser(requestCodeFilePicker)
+ }
+
+ is FilePickerResult.StoragePermissionDenied -> {
+ filePickerCallback?.onStoragePermissionDenied(requestCodeFilePicker, result.files)
+ }
+ }
+ }
+
+ internal fun onFileCreationResult(result: FileCreationResult) {
+ when (result) {
+ is FileCreationResult.Created -> {
+ createFileCallback?.onFileCreated(requestCodeCreateFile, result.file)
+ }
+
+ is FileCreationResult.CanceledByUser -> {
+ createFileCallback?.onCanceledByUser(requestCodeCreateFile)
+ }
+
+ is FileCreationResult.StoragePermissionDenied -> {
+ // This should not happen, but just in case
+ Log.e(TAG, "Unexpected result for file creation: $result")
+ }
+ }
+ }
+
fun onSaveInstanceState(outState: Bundle) {
outState.putString(KEY_LAST_VISITED_FOLDER, lastVisitedFolder.path)
outState.putString(KEY_EXPECTED_BASE_PATH_FOR_ACCESS_REQUEST, expectedBasePathForAccessRequest)
@@ -613,6 +494,10 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) {
}
private fun checkRequestCode() {
+ if (wrapper is ComponentActivityWrapper) {
+ return
+ }
+
if (requestCodeFilePicker == 0) {
requestCodeFilePicker = DEFAULT_REQUEST_CODE_FILE_PICKER
}
@@ -645,17 +530,6 @@ 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
- context.contentResolver.takePersistableUriPermission(root, writeFlags)
- thread { cleanupRedundantUriPermissions(context.applicationContext) }
- true
- } catch (e: SecurityException) {
- false
- }
-
companion object {
private const val KEY_REQUEST_CODE_STORAGE_ACCESS =
diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt
index bf1432e9..2e247181 100644
--- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt
+++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorageHelper.kt
@@ -11,11 +11,11 @@ import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
+import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import com.anggrayudi.storage.callback.CreateFileCallback
import com.anggrayudi.storage.callback.FilePickerCallback
-import com.anggrayudi.storage.callback.FileReceiverCallback
import com.anggrayudi.storage.callback.FolderPickerCallback
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.extension.getStorageId
@@ -173,21 +173,6 @@ class SimpleStorageHelper {
}
}
- var onFileReceived: OnFileReceived? = null
- set(callback) {
- field = callback
- storage.fileReceiverCallback =
- object : FileReceiverCallback {
- override fun onFileReceived(files: List) {
- callback?.onFileReceived(files)
- }
-
- override fun onNonFileReceived(intent: Intent) {
- callback?.onNonFileReceived(intent)
- }
- }
- }
-
@SuppressLint("NewApi")
private fun init(savedState: Bundle?) {
savedState?.let { onRestoreInstanceState(it) }
@@ -298,7 +283,13 @@ 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,
+ if (expectedStorageType == StorageType.UNKNOWN) selectedStorageType
+ else expectedStorageType,
+ expectedBasePath,
+ ),
expectedStorageType = expectedStorageType,
expectedBasePath = expectedBasePath,
)
@@ -466,7 +457,7 @@ class SimpleStorageHelper {
val intentSetting =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
- Uri.parse("package:${context.packageName}"),
+ "package:${context.packageName}".toUri(),
)
.addCategory(Intent.CATEGORY_DEFAULT)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
diff --git a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt
new file mode 100644
index 00000000..9a944715
--- /dev/null
+++ b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt
@@ -0,0 +1,486 @@
+package com.anggrayudi.storage.contract
+
+import android.Manifest
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.storage.StorageManager
+import android.provider.DocumentsContract
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.ACTION_REQUEST_PERMISSIONS
+import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.EXTRA_PERMISSIONS
+import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions.Companion.EXTRA_PERMISSION_GRANT_RESULTS
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.documentfile.provider.DocumentFile
+import com.anggrayudi.storage.EmptyActivity
+import com.anggrayudi.storage.SimpleStorage.Companion.cleanupRedundantUriPermissions
+import com.anggrayudi.storage.SimpleStorage.Companion.externalStoragePath
+import com.anggrayudi.storage.SimpleStorage.Companion.getDefaultExternalStorageIntent
+import com.anggrayudi.storage.SimpleStorage.Companion.hasStoragePermission
+import com.anggrayudi.storage.SimpleStorage.Companion.isSdCardPresent
+import com.anggrayudi.storage.callback.StorageAccessCallback
+import com.anggrayudi.storage.extension.fromSingleUri
+import com.anggrayudi.storage.extension.fromTreeUri
+import com.anggrayudi.storage.extension.getStorageId
+import com.anggrayudi.storage.extension.isDocumentsDocument
+import com.anggrayudi.storage.extension.isDownloadsDocument
+import com.anggrayudi.storage.extension.isExternalStorageDocument
+import com.anggrayudi.storage.file.DocumentFileCompat
+import com.anggrayudi.storage.file.FileFullPath
+import com.anggrayudi.storage.file.MimeType
+import com.anggrayudi.storage.file.PublicDirectory
+import com.anggrayudi.storage.file.StorageId.PRIMARY
+import com.anggrayudi.storage.file.StorageType
+import com.anggrayudi.storage.file.canModify
+import com.anggrayudi.storage.file.getAbsolutePath
+import com.anggrayudi.storage.file.getBasePath
+import java.io.File
+import java.io.FileNotFoundException
+import kotlin.concurrent.thread
+
+internal fun saveUriPermission(context: Context, root: Uri) =
+ try {
+ val writeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ context.contentResolver.takePersistableUriPermission(root, writeFlags)
+ thread { cleanupRedundantUriPermissions(context.applicationContext) }
+ true
+ } catch (_: SecurityException) {
+ false
+ }
+
+/** It returns an intent to be dispatched via [Activity.startActivityForResult] */
+internal fun getExternalStorageRootAccessIntent(context: Context): Intent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
+ sm.primaryStorageVolume.createOpenDocumentTreeIntent()
+ } else {
+ getDefaultExternalStorageIntent(context)
+ }
+
+/**
+ * It returns an intent to be dispatched via [Activity.startActivityForResult] to access to the
+ * first removable no primary storage. This function requires at least Nougat because on previous
+ * Android versions there's no reliable way to get the volume/path of SdCard, and of course, SdCard
+ * != External Storage.
+ */
+@Suppress("DEPRECATION")
+@RequiresApi(api = Build.VERSION_CODES.N)
+internal fun getSdCardRootAccessIntent(context: Context): Intent {
+ val sm = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
+ return sm.storageVolumes
+ .firstOrNull { it.isRemovable }
+ ?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ it.createOpenDocumentTreeIntent()
+ } else {
+ // Access to the entire volume is only available for non-primary volumes
+ if (it.isPrimary) {
+ getDefaultExternalStorageIntent(context)
+ } else {
+ it.createAccessIntent(null)
+ }
+ }
+ } ?: getDefaultExternalStorageIntent(context)
+}
+
+internal fun addInitialPathToIntent(context: Context, intent: Intent, initialPath: FileFullPath?) {
+ if (Build.VERSION.SDK_INT >= 26) {
+ initialPath?.toDocumentUri(context)?.let {
+ intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it)
+ }
+ }
+}
+
+internal fun intentToDocumentFiles(context: Context, intent: Intent?): List {
+ val uris =
+ intent?.clipData?.run {
+ val list = mutableListOf()
+ for (i in 0 until itemCount) {
+ list.add(getItemAt(i).uri)
+ }
+ list.takeIf { it.isNotEmpty() }
+ } ?: listOf(intent?.data ?: return emptyList())
+
+ return uris
+ .mapNotNull { uri ->
+ if (
+ uri.isDownloadsDocument &&
+ Build.VERSION.SDK_INT < 28 &&
+ uri.path?.startsWith("/document/raw:") == true
+ ) {
+ val fullPath = uri.path.orEmpty().substringAfterLast("/document/raw:")
+ DocumentFile.fromFile(File(fullPath))
+ } else {
+ context.fromSingleUri(uri)
+ }
+ }
+ .filter { it.isFile }
+}
+
+/** This contract may throws [ActivityNotFoundException] or [StoragePermissionDeniedException]. */
+class OpenFolderPickerContract(context: Context) :
+ ActivityResultContract() {
+
+ private val appContext = context.applicationContext
+
+ override fun createIntent(context: Context, input: Options): Intent {
+ input.initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || hasStoragePermission(context)) {
+ val intent =
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
+ } else {
+ getExternalStorageRootAccessIntent(context)
+ }
+ addInitialPathToIntent(context, intent, input.initialPath)
+ return intent
+ }
+ throw StoragePermissionDeniedException()
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): FolderPickerResult {
+ val uri =
+ intent?.takeIf { resultCode == Activity.RESULT_OK }?.data
+ ?: return FolderPickerResult.CanceledByUser
+
+ val folder = appContext.fromTreeUri(uri)
+ val storageId = uri.getStorageId(appContext)
+ val storageType = StorageType.fromStorageId(storageId)
+
+ if (folder == null || !folder.canModify(appContext)) {
+ return FolderPickerResult.AccessDenied(folder, storageType, storageId)
+ }
+ 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(appContext, storageId)
+ ) {
+ saveUriPermission(appContext, uri)
+ }
+ if (
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storageType == StorageType.EXTERNAL ||
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && saveUriPermission(appContext, uri) ||
+ folder.canModify(appContext) &&
+ (uri.isDocumentsDocument || !uri.isExternalStorageDocument) ||
+ DocumentFileCompat.isStorageUriPermissionGranted(appContext, storageId)
+ ) {
+ return FolderPickerResult.Picked(folder)
+ } else {
+ return FolderPickerResult.AccessDenied(folder, storageType, storageId)
+ }
+ }
+
+ class Options
+ @JvmOverloads
+ constructor(
+ /** It only takes effect on API 26+ */
+ val initialPath: FileFullPath? = null
+ )
+}
+
+/** This contract may throws [ActivityNotFoundException] */
+class OpenFilePickerContract(context: Context) :
+ ActivityResultContract() {
+
+ private val appContext = context.applicationContext
+
+ override fun createIntent(context: Context, input: Options): Intent {
+ input.initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
+ val mimeTypes = input.filterMimeTypes
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, input.allowMultiple)
+ if (mimeTypes.size > 1) {
+ intent.setType(MimeType.UNKNOWN).putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
+ } else {
+ intent.type = mimeTypes.firstOrNull() ?: MimeType.UNKNOWN
+ }
+ addInitialPathToIntent(context, intent, input.initialPath)
+ return intent
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): FilePickerResult {
+ if (resultCode != Activity.RESULT_OK) {
+ return FilePickerResult.CanceledByUser
+ }
+ val files = intentToDocumentFiles(appContext, intent)
+ return if (files.isNotEmpty() && files.all { it.canRead() }) {
+ FilePickerResult.Picked(files)
+ } else {
+ FilePickerResult.StoragePermissionDenied(files)
+ }
+ }
+
+ class Options
+ @JvmOverloads
+ constructor(
+ val allowMultiple: Boolean = false,
+ /** It only takes effect on API 26+ */
+ val initialPath: FileFullPath? = null,
+ val filterMimeTypes: Set = emptySet(),
+ )
+}
+
+/** Show interactive UI to create a file. This contract may throws [ActivityNotFoundException] */
+class FileCreationContract(context: Context) :
+ ActivityResultContract() {
+
+ private val appContext = context.applicationContext
+
+ override fun createIntent(context: Context, input: Options): Intent {
+ input.initialPath?.checkIfStorageIdIsAccessibleInSafSelector()
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).setType(input.mimeType)
+ addInitialPathToIntent(context, intent, input.initialPath)
+ input.fileName?.let { intent.putExtra(Intent.EXTRA_TITLE, it) }
+ return intent
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): FileCreationResult {
+ // resultCode is always OK for creating files
+ val uri = intent?.data ?: return FileCreationResult.CanceledByUser
+ val file =
+ DocumentFileCompat.fromUri(appContext, uri)
+ ?: return FileCreationResult.StoragePermissionDenied
+ return FileCreationResult.Created(file)
+ }
+
+ class Options
+ @JvmOverloads
+ constructor(
+ val mimeType: String,
+ val fileName: String? = null,
+ val initialPath: FileFullPath? = null,
+ )
+}
+
+/**
+ * Requests `android.permission.READ_EXTERNAL_STORAGE` and
+ * `android.permission.WRITE_EXTERNAL_STORAGE`. It only takes effect on API 28-, because API 29+ has
+ * scoped storage.
+ */
+class StoragePermissionContract() :
+ ActivityResultContract>() {
+
+ fun getPermissions() =
+ arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
+
+ override fun createIntent(context: Context, input: Unit): Intent {
+ return Intent(ACTION_REQUEST_PERMISSIONS).putExtra(EXTRA_PERMISSIONS, getPermissions())
+ }
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Unit,
+ ): SynchronousResult