package expo.modules.medialibrary import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.content.pm.PackageManager import android.media.MediaScannerConnection import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import android.text.TextUtils import android.util.Log import android.webkit.MimeTypeMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine object MediaLibraryUtils { class AssetFile(pathname: String, val assetId: String, val mimeType: String) : File(pathname) /** * Splits given filename into two-element tuple: name, extension * * Example: * ``` * getFileNameAndExtension("foo.jpg") // returns arrayOf("foo", "jpg") * getFileNameAndExtension("bar") // returns arrayOf("bar", "") * ``` * * @return Pair of strings: first is filename, second is extension */ fun getFileNameAndExtension(name: String): Pair { val dotIdx = name.lastIndexOf(".").takeIf { it != -1 } ?: name.length val extension = name.substring(startIndex = dotIdx) val filename = name.substring(0, dotIdx) return Pair(filename, extension) } /** * Moves the [src] file into [destDir] directory. Ensures that destination is NOT overwritten. * If the filename already exists at destination, a suffix is added to the copied filename. */ @Throws(IOException::class) fun safeMoveFile(src: File, destDir: File): File = safeCopyFile(src, destDir).also { src.delete() } /** * Copies the [src] file into [destDir] directory. Ensures that destination is NOT overwritten. * If the filename already exists at destination, a suffix is added to the copied filename. */ @Throws(IOException::class) fun safeCopyFile(src: File, destDir: File): File { var newFile = File(destDir, src.name) var suffix = 0 val (filename, extension) = getFileNameAndExtension(src.name) val suffixLimit = Short.MAX_VALUE.toInt() while (newFile.exists()) { newFile = File(destDir, filename + "_" + suffix + extension) suffix++ if (suffix > suffixLimit) { throw IOException("File name suffix limit reached ($suffixLimit)") } } FileInputStream(src).channel.use { input -> FileOutputStream(newFile).channel.use { output -> val transferred = input.transferTo(0, input.size(), output) if (transferred != input.size()) { newFile.delete() throw IOException("Could not save file to $destDir Not enough space.") } return newFile } } } suspend fun deleteAssets( context: Context, selection: String?, selectionArgs: Array? ): Boolean = withContext(Dispatchers.IO) { val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA) try { context.contentResolver.query( EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, null ).use { filesToDelete -> if (filesToDelete == null) { throw AssetFileException("Could not delete assets. Cursor is null.") } else { while (filesToDelete.moveToNext()) { // Interrupting file deletion when scope is closed is desired, as // user might want to stop this process in the meantime coroutineContext.ensureActive() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val columnId = filesToDelete.getColumnIndex(MediaStore.MediaColumns._ID) val id = filesToDelete.getLong(columnId) val assetUri = ContentUris.withAppendedId(EXTERNAL_CONTENT_URI, id) val rowsDeleted = context.contentResolver.delete(assetUri, null) if (rowsDeleted == 0) { throw AssetFileException("Could not delete file.") } } else { val dataColumnIndex = filesToDelete.getColumnIndex(MediaStore.MediaColumns.DATA) val filePath = filesToDelete.getString(dataColumnIndex) val file = File(filePath) if (file.delete()) { context.contentResolver.delete( EXTERNAL_CONTENT_URI, "${MediaStore.MediaColumns.DATA}=?", arrayOf(filePath) ) } else { throw AssetFileException("Could not delete file.") } } } } } return@withContext true } catch (e: SecurityException) { throw UnableToDeleteException("Could not delete asset: need WRITE_EXTERNAL_STORAGE permission.", e) } catch (e: Exception) { throw UnableToDeleteException("Could not delete file: ${e.message}", e) } } /** * Creates placeholders for parametrized query. Usable inside `IN` clause. * Example: * ``` * queryPlaceholdersFor(arrayOf("John, 24")) // returns "?,?" * ``` */ fun queryPlaceholdersFor(assetIds: Array): String = arrayOfNulls(assetIds.size) .apply { fill("?") } .joinToString(separator = ",") // Used in albums and migrations only - consider moving it there fun getAssetsById(context: Context, vararg assetsId: String?): List { val path = arrayOf( MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.BUCKET_ID, MediaStore.MediaColumns.MIME_TYPE ) val selection = MediaStore.Images.Media._ID + " IN ( " + queryPlaceholdersFor(assetsId) + " )" context.contentResolver.query( EXTERNAL_CONTENT_URI, path, selection, assetsId, null ).use { assets -> if (assets == null) { throw AssetFileException("Could not get assets. Query returns null.") } else if (assets.count != assetsId.size) { throw AssetFileException("Could not get all of the requested assets") } val assetFiles = mutableListOf() while (assets.moveToNext()) { val data = assets.getColumnIndex(MediaStore.Images.Media.DATA) val assetPath = assets.getString(data) val id = assets.getColumnIndex(MediaStore.MediaColumns._ID) val mimeType = assets.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) val asset = AssetFile( assetPath, assets.getString(id), assets.getString(mimeType) ) if (!asset.exists() || !asset.isFile) { throw AssetFileException("Path $assetPath does not exist or isn't file.") } assetFiles.add(asset) } return assetFiles } } private fun getMimeTypeFromFileUrl(url: String): String? { val extension = MimeTypeMap.getFileExtensionFromUrl(url) ?: return null return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) } fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? = contentResolver.getType(uri) ?: getMimeTypeFromFileUrl(uri.toString()) fun getAssetsUris(context: Context, assetsId: Array): List { val result = mutableListOf() val selection = MediaStore.MediaColumns._ID + " IN (" + TextUtils.join(",", assetsId) + " )" val selectionArgs: Array? = null val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.MIME_TYPE) context.contentResolver.query( EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, null )?.use { cursor -> while (cursor.moveToNext()) { val columnId = cursor.getColumnIndex(MediaStore.MediaColumns._ID) val columnMimeType = cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE) val id = cursor.getLong(columnId) val mimeType = cursor.getString(columnMimeType) val assetUri = ContentUris.withAppendedId(mimeTypeToExternalUri(mimeType), id) result.add(assetUri) } } return result } fun mimeTypeToExternalUri(mimeType: String?): Uri = when { mimeType == null -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI mimeType.contains("image") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI mimeType.contains("video") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI mimeType.contains("audio") -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI // For backward compatibility else -> EXTERNAL_CONTENT_URI } fun getRelativePathForAssetType(mimeType: String?, useCameraDir: Boolean): String { if (mimeType?.contains("image") == true || mimeType?.contains("video") == true) { return if (useCameraDir) Environment.DIRECTORY_DCIM else Environment.DIRECTORY_PICTURES } else if (mimeType?.contains("audio") == true) { return Environment.DIRECTORY_MUSIC } // For backward compatibility return if (useCameraDir) Environment.DIRECTORY_DCIM else Environment.DIRECTORY_PICTURES } @Deprecated("It uses deprecated Android method under the hood. See implementation for details.") fun getEnvDirectoryForAssetType(mimeType: String?, useCameraDir: Boolean): File = Environment.getExternalStoragePublicDirectory(getRelativePathForAssetType(mimeType, useCameraDir)) private fun getManifestPermissions(context: Context): Set { val pm: PackageManager = context.packageManager return try { val packageInfo = pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) packageInfo.requestedPermissions?.toSet() ?: emptySet() } catch (e: PackageManager.NameNotFoundException) { Log.e("expo-media-library", "Failed to list AndroidManifest.xml permissions") e.printStackTrace() emptySet() } } /** * Checks, whenever an application represented by [context] contains specific [permission] * in `AndroidManifest.xml`: * * ```xml * * ``` */ fun hasManifestPermission(context: Context, permission: String): Boolean = getManifestPermissions(context).contains(permission) suspend fun scanFile(context: Context, paths: Array, mimeTypes: Array?) = suspendCoroutine { complete -> MediaScannerConnection.scanFile(context, paths, mimeTypes) { path: String, uri: Uri? -> complete.resume(Pair(path, uri)) } } }