diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24f6f83..52d4a5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ formatting and comments — plus the checks a change is expected to pass. ## Building -* minSdk 26, compileSdk/targetSdk 37, Java 11 source/target level. +* minSdk 23, compileSdk/targetSdk 37, Java 11 source/target level. * `./gradlew :storage:assembleDebug :storage-compose:assembleDebug` builds the libraries. * `:storage` and `:storage-compose` compile with `-Xexplicit-api=strict`, so every public declaration needs an explicit visibility modifier and an explicit return type. Test sources are diff --git a/MIGRATION.md b/MIGRATION.md index c4d507d..e628971 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -8,7 +8,7 @@ Platform changes: | | 2.x | 3.0 | |---|---|---| -| minSdk | 23 | **26** | +| minSdk | 23 | 23 — 3.0.x required **26**, lowered back to 23 in **3.1.0** | | compileSdk / targetSdk | 36 | **37** (Android 17) | | AGP / Gradle (to build this repo) | 8.13 / 8.14 | 9.2 / 9.4 | diff --git a/README.md b/README.md index 2dfc432..886efb0 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Version `3.0.0` introduces a redesigned API: one [`StorageFile`](storage/src/mai abstraction over `DocumentFile`/`MediaFile`/`java.io.File`, one-shot suspend operations (`copyTo`, `moveTo`, `zipTo`, `unzipTo`) with a unified `TransferResult`, suspend-lambda conflict resolution, and [`StorageAccessManager`](storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt) -replacing `SimpleStorageHelper`. It requires **minSdk 26** and is compiled against **API 37 +replacing `SimpleStorageHelper`. It requires **minSdk 23** and is compiled against **API 37 (Android 17)**; all operations need Kotlin coroutines. The 2.x API keeps working during the 3.x cycle, but 2.x is closed for maintenance: no bugfix diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 92fea0c..d81f99b 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -30,7 +30,7 @@ android { defaultConfig { applicationId = "com.anggrayudi.storage.sample" - minSdk = 26 + minSdk = 23 targetSdk = 37 versionCode = 1 versionName = rootProject.extra["VERSION_NAME"] as String diff --git a/storage-compose/build.gradle.kts b/storage-compose/build.gradle.kts index 48fe0e1..9a15b19 100644 --- a/storage-compose/build.gradle.kts +++ b/storage-compose/build.gradle.kts @@ -14,7 +14,7 @@ android { resourcePrefix = "ss_" defaultConfig { - minSdk = 26 + minSdk = 23 consumerProguardFiles("consumer-rules.pro") } 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 index cef25e7..158e24c 100644 --- a/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt +++ b/storage-compose/src/main/java/com/anggrayudi/storage/compose/SimpleStorageCompose.kt @@ -207,6 +207,7 @@ internal data class StorageAccessDialogData( public fun rememberLauncherForStorageAccess( expectedStorageType: StorageType = StorageType.UNKNOWN, expectedBasePath: String = "", + /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onStorageAccessGranted: (root: DocumentFile) -> Unit, ): StorageAccessLauncher { @@ -350,6 +351,7 @@ internal constructor( @Composable public fun rememberLauncherForFilePicker( allowMultiple: Boolean = false, + /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, filterMimeTypes: Set = emptySet(), onFilesPicked: (files: List) -> Unit, @@ -411,6 +413,7 @@ internal constructor( public fun rememberLauncherForFileCreation( mimeType: String, fileName: String? = null, + /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onFileCreated: (file: DocumentFile) -> Unit, ): FileCreationLauncher { @@ -468,6 +471,7 @@ internal constructor( @Composable public fun rememberLauncherForFolderPicker( + /** It only takes effect on API 26+ */ initialPath: FileFullPath? = null, onFolderPicked: (folder: DocumentFile) -> Unit, ): FolderPickerLauncher { diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 0ddfbcd..6811c84 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -12,7 +12,7 @@ android { resourcePrefix = "ss_" defaultConfig { - minSdk = 26 + minSdk = 23 consumerProguardFiles("consumer-rules.pro") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt index 90d514b..e6691ed 100644 --- a/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt +++ b/storage/src/main/java/com/anggrayudi/storage/SimpleStorage.kt @@ -192,7 +192,11 @@ public class SimpleStorage private constructor(private val wrapper: ComponentWra context.startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) } - /** Show interactive UI to create a file. */ + /** + * Show interactive UI to create a file. + * + * @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"), @@ -222,6 +226,7 @@ public class SimpleStorage private constructor(private val wrapper: ComponentWra 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"), @@ -258,6 +263,7 @@ public class SimpleStorage private constructor(private val wrapper: ComponentWra 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"), diff --git a/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt b/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt index 510ffc2..148c28d 100644 --- a/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt +++ b/storage/src/main/java/com/anggrayudi/storage/access/StorageAccessManager.kt @@ -292,6 +292,7 @@ public class StorageAccessManager(activity: ComponentActivity) { * and returns [BookmarkResult.Granted] with an **updated** bookmark to persist. * 3. Nothing matches → [BookmarkResult.VolumeNotMounted]. */ + @RequiresApi(Build.VERSION_CODES.N) public suspend fun resolveBookmark( bookmark: VolumeBookmark, requiresWriteAccess: Boolean = true, @@ -325,6 +326,7 @@ public class StorageAccessManager(activity: ComponentActivity) { * Builds a [VolumeBookmark] for [folder] so it can be re-resolved later with [resolveBookmark]. * Returns `null` when the folder has no resolvable [StorageFile.path]. */ + @RequiresApi(Build.VERSION_CODES.N) public fun createBookmark(folder: StorageFile): VolumeBookmark? { val path = folder.path ?: return null val label = diff --git a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt index d012372..0c3423e 100644 --- a/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt +++ b/storage/src/main/java/com/anggrayudi/storage/contract/SimpleStorageResultContracts.kt @@ -14,14 +14,10 @@ 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.file.DocumentFileCompat.cleanupRedundantUriPermissions -import com.anggrayudi.storage.file.DocumentFileCompat.externalStoragePath -import com.anggrayudi.storage.file.DocumentFileCompat.getDefaultExternalStorageIntent -import com.anggrayudi.storage.file.DocumentFileCompat.hasStoragePermission -import com.anggrayudi.storage.file.DocumentFileCompat.isSdCardPresent import com.anggrayudi.storage.callback.StorageAccessCallback import com.anggrayudi.storage.extension.fromSingleUri import com.anggrayudi.storage.extension.fromTreeUri @@ -30,6 +26,11 @@ 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.DocumentFileCompat.cleanupRedundantUriPermissions +import com.anggrayudi.storage.file.DocumentFileCompat.externalStoragePath +import com.anggrayudi.storage.file.DocumentFileCompat.getDefaultExternalStorageIntent +import com.anggrayudi.storage.file.DocumentFileCompat.hasStoragePermission +import com.anggrayudi.storage.file.DocumentFileCompat.isSdCardPresent import com.anggrayudi.storage.file.FileFullPath import com.anggrayudi.storage.file.MimeType import com.anggrayudi.storage.file.PublicDirectory @@ -68,6 +69,7 @@ internal fun getExternalStorageRootAccessIntent(context: Context): Intent = * != 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 @@ -87,8 +89,10 @@ internal fun getSdCardRootAccessIntent(context: Context): Intent { } internal fun addInitialPathToIntent(context: Context, intent: Intent, initialPath: FileFullPath?) { - initialPath?.toDocumentUri(context)?.let { - intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) + if (Build.VERSION.SDK_INT >= 26) { + initialPath?.toDocumentUri(context)?.let { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) + } } } @@ -156,7 +160,8 @@ public class OpenFolderPickerContract(context: Context) : it == DocumentFileCompat.DOWNLOADS_TREE_URI || it == DocumentFileCompat.DOCUMENTS_TREE_URI } || DocumentFileCompat.isRootUri(uri) && - Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && + (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) @@ -177,6 +182,7 @@ public class OpenFolderPickerContract(context: Context) : public class Options @JvmOverloads constructor( + /** It only takes effect on API 26+ */ public val initialPath: FileFullPath? = null ) } @@ -217,6 +223,7 @@ public class OpenFilePickerContract(context: Context) : @JvmOverloads constructor( public val allowMultiple: Boolean = false, + /** It only takes effect on API 26+ */ public val initialPath: FileFullPath? = null, public val filterMimeTypes: Set = emptySet(), ) @@ -289,10 +296,9 @@ public class StoragePermissionContract() : input: Unit, ): SynchronousResult>? { val permissions = getPermissions() - val allGranted = - permissions.all { permission -> - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - } + val allGranted = permissions.all { permission -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } return if (allGranted) { SynchronousResult(permissions.associateWith { true }) } else null @@ -332,6 +338,7 @@ public class RequestStorageAccessContract( public class Options @JvmOverloads constructor( + /** It only takes effect on API 26+ */ public val initialPath: FileFullPath? = null ) @@ -363,7 +370,9 @@ public class RequestStorageAccessContract( getExternalStorageRootAccessIntent(context).also { addInitialPathToIntent(context, it, input.initialPath) } - } else if (expectedStorageType == StorageType.SD_CARD) { + } else if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && expectedStorageType == StorageType.SD_CARD + ) { getSdCardRootAccessIntent(context) } else { getExternalStorageRootAccessIntent(context) @@ -470,7 +479,10 @@ public class RequestStorageAccessContract( ) } else { var sdCardIntent: Intent? = null - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + 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) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt index 489f3cb..e90caf6 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileCompat.kt @@ -12,6 +12,7 @@ import android.os.StatFs import android.os.storage.StorageManager import android.provider.DocumentsContract import android.system.Os +import androidx.annotation.RequiresApi import androidx.annotation.RestrictTo import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat @@ -116,6 +117,7 @@ public object DocumentFileCompat { * format. This is the authoritative check; [SD_CARD_STORAGE_ID_REGEX] is only a fallback for when * the volume list cannot be consulted. */ + @RequiresApi(Build.VERSION_CODES.N) @JvmStatic public fun isMountedVolumeId(context: Context, storageId: String): Boolean { if (storageId.isEmpty()) return false @@ -593,11 +595,14 @@ public object DocumentFileCompat { /** Intent that opens the SAF folder picker at the primary volume. */ @JvmStatic public fun getDefaultExternalStorageIntent(context: Context): Intent { - return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - .putExtra( - DocumentsContract.EXTRA_INITIAL_URI, - context.fromTreeUri(createDocumentUri(PRIMARY))?.uri, - ) + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (Build.VERSION.SDK_INT >= 26) { + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + context.fromTreeUri(createDocumentUri(PRIMARY))?.uri, + ) + } + } } /** Runtime read and write permission. Only meaningful on API 26–29. */ @@ -692,9 +697,13 @@ public object DocumentFileCompat { // cards but not on every removable volume, so a mounted, granted OTG drive was invisible here. val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val mountedVolumes = - storageManager.storageVolumes - .filter { it.state == Environment.MEDIA_MOUNTED } - .mapNotNull { if (it.isPrimary) PRIMARY else it.uuid } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + storageManager.storageVolumes + .filter { it.state == Environment.MEDIA_MOUNTED } + .mapNotNull { if (it.isPrimary) PRIMARY else it.uuid } + } else { + emptyList() + } val persistedStorageIds = context.contentResolver.persistedUriPermissions .filter { it.isReadPermission && it.isWritePermission && it.uri.isExternalStorageDocument } @@ -732,7 +741,8 @@ public object DocumentFileCompat { "${Environment.getExternalStorageDirectory()}/$rootFolder".trimEnd('/') ) } else if ( - isMountedVolumeId(context, storageId) || storageId.matches(SD_CARD_STORAGE_ID_REGEX) + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + isMountedVolumeId(context, storageId)) || storageId.matches(SD_CARD_STORAGE_ID_REGEX) ) { val paths = storages[storageId] ?: HashSet() paths.add("/storage/$storageId/$rootFolder".trimEnd('/')) diff --git a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt index 854ee18..4d627cd 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/DocumentFileExt.kt @@ -2322,7 +2322,11 @@ private fun DocumentFile.tryMoveFolderByRenamingPath( } try { - if (!isRawFile && writableTargetParentFolder.isTreeDocumentFile) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + !isRawFile && + writableTargetParentFolder.isTreeDocumentFile + ) { val movedFileUri = parentFile?.uri?.let { DocumentsContract.moveDocument( @@ -3287,7 +3291,8 @@ private fun DocumentFile.moveFileTo( try { if ( - !isRawFile && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + !isRawFile && writableTargetFolder.isTreeDocumentFile && getStorageId(context) == targetStorageId ) { diff --git a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt index db9e91e..49bd02f 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/FileExt.kt @@ -67,7 +67,7 @@ public fun File.child(path: String): File = File(this, path) * @see [Context.getFilesDir] */ public val Context.dataDirectory: File - get() = dataDir + get() = if (Build.VERSION.SDK_INT > 23) dataDir else filesDir.parentFile!! public fun File.getBasePath(context: Context): String { val externalStoragePath = DocumentFileCompat.externalStoragePath diff --git a/storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt b/storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt index e4efd2c..906ba7f 100644 --- a/storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt +++ b/storage/src/main/java/com/anggrayudi/storage/file/StorageType.kt @@ -2,7 +2,7 @@ package com.anggrayudi.storage.file import android.content.Context import android.net.Uri -import com.anggrayudi.storage.SimpleStorage +import android.os.Build /** * Created on 17/08/20 @@ -49,7 +49,8 @@ public enum class StorageType { when { storageId == StorageId.PRIMARY -> EXTERNAL storageId == StorageId.DATA -> DATA - DocumentFileCompat.isMountedVolumeId(context, storageId) || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + DocumentFileCompat.isMountedVolumeId(context, storageId)) || storageId.matches(DocumentFileCompat.SD_CARD_STORAGE_ID_REGEX) -> SD_CARD else -> UNKNOWN }