From 46455122a052aa57798bda059562b7aeeee5e2c3 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Wed, 11 Jun 2025 22:29:07 +0700 Subject: [PATCH 01/10] Updated license year --- LICENSE | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..056de264 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,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. From 40c145a5c3e39864b561c43c38b5c88d61984fc2 Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Sun, 15 Jun 2025 06:09:42 +0700 Subject: [PATCH 02/10] Migrated to activity launcher with contracts --- gradle.properties | 3 +- gradle/libs.versions.toml | 4 +- .../storage/sample/activity/MainActivity.kt | 33 +- storage/build.gradle.kts | 1 - storage/gradle.properties | 1 + storage/src/main/AndroidManifest.xml | 6 + .../com/anggrayudi/storage/EmptyActivity.kt | 13 + .../com/anggrayudi/storage/SimpleStorage.kt | 505 ++++++------------ .../anggrayudi/storage/SimpleStorageHelper.kt | 19 +- .../storage/contract/SimpleStorageResult.kt | 63 +++ .../contract/SimpleStorageResultContract.kt | 437 +++++++++++++++ .../StoragePermissionDeniedException.kt | 10 + 12 files changed, 716 insertions(+), 379 deletions(-) create mode 100644 storage/gradle.properties create mode 100644 storage/src/main/java/com/anggrayudi/storage/EmptyActivity.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResult.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContract.kt create mode 100644 storage/src/main/java/com/anggrayudi/storage/contract/StoragePermissionDeniedException.kt diff --git a/gradle.properties b/gradle.properties index e8e8cba7..94e551fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,8 +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 +VERSION_NAME=2.2.0-SNAPSHOT RELEASE_SIGNING_ENABLED=false SONATYPE_AUTOMATIC_RELEASE=true SONATYPE_HOST=DEFAULT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89b2d466..f9eaf763 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +agp = "8.9.3" kotlin = "2.1.20" activityCompose = "1.10.1" coroutines = "1.10.2" @@ -47,7 +48,8 @@ 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" } 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..eac90ea4 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 @@ -237,19 +239,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 +943,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) @@ -972,9 +975,9 @@ class MainActivity : AppCompatActivity() { 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/storage/build.gradle.kts b/storage/build.gradle.kts index 7bb03dc3..ab7a404f 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -16,7 +16,6 @@ android { } testOptions { targetSdk = 35 } - lint { targetSdk = 35 } buildTypes { 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..b16ecc04 100644 --- a/storage/src/main/AndroidManifest.xml +++ b/storage/src/main/AndroidManifest.xml @@ -7,4 +7,10 @@ + + + + \ No newline at end of file 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/SimpleStorage.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt index e5476623..08b9b1ee 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt @@ -6,11 +6,9 @@ import android.app.Activity 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 +17,30 @@ 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.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 +74,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 +101,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 +128,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,37 +139,15 @@ 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 { + val contract = + RequestStorageAccessContract(wrapper.context, expectedStorageType, expectedBasePath) + val intent = + try { + contract.createIntent(wrapper.context, RequestStorageAccessContract.Options(initialPath)) + } catch (e: StoragePermissionDeniedException) { storageAccessCallback?.onStoragePermissionDenied(requestCode) return } - } - - 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 - } if (wrapper.startActivityForResult(intent, requestCode)) { requestCodeStorageAccess = requestCode @@ -236,6 +176,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 +187,52 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { initialPath: FileFullPath? = null, requestCode: Int = requestCodeCreateFile, ) { - initialPath?.checkIfStorageIdIsAccessibleInSafSelector() + val contract = FileCreationContract(wrapper.context) + val intent = + contract.createIntent( + wrapper.context, + FileCreationContract.Options( + mimeType = mimeType, + fileName = fileName, + initialPath = initialPath, + ), + ) 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() + val contract = OpenFolderPickerContract(wrapper.context) + val intent = + try { + contract.createIntent(wrapper.context, OpenFolderPickerContract.Options(initialPath)) + } catch (e: StoragePermissionDeniedException) { + folderPickerCallback?.onStoragePermissionDenied(requestCode) + return + } 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) - } + 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 +240,89 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { initialPath: FileFullPath? = null, vararg filterMimeTypes: String, ) { - initialPath?.checkIfStorageIdIsAccessibleInSafSelector() - requestCodeFilePicker = requestCode - + val contract = OpenFilePickerContract(wrapper.context) 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) + contract.createIntent( + wrapper.context, + OpenFilePickerContract.Options( + allowMultiple = allowMultiple, + initialPath = initialPath, + filterMimeTypes = filterMimeTypes.toSet(), + ), + ) + requestCodeFilePicker = requestCode 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, - ) - } - } - } - - 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 } - } - - 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(), + ) + when (val result = contract.parseResult(resultCode, data)) { + is RequestStorageAccessResult.CanceledByUser -> { + storageAccessCallback?.onCanceledByUser(requestCode) + } + + is RequestStorageAccessResult.StoragePermissionDenied -> { + storageAccessCallback?.onStoragePermissionDenied(requestCode) + } + + is RequestStorageAccessResult.RootPathNotSelected -> { + if (result.expectedIntent != null) { + if (!wrapper.startActivityForResult(result.expectedIntent, requestCode)) { + storageAccessCallback?.onActivityHandlerNotFound( + requestCode, + result.expectedIntent, + ) + } + return + } + storageAccessCallback?.onRootPathNotSelected( + requestCode, + result.rootPath, + result.uri, + result.selectedStorageType, + expectedStorageTypeForAccessRequest, + ) + } + + is RequestStorageAccessResult.ExpectedStorageNotSelected -> { + storageAccessCallback?.onExpectedStorageNotSelected( + requestCode, + result.selectedFolder, + result.selectedStorageType, + result.expectedBasePath, + expectedStorageTypeForAccessRequest, + ) + } + + is RequestStorageAccessResult.RootPathPermissionGranted -> { + storageAccessCallback?.onRootPathPermissionGranted(requestCode, result.root) + } + } } else { storageAccessCallback?.onCanceledByUser(requestCode) } @@ -545,7 +330,25 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { requestCodeFolderPicker -> { if (resultCode == Activity.RESULT_OK) { - handleActivityResultForFolderPicker(requestCode, data?.data ?: return) + val contract = OpenFolderPickerContract(wrapper.context) + when (val result = contract.parseResult(resultCode, data)) { + is FolderPickerResult.Picked -> { + folderPickerCallback?.onFolderSelected(requestCode, result.folder) + } + + is FolderPickerResult.AccessDenied -> { + folderPickerCallback?.onStorageAccessDenied( + requestCode, + result.folder, + result.storageType, + result.storageId, + ) + } + + FolderPickerResult.CanceledByUser -> { + folderPickerCallback?.onCanceledByUser(requestCode) + } + } } else { folderPickerCallback?.onCanceledByUser(requestCode) } @@ -553,7 +356,20 @@ class SimpleStorage private constructor(private val wrapper: ComponentWrapper) { requestCodeFilePicker -> { if (resultCode == Activity.RESULT_OK) { - handleActivityResultForFilePicker(requestCode, data ?: return) + val contract = OpenFilePickerContract(wrapper.context) + when (val result = contract.parseResult(resultCode, data)) { + is FilePickerResult.Picked -> { + filePickerCallback?.onFileSelected(requestCode, result.files) + } + + is FilePickerResult.CanceledByUser -> { + filePickerCallback?.onCanceledByUser(requestCode) + } + + is FilePickerResult.StoragePermissionDenied -> { + filePickerCallback?.onStoragePermissionDenied(requestCode, result.files) + } + } } else { filePickerCallback?.onCanceledByUser(requestCode) } @@ -563,7 +379,21 @@ 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) + when (val result = contract.parseResult(resultCode, data)) { + is FileCreationResult.Created -> { + createFileCallback?.onFileCreated(requestCode, result.file) + } + + is FileCreationResult.CanceledByUser -> { + createFileCallback?.onCanceledByUser(requestCode) + } + + is FileCreationResult.StoragePermissionDenied -> { + // This should not happen, but just in case + Log.e(TAG, "Unexpected result for file creation: $result") + } + } } else { createFileCallback?.onCanceledByUser(requestCode) } @@ -645,17 +475,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..65bccc59 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) } @@ -466,7 +451,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/SimpleStorageResult.kt b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResult.kt new file mode 100644 index 00000000..76403579 --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResult.kt @@ -0,0 +1,63 @@ +package com.anggrayudi.storage.contract + +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.StorageType + +sealed class FolderPickerResult { + data class Picked(val folder: DocumentFile) : FolderPickerResult() + + data class AccessDenied( + val folder: DocumentFile?, + val storageType: StorageType, + val storageId: String, + ) : FolderPickerResult() + + data object CanceledByUser : FolderPickerResult() +} + +sealed class FilePickerResult { + data class Picked(val files: List) : FilePickerResult() + + data object CanceledByUser : FilePickerResult() + + /** @see [StoragePermissionDeniedException] */ + data class StoragePermissionDenied(val files: List) : FilePickerResult() +} + +sealed class FileCreationResult { + data class Created(val file: DocumentFile) : FileCreationResult() + + data object CanceledByUser : FileCreationResult() + + /** @see [StoragePermissionDeniedException] */ + data object StoragePermissionDenied : FileCreationResult() +} + +sealed class RequestStorageAccessResult { + data object CanceledByUser : RequestStorageAccessResult() + + /** @see [StoragePermissionDeniedException] */ + data object StoragePermissionDenied : RequestStorageAccessResult() + + /** Triggered on Android 10 and lower. */ + data class RootPathNotSelected( + val rootPath: String, + val uri: Uri, + val selectedStorageType: StorageType, + val expectedStorageType: StorageType, + /** Expected [Intent] to launch when the root path is not selected. */ + val expectedIntent: Intent? = null, + ) : RequestStorageAccessResult() + + /** Triggered on Android 11 and higher. */ + data class ExpectedStorageNotSelected( + val selectedFolder: DocumentFile, + val selectedStorageType: StorageType, + val expectedBasePath: String, + val expectedStorageType: StorageType, + ) : RequestStorageAccessResult() + + data class RootPathPermissionGranted(val root: DocumentFile) : RequestStorageAccessResult() +} diff --git a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContract.kt b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContract.kt new file mode 100644 index 00000000..fd552e3e --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContract.kt @@ -0,0 +1,437 @@ +package com.anggrayudi.storage.contract + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +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.annotation.RequiresApi +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 (e: 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 || intent?.data == null) { + 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, + ) +} + +/** + * Managing files in direct storage requires root access. Thus we need to make sure users select + * root path. This contract may throws [ActivityNotFoundException] or + * [StoragePermissionDeniedException]. + */ +class RequestStorageAccessContract( + context: Context, + /** + * For example, if you set [StorageType.SD_CARD] but the user selects [StorageType.EXTERNAL], then + * trigger [StorageAccessCallback.onRootPathNotSelected]. Set to [StorageType.UNKNOWN] to accept + * any storage type. + */ + private val expectedStorageType: StorageType = StorageType.UNKNOWN, + /** Applicable for API 30+ only, because Android 11 does not allow selecting the root path. */ + private val expectedBasePath: String = "", +) : ActivityResultContract() { + + class Options + @JvmOverloads + constructor( + /** It only takes effect on API 26+ */ + val initialPath: FileFullPath? = null + ) + + private val appContext = context.applicationContext + + override fun createIntent(context: Context, input: Options): Intent { + input.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) + ?: throw StoragePermissionDeniedException() + saveUriPermission(context, root.uri) + return Intent(context, EmptyActivity::class.java).setData(root.uri) + } + } else { + throw StoragePermissionDeniedException() + } + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getExternalStorageRootAccessIntent(context).also { + addInitialPathToIntent(context, it, input.initialPath) + } + } else if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && expectedStorageType == StorageType.SD_CARD + ) { + getSdCardRootAccessIntent(context) + } else { + getExternalStorageRootAccessIntent(context) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): RequestStorageAccessResult { + val uri = + intent?.takeIf { resultCode == Activity.RESULT_OK }?.data + ?: return RequestStorageAccessResult.CanceledByUser + val storageId = uri.getStorageId(appContext) + val storageType = StorageType.fromStorageId(storageId) + + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { + val selectedFolder = + appContext.fromTreeUri(uri) ?: throw SecurityException("Lost access to URI: $uri") + if ( + !expectedStorageType.isExpected(storageType) || + expectedBasePath.isNotEmpty() && + selectedFolder.getBasePath(appContext) != expectedBasePath + ) { + return RequestStorageAccessResult.ExpectedStorageNotSelected( + selectedFolder, + storageType, + expectedBasePath, + expectedStorageType, + ) + } + } else if (!expectedStorageType.isExpected(storageType)) { + val rootPath = appContext.fromTreeUri(uri)?.getAbsolutePath(appContext).orEmpty() + return RequestStorageAccessResult.RootPathNotSelected( + rootPath, + uri, + storageType, + expectedStorageType, + ) + } + + if (uri.isDownloadsDocument) { + if (uri.toString() == DocumentFileCompat.DOWNLOADS_TREE_URI) { + saveUriPermission(appContext, uri) + return RequestStorageAccessResult.RootPathPermissionGranted( + appContext.fromTreeUri(uri) + ?: throw FileNotFoundException("Failed to get root path from URI: $uri") + ) + } + return RequestStorageAccessResult.RootPathNotSelected( + PublicDirectory.DOWNLOADS.absolutePath, + uri, + StorageType.EXTERNAL, + expectedStorageType, + ) + } + + if (uri.isDocumentsDocument) { + if (uri.toString() == DocumentFileCompat.DOCUMENTS_TREE_URI) { + saveUriPermission(appContext, uri) + return RequestStorageAccessResult.RootPathPermissionGranted( + appContext.fromTreeUri(uri) + ?: throw FileNotFoundException("Failed to get root path from URI: $uri") + ) + } + return RequestStorageAccessResult.RootPathNotSelected( + PublicDirectory.DOCUMENTS.absolutePath, + uri, + StorageType.EXTERNAL, + expectedStorageType, + ) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R && !uri.isExternalStorageDocument) { + return RequestStorageAccessResult.RootPathNotSelected( + externalStoragePath, + uri, + StorageType.EXTERNAL, + expectedStorageType, + ) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && storageId == PRIMARY) { + saveUriPermission(appContext, uri) + return RequestStorageAccessResult.RootPathPermissionGranted( + appContext.fromTreeUri(uri) + ?: throw FileNotFoundException("Failed to get root path from URI: $uri") + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || DocumentFileCompat.isRootUri(uri)) { + return if (saveUriPermission(appContext, uri)) { + RequestStorageAccessResult.RootPathPermissionGranted( + appContext.fromTreeUri(uri) + ?: throw FileNotFoundException("Failed to get root path from URI: $uri") + ) + } else { + RequestStorageAccessResult.StoragePermissionDenied + } + } else { + if (storageId == PRIMARY) { + return RequestStorageAccessResult.RootPathNotSelected( + externalStoragePath, + uri, + StorageType.EXTERNAL, + expectedStorageType, + ) + } else { + var sdCardIntent: Intent? = null + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + ) { + val sm = appContext.getSystemService(Context.STORAGE_SERVICE) as StorageManager + @Suppress("DEPRECATION") + sdCardIntent = sm.storageVolumes.firstOrNull { !it.isPrimary }?.createAccessIntent(null) + } + return RequestStorageAccessResult.RootPathNotSelected( + "/storage/$storageId", + uri, + StorageType.SD_CARD, + expectedStorageType, + sdCardIntent, + ) + } + } + } +} diff --git a/storage/src/main/java/com/anggrayudi/storage/contract/StoragePermissionDeniedException.kt b/storage/src/main/java/com/anggrayudi/storage/contract/StoragePermissionDeniedException.kt new file mode 100644 index 00000000..1be5bebe --- /dev/null +++ b/storage/src/main/java/com/anggrayudi/storage/contract/StoragePermissionDeniedException.kt @@ -0,0 +1,10 @@ +package com.anggrayudi.storage.contract + +/** + * Thrown when `android.permission.READ_EXTERNAL_STORAGE` and/or + * `android.permission.WRITE_EXTERNAL_STORAGE` is not granted. + */ +class StoragePermissionDeniedException( + message: String? = + "Please grant permissions in manifest for android.permission.READ_EXTERNAL_STORAGE and android.permission.WRITE_EXTERNAL_STORAGE" +) : SecurityException(message) From 62ae6b330942d44d3cdd69ea05fc6285fab83e2c Mon Sep 17 00:00:00 2001 From: Anggrayudi Hardiannico Date: Thu, 10 Jul 2025 03:39:33 +0700 Subject: [PATCH 03/10] Added support for Jetpack Compose (#151) --- sample/src/main/AndroidManifest.xml | 5 + .../sample/compose/StorageComposeActivity.kt | 14 + .../sample/compose/StorageComposeApp.kt | 117 +++++ .../storage/sample/compose/theme/Color.kt | 29 ++ .../storage/sample/compose/theme/Theme.kt | 56 ++ .../storage/sample/compose/theme/Type.kt | 35 ++ sample/src/main/res/menu/main.xml | 5 + sample/src/main/res/values/strings.xml | 1 + sample/src/main/res/values/styles.xml | 1 + settings.gradle.kts | 2 +- storage-compose/.gitignore | 1 + storage-compose/build.gradle.kts | 67 +++ storage-compose/consumer-rules.pro | 0 storage-compose/gradle.properties | 1 + storage-compose/proguard-rules.pro | 21 + storage-compose/src/main/AndroidManifest.xml | 4 + .../storage/compose/SimpleStorageCompose.kt | 482 ++++++++++++++++++ .../storage/compose/ExampleUnitTest.kt | 16 + .../com/anggrayudi/storage/SimpleStorage.kt | 5 +- .../anggrayudi/storage/SimpleStorageHelper.kt | 8 +- .../contract/SimpleStorageResultContract.kt | 51 +- .../anggrayudi/storage/file/FileFullPath.kt | 37 +- 22 files changed, 952 insertions(+), 6 deletions(-) create mode 100644 sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeActivity.kt create mode 100644 sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeApp.kt create mode 100644 sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Color.kt create mode 100644 sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Theme.kt create mode 100644 sample/src/main/java/com/anggrayudi/storage/sample/compose/theme/Type.kt create mode 100644 storage-compose/.gitignore create mode 100644 storage-compose/build.gradle.kts create mode 100644 storage-compose/consumer-rules.pro create mode 100644 storage-compose/gradle.properties create mode 100644 storage-compose/proguard-rules.pro create mode 100644 storage-compose/src/main/AndroidManifest.xml create mode 100644 storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt create mode 100644 storage-compose/src/test/java/com/anggrayudi/storage/compose/ExampleUnitTest.kt 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/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..d6e18c8d --- /dev/null +++ b/sample/src/main/java/com/anggrayudi/storage/sample/compose/StorageComposeApp.kt @@ -0,0 +1,117 @@ +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.runtime.rememberCoroutineScope +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.file.fullName +import com.anggrayudi.storage.file.getAbsolutePath +import kotlinx.coroutines.CoroutineScope + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageComposeApp( + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope = rememberCoroutineScope(), +) { + 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 -> + println(result) + } + val storagePermissionLauncher = rememberLauncherForStoragePermission { result -> + println(result) + } + 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 +